continuation 与 call/cc

random SICP anime banner
via Anime-Girls-Holding-Programming-Books

Continuation 与 call/cc

前言

好久没碰函数式相关的内容了,这篇文章想总结一下我学习函数式编程时对 call/cc 的疑惑与理解。要理解 call/cc 完全离不开 continuation——continuation 是概念,call/cc 是利用这个概念的函数。文章里也会聊一些我认为是理解它们的关键点。

看完这篇你会发现:原来 Java 的异常本质上就是 call/cc。

Continuation

continuation 的字面意思就是”延续”,指当前过程剩下未执行的部分(或者说剩下的流程)。脱离语境讲清楚 continuation 有点难,这里用一个 Python 的例子来类比:

def normal_add(a, b, c):
    s1 = a + b
    s2 = s1 + c
    return s2


def gen_add(a, b, c):
    s1 = a + b
    yield s1
    s2 = s1 + c
    yield s2
    return "done"

对于 gen_add 来说,第一次调用 next 会返回 s1,第二次返回 s2,第三次返回 done而每一次 yield 之后没有执行的那部分代码,就是当前这一刻的 continuation。

yield 是针对返回的、只能一次性”暂停”的语法,可以看作是 continuation 的一种特殊形态。这样理解的话,continuation 本身其实不算难。

想继续深入 continuation 的话,还可以了解一下 CPS(Continuation-Passing Style)——它是一种和正常顺序流程很不一样的程序组织方式,在一些场景下能让控制流变得更简洁,而且和 call/cc 有直接关联:在 CPS 风格下,由于 continuation 是显式传递的参数,我们可以直接丢弃后续流程,所以不需要 call/cc;而在非 CPS 风格(直接风格)下,我们就需要 call/cc 来实现跳转,从而达到丢弃后续流程的效果。

顺便一提,continuation 是 Scheme 的一等公民(first-class),也就是说你可以把它保存,调用,作为参数。

call/cc

接下来是稍微难理解一些的 call/cc,全称 call-with-current-continuation。如果说 continuation 是描述”剩余流程”的概念,那么 call/cc 就是函数式语言中利用 continuation 来完成流程控制的函数。

call/cc 的作用是什么呢,简单描述:保存当前调用 call/cc 时的 continuation,并在后续被调用并传入值的时候跳转回那个位置,把传入的值替换到 call/cc 对应的位置上。表述有点绕,但有两个关键词:跳转替换

再用一个不太准确但直观的描述:“反选”。我们平时只能影响当前流程以及它向下调用的子流程,而不能影响当前流程的调用者;而 call/cc 反过来——它选中的是”当前流程的外侧”,也就是这里返回之后还要执行的那部分流程。

下面贴一段 AI 生成描述更严谨的定义:

把”调用 call/cc 这个表达式所处位置的 continuation”打包成一个一元函数 k; 把 k 作为参数传给你写的 lambda; 在 lambda 里:

  • 如果你不调用 k,则 lambda 的返回值就是整个 (call/cc ...) 表达式的值(行为像普通函数);
  • 如果你调用 (k v),整个程序会”跳回” (call/cc ...) 那个位置,并把 v 当作它的返回值,lambda 中后续的代码全部被丢弃。

来两段 Scheme 代码具体看看:

不调用 k

> (+ 1 (call/cc (lambda (k) 2)))
3

求值过程: (+ 1 (call/cc (lambda (k) 2)))(+ 1 2)3

k 没被用到,(call/cc ...) 的值就是 lambda 的返回值 2

调用 (k v)

> (+ 1 (call/cc (lambda (k)
                  (+ 2 (k 3)))))
4

求值过程(示意,不严谨): (+ 1 (call/cc (lambda (k) (+ 2 (k 3)))))(+ 1 (k 3))(+ 1 3)4

注意中间 + 2 这一步被整个丢弃了——因为调用 (k 3) 时,控制流直接跳回了 call/cc 所在位置,lambda 中 (k 3) 后面的代码再也不会被执行。

