传递代表“世界”的值是在纯声明式编程中创建用于执行 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!”一样,但在其中。已打印到终端,并且在此期间发生的任何其他事情InitialWorld
也main
被包含在内。然后它传递TmpWorld
给nl
,它返回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_X
和IO_Y
,其中X
和Y
部分由编译器自动填充,以便状态变量按照编写顺序通过目标“线程化”。这不仅在 IO 的上下文中很有用,顺便说一句,状态变量是 Mercury 中非常方便的语法糖。
因此,程序员实际上倾向于将其视为一系列步骤(取决于并影响外部状态),这些步骤按照编写顺序执行。!IO
几乎变成了一个魔术标签,只标记了适用的调用。
在 Haskell 中,IO 的纯模型是 monad,“hello world”程序如下所示:
main :: IO ()
main = putStrLn "Hello world!"
解释IO
monad 的一种方法类似于State
monad。它会自动将状态值通过线程,并且 monad 中的每个值都可以依赖或影响该状态。只有在IO
状态被线程化的情况下才是整个宇宙,就像在 Mercury 程序中一样。使用 Mercury 的状态变量和 Haskell 的 do 表示法,这两种方法最终看起来非常相似,“世界”以一种尊重源代码中编写调用顺序的方式自动穿过,=但仍然具有IO
明确的操作标记。
sacundim
正如's answer中很好解释的那样,将Haskell 的monad解释IO
为 IO-y 计算模型的另一种方法是想象这putStrLn "Hello world!"
实际上不是“宇宙”需要线程化的计算,而是它putStrLn "Hello World!"
本身描述可以采取的 IO 操作的数据结构。基于这种理解,IO
monad 中的程序正在使用纯 Haskell 程序在运行时生成命令式程序。在纯 Haskell 中,没有办法实际执行该程序,但由于main
is 类型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
允许您从IO
monad 中“提取”纯数据的值的函数。这意味着只有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从操作上考虑程序意味着根据计算机在执行它们时将执行的操作来理解它们。