25

我已经潜入函数式编程 3 年多了,并且我一直在阅读和理解函数式编程的许多文章和方面。

但我经常偶然发现许多关于副作用计算中的“世界”以及在 IO monad 样本中携带和复制“世界”的文章。在这种情况下,“世界”意味着什么?这是所有副作用计算上下文中的同一个“世界”还是仅适用于 IO 单子?

此外,有关 Haskell 的文档和其他文章也多次提到“世界”。

关于这个“世界”的一些参考:http: //channel9.msdn.com/Shows/Going+Deep/Erik-Meijer-Functional-Programming

这个: http: //www.infoq.com/presentations/Taming-Effect-Simon-Peyton-Jones

我期待一个样本,而不仅仅是对世界概念的解释。我欢迎 Haskell、F#、Scala、Scheme 中的示例代码。

4

7 回答 7

50

“世界”只是一个抽象概念,它捕捉了“世界的状态”,即当前计算之外的一切状态。

以这个 I/O 函数为例:

write : Filename -> String -> ()

这是无效的,因为它会通过副作用更改文件(其内容是世界状态的一部分)。然而,如果我们将世界建模为一个显式对象,我们可以提供这个函数:

write : World -> Filename -> String -> World

这将获取当前世界并在功能上产生一个“新”世界,修改文件,然后您可以将其传递给连续调用。World 本身只是一个抽象类型,没有办法直接看到它,只能通过read.

现在,上面的接口有一个问题:如果没有进一步的限制,它会允许程序“复制”世界。例如:

w1 = write w "file" "yes"
w2 = write w "file" "no"

你使用了同一个世界w两次,产生了两个不同的未来世界。显然,这作为物理 I/O 的模型毫无意义。为了防止出现这样的例子,需要一个更花哨的类型系统来确保世界是线性处理的,即从不使用两次。Clean 语言就是基于这个想法的一个变体。

或者,您可以封装世界,使其永远不会变得明确,从而不能通过构造来复制。这就是 I/O monad 所实现的——它可以被认为是一个状态 monad,其状态是世界,它隐含地穿过 monadic 动作。

于 2012-11-12T09:56:07.973 回答
12

“世界”是一种将命令式编程嵌入到纯函数式语言中的概念。

你肯定知道,纯函数式编程要求函数的结果完全依赖于参数的值。所以假设我们想将一个典型的getLine操作表示为一个纯函数。有两个明显的问题:

  1. getLine每次使用相同的参数调用它时都会产生不同的结果(在这种情况下没有参数)。
  2. getLine具有消耗流的某些部分的副作用。如果您的程序使用getLine,那么(a)它的每次调用都必须消耗输入的不同部分,(b)程序输入的每个部分都必须被某些调用消耗。(您不能两次调用getLine两次读取同一输入行,除非该行在输入中出现两次;您也不能让程序随机跳过一行输入。)

所以getLine不能是一个函数,对吧?好吧,不是那么快,我们可以做一些技巧:

  1. 多次调用getLine可以返回不同的结果。为了使其与纯函数行为兼容,这意味着纯函数getLine可以接受一个参数:getLine :: W -> String. 然后,我们可以通过规定每次调用必须使用不同的W参数值来调和每次调用的不同结果的想法。你可以想象它W代表了输入流的状态。
  2. 多次调用getLine必须以某种确定的顺序执行,并且每次调用都必须消耗上一次调用留下的输入。更改:给出getLinetype W -> (String, W),并禁止程序W多次使用一个值(我们可以在编译时检查)。现在要getLine在您的程序中多次使用,您必须注意将先前调用的W结果提供给后续调用。

只要您可以保证Ws 不被重用,您就可以使用这种技术将任何(单线程)命令式程序转换为纯函数式程序。您甚至不需要为该W类型提供任何实际的内存中对象——您只需对程序进行类型检查并分析它以证明每个W只使用一次,然后发出不引用任何类型的代码.

所以“世界”就是这个想法,但被概括为涵盖所有命令式操作,而不仅仅是getLine.


现在已经解释了所有这些,您可能想知道您是否最好知道这一点。我的意见是不,你不是。看,IMO,整个“环游世界”的想法是诸如 monad 教程之类的东西之一,其中太多的 Haskell 程序员选择以实际上没有的方式“提供帮助”。

“Passing the world around”通常作为“解释”提供,以帮助新手了解 Haskell IO。但问题是(a)对于许多人来说,这是一个非常奇特的概念(“你的意思是我要绕过整个世界的状态?”),(b)非常抽象(很多人都无法接受这样的想法,即您的程序几乎每个函数都有一个未使用的虚拟参数,既不会出现在源代码中,也不会出现在目标代码中),并且(c)无论如何都不是最简单,最实用的解释.

