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

时间:2018-08-09 15:25:27

标签: haskell io monads

在向初学者解释类似Monad的概念时,我认为避免使用任何复杂的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。但是,您如何自己为andThen定义IO?这引出了我自己的问题……我对andThenIO的幼稚实现就像这样:

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

但是我知道这不是您使用>>= IO时实际发生的情况。看看bindIOGHC.Base的实现,我看到了:

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的了解几乎是零,但这似乎与ST单子有关。我想我的问题是,我的天真的实现与使用ST的实现?我的幼稚实现在给出示例时是否有用,或者是否没有在幕后进行(可能会引起误解)

1 个答案:

答案 0 :(得分:10)

(注意:这回答了“如何解释IO对初学者的作用”。它没有试图解释GHC使用的RealWorld# hack。实际上,后者不是介绍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是JavaScript闭包,它由整个Haskell程序生成,并且将由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;
}

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

纯功能性(又名免费)

另一个选择是保持所有内容纯净。或至少尽可能纯。可以假装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类型。大多数程序员应该对这些类型的含义有很强的直觉。

现在,在函数式编程中,我们遇到了curring,这是之间的同构状态

(a, b) -> c

a -> b -> c

经过一番思考,人们可以看到不纯函数也应该承认一些麻烦。确实可以确信,应该存在一些与

类似的同构
(a, b) ~> c
   <===>
a ~> b ~> c

再三考虑,甚至可以理解~>中的第一个a ~> b ~> c实际上是不正确的。仅当传递a时,上面的curried函数并不会真正产生副作用-传递b会触发原始非curried函数的执行,从而产生副作用。

因此,考虑到这一点,我们可以将curring视为

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

在特定情况下,我们得到同构

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

此外,由于(a, ())a是同构的(此处需要更多说服力),因此我们可以将currying解释为

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

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

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

啊哈!这告诉我们,我们实际上不需要通用的不纯函数类型a ~> c。只要我们有其特殊情况IO c = () ~> c,我们就可以表示(直到同构)任何a ~> c函数。

从这里开始,人们可以对IO c的工作方式产生一种心理印象,并最终实现其单子结构。从本质上讲,IO c的这种解释现在与上面给出的利用闭包的解释非常相似。