“世界”在函数式编程世界中意味着什么?

时间:2012-11-12 08:34:21

标签: scala haskell f# functional-programming

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

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

关于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中的示例代码。

7 个答案:

答案 0 :(得分:49)

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

采用此I / O功能,例如:

write : Filename -> String -> ()

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

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

这将获取当前世界并在功能上生成一个“新”文件,并修改文件,然后您可以将其传递给连续调用。世界本身只是一种抽象类型,除了通过read之类的相应函数外,没有办法直接窥视它。

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

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

你曾两次使用相同的世界w,产生两个不同的未来世界。显然,作为物理I / O的模型,这没有任何意义。为了防止这样的例子,需要一个更花哨的类型系统,以确保世界被线性处理,即从未使用过两次。 Clean的语言基于这种想法的变化。

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

答案 1 :(得分:12)

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

正如您当然所知,纯函数式编程要求函数的结果完全依赖于参数的值。因此,假设我们想要将典型的getLine操作表示为纯函数。有两个明显的问题:

  1. getLine每次使用相同的参数调用时都会产生不同的结果(在本例中没有参数)。
  2. getLine具有消耗流的某些部分的副作用。如果你的程序使用getLine,那么(a)每次调用它必须使用输入的不同部分,(b)程序输入的每个部分必须被某些调用使用。 (您不能两次调用getLine两次读同一输入行,除非该行在输入中出现两次;您不能让程序随机跳过一行输入。)
  3. 所以getLine只是不能成为一种功能,对吧?嗯,不是那么快,我们可以做一些技巧:

    1. 多次调用getLine可能会返回不同的结果。为了使其与纯函数行为兼容,这意味着纯函数getLine可以采用参数:getLine :: W -> String。然后我们可以通过规定每个调用必须使用W参数的不同值来调和每个调用的不同结果的概念。您可以想象W表示输入流的状态。
    2. 必须以某种确定的顺序执行对getLine的多次调用,并且每次调用必须消耗前一次调用留下的输入。更改:给getLine类型W -> (String, W),并禁止程序多次使用W值(我们可以在编译时检查)。现在,要在程序中多次使用getLine,您必须注意将之前调用的W结果提供给后续调用。
    3. 只要您可以保证W不被重用,您就可以使用这种技术将任何(单线程)命令式程序转换为纯粹的功能性程序。您甚至不需要为W类型提供任何实际的内存中对象 - 您只需键入检查程序并对其进行分析以证明每个W仅使用一次,然后发出代码这并不是指任何类型的东西。

      所以“世界”只是这个想法,但是通用化涵盖了所有必要的操作,而不仅仅是getLine


      现在解释了所有这些,你可能想知道你是否更好地了解这一点。我的意见不是,你不是。看,IMO,整个“传遍世界”的想法就像monad教程之类的东西,其中有太多的Haskell程序员选择以实际上没有的方式“有用”。

      “绕过世界”通常作为“解释”来帮助新手了解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程序。

答案 2 :(得分:7)

传递代表"世界的价值观。是纯粹的声明性编程中用于执行IO(和其他副作用)的纯模型的一种方法。

"问题"使用纯声明(不仅仅是功能)编程是显而易见的。纯声明性编程提供计算的模型。这些模型可以表达任何可能的计算,但在现实世界中,我们使用程序让计算机做一些在理论意义上无法计算的事情:获取输入,渲染到显示,读取和写入存储,使用网络,控制机器人您可以直接将所有这些程序建模为计算(例如,如果输入是一个计算,应该将哪个输出写入文件),但实际与程序外部事物的交互不是一部分。纯粹的模型。

命令式编程实际上也是如此。 "模型"作为C编程语言的计算无法写入文件,从键盘读取或任何东西。但是命令式编程中的解决方案是微不足道的。在命令式模型中执行计算是执行指令序列,并且每个指令实际执行的操作取决于程序执行时的整个环境。所以你可以提供"魔法"执行时执行IO操作的指令。由于命令式程序员习惯于在操作上操作 1 ,所以这非常适合他们已经在做的事情。

但是在所有计算模型中,给定的计算单位(函数,谓词等)应该只依赖于它的输入,而不是依赖于某些可能不同的任意环境时间。因此,不仅可以执行IO操作,而且还可以在程序外部实现依赖的计算。

解决方案的想法很简单。您构建了一个模型,用于说明IO操作如何在整个纯计算模型中工作。那么适用于纯模型的所有原理和理论也将适用于模拟IO的部分。然后,在语言或库实现中(因为它在语言本身中无法表达),您可以将IO模型的操作与实际的IO操作联系起来。

这使我们传递代表​​世界的价值。例如,一个" hello world"水星中的程序看起来像这样:

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

该程序被赋予InitialWorldio类型的值代表程序之外的整个Universe。它将这个世界传递给print,它将它返回TmpWorld,这个世界就像InitialWorld,但其中有#34; Hello world!"已被打印到终端,并且同时发生了InitialWorld传递给main的其他任何事情。然后它会将TmpWorld传递给nl,这会返回FinalWorld(一个非常像TmpWorld的世界,但它包含了换行符的打印以及发生的任何其他效果同时)。 FinalWorld是世界从main传回操作系统的最终状态。

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

事实上,通常你甚至不会认为执行IO的程序就像他们在宇宙中传递一样。在真正的Mercury代码中,您使用"状态变量"语法糖,并写这样的程序:

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

感叹号语法表示!IO确实代表两个参数IO_XIO_Y,其中XY部分会自动填写由编译器使得状态变量是"线程"通过他们写作顺序的目标。这不仅仅适用于IO btw的上下文,状态变量在Mercury中是非常方便的语法糖。

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

在Haskell中,IO的纯模型是一个monad,一个" hello world"程序看起来像这样:

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