恕我直言,Haskell I/O 最简单、最实用的解释是这样的:

  1. Haskell 是纯函数式的,所以getLine不能是函数。
  2. 但是 Haskell 有类似的东西getLine。这意味着这些东西是其他不是功能的东西。我们称之为行动
  3. Haskell 允许您将操作视为值。您可以拥有产生动作的函数(例如,putStrLn :: String -> IO ()),接受动作作为参数的函数(例如,(>>) :: IO a -> IO b -> IO b)等)。
  4. 然而,Haskell 没有执行动作的函数。不可能有一个,execute :: IO a -> a因为它不是一个真正的功能。
  5. Haskell 具有用于组合动作的内置函数:从简单动作中生成复合动作。使用基本动作和动作组合器,您可以将任何命令式程序描述为动作。
  6. Haskell 编译器知道如何将动作翻译成可执行的本机代码。main :: IO ()因此,您通过根据子动作编写动作来编写可执行的 Haskell 程序。
于 2012-11-12T18:30:48.393 回答
7

传递代表“世界”的值是在纯声明式编程中创建用于执行 IO(和其他副作用)的纯模型的一种方法。

纯声明式(不仅仅是函数式)编程的“问题”是显而易见的。纯声明式编程提供了一种计算模型。这些模型可以表达任何可能的计算,但在现实世界中,我们使用程序让计算机做理论上不是计算的事情:接受输入、渲染到显示器、读写存储、使用网络、控制机器人等等。您可以直接将几乎所有此类程序建模为计算(例如,鉴于此输入是计算,应将什么输出写入文件),但与程序外部事物的实际交互并不是纯模型的一部分.

命令式编程实际上也是如此。作为 C 编程语言的计算“模型”没有提供写入文件、从键盘读取或任何东西的方法。但是命令式编程中的解决方案是微不足道的。在命令式模型中执行计算是执行一系列指令,每条指令实际执行的操作取决于程序执行时的整个环境。因此,您只需提供执行 IO 操作的“神奇”指令即可。而且由于命令式程序员习惯于从操作上考虑他们的程序1,这与他们已经在做的事情非常自然地吻合。

但是在所有计算模型中,给定的计算单元(函数、谓词等)将做什么应该只取决于它的输入,而不是取决于每次都可能不同的任意环境。因此,不仅执行 IO 操作,而且执行依赖于程序外部宇宙的计算是不可能的。

不过,解决方案的想法相当简单。您为 IO 操作如何在整个纯计算模型中工作构建模型。那么,所有适用于纯模型的原则和理论也将适用于它对 IO 建模的部分。然后,在语言或库实现中(因为它不能在语言本身中表达),您将 IO 模型的操作连接到实际的 IO 操作。

这使我们能够传递一个代表世界的值。例如,Mercury 中的“hello world”程序如下所示:

:- pred main(io::di, io::uo) is det.
main(InitialWorld, FinalWorld) :-
    print("Hello world!", InitialWorld, TmpWorld),
    nl(TmpWorld, FinalWorld).

程序被给定InitialWorld,类型中的一个值io代表程序之外的整个宇宙。它将这个世界传递给print,它把它还给它TmpWorld,那个世界就像InitialWorld“Hello world!”一样,但在其中。已打印到终端,并且在此期间发生的任何其他事情InitialWorldmain被包含在内。然后它传递TmpWorldnl,它返回FinalWorld(一个非常相似的世界,TmpWorld但它包含换行符的打印,以及同时发生的任何其他效果)。是传回操作系统FinalWorld的世界的最终状态。main