它要解决什么问题

那么 call/cc 究竟是为了解决什么问题呢?

考虑这样一个场景:在一段很长的流程中,某种特殊情况下我们需要中途跳出。如果不使用 call/cc,我们就必须让外层流程对这种”中途跳出”的情况层层做处理(一级一级把信号往上传);而使用 call/cc,可以直接一步跳到目标位置。

此外,call/cc 还能用来实现协程(单线程模拟多任务、任务暂停与恢复)、生成器(类似 Python 的 yield,随着调用不断产出值)等等——它能实现控制流抽象的多种功能。

例子:求列表乘积

举个乘法的 Scheme 例子。需求是:求列表元素之积,遇到非数字返回 'error,遇到 0 直接返回 0(避免无谓地把后面的乘法都做完)。

使用 call/cc:

(define (product-with-cc lst)
  (call/cc
   (lambda (return)              ; return 是"逃逸续延"
     (let loop ((lst lst))
       (cond
         ((null? lst) 1)
         ((not (number? (car lst)))
          (return 'error))        ; 直接跳出整个 call/cc,返回 'error
         ((= (car lst) 0)
          (return 0))              ; 直接跳出,无需展开调用栈
         (else
          (* (car lst) (loop (cdr lst)))))))))

不使用 call/cc:

(define (product-without-cc lst)
  (cond
    ((null? lst) 1)
    ((not (number? (car lst))) 'error)
    ((= (car lst) 0) 0)
    (else
     (let ((rest (product-without-cc (cdr lst))))
       (if (eq? rest 'error)
           'error
           (* (car lst) rest))))))

可以看到,不使用 call/cc 时,我们必须显式向上传播 'error,递归的每一层都要多一次判断。在更复杂的流程里,这种”层层判断、层层透传”的代码会越积越多,每种异常情况都需要单独处理一遍。

其他语言中的类似控制

看到”向上传播 'error”这个场景,可能不少 Java Bros 已经想到异常了。我们来一个简单对比:

static int k(int v) {
    throw new Continuation(v);
}

public static void main(String[] args) {
    int innerResult;

    try {
        int kRet = k(3);
        innerResult = 2 + kRet;
    } catch (Continuation c) {
        innerResult = c.value;
    }

    int result = 1 + innerResult;
    System.out.println(result);
}

真相大白了——原来异常就是 call/cc 啊!准确地说,Java 的异常系统可以看作函数式里 call/cc 的一种受限特例:它只能向上跳(escape),不能像完整的 call/cc 那样把 continuation 保存下来反复调用。这种 call/cc 特例被 Java 做成了语言的基础特性,实用、流行、被广泛使用。

但这是有代价的:所有函数都需要额外思考”这里要不要处理异常”。有些语言就不愿意承担这个代价,宁愿保持函数的简洁单纯,比如 Go,引来的争议与战斗这里也无须赘述(不过 Go 又支持 goto,我也搞不清设计者是什么想法)。

总结

call/cc 就像一个爹,生出了好多儿子:Rust,JavaScript 的 async/awit,Python 的 yield,Java 的 Exception 等等,也能感觉出这些语法对于各自的语言来说并不是那么的”原生“,他们是新介绍的关键字,只针对这一种用法。

call/cc 就像是控制流的“原子”,而上述语法是为了安全性性能(捕获完整堆栈开销极大)对原子进行的封装。如果你拥有完全体的 call/cc,你可以自己实现上述所有语法,但反之则不行,即使勉强实现也很别扭。

还没玩够想再深挖一下可以看看 call/cc 经典的阴阳问题,如下:

(let* [(yin ((lambda (foo) (newline) foo)
             (call/cc (lambda (bar) bar))))
       (yang ((lambda (foo) (display "*") foo)
              (call/cc (lambda (bar) bar))))]
  (yin yang))

评论