`IO` 的 >>= 到底是如何工作的?
How exactly does `IO`'s >>= work under the hood?
在向初学者解释像 Monad
s 这样的概念时,我认为避免使用任何复杂的 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
。但是您如何为 IO
定义 andThen
自己呢?这让我想到了我自己的问题......我对 andThenIO
的天真实现是这样的:
andThenIO :: IO a -> (a -> IO b) -> IO b
andThenIO io f = f (unsafePerformIO io)
但我知道当您 >>=
使用 IO
时,实际情况并非如此。查看 GHC.Base
中 bindIO
的实现,我看到了这个:
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
的这种解释现在与上面给出的利用闭包的解释非常相似。
在向初学者解释像 Monad
s 这样的概念时,我认为避免使用任何复杂的 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
。但是您如何为 IO
定义 andThen
自己呢?这让我想到了我自己的问题......我对 andThenIO
的天真实现是这样的:
andThenIO :: IO a -> (a -> IO b) -> IO b
andThenIO io f = f (unsafePerformIO io)
但我知道当您 >>=
使用 IO
时,实际情况并非如此。查看 GHC.Base
中 bindIO
的实现,我看到了这个:
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
的这种解释现在与上面给出的利用闭包的解释非常相似。