Haskell Cont monad如何以及为何起作用?


77

这是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

您能解释一下它如何运作以及为什么起作用吗?到底在做什么


1
您熟悉CPS吗?如果不是这样,您应该寻找有关此的教程(我自己也不知道),因为这样可以使Cont更加容易。
约翰L

Answers:


120

关于延续monad的第一件事是,从根本上讲,它根本没有任何事情。这是真的!

通常,延续的基本思想是它代表计算的其余部分。假设我们有一个这样的表达:foo (bar x y) z。现在,仅提取括号部分,bar x y这是总表达式的一部分,但不仅仅是我们可以应用的函数。相反,它的东西,我们需要一个功能应用。因此,在这种情况下,我们可以将“其余的计算”称为\a -> foo a z,我们可以将其bar x y应用于重构完整的表单。

现在,碰巧“其余的计算”这一概念很有用,但是处理起来很尴尬,因为它不在我们正在考虑的子表达式之外。为了使事情更好,我们可以将事情由内而外:提取我们感兴趣的子表达式,然后将其包装在一个函数中,该函数接受一个表示其余计算的参数:\k -> k (bar x y)

这个修改后的版本为我们提供了很大的灵活性-不仅从其上下文中提取了子表达式,而且还使我们能够在子表达式本身内操纵外部上下文。我们可以将其视为一种暂停的计算,从而使我们可以对接下来发生的事情进行明确控制。现在,我们如何概括这一点?好吧,子表达式几乎没有什么变化,所以我们只需要用一个由内而外的函数的参数替换它,\x k -> k x换句话说,就是给函数应用程序“ reversed”。我们既可以轻松地编写代码flip ($),也可以添加一些具有异国情调的外语风格并将其定义为运算符|>

现在,将表达式的每一部分转换为这种形式将是简单,繁琐且令人费解的。幸运的是,有更好的方法。作为Haskell程序员,当我们认为在后台上下文中构建计算时,我们认为接下来要说的是,这是monad吗?在这种情况下,答案是肯定的,是的。

为了将其变成单子,我们从两个基本的构建块开始:

  • 对于monad m,type的值m a表示可以a在monad的上下文中访问type的值。
  • 我们“暂停计算”的核心是翻转函数应用程序。

a在这种情况下访问某种类型的东西意味着什么?这只是意味着,对于某些值x :: a,我们已经应用flip ($)到它x,给我们一个函数,该函数采用一个接受类型为实参的a函数,然后将该函数应用于x。假设我们有一个暂挂的计算,它持有type的值Bool。这给我们什么类型?

> :t flip ($) True
flip ($) True :: (Bool -> b) -> b

因此对于暂停的计算,该类型m a适用于(a -> b) -> b...,这可能是一个反高潮,因为我们已经知道了的签名Cont,但现在让我很幽默。

这里要注意的一个有趣的事情是,一种“逆转”也适用于monad的类型:Cont b a表示一个函数,该函数接受一个函数a -> b并求值为b。由于延续表示计算的“未来”,因此a签名中的类型在某种意义上表示“过去”。

因此,更换(a -> b) -> bCont b a,有什么单子类型为我们的反向功能的应用程序的基本构建块?a -> (a -> b) -> b转换为与a -> Cont b a...具有相同的类型签名return,实际上,这正是它的本质。

从现在开始,几乎所有内容都直接从类型中掉了出来:>>=除了实际的实现之外,基本上没有明智的实现方法。但是它实际上在做什么呢?

在这一点上,我们回到我最初所说的:延续monad实际上并没有任何事情。类型的东西Cont r a是平凡只相当于类型的东西a,只需通过提供id作为参数传递给暂停计算。这可能会引起一个问题:如果Cont r a是单声道,但转换是否如此琐碎,难道不是a一个人应该是单声道吗?当然,这并不能正常工作,因为没有类型构造函数可以定义为Monad实例,但是要说我们添加了一个琐碎的包装器,例如data Id a = Id a。这确实是一个monad,即标识monad。

什么是>>=对身份的单子呢?类型签名是Id a -> (a -> Id b) -> Id b,相当于a -> (a -> b) -> b,再次是简单函数应用程序。建立了Cont r a与之等效的东西Id a,我们就可以得出在这种情况下,(>>=)也仅仅是功能应用

当然,这Cont r a是一个疯狂的倒置世界,每个人都有山羊胡子,因此实际发生的事情是以令人困惑的方式将周围的事物改组,以便将两个暂挂的计算链在一起成为一个新的暂挂的计算,但实际上,没有任何异常上!将函数应用于参数,等等,这是函数式程序员的第二天。


5
我刚刚在Haskell升级。真是个答案。
clintm '02

6
“仅通过将id作为挂起计算的参数提供,类型Cont ra的事物就等同于类型a的事物。” 但是除非提供a = r,否则您不能提供id,我认为至少应该提到它。
OmarAntolín-Camarena13年

因此,基本上,绑定只是CPS转换的功能应用程序吗?
saolof

1
还要注意在Haskell中的运算符部分,您可以编写flip ($) a($ a)
Reuben Steenekamp

41

这是斐波那契:

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' n c=计算fib n并应用于c结果”?

