[英]How and why does the Haskell Cont monad work?
这是 Cont monad 的定义方式:
newtype Cont r a = Cont { runCont :: (a -> r) -> r }
instance Monad (Cont r) where
return a = Cont ($ a)
m >>= k = Cont $ \c -> runCont m $ \a -> runCont (k a) c
你能解释一下这是如何以及为什么起作用的吗? 它在做什么?
关于延续 monad,首先要意识到的是,从根本上说,它根本没有真正做任何事情。 这是真的!
一般而言,延续的基本思想是它代表计算的其余部分。 假设我们有一个这样的表达式: foo (bar xy) z
。 现在,只提取括号中的部分bar xy
xy——这是整个表达式的一部分,但它不仅仅是我们可以应用的函数。 相反,我们需要将函数应用于. 因此,在这种情况下,我们可以将“其余计算”称为\\a -> foo az
,我们可以将其应用于bar xy
以重建完整的形式。
现在,碰巧这个“计算的其余部分”的概念很有用,但使用起来很尴尬,因为它是我们正在考虑的子表达式之外的东西。 为了让事情做得更好,我们可以把事情从里到外:提取我们感兴趣的子表达式,然后将它包装在一个函数中,该函数接受一个表示计算其余部分的参数: \\k -> k (bar xy)
。
这个修改后的版本给了我们很大的灵活性——它不仅从它的上下文中提取子表达式,而且它允许我们在子表达式本身内操纵外部上下文。 我们可以将其视为一种暂停计算,让我们可以明确控制接下来发生的事情。 现在,我们如何概括这一点? 好吧,子表达式几乎没有变化,所以让我们用一个由内向外函数的参数替换它,给我们\\xk -> kx
-- 换句话说,无非是函数 application, reversed 。 我们可以很容易地写成flip ($)
,或者添加一点异国情调的外语并将其定义为运算符|>
。
现在,将表达式的每一个部分翻译成这种形式会很简单,尽管很乏味而且非常令人困惑。 幸运的是,有更好的方法。 作为 Haskell 程序员,当我们考虑在后台上下文中构建计算时,我们认为接下来要说的是,这是一个 monad 吗? 在这种情况下,答案是肯定的,是的。
为了把它变成一个 monad,我们从两个基本的构建块开始:
m
, ma
类型的值表示可以访问 monad 上下文中的a
类型值。 在此上下文中访问a
类型a
内容是什么意思? 这只是意味着,对于某些值x :: a
,我们已经将flip ($)
应用于x
,从而为我们提供了一个函数,该函数采用一个a
类型参数的函数,并将该函数应用于x
。 假设我们有一个挂起的计算,其中包含一个Bool
类型的值。 这给了我们什么类型?
> :t flip ($) True
flip ($) True :: (Bool -> b) -> b
所以对于挂起的计算,类型ma
计算为(a -> b) -> b
... 这可能是一个反高潮,因为我们已经知道Cont
的签名,但现在让我幽默一下。
这里要注意的一件有趣的事情是,一种“反转”也适用于 monad 的类型: Cont ba
表示一个函数,它采用函数a -> b
并计算为b
。 由于延续代表计算的“未来”,因此签名中的类型a
在某种意义上代表“过去”。
那么,将(a -> b) -> b
替换为Cont ba
,我们反向函数应用程序的基本构建块的单子类型是什么? a -> (a -> b) -> b
转换为a -> Cont ba
...与return
相同的类型签名,事实上,这正是它的含义。
从现在开始,几乎所有的东西都直接从类型中脱离出来:除了实际的实现之外,基本上没有明智的方法来实现>>=
。 但它实际上在做什么?
在这一点上,我们回到我最初所说的:延续 monad 并没有真正做任何事情。 类型的东西Cont ra
是平凡相当于只需键入一些a
,只需通过提供id
作为参数传递给暂停计算。 这可能会导致人们问,如果Cont ra
是一个 monad 但转换是如此微不足道,那么a
难道不应该也是一个 monad 吗? 当然,这不能正常工作,因为没有类型构造函数可以定义为Monad
实例,但是假设我们添加了一个简单的包装器,例如data Id a = Id a
。 这确实是一个 monad,即身份 monad。
>>=
对身份 monad 有什么作用? 类型签名为Id a -> (a -> Id b) -> Id b
,相当于a -> (a -> b) -> b
,又是简单的函数应用。 确定Cont ra
与Id a
等价之后,我们也可以推断出,在这种情况下, (>>=)
只是函数 application 。
当然, Cont ra
是一个疯狂的颠倒世界,每个人都有山羊胡子,所以实际发生的事情涉及以令人困惑的方式将事物打乱,以便将两个暂停的计算链接到一个新的暂停计算中,但本质上,实际上没有任何东西不寻常的事情! 将函数应用于参数,呵呵,函数式程序员生活中的又一天。
这是斐波那契数列:
fib 0 = 0
fib 1 = 1
fib n = fib (n-1) + fib (n-2)
想象一下你有一台没有调用栈的机器——它只允许尾递归。 如何在那台机器上执行fib
? 您可以轻松地将函数重写为线性工作,而不是指数时间,但这需要一点洞察力,而且不是机械的。
使其尾递归的障碍是第三行,其中有两个递归调用。 我们只能进行一次调用,它也必须给出结果。 这是延续进入的地方。
我们将让fib (n-1)
接受额外的参数,这将是一个函数,指定在计算其结果后应该做什么,称为x
。 当然,它会向其中添加fib (n-2)
。 所以:要计算fib n
你计算fib (n-1)
,如果你调用结果x
,你计算fib (n-2)
,之后,如果你调用结果y
,你返回x+y
。
换句话说,你必须告诉:
如何进行以下计算:“ fib' nc
= 计算fib n
并将c
应用于结果”?
答案是您执行以下操作:“计算fib (n-1)
并将d
应用于结果”,其中dx
表示“计算fib (n-2)
并将e
应用于结果”,其中ey
表示c (x+y)
。 在代码中:
fib' 0 c = c 0
fib' 1 c = c 1
fib' n c = fib' (n-1) d
where d x = fib' (n-2) e
where e y = c (x+y)
同样,我们可以使用 lambdas:
fib' 0 = \c -> c 0
fib' 1 = \c -> c 1
fib' n = \c -> fib' (n-1) $ \x ->
fib' (n-2) $ \y ->
c (x+y)
要获得实际的斐波那契数,请使用 identity: fib' n id
。 您可以认为fib (n-1) $ ...
行将其结果x
传递给下一个。
最后三行闻起来像一个do
块,事实上
fib' 0 = return 0
fib' 1 = return 1
fib' n = do x <- fib' (n-1)
y <- fib' (n-2)
return (x+y)
根据 monad Cont
的定义,直到新类型都是相同的。 注意差异。 有\\c ->
开头,而不是x <- ...
... $ \\x ->
和c
而不是return
。
尝试使用 CPS 以尾递归样式编写factorial n = n * factorial (n-1)
。
>>=
如何工作的? m >>= k
等价于
do a <- m
t <- k a
return t
以与fib'
相同的风格进行翻译,你会得到
\c -> m $ \a ->
k a $ \t ->
c t
简化\\t -> ct
到c
m >>= k = \c -> m $ \a -> k a c
添加您获得的新类型
m >>= k = Cont $ \c -> runCont m $ \a -> runCont (k a) c
这是在这个页面的顶部。 这很复杂,但是如果您知道如何在do
表示法和直接使用之间进行转换,您就不需要知道>>=
确切定义! 如果您查看 do-blocks,则 Continuation monad 会更加清晰。
单子和延续
如果您查看 list monad 的这种用法...
do x <- [10, 20]
y <- [3,5]
return (x+y)
[10,20] >>= \x ->
[3,5] >>= \y ->
return (x+y)
([10,20] >>=) $ \x ->
([3,5] >>=) $ \y ->
return (x+y)
看起来像继续! 事实上, (>>=)
当您应用一个参数时,类型为(a -> mb) -> mb
,即Cont (mb) a
。 有关解释,请参阅 sigfpe 的所有 monad之母。 我认为这是一个很好的延续 monad 教程,尽管它可能不是这个意思。
由于延续和 monad 在两个方向上都如此密切相关,我认为适用于 monad 的内容也适用于延续:只有努力工作才能教会你它们,而不是阅读一些墨西哥卷饼的比喻或类比。
编辑:文章迁移到下面的链接。
我已经写了一个直接解决这个主题的教程,我希望你会觉得有用。 (它确实帮助巩固了我的理解!)它有点太长,无法舒适地适应 Stack Overflow 主题,所以我已将其迁移到 Haskell Wiki。
请参阅:引擎盖下的 MonadCont
我认为掌握Cont
monad 的最简单方法是了解如何使用其构造函数。 我现在将假设以下定义,尽管transformers
包的实际情况略有不同:
newtype Cont r a = Cont { runCont :: (a -> r) -> r }
这给出:
Cont :: ((a -> r) -> r) -> Cont r a
所以要构建一个Cont ra
类型的值,我们需要给Cont
一个函数:
value = Cont $ \k -> ...
现在, k
本身具有类型a -> r
,并且 lambda 的主体需要具有类型r
。 一个显而易见的事情是将k
应用于 a 类型a
值,并获得r
类型的r
。 我们可以做到这一点,是的,但这实际上只是我们可以做的许多事情之一。 请记住, r
中的value
不必是多态的,它可能是Cont String Integer
类型或其他具体类型。 所以:
k
应用于k
类型a
多个值,并以某种方式组合结果。k
于类型的值a
,观察效果,然后再决定采取k
基于对别的东西。k
并自己生成一个类型为r
的值。 但这一切意味着什么? k
最终是什么? 好吧,在 do-block 中,我们可能有这样的东西:
flip runCont id $ do
v <- thing1
thing2 v
x <- Cont $ \k -> ...
thing3 x
thing4
这是有趣的部分:我们可以,在我们的头脑中,有点非正式地,在Cont
构造函数出现时将 do-block 分成两部分,并将其后的整个计算的其余部分视为一个值本身。 但是且慢,什么是取决于什么x
是,所以真的从一个值函数x
型的a
对一些结果值:
restOfTheComputation x = do
thing3 x
thing4
事实上,这个restOfTheComputation
粗略地说k
最终是什么。 换句话说,您使用一个值调用k
,该值成为您的Cont
计算的结果x
,其余的计算运行,然后产生的r
作为对k
调用的结果返回到您的 lambda 中。 所以:
k
,其余的计算将运行多次,并且结果可以根据您的意愿进行组合。k
,则整个计算的其余部分将被跳过,并且封闭的runCont
调用将返回您设法综合的任何类型r
值。 也就是说,除非计算的其他部分从他们的k
调用您,并弄乱结果......如果此时您还和我在一起,应该很容易看出这可能非常强大。 为了说明这一点,让我们实现一些标准类型类。
instance Functor (Cont r) where
fmap f (Cont c) = Cont $ \k -> ...
我们得到了一个带有类型a
绑定结果x
的Cont
值和一个函数f :: a -> b
,我们想要创建一个带有类型b
绑定结果fx
的Cont
值。 好吧,要设置绑定结果,只需调用k
...
fmap f (Cont c) = Cont $ \k -> k (f ...
等等,我们从哪里得到x
? 好吧,它将涉及c
,我们还没有使用它。 记住c
是如何工作的:它得到一个函数,然后用它的绑定结果调用该函数。 我们想调用我们的函数,并将f
应用于该绑定结果。 所以:
fmap f (Cont c) = Cont $ \k -> c (\x -> k (f x))
多田! 接下来, Applicative
:
instance Applicative (Cont r) where
pure x = Cont $ \k -> ...
这个很简单。 我们希望绑定结果是我们得到的x
。
pure x = Cont $ \k -> k x
现在, <*>
:
Cont cf <*> Cont cx = Cont $ \k -> ...
这有点棘手,但使用与 fmap 基本相同的想法:首先从第一个Cont
获取函数,通过为它创建一个 lambda 来调用:
Cont cf <*> Cont cx = Cont $ \k -> cf (\fn -> ...
然后从第二个中获取值x
,并使fn x
成为绑定结果:
Cont cf <*> Cont cx = Cont $ \k -> cf (\fn -> cx (\x -> k (fn x)))
Monad
大致相同,但需要runCont
或 case 或 let 来解压 newtype。
这个答案已经很长了,所以我不会进入ContT
(简而言之:它与Cont
完全相同!唯一的区别在于类型构造函数的类型,所有内容的实现都是相同的)或callCC
(一个有用的组合器,提供了一种方便的方法来忽略k
,实现从子块的提前退出)。
对于一个简单而合理的应用程序,请尝试 Edward Z. Yang 的博客文章实现 标记为 break 并在 for 循环中继续。
试图补充其他答案:
嵌套的 lambda 表达式的可读性很差。 这正是 let... in... 和 ... where ... 存在的原因,以通过使用中间变量摆脱嵌套的 lambda。 使用这些,绑定实现可以重构为:
newtype Cont r a = Cont { runCont :: (a -> r) -> r }
instance Monad (Cont r) where
return a = Cont ($ a)
m >>= k = k a
where a = runCont m id
希望这能让正在发生的事情变得更清楚。 使用延迟应用返回实现框值。 使用 runCont id 将 id 应用于装箱值,它返回原始值。
对于可以简单地拆箱任何装箱值的任何 monad,通常有一个简单的 bind 实现,即简单地拆箱值并对其应用 monadic 函数。
要获得原始问题中的混淆实现,首先将 ka 替换为 Cont $ runCont (ka) ,而后者又可以替换为 Cont $ \\c-> runCont (ka) c
现在,我们可以将 where 移动到子表达式中,这样我们就剩下
Cont $ \c-> ( runCont (k a) c where a = runCont m id )
括号内的表达式可以脱糖为 \\a -> runCont (ka) c $ runCont m id。
最后,我们使用 runCont 的属性,f (runCont mg) = runCont m (fg),我们回到原始的混淆表达式。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.