在向初学者解释类似Monad
的概念时,我认为避免使用任何复杂的Haskell术语或类似类别理论的方法都是有帮助的。我认为,解释它的一种好方法是使用a -> m b
这样的简单类型为函数Maybe
建立动机:
data Maybe = Just a | Nothing
全有或全无。但是,如果我们有一些功能f :: a -> Maybe b
和g :: 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
时实际发生的情况。看看bindIO
中GHC.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
的实现?我的幼稚实现在给出示例时是否有用,或者是否没有在幕后进行(可能会引起误解)
答案 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
的这种解释现在与上面给出的利用闭包的解释非常相似。