当然,我们并没有真正将整个宇宙作为程序中的值传递。在底层实现中,通常根本没有类型的值io,因为没有实际传递有用的信息;这一切都存在于程序之外。但是使用传递io值的模型允许我们整个宇宙是受它影响的每个操作的输入和输出一样进行编程(因此看到任何接受输入和输出io参数的操作都可以'不受外部世界的影响)。

事实上,通常你甚至不会真正想到执行 IO 的程序,就好像它们在宇宙中传递一样。在真正的 Mercury 代码中,您将使用“状态变量”语法糖,并像这样编写上面的程序:

:- pred main(io::di, io::uo) is det.
main(!IO) :-
    print("Hello world!", !IO),
    nl(!IO).

感叹号语法表示 that!IO真的代表两个参数,IO_XIO_Y,其中XY部分由编译器自动填充,以便状态变量按照编写顺序通过目标“线程化”。这不仅在 IO 的上下文中很有用,顺便说一句,状态变量是 Mercury 中非常方便的语法糖。

因此,程序员实际上倾向于将其视为一系列步骤(取决于并影响外部状态),这些步骤按照编写顺序执行。!IO几乎变成了一个魔术标签,只标记了适用的调用。

在 Haskell 中,IO 的纯模型是 monad,“hello world”程序如下所示:

main :: IO ()
main = putStrLn "Hello world!"

解释IOmonad 的一种方法类似于Statemonad。它会自动将状态值通过线程,并且 monad 中的每个值都可以依赖或影响该状态。只有在IO状态被线程化的情况下才是整个宇宙,就像在 Mercury 程序中一样。使用 Mercury 的状态变量和 Haskell 的 do 表示法,这两种方法最终看起来非常相似,“世界”以一种尊重源代码中编写调用顺序的方式自动穿过,=但仍然具有IO明确的操作标记。

sacundim正如's answer中很好解释的那样,将Haskell 的monad解释IO为 IO-y 计算模型的另一种方法是想象这putStrLn "Hello world!"实际上不是“宇宙”需要线程化的计算,而是它putStrLn "Hello World!"本身描述可以采取的 IO 操作的数据结构。基于这种理解,IOmonad 中的程序正在使用纯 Haskell 程序在运行时生成命令式程序。在纯 Haskell 中,没有办法实际执行该程序,但由于mainis 类型IO () main本身评估为这样的程序,我们只知道在操作上 Haskell 运行时将执行该main程序。

由于我们将这些纯IO模型与与外部世界的实际交互联系起来,因此我们需要小心一点。我们正在编程,就好像整个宇宙是一个我们可以像其他值一样传递的值。但是其他值可以传递到多个不同的调用中,存储在多态容器中,以及许多其他对实际宇宙没有任何意义的东西。所以我们需要一些限制来阻止我们对模型中的“世界”做任何与现实世界实际可以做的事情不对应的事情。

Mercury 采用的方法是使用唯一模式来强制io保持值唯一。这就是为什么输入和输出世界被分别声明为io::di和的原因io::uo;它是声明第一个参数的类型是io,它的模式是di(“破坏性输入”的缩写)的简写,而第二个参数的类型是io,它的模式是uo(“唯一输出”的缩写)。由于io是抽象类型,没有办法构造新的,所以满足唯一性要求的唯一方法是始终将io值传递给最多一次调用,这也必须给你一个唯一的io值,然后输出您调用的最后一件事的最终io值。

Haskell 中采用的方法是使用 monad 接口允许IO从纯数据和其他IO值构造 monad 中的值,但不公开任何IO允许您从IOmonad 中“提取”纯数据的值的函数。这意味着只有IO包含在其中的值main才能做任何事情,并且这些动作必须正确排序。

我之前提到过,IO使用纯语言工作的程序员仍然倾向于从操作上考虑他们的大部分 IO。那么,如果我们只是像命令式程序员一样思考它,为什么还要费尽心思来为 IO 提出一个纯模型呢?最大的优势是现在所有适用于所有语言的理论/代码/任何东西也适用于 IO 代码。

例如,在 Mercury 中,等效于fold逐个元素处理列表以建立累加器值,这意味着fold将某个任意类型的变量的输入/输出对作为累加器(这是 Mercury 中非常常见的模式标准库,这就是为什么我说状态变量语法在 IO 之外的其他上下文中通常非常方便)。由于“世界”在 Mercury 程序中明确显示为 type 中的值io,因此可以将io值用作累加器!在 Mercury 中打印字符串列表就像foldl(print, MyStrings, !IO). 同样在 Haskell 中,通用 monad/functor 代码在IO价值观。我们得到了一大堆“高阶” IO 操作,这些操作必须用一种完全特殊的机制来处理 IO 的语言重新专门针对 IO 实现。

此外,由于我们避免通过 IO 破坏纯模型,因此即使存在 IO,计算模型的正确理论仍然适用。这使得程序员和程序分析工具的推理不必考虑是否可能涉及 IO。例如,在像 Scala 这样的语言中,尽管许多“普通”代码实际上是纯代码,但适用于纯代码的优化和实现技术通常是不适用的,因为编译器必须假定每个调用都可能包含 IO 或其他影响。


1从操作上考虑程序意味着根据计算机在执行它们时将执行的操作来理解它们。

于 2012-11-13T07:12:21.320 回答
4

我认为我们应该阅读关于这个主题的第一件事是解决尴尬的小队。(我没有这样做,我很后悔。)作者实际上将 GHC 的内部表示描述IOworld -> (a,world)“有点骇人听闻”。我认为这种“黑客”是一种无辜的谎言。我认为这里有两种谎言:

  1. GHC 假装“世界”可以用某个变量来表示。
  2. 该类型world -> (a,world) 基本上说,如果我们可以以某种方式实例化世界,那么我们世界的“下一个状态”在功能上由运行在计算机上的一些小程序决定。由于这显然无法实现,原语(当然)被实现为具有副作用的函数,忽略无意义的“世界”参数,就像在大多数其他语言中一样。

作者在两个基础上为这种“黑客行为”辩护:

  1. 通过将 IO 视为 type 的瘦包装器world -> (a,world),GHC 可以对 IO 代码复用很多优化,因此这种设计非常实用和经济。
  2. 如果编译器满足某些属性,则可以证明如上实现的 IO 计算的操作语义是合理的。这篇论文被引用来证明这一点。

问题(我想在这里问,但你先问了,所以请原谅我在这里写)是在标准的“延迟 IO”功能的存在下,我不再确定 GHC 的操作语义是否仍然正确.

标准的“惰性 IO”功能,例如hGetContents内部调用unsafeInterleaveIO,这又相当于 unsafeDupableInterleaveIO单线程程序。

unsafeDupableInterleaveIO :: IO a -> IO a
unsafeDupableInterleaveIO (IO m)
     = IO ( \ s -> let  r = case m s of (# _, res #) -> res
                   in  (# s, r #))

假设等式推理仍然适用于这种程序(注意 m 是一个不纯的函数)并忽略构造函数,我们有 unsafeDupableInterleaveIO m >>= f==> \world -> f (snd (m world)) world,它在语义上与上面描述的 Andreas Rossberg 具有相同的效果:它“复制”了世界。由于我们的世界不能以这种方式复制,而且 Haskell 程序的精确评估顺序几乎是不可预测的 --- 我们得到的是对一些宝贵的系统资源(如文件句柄)的几乎不受约束和不同步的并发竞争。这种操作在Ariola&Sabry中当然是从来没有考虑过的。所以我在这方面不同意 Andreas —— IO monad 没有(这就是为什么有些人说惰性 IO 不好)。

于 2012-11-13T13:54:22.970 回答
2

世界就是这个意思——物理的、真实的世界。(只有一个,请注意。)

通过忽略仅限于 CPU 和内存的物理进程,可以对每个功能进行分类:

  1. 那些在物理世界中没有影响的(除了短暂的,在 CPU 和 RAM 中几乎无法观察到的影响)
  2. 那些确实有可观察到的效果。例如:在打印机上打印东西、通过网络电缆发送电子、发射火箭或移动磁盘磁头。

这种区别有点人为,因为即使在现实中运行最纯粹的 Haskell 程序也确实有明显的影响,比如:你的 CPU 变热,这会导致风扇打开。

于 2012-11-12T10:09:58.353 回答
1

基本上您编写的每个程序都可以分为两部分(在 FP 中,在命令式/OO 世界中没有这样的区别)。

  1. 核心/纯部分:这是您的应用程序的实际逻辑/算法,用于解决您构建应用程序的问题。(今天 95% 的应用程序缺少这部分,因为它们只是一堆带有 if/else 的 API 调用,人们开始称自己为程序员) 例如:在图像处理工具中,将各种效果应用于图像的算法属于这个核心部分。因此,在 FP 中,您使用纯度等 FP 概念构建这个核心部分。您构建的函数接受输入并返回结果,并且在您的应用程序的这一部分中没有任何变化。

  2. 外层部分:现在假设您已经完成了图像处理工具的核心部分,并通过调用具有各种输入的函数并检查输出来测试算法,但这不是您可以发布的东西,用户应该如何使用这个核心部分,没有它的面子,只是一堆功能而已。现在要从最终用户的角度制作这个核心usable,您需要构建某种 UI,从磁盘读取文件的方法,可能是使用一些嵌入式数据库来存储用户偏好等等。这种与其他各种东西的交互,这不是您的应用程序的核心概念,但仍然是使其可用所必需的,称为worldin FP。

练习:考虑一下您之前构建的任何应用程序,并尝试将其分为上述两部分,希望这会使事情变得更清楚。

于 2012-11-12T09:16:11.093 回答
1

世界是指与现实世界互动/有副作用 - 例如

fprintf file "hello world"

这有一个副作用 - 文件已"hello world"添加到其中。

这与纯粹的功能代码相反

let add a b = a + b

没有副作用

于 2012-11-12T09:20:12.533 回答