解释IO monad的一种方法与State monad相似;它自动穿过状态值,monad中的每个值都可以依赖或影响这个状态。只有在IO的情况下,状态被线程化才是整个宇宙,就像Mercury程序一样。使用Mercury的状态变量和Haskell的符号表示,这两种方法看起来非常相似,其中" world"以一种尊重源代码中调用的顺序的方式自动穿过,但仍然明确标记了IO个动作。

正如在sacundim的答案中所解释的那样,另一种解释 Haskell的IO monad作为IO-y计算模型的方法是想象putStrLn "Hello world!"实际上并不是一个计算,通过这个计算,宇宙"需要进行线程化,而是putStrLn "Hello World!"本身就是一个描述可以采取的IO操作的数据结构。基于这种理解,IO monad中的哪些程序正在使用纯Haskell程序在运行时生成一个命令式程序。在纯Haskell中,没有办法真正执行该程序,但由于main的类型为IO () main,因此它自己会对这样的程序进行求值,而我们只是在操作上知道Haskell运行时将执行main程序。

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

Mercury采用的方法是使用独特的模式来强​​制io值保持唯一。这就是为什么输入和输出世界分别被声明为io::diio::uo的原因;它是宣称第一个参数的类型为io并且它的模式为di("破坏性输入"的缩写)的简写,而第二个参数的类型为io,其模式为uo("唯一输出"的缩写)。由于io是抽象类型,因此无法构造新的类型,因此满足唯一性要求的唯一方法是始终将io值传递给最多一个调用,还必须返回一个唯一的io值,然后从您调用的最后一个内容中输出最终的io值。

Haskell采用的方法是使用monad接口允许IO monad中的值由纯数据和其他IO值构造,但不会在{{1}上公开任何函数允许你提取"的值来自IO monad的纯数据。这意味着只有合并到IO中的IO值才会执行任何操作,并且这些操作必须正确排序。

之前我曾提到,用纯语言执行main的程序员仍倾向于在操作上思考他们的大多数IO。那么,如果我们只是按照命令式程序员的方式来思考它,为什么要为IO提出一个纯粹的模型呢?最大的优点是,现在所有适用于所有语言的理论/代码/适用于IO代码。

例如,在Mercury中,相当于IO逐个元素地处理列表以构建累加器值,这意味着fold接受一些任意类型的输入/输出变量对作为累加器(这是Mercury标准库中非常常见的模式,这就是为什么我说状态变量语法在其他上下文中经常被证明非常方便而不是IO)。自从"世界" Mercury程序中明确显示为fold类型的值,可以使用io值作为累加器!在Mercury中打印字符串列表就像io一样简单。类似地,在Haskell中,通用monad / functor代码在foldl(print, MyStrings, !IO)值上运行良好。我们得到了很多"更高阶的" IO操作必须在一种语言中专门针对IO实现,该语言通过一些完全特殊的机制来处理IO。

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


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

答案 3 :(得分:4)

我认为我们应该首先阅读这个主题是Tackling the Awkward Squad。 (我没有这样做,我后悔了。) 作者实际上将GHC的IO内部表示形式描述为world -> (a,world)为“有点黑客”。 我认为这种“黑客”意味着一种无辜的谎言。我认为这里有两种谎言:

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

    1. 通过将IO视为类型world -> (a,world)的薄包装器,GHC可以重用IO代码的许多优化,因此这种设计非常实用且经济。
    2. 如果编译器满足某些属性,则可以证明如上实现的IO计算的操作语义是合理的。引用This paper作为证明。
    3. 问题(我想在这里问一下,但是你先问过这个,请原谅我在这里写)是在标准的'懒惰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,在语义上与安德烈亚斯罗斯伯格描述的效果相同:它“重复”世界。由于我们的世界不能以这种方式复制,并且Haskell程序的精确评估顺序几乎是不可预测的 - 我们得到的是一些几乎不受约束和不同步的并发竞争,用于某些宝贵的系统资源,如文件句柄。当然,Ariola&Sabry中从未考虑过这种操作。所以我在这方面不同意安德烈亚斯 - 即使我们将自己限制在标准库的限制范围内,IO monad也没有真正正确地将世界打造成(这就是为什么有些人说懒惰的IO很糟糕)。

答案 4 :(得分:2)

世界就是这个 - 物质的,真实的世界。 (请注意,只有一个。)

忽略限制在CPU和内存中的物理过程,可以对每个函数进行分类:

  1. 那些在物理世界中没有影响的东西(除了短暂的,在CPU和RAM中几乎不可观察的效果)
  2. 那些确实有可观察效果的。例如:在打印机上打印一些东西,通过网络电缆发送电子,发射火箭或移动磁头。
  3. 区别是有点人为的,因为在现实中运行即使是最纯粹的Haskell程序也会具有可观察到的效果,例如:你的CPU越来越热,导致风扇打开。

答案 5 :(得分:1)

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

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

  2. 外层部分:现在让我们说你已经完成了图像处理工具的核心部分,并通过调用具有各种输入的函数并检查输出来测试算法,但这不是你可以发货的东西,如何用户应该使用这个核心部分,没有面子,它只是一堆功能。现在从最终用户的角度来看这个核心usable,你需要构建某种UI,从磁盘读取文件的方式,可能使用一些嵌入式数据库来存储用户首选项,然后列表继续。这种与其他各种东西的交互,这不是您的应用程序的核心概念,但仍然需要使其可用,在FP中称为world

  3. 练习:考虑一下你之前构建的任何应用程序,并尝试将其划分为上面提到的两个部分,希望这会使事情更加清晰。

答案 6 :(得分:1)

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

fprintf file "hello world"

有副作用 - 文件已添加"hello world"

这与像

这样的纯功能代码相反
let add a b = a + b

没有副作用