Haskell中的monadic IO构造只是一个约定吗?

Kon*_*ele 39 haskell

Haskell中的monadic IO构造只是一个约定,还是有一个实现原因呢?

你能不能只用FFI进入libc.so而不是你的IO,并跳过IO Monad组件?

无论如何它会起作用,或者结果是不确定的,因为Haskell评估懒惰或其他东西,比如GHC是IO Monad的模式匹配,然后以特殊方式或其他方式处理它.

真正的原因是什么?最后你最终会产生副作用.那么为什么不这么简单呢?

Ale*_*ing 69

是的,monadic I/O是Haskell懒惰的结果.但具体来说,monadic I/O是Haskell 纯粹的结果,这对于懒惰语言是可预测的实际上是必要的.

这很容易通过一个例子来说明.想象一下哈斯克尔不是纯粹的,但它仍然是懒惰的.它不是putStrLn具有类型String -> IO (),而是简单地具有类型String -> (),并且它将字符串作为副作用打印到stdout.这样做的麻烦在于,这只会在putStrLn实际调用时发生,而在惰性语言中,仅在需要结果时调用函数.

这是麻烦:putStrLn生产().看一下类型的值()是没用的,因为()意思是"无聊".这意味着该程序可以达到您的预期:

main :: ()
main =
  case putStr "Hello, " of
    () -> putStrLn " world!"

-- prints “Hello, world!\n”
Run Code Online (Sandbox Code Playgroud)

但我认为你可以同意编程风格很奇怪.case ... of但是,这是必要的,因为它强制putStr通过匹配来评估呼叫().如果你稍微调整程序:

main :: ()
main =
  case putStr "Hello, " of
    _ -> putStrLn " world!"
Run Code Online (Sandbox Code Playgroud)

...现在它只打印world!\n,并且根本不评估第一个调用.

然而,这实际上变得更糟,因为一旦你开始尝试进行任何实际的编程,它就变得更难预测.考虑这个程序:

printAndAdd :: String -> Integer -> Integer -> Integer
printAndAdd msg x y = putStrLn msg `seq` (x + y)

main :: ()
main =
  let x = printAndAdd "first" 1 2
      y = printAndAdd "second" 3 4
  in (y + x) `seq` ()
Run Code Online (Sandbox Code Playgroud)

这个程序打印出来first\nsecond\n还是second\nfirst\n?在不知道(+)评估其论点的顺序的情况下,我们不知道.在Haskell中,评估顺序甚至不总是定义明确,因此完全有可能实现两个效果的顺序实际上完全无法确定!

在具有明确定义的评估顺序的严格语言中不会出现此问题,但在像Haskell这样的惰性语言中,我们需要一些额外的结构来确保副作用是(a)实际评估和(b)以正确的顺序执行.Monads碰巧是一个优雅地提供必要结构来强制执行该命令的接口.

这是为什么?那怎么可能呢?好吧,monadic接口在签名中提供了数据依赖的概念>>=,它强制执行明确定义的评估顺序.Haskell的实现IO是"魔术",从某种意义上说它是在运行时实现的,但是monadic接口的选择远非任意.在纯语言中编码顺序动作的概念似乎是一种相当好的方法,它使得Haskell可以在不牺牲可预测的效果排序的情况下保持懒惰和引用透明.

值得注意的是,monad不是以纯粹方式编码副作用的唯一方法 - 事实上,从历史上看,它们甚至不是Haskell处理副作用的唯一方式.不要误以为monad只用于I/O(它们不是),只在懒惰的语言中有用(即使用严格的语言它们对于保持纯度也很有用),只能用于纯语言(很多东西都是有用的monad,不仅仅是为了强制执行纯度),或者你需要monad来做I/O(你没有).不过,他们确实在Haskell中为这些目的做得很好.


†关于这一点,西蒙佩顿琼斯曾经指出,"懒惰使你诚实"纯洁.

  • 我必须(稍微)不同意monadic IO < - > laziness的关联.从理论上讲,懒惰的语言可能是不纯的,并允许副作用.那里没有任何内在的"错误",或者至少它与一种急切的不纯语言一样错误.然而,主要问题是 - 正如您所展示的那样 - 评估顺序变得非常重要.即使我们在语义中修复它(牺牲许多优化),人类也很难预测副作用的顺序.因此,语言实际上变得无法使用.我想这可能就是"有效必要"的意思. (3认同)
  • 此外,我认为你应该添加一个说明,澄清一个急切的语言,monadic IO也会有很多意义.虽然在一个渴望不纯的世界中的副作用更易于人类理解,但纯粹热切的语言仍然可以选择使用monadic IO来保持纯度. (2认同)
  • @Konrad 从某种意义上说,副作用从代码中并不明显。您可以将 IO monad 中的值视为纯值,实际上,如果函数产生这样的值,您可以评估该值,但不要将其扔到 main 中,副作用不会执行。您可以将 main 视为返回一系列操作供运行时执行,然后它就会执行这些操作。Main 仍然是完全纯净的。- 另外,您可能想看看 Writer monad,它本质上做同样的事情,但它甚至“看起来”是纯粹的。 (2认同)
  • @Alphonse23,这些示例不是有效的 Haskell,它们是 Haskell 的想象方言的演示,其中 IO 不是单子的。 (2认同)
  • @chi 我对你的两个观点进行了一些澄清。是的,我想说这个答案的“全部要点”,或者至少是大部分,是为了证明为什么惰性语言中的杂质很难推理。 (2认同)
  • @BenjaminHodgson是的,我在最后链接的另一个答案(我写的!)提到了这一点,但我编辑了这个答案以明确地注明它。 (2认同)
  • @KonradEisele 这取决于你如何看待事物。例如,考虑[这个答案,它描述了“IO”操作如何真正完全纯粹地评估](/sf/answers/2939627071/),它暗示了这样一个概念:从某个角度看来,Haskell**真的**纯粹,甚至包括`IO`!诀窍在于“IO”操作是对要执行的副作用计算的描述,由 GHC 运行时解释。当然,另一种解释是“IO”是一种副作用、命令式、嵌入式 DSL。有时考虑这两种解释是有用的。 (2认同)

Mic*_*mza 24

您是否可以将FFI转换为libc.so而不是执行IO并跳过IO Monad的事情?

取自https://en.wikibooks.org/wiki/Haskell/FFI#Impure_C_Functions,如果将FFI函数声明为纯(因此,不引用IO),则

GHC认为计算两次纯函数结果没有意义

这意味着函数调用的结果被有效地缓存.例如,声明外部不纯伪随机数生成器返回a的程序CUInt

{-# LANGUAGE ForeignFunctionInterface #-}

import Foreign
import Foreign.C.Types

foreign import ccall unsafe "stdlib.h rand"
  c_rand :: CUInt

main = putStrLn (show c_rand) >> putStrLn (show c_rand)
Run Code Online (Sandbox Code Playgroud)

每次调用都返回相同的东西,至少在我的编译器/系统上:

16807
16807
Run Code Online (Sandbox Code Playgroud)

如果我们更改声明以返回a IO CUInt

{-# LANGUAGE ForeignFunctionInterface #-}

import Foreign
import Foreign.C.Types

foreign import ccall unsafe "stdlib.h rand"
  c_rand :: IO CUInt

main = c_rand >>= putStrLn . show >> c_rand >>= putStrLn . show
Run Code Online (Sandbox Code Playgroud)

然后这导致(可能)每个调用返回一个不同的数字,因为编译器知道它是不纯的:

16807
282475249
Run Code Online (Sandbox Code Playgroud)

因此,无论如何,您都必须使用IO来调用标准库.

  • @tomsmeding,在 IO monad 之外,编译器可以自由调用 c_rand 一次并重用结果或为每次调用调用它,因此结果取决于实现。如果 c_rand 是纯的,因此每次都返回相同的结果,这不会对结果产生任何影响,但因为它是不纯的,所以优化会产生影响。 (2认同)

luq*_*qui 12

假设使用FFI我们定义了一个函数

c_write :: String -> ()
Run Code Online (Sandbox Code Playgroud)

这取决于它的纯度,因为每当它的结果被强制它打印字符串.因此,我们不会遇到Michal的答案中的缓存问题,我们可以定义这些函数以进行额外的()论证.

c_write :: String -> () -> ()
c_rand :: () -> CUInt
Run Code Online (Sandbox Code Playgroud)

在实现级别上,只要CSE不是过于激进(它不在GHC中,因为它可能导致意外的内存泄漏,它就会发挥作用).既然我们已经用这种方式定义了东西,那么Alexis指出了许多尴尬的使用问题 - 我们可以使用monad解决它们:

newtype IO a = IO { runIO :: () -> a }

instance Monad IO where
    return = IO . const
    m >>= f = IO $ \() -> let x = runIO m () in x `seq` f x

rand :: IO CUInt
rand = IO c_rand
Run Code Online (Sandbox Code Playgroud)

基本上,我们只是将所有Alexis笨拙的使用问题都填充到monad中,只要我们使用monadic接口,一切都保持可预测.从这个意义上说,IO这只是一个约定 - 因为我们可以在Haskell中实现它,没有任何基本的东西.

这是从运营的有利位置.

另一方面,Haskell在报告中的语义仅使用指称语义来指定.而且,在我看来,Haskell具有精确的指称语义这一事实是该语言最美丽和最有用的特性之一,它允许我一个精确的框架来思考抽象,从而精确地管理复杂性.虽然通常的抽象IOmonad没有公认的指称语义(对于我们中的一些人来说),但至少可以想象我们可以为它创建一个指称模型,从而保留了Haskell指称模型的一些好处.但是,我们刚刚给出的I/O形式与Haskell的指称语义完全不兼容.

简单地说,只有两个可区分的值(模数致命错误消息)类型(): ()和⊥.如果我们将FFI视为I/O的基础并且IO仅使用monad"作为约定",那么我们有效地为每种类型添加数十亿个值- 继续具有指称语义,每个值必须与执行的可能性相邻在评估之前的I/O,以及这引入的额外复杂性,我们基本上失去了考虑任何两个不同程序等效的能力,除非在最琐碎的情况下 - 也就是说,我们失去了重构的能力.

当然,因为unsafePerformIO技术上已经是这种情况,高级Haskell程序员也需要考虑操作语义.但大多数时候,包括使用I/O时,我们都可以忘记所有这些并自信地重构,正是因为我们已经了解到,当我们使用时unsafePerformIO,我们必须非常小心地确保它能够很好地运行,它仍然可以提供我们尽可能多地进行指称推理.如果一个函数有unsafePerformIO,我自动给它比普通函数多5或10倍,因为我需要了解有效的使用模式(通常类型签名告诉我需要知道的一切),我需要考虑缓存和竞争条件,我需要考虑我需要多深才能强制其结果等等.这太糟糕了[1].FFI I/O需要同样的谨慎.

总结:是的,这是一个惯例,但如果你不遵循它,那么我们就不能拥有美好的东西.

[1]实际上我认为这很有趣,但是一直考虑所有这些复杂性肯定是不切实际的.