答案是您要执行以下操作:“计算fib (n-1)并应用于d结果”,其中d x表示“计算fib (n-2)并应用于e结果”,其中e y表示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)

同样,我们可以使用lambda:

fib' 0 = \c -> c 0
fib' 1 = \c -> c 1
fib' n = \c -> fib' (n-1) $ \x ->
               fib' (n-2) $ \y ->
               c (x+y)

要获取实际的斐波那契使用标识: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

尝试factorial n = n * factorial (n-1)使用CPS以尾部递归样式编写。

>>=工作如何?m >>= k相当于

do a <- m
   t <- k a
   return t

使得翻译回来,在相同的样式在fib',你

\c -> m $ \a ->
      k a $ \t ->
      c t

简化\t -> c tc

m >>= k = \c -> m $ \a -> k a c

添加新类型

m >>= k  = Cont $ \c -> runCont m $ \a -> runCont (k a) c

在此页面顶部。这很复杂,但是如果您知道如何在do符号和直接使用之间进行转换,则无需知道>>=!的确切定义。如果您看一下do-block,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 -> m b) -> m bCont (m b) a。有关详细信息,请参见sigfpe的《单子母》。我认为这是一个不错的续集monad教程,尽管可能并非如此。

由于延续和单子在两个方向上都紧密相关,因此我认为适用于单子的东西也适用于延续:只有努力工作才能教给您它们,而不是读一些墨西哥卷饼的比喻或类比。


1
因此,该机器没有调用堆栈,但是允许任意深度的关闭?例如where e y = c (x+y)
Thomas Eding

是。我知道这有点人为。
sdcvvc

18

编辑:文章迁移到下面的链接。

我已经写了一个直接解决该主题的教程,希望对您有用。(它肯定有助于巩固我的理解!)由于太长了,无法轻松地适应Stack Overflow主题,因此我已将其迁移到Haskell Wiki。

请参阅:引擎盖下的MonadCont


9

我认为掌握Contmonad的最简单方法是了解如何使用其构造函数。尽管transformers软件包的实际情况略有不同,但我现在将假定以下定义:

newtype Cont r a = Cont { runCont :: (a -> r) -> r }

这给出:

Cont :: ((a -> r) -> r) -> Cont r a

因此,要构建type的值Cont r a,我们需要给以下函数Cont

value = Cont $ \k -> ...

现在,k它本身具有类型a -> r,并且lambda的主体需要具有type r。显而易见的事情是将ktype应用于值a,并获得type的值r。是的,我们可以这样做,但这实际上只是我们可以做的许多事情之一。请记住,value不需要在中是多态的r,它可以是类型的Cont String Integer或其他具体的东西。所以:

  • 我们可以应用k几个type的值a,并以某种方式组合结果。
  • 我们可以将k类型的值应用于a,观察结果,然后k基于该值决定将其应用于其他对象。
  • 我们可以k完全忽略,而只是r自己产生类型的值。

但是,这一切意味着什么?是什么k最终会被?好吧,在一个do-block中,我们可能会有类似以下的内容:

flip runCont id $ do
  v <- thing1
  thing2 v
  x <- Cont $ \k -> ...
  thing3 x
  thing4

这是有趣的部分:我们可以在脑海中以某种非正式的方式将do块在Cont构造函数出现时一分为二,然后将整个计算的其余部分视为自身的值。但是且慢,什么是取决于什么x是,所以这是一个真正的函数从数值x型的a一些结果值:

restOfTheComputation x = do
  thing3 x
  thing4

事实上,这restOfTheComputation粗略地说什么k了是结束。换句话说,你可以调用k与变成结果的值x您的Cont计算,计算运行的其余部分,然后将r产生的风的方式回到你的lambda作为调用的结果k。所以:

  • 如果您k多次调用,则其余计算将多次运行,并且结果可以按您希望的方式合并。
  • 如果您根本不调用k,则将跳过整个计算的其余部分,而封闭的runCont调用将只返回r您设法合成的任何类型的值。也就是说,除非计算的其他部分在呼唤他们的 k,和摆弄结果...

如果此时您仍然与我在一起,那么应该很容易看到它可能非常强大。为了说明这一点,让我们实现一些标准类型类。

instance Functor (Cont r) where
  fmap f (Cont c) = Cont $ \k -> ...

我们给定了Cont带有x类型为bind结果的值a和一个函数f :: a -> b,并且我们想为一个Cont带有f x类型为bind结果的值b。好吧,要设置绑定结果,只需调用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使其调用来从first中获取函数:

  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打开包装或拆开包装。

这个答案已经很长了,所以我不再赘述ContT(简而言之:它与Cont!完全一样!唯一的区别在于类型构造函数的类型,所有实现都相同)或callCC(提供了一种忽略的便捷方法k,实现了子块的提前退出。

对于一个简单而合理的应用程序,请尝试Edward Z. Yang的博客文章,该文章实现了带标签的break并继续进行for循环


1

试图补充其他答案:

嵌套的lambda可读性极差。这就是为什么让...在...和...在...存在的地方通过使用中间变量来摆脱嵌套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函数。

要在原始问题中获得模糊的实现,请先用Cont $ runCont(ka)替换ka,然后用Cont $ \ c-> runCont(ka)c替换ka

现在,我们可以将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),我们回到原始的混淆表达式。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.