繁体   English   中英

Haskell Cont monad 如何以及为什么工作?

[英]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,我们从两个基本的构建块开始:

  • 对于 monad mma类型的值表示可以访问 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 raId 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 -> ctc

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绑定结果xCont值和一个函数f :: a -> b ,我们想要创建一个带有类型b绑定结果fxCont值。 好吧,要设置绑定结果,只需调用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.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM