为什么 Haskell 没有一个专门处理输入的 I Monad(与 IO Monad 相比,只处理输入)?

22

从概念上讲,执行输出的计算与仅执行输入的计算非常不同。某种意义上,后者更加纯粹。

就我而言,我希望有一种方法将程序中仅涉及输入的部分与可能实际写出内容的部分分开。

那么,为什么没有仅限输入的Monad呢?

是否有任何原因不能使用I Monad(和O Monad),并将其组合成IO Monad?

编辑:我主要指读取文件作为输入,而不是与用户交互。这也是我的使用情况,我可以假设输入文件在程序执行期间不会改变(否则,获得未定义行为是可以接受的)。


3
没有更纯或者不够纯这一说法。它要么是纯的,要么不是。输出并不是纯的,如果你期望 print 函数返回什么,那就有些困难了。print :: String -> () 不起作用:如果你评估类似 runAll [print "foo", print "foo"] 这样的表达式,由于 Haskell 是惰性求值的,你可能只会打印一个 "foo" ,因为一旦你评估了一个带有特定参数的纯函数,你就不需要再次评估它了。 (还可能发生其他“疯狂”的事情:乱序打印、根本什么也没有打印...) - R. Martinho Fernandes
3
我指的是,至少在概念上,您可以将所有读取输入文件的函数重写为具有一个巨大的“Map String ByteStream”的隐式参数,该映射将文件名映射到文件内容。这是纯粹的。 - luispedro
5个回答

21
我不同意bdonlan的回答。虽然输入和输出都不是更“纯”的,但它们确实非常不同。批评IO作为单一的“罪恶之地”,将所有效果都塞在一起是完全有效的,这使得确保某些属性更加困难。例如,如果您有许多函数,您只知道从某些内存位置读取,并且永远不会导致这些位置被更改,那么可以重新排序它们的执行将是很好的。或者,如果您有一个使用forkIO和MVars的程序,那么基于其类型,了解它也没有读取/ etc / passwd将是很好的。
此外,人们可以以除了堆叠转换器之外的方式组合单调效果。您无法对所有单子(仅对自由单子)执行此操作,但对于像这样的情况,这是您真正需要的全部内容。例如,iospec包提供了IO的纯规范 - 它不分离读取和写入,但它确实将它们与STM,MVars,forkIO等分开。

http://hackage.haskell.org/package/IOSpec

如何干净地组合不同的单子的关键思想在Data Types a la Carte论文中有描述(非常好的阅读材料,具有很大的影响力,强烈推荐等等)。


1
我们已经为Capabilities(也基于Data Types a la Carte)创建了一个Haskell库,它允许用户通过组合不同的能力来创建受限类型:类型为Restr (Stdin :+: W) ()的操作只能从标准输入读取并写入文件,不能进行其他操作。然后,您可以创建任意新的能力以满足您的需求。 - Iceland_jack

12
'Input' 的部分在IO单子中既是输入也是输出。如果你消耗了一行输入,那么"你已经消耗了这个输入"的事实就会被传达到外部,并且被记录为不纯的状态(也就是说,以后你不会再次消耗同一行);它和 putStrLn 一样,是一个输出操作。此外,输入操作必须与输出操作相对应;这再次限制了你能够将两者分离的程度。
如果你想要一个纯的只读单子,你应该使用reader单子
话虽如此,你似乎有点糊涂关于组合单子的能力。尽管你可以组合两个单子(假设其中一个是单子变换器)并获得某种混合语义,你必须能够运行结果。也就是说,即使你能够定义 IT (OT Identity) r,你如何运行它呢?在这种情况下,你没有根IO单子,因此主函数必须是纯函数。这意味着你会得到从纯上下文中得到非纯效果的荒谬结果,因为你无法运行它。
换句话说,IO单子的类型必须是固定的。它不能是用户可选择的转换类型,因为它的类型被固定到了main中。当然,你可以将其命名为I (O Identity),但你不会得到任何好处;O (I Identity) 将会是一个无用的类型,I []O Maybe也一样,因为你永远无法运行其中的任何一个。
当然,如果IO保持作为基本的IO单子类型,你可以定义像下面这样的例程:
runI :: I Identity r -> IO r

这个方案可以运行,但是你很难在这个 I Monad 下面添加任何内容,并且你并没有从中得到太多的复杂性收益。反正,一个基于列表的单元格被转换为输出单元格的意义是什么?


2
纯函数的计算可能需要时间和消耗内存,这些都会“传递到外部”;这是否会使函数不那么纯呢? - Reid Barton
@Reid,这是一个实现细节。消耗的时间和内存被意外地传递到外部,但是使用纯代码并不能可靠地预测或控制将要传递什么(除了限制最大内存/CPU使用之外,我想)。使用显式输入原语,您可以保证效果的非常特定的排序,这需要来自I单子的特定保证,这些保证在纯代码中明确不存在。 - bdonlan
1
我的观点是,“外部可观察”的概念在很大程度上是任意的,并且是根据情况量身定制的。例如,如果您只读取在程序执行期间不会更改的静态数据文件,则将它们的输入视为纯粹是完全合理的——就像内存和处理器时间的消耗一样。 - Reid Barton
@Reid,“在这种情况下,可外部观察”是指程序可以确定性地控制向外部提供输出的事物。这种确定性控制在纯代码中不存在;然而,在IO单子的输入和输出代码中都存在。 - bdonlan
如果您消耗了一行输入,那么您已经消耗了该输入的事实会被传达到外部 - 是的,这就是当前的工作方式,但它并不一定要这样工作。 - user253751

3
当你获取输入时,你会造成副作用,这些副作用会同时改变外部世界的状态(输入被消耗)和你的程序状态(输入被使用)。当你输出时,你会造成只改变外部世界状态的副作用(产生输出);输出本身并不改变你的程序状态。因此,你实际上可以说OI更加"纯粹"。
除了输出实际上确实会改变程序的执行状态(它不会一遍又一遍地重复相同的输出操作;它必须有某种状态改变才能继续前进)。这完全取决于你如何看待它。但是将输入和输出的脏乱混在同一个单子里要简单得多。任何有用的程序都将输入和输出结合起来。你可以将使用的操作分类为其中之一,但我没有看到使用类型系统的令人信服的理由。
无论你是干扰外部世界还是不干扰。

获取输入并不一定会改变世界状态。输入可以被多次使用。 - user253751

2
简短回答:IO 不是 I/O。 如果您愿意,其他人可以提供更长的答案。

0

我认为纯代码和非纯代码之间的划分有些主观。这取决于您在哪里设置障碍。Haskell的设计者决定明确地将语言的纯函数部分与其他部分区分开来。

因此,我们有IO monad,它包含所有可能的影响(如磁盘读写、网络、内存访问等)。语言通过返回类型强制执行清晰的分离。这引发了一种思考方式,将一切划分为纯和非纯。

如果涉及信息安全,自然而然地会将读取和写入分开。但对于Haskell最初的目标——成为标准的惰性纯函数语言而言,这是过度。


由于在Plan 9/Inferno中所有这些效果都被混合在一起,我怀疑Haskell的“corral”并不像它看起来那么随意。虽然,如果能使用RNG忠实地模拟文件输出,那将是一项令人惊叹的工程壮举。相反的情况似乎很简单。 - BMeph

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接