首页 > 解决方案 > How exactly does `IO`'s >>= work under the hood?

问题描述

When explaining a concept like Monads to a beginner, I think it is helpful to avoid any complicated Haskell terminology, or anything category theory-like. I think a nice way to explain it is to build up a motivation for the function a -> m b with a straightforward type like Maybe:

data Maybe = Just a | Nothing

It's all or nothing. But what if we have some functions f :: a -> Maybe b and g :: b -> Maybe c and we want a nice way to combine them?

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...

You can then move into saying andThen could be defined for a variety of types (eventually forming the monad typeclass)... a compelling next example to me would be IO. But how would you define andThen for IO yourself? This has lead me to a question of my own... my naive implementation of andThenIO would be like so:

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

But I know this isn't what is actually going on when you >>= using IO. Looking at the implementation of bindIO in GHC.Base I see this:

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)

And for unIO this:

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

Which seems to relate to the ST monad somehow, though my knowledge of ST is next to nothing... I suppose my question is, what exactly is the difference between my naive implementation, and an implementation that uses ST? Is my naive implementation useful given the example or not given it isn't actually going on under the hood (and could be a misleading explanation)

标签: haskelliomonads

解决方案


(注意:这回答了“如何向IO初学者解释如何工作”部分。它并不试图解释RealWorld#GHC 使用的 hack。事实上,后者不是介绍的好方法IO。)

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

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

命令式闭包

例如,当学习者已经熟悉一些带有闭包的命令式语言(例如 JavaScript)时,我倾向于告诉他们,他们可以假装 Haskell 程序的全部目的是生成一个 JavaScript 闭包,然后使用 JavaScript 实现来运行该闭包. 在这个虚构的解释中,IO T类型代表封装 JavaScript 闭包的不透明类型,它在运行时会产生一个 type 的值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"or f() ++ "hi"

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

main :: IO ()
main = g

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

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

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;
}

这些功能需要一段时间来解释,但如果理解闭包,掌握它们并不难。

纯功能(AKA 保持免费)

另一种选择是保持一切纯净。或者至少尽可能纯净。可以假装它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现在与上面给出的利用闭包的解释非常相似。


推荐阅读