Continuation 与 Monad

lsdsjylsdsjy

结构

Monad 与 Continuation 都用于表示计算。用两个例子可以看到它们的结构非常相似:

foo :: Maybe Int
foo =
    Just 5 >>= \x ->
    Just 4 >>= \y ->
    return $ x + y
add :: Int -> Int -> (Int -> Int) -> Int
add x y k = k (x + y)

bar :: Int
bar =
    add 1 2 $ \x ->
    add x 4 $ \y ->
    y

两者都有类似于 bind 的操作:Monad 中,>>= 用于将 context 中的值绑定到另一个函数的参数;而 CPS 里,函数主动将当前计算的结果传递给 continuation。

Monad of continuation

看到结构上的相似性之后我们可以尝试定义与 continuation 有关的 monad。出于历史原因,大多数文章都用 Scheme 介绍 continuation,而在 Haskell 这样的静态类型语言中 continuation 需要被赋予类型。显然,continuation 可以简单地表示为函数:a -> Answer 代表接受 a 类型的值的 continuation,而 Answer 则是计算的最终结果的类型。而 Haskell 定义的 Cont monad 形式如下:

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

Cont r a 代表一个 CPS 计算,其中 a -> r 表示一个 continuation, r 表示最终结果的类型(即 continuation 返回的类型),而 a 表示该计算本身的结果,它会将某个 a 类型的值传递给 continuation。只有当外界给它传递一个 continuation 时它才会将结果进一步传递,某种程度上这也是一种延迟计算(suspended computation),类似于 lazy-evalution 中的 thunk。看两个简单的例子:

calculateLength :: [a] -> Cont r Int
calculateLength l = return (length l)

double :: Int -> Cont r Int
double n = return (n * 2)

它们对应的 Lisp 代码如下:

(define (calculate-length xs k)
  (k (length xs)))

(define (double n k)
  (k (* n 2)))

可以看到,CPS 式的函数不再是直接返回值,而是返回一个由 Cont monad 表示的计算过程。与 Scheme 代码相比,不用显式地在函数签名中指出额外需要的 continuation 参数,这也是 monad 这种抽象手段的好处之一。而 monad 的其他方便之处,则在于 return 和 bind 操作可以方便地构造、串联这种结构。Cont monad 的相关操作定义如下

return :: a -> Cont r a
return a = Cont $ \c -> c a

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

可以看到,return 只是简单地将某个值传入 continuation,而 >>= 里则是通过 continuation-passing 的方式将 m 得到的结果绑定到了 a 然后传入函数 k 作进一步计算。下面的例子展示了如何用 bind 操作串联 Cont monad:

main = do
  runCont (calculateLength "123" >>= double) print

这个例子中,double 是 calculateLength 函数的 continuation,两者通过 bind 串联成为一个更大的 CPS 计算,最终由 runCont 将其结果输出。

callCC

作了 CPS 变换之后,call-with-current-continuation 就很容易实现。假设在 Scheme 中我们已经对所有代码作了 CPS 变换,那么 call/cc 就可以这样实现:

(define (call/cc f k)
  (f (lambda (x) (lambda (dc) (k x))) k))

其中,dc 即 escape function 被调用时的 continuation,它会被直接丢弃,转而将值传递给 call/cc 的 continuation 即 k,从而实现 escape。假如 escape function 没有被调用,那么 f 执行结束之后就会继续执行 call/cc 后面的部分,亦即 k。

同样地,在 Haskell 里,我们有

calCC :: ((a -> Cont r b) -> Cont r a) -> Cont r a
callCC f = Cont $ \h -> runCont (f (\a -> Cont $ \_ -> h a)) h

这里的 _ 即指明了调用 escape function 处的 continuation 会被丢弃。

而有了 callCC 之后我们就可以显式地合成各种控制流,可以参见知乎用户御坂黒子的专栏文章《用callcc合成控制流》

参考

  1. P. Wadler, The essence of functional programming
  2. Wikibooks, Haskell/Continuation passing style
文章被以下专栏收录
4 条评论
推荐阅读