`IO` 的 >>= 到底是如何工作的?

How exactly does `IO`'s >>= work under the hood?

在向初学者解释像 Monads 这样的概念时,我认为避免使用任何复杂的 Haskell 术语或任何类似范畴论的术语会很有帮助。我认为解释它的一个很好的方法是为函数 a -> m b 建立一个像 Maybe:

这样简单的类型的动机
data Maybe = Just a | Nothing

全有或全无。但是如果我们有一些函数 f :: a -> Maybe bg :: b -> Maybe c 并且我们想要一个很好的方法来组合它们呢?

andThen :: Maybe a -> (a -> Maybe b) -> Maybe b
andThen Nothing _ = Nothing
andThen (Just a) f = f a

comp :: Maybe Text
comp = f a `andThen` g
  where f g a = etc...

然后你可以说 andThen 可以定义为多种类型(最终形成 monad 类型类)......下一个对我来说引人注目的例子是 IO。但是您如何为 IO 定义 andThen 自己呢?这让我想到了我自己的问题......我对 andThenIO 的天真实现是这样的:

andThenIO :: IO a -> (a -> IO b) -> IO b
andThenIO io f = f (unsafePerformIO io) 

但我知道当您 >>= 使用 IO 时,实际情况并非如此。查看 GHC.BasebindIO 的实现,我看到了这个:

bindIO :: IO a -> (a -> IO b) -> IO b
bindIO (IO m) k = IO (\ s -> case m s of (# new_s, a #) -> unIO (k a) new_s)

对于 unIO 这个:

unIO :: IO a -> (State# RealWorld -> (# State# RealWorld, a #))
unIO (IO a) = a

这似乎与 ST monad 有某种关系,尽管我对 ST 的了解几乎为零……我想我的问题是,我的天真实现之间到底有什么区别,以及使用 ST 的实现?我天真的实现是否对这个例子有用,或者没有给出它实际上并没有在幕后进行(并且可能是一个误导性的解释)

(注意:这回答了 “如何向初学者解释 IO 如何工作” 部分。它不试图解释 RealWorld# hack GHC uses.确实后者不是很好的介绍方式IO.)

有很多方法可以向初学者解释 IO monad。这很难,因为不同的人在心理上将单子与不同的想法联系起来。您可以使用类别理论,或将它们描述为可编程的分号,甚至是 burritos.

正因为如此,当我过去尝试这样做时,我通常会尝试多种方法,直到其中一种方法“点击”到学习者的思维模式中。了解他们的背景很有帮助。

命令式关闭

例如,当学习者已经熟悉一些带有闭包的命令式语言时,例如JavaScript,我倾向于告诉他们,他们可以假装 Haskell 程序的全部意义在于生成一个 JavaScript 闭包,然后 运行 使用 JavaScript 实施。在这个虚构的解释中,IO T 类型代表封装了 JavaScript 闭包的不透明类型,当 运行 时,将产生 T 类型的值,可能在造成一些副作用之后——正如 JavaScript 可以做到的那样。

因此,值 f :: IO String 可以实现为

let f = () => {
    print("side effect");
    return "result";
    };

g :: IO ()可以实现为

let g = () => {
    print("g here");
    return {};
    };

现在,假设有这样的 f 闭包,如何从 Haskell 调用它?好吧,不能直接这样做,因为 Haskell 想要控制副作用。也就是说,我们不能做f ++ "hi"f() ++ "hi".

相反,要“调用闭包”,我们可以将其绑定到 main

main :: IO ()
main = g

确实,main 是由整个 Haskell 程序生成的 JavaScript 闭包,它将被 Haskell 实现调用。

OK,现在问题变成了:“如何调用多个闭包?”。为此,可以引入 >> 并假装它实现为

function andThenSimple(f, g) {
   return () => {
      f();
      return g();
      };
}

或者,对于 >>=

function andThen(f, g) {
   return () => {
      let x = f();
      return g(x)();  // pass x, and then invoke the resulting closure
      };
}

return更容易

function ret(x) {
   return () => x;
}

这些函数需要花一些时间来解释,但如果理解闭包,就不难掌握它们。

纯功能性(又名住宿免费)

另一种选择是保持一切纯净。或者至少尽可能纯净。可以假设 IO a 是定义为

的不透明类型
data IO a
   = Return a
   | Output String (IO a)
   | Input (String -> IO a)
   -- ... other IO operations here

然后假装值 main :: IO () 之后被一些命令式引擎“运行”。像

这样的程序
foo :: IO Int
foo = do
  l <- getLine
  putStrLn l
  putStrLn l
  return (length l)

实际上意味着,根据这个解释,

foo :: IO Int
foo = Input (\l -> Output l (Output l (Return (length l))))

当然在这里 return = Return,定义 >>= 是一个很好的练习。

混杂杂质

忘记 IO、monad 和所有那些东西。可以更好地理解两个简单的概念

a -> b   -- pure function type
a ~> b   -- impure function type

后者是虚构的 Haskell 类型。大多数程序员应该能够对这些类型代表什么有强烈的直觉。

现在,在函数式编程中,我们有柯里化,它是

之间的同构
(a, b) -> c

a -> (b -> c)

经过一番思考,可以看出非纯函数也应该允许一些柯里化。确实可以确信应该存在类似于

的某种同构
(a, b) ~> c
   <===>
a ~> (b ~> c)

稍加思索,甚至可以明白a ~> (b ~> c)中的第一个~>其实是不准确的。当单独传递 a 时,上面的柯里化函数并没有真正执行副作用——它是 b 的传递触发原始未柯里化函数的执行,导致副作用。

因此,考虑到这一点,我们可以将不纯柯里化视为

(a, b) ~> c
   <===>
a -> (b ~> c)
--^^-- pure!

作为一个特例,我们得到同构

(a, ()) ~> c
   <===>
a -> (() ~> c)

此外,由于(a, ())同构于a(这里需要更有说服力),我们可以将柯里化解释为

a ~> c
  <===>
a -> (() ~> c)

现在,如果我们将 () ~> c 洗礼为 IO c,我们得到

a ~> c
  <===>
a -> IO c

啊哈!这告诉我们,我们并不真正需要一般的不纯函数类型a ~> c。只要我们有它的特例 IO c = () ~> c,我们就可以表示(直到同构)任何 a ~> c 函数。

从这里开始,人们可以开始在脑海中描绘出 IO c 应该如何工作,并最终实现其单子结构。本质上,IO c 的这种解释现在与上面给出的利用闭包的解释非常相似。