1 什么是Continuation-passing Style:
Continuation-passing style(CPS)是指将控制流(Control flow)1显式的当做参数传递的编程风格。 函数的返回不在通过return语句,而是将返回值当做参数,调用控制流。
我们先来看一些例子,
普通的js函数我们这样定义:
- function foo(x)
- {
- return x;
- }
CPS风格的函数都会有一个额外的参数k,显式的表示了continuation(可以理解成控制流的流向,what comes next)。当CPS函数需要返回的时候,调用k,并将返回值作为k的参数。
- function foo(x, k)
- {
- k(x);
- //注意:调用k(x)相当于“return”,所以这之后的语句都不会被执行了。
- assert(false, "should not be here");
- }
另一个嵌套的例子:
- function goo(x)
- {
- function foo(y)
- {
- return y + 100;
- }
- return foo(x+100);
- }
- console.log(goo(100));
CPS代码:
- function goo(x, k)
- {
- function foo(y, ky)
- {
- y += 100;
- ky(y);
- }
- foo(x+100, function(z)
- {
- k(z);
- });
- }
- goo(100, function(x)
- {
- console.log(x);
- });
CPS程序与普通程序相比有如下明显的不同:
- CPS函数都有一个额外的参数 k ,表示控制流。函数需要返回,必须显式的调用 k .
- 在函数的末尾调用了另外一个函数,这种调用称为尾调用,tail call。相应的在尾部递归调用,称之为尾递归,tail recursion。 CPS所有函数都是尾调用。
CPS最初主要用于编译高级语言时一种中间代码表示,有了这种中间代码,编译器的复杂度大大降低。现在发现CPS在异步编程里,分布式编程里大显身手。
CPS风格代码也有自己的缺点:
- CPS代码基本没法阅读和理解,看上面递归的例子就知道有多晦涩。
- CPS代码不断的嵌套调用,在不支持尾递归的语言中,可能出现栈溢出。
2 与 continuation 的区别:
continuaion 是对控制流2的一种抽象表示。此时控制流是一等公民,我们不用写CPS代码,但是在程序运行的时候,我们可以获取 控制流。
- (call/cc (lambda (k)
- (displayln "before call k")
- (k "I'm back") ;调用k,控制流直接出去,后面的语句不会被调用掉。
- (displayln "after call k")))
- (displayln "I'm the point of control flow k") ;调用k,控制流到了这里。 我们可以保存k,在以后任何时候调用,控制流还是会到这里。
continuation这个数据表示了程序执行过程中的某一个点。就好像程序断点一样,但是continuation这种”断点“是一个数据,我们可以保存他,可以在任意时刻恢复他。
将程序的控制流作为数据暴露给程序员,并且可以任意操作他们,此时控制流成为了first class value。通过对控制流的操作,我们可以方便的实现多线程,coroutine,generator等。
如果语言支持continuation,我们可以像写同步程序一样写异步程序。这一点可以看这篇文章racket web serve
CPS更多表示一种编程风格。当然使用了这种编程风格,我们可以实现continuation。
3 CPS在编译器中的应用
前面提到,CPS风格的代码作为编译的代码的一种中间表示,使得编译器实现复杂度大大降低。
- CPS中每个函数都有一个额外的参数用来表示当一个函数执行完毕该做什么。
- 函数调用时的参数必须是一个变量,一个值或者是另外一个函数,不能是一个更复杂的表达式。
这种受限制的style不在使用一些我们经常使用的控制结构,譬如while,break,continue等,这使得编译器可以更加简单的去分析程序的语法。
但与此同时,我们也可以非常简单的实现一些非常规的控制结构,譬如try-catch(下面有例子)或者其他非局部的控制流的跳转。
有很多方法可以将非CPS代码 自动 的转换成CPS代码,但是不同的方法翻译的质量可能不一样。翻译的方法描述起来非常繁琐,强烈建议有兴趣的同学去看这本书<Compiling with continuation>.
这里我列举一些翻译好的代码:
- 普通没有分支结构的
- function foo(x)
- {
- return x + 1;
- }
- function cps_foo(x, k)
- {
- (function(k1)
- {
- k1(x+1);
- })(function(x1)
- {
- k(x1);
- });
- }
- //有赋值语句的
- function foo(x)
- {
- var a = 200;
- return x + a;
- }
- function foo(x, k)
- {
- var a;
- (function(k1)
- {
- a = 200;
- k1();
- })(function()
- {
- (function(k2)
- {
- k2(a + x);
- })(function(x2)
- {
- k(x2);
- });
- });
- }
- 有分支结构的
- function foo(x)
- {
- if (x > 100)
- {
- console.log("x>100");
- }
- else
- {
- console.log("x<100");
- }
- return x;
- }
- function cps_foo(x, k)
- {
- (function(k1)
- {
- k1(x > 100);
- })(function(b)
- {
- function k2()
- {
- k(x);
- }
- if (b)
- (function(k3)
- {
- console.log("x>100");
- k3();
- })(k2);
- else
- (function(k4)
- {
- console.log("x<100");
- k4();
- })(k2);
- });
- }
- CPS实现try-catch
- function goo(x)
- {
- function foo(a, b)
- {
- if (a > b)
- return a - b;
- else
- throw "a must gt b";
- }
- try{
- x = foo(x, 100);
- }
- catch(ex)
- {
- console.log("parameter error");
- return;
- }
- return x;
- }
- function cps_goo(x, k)
- {
- (function(a, b, k, thro)
- {
- //为了说明方便,不在将if翻译成CPS了
- if (a > b)
- k(a-b);
- else
- thro("a must gt b");
- })(function(aminusb)
- {
- x = aminusb;
- k(x);
- },
- function(ex)
- {
- console.log("parameter error");
- k();
- });
- }
- 翻译成CPS的表达式,有一种inside-out的效果
- function foo(a, b)
- {
- return a * (a + b) * b;
- }
- function cps_foo(a, b, k)
- {
- (function(k)
- {
- k(a + b); //a * (a + b) * b 最先被计算的在最外层 --> inside-out
- })(function(c)
- {
- (function(k)
- {
- k(a * c);
- })(function(d)
- {
- (function(k1)
- {
- k1(d * b);
- })(function(ret)
- {
- k(ret);
- });
- });
- });
- }
- 总结转换的一些策略:
- 可以看到,每一条语句都被包装在一个函数内。原函数内剩下的语句被包装在continuation中。
- 最终每个函数内只做一件不能在被分割的事情(譬如+,-, *, / 或者调用系统API等)
- 可以更多的优化,譬如上面的例子每个con的参数名都不一样,其实是没必要的,因为每个函数实际上只关心传入自身的continuation参数。
- 更多体会:
- return语句只是一个语法糖而已。CPS中,我们没有在调用return了,控制流必须显式通过continuation传递。
- exception仅仅是一个特殊的continuation而已。try-catch还是语法糖。
- 如果语言支持尾递归优化,完全是CPS风格的代码的堆栈是永远随着函数调用的深入而消耗。 如果语言本身不支持尾递归优化, 那么翻译后的CPS代码及有可能很快的消耗完堆栈。当然在不支持尾递归优化的语言中,我们可以使用Trampoline技术进行优化,实现尾递归优化。
- 表达式程序一种"inside-out"的效果:因为表达式最内部的部分需要最先被计算出来。看最后一个例子。
4 CPS在异步编程中的应用:
- 何为异步编程:
异步编程一般是指调用者(caller)不去等待一个耗时操作(callee)执行结束。譬如:img=load(url); setTimeout; UI Event等。我们一般通过ballback来获悉异步操作执行情况。 一种实现方案是耗时的操作单独作为一个线程,当操作结束的时候,在原来的线程中回调callback。java的Swing UI framework采用这种方案。
浏览器端的js是单进程单线程的运行环境,如果我们同步请求网络数据,那么必然整个浏览器都暂停下载,等待网络数据,这显然不是很好的用户体现。所以一般我们会异步的请求网络数据。
loadAsync(url, callback)
这样,我们的控制流还是继续执行下去。当网络请求结束的时候,callback将被调用。
目前火热的Nodejs最大的优点就是non-blocking programming(当然我觉得最大的优势是前端工程师可以直接上手使用)。在NodeJs中,所有原本可能阻塞的操作全部都接受一个callback,当请求完成 的时候调用callback。
这种通过callback进行异步编程的风格是不完全的CPS(Partially CPS-converting),callback可以看成continuation。
异步编程相较于多线程编程有很多优势,可以参考这边文章:Async socket。采用这种不完全的CPS相对于select-base的实现更加优雅和简单。The world of select.
- 缺点: 通过callback进行异步编程是很困难的,程序的局部性被打碎了(no if while..),逻辑分割在各个callbcak里,如何组合它们,cancel,异常等都是难题。我们淹没在了callback(continuation)中。
这种困难的本质原因:
- CPS威力是非常强大的,但是怎奈CPS是 非常难以编写和阅读的 ,一般由编译器生成。
- 通过修改外部状态来达到影响程序控制流是非常困难的(程序依赖状态有多痛苦,这里就有多痛苦),譬如我们在回调里注册新的回调等。
解决之道: