现在,CPS作为非阻塞式(通常是分布式的)系统的编程风格而被再次发掘出来。
我对CPS很有好感,因为它是我获取博士学位的一个秘密武器。它十有八九帮我消减掉了一两年的时间和一些难以估量的痛苦。
本文介绍了CPS所扮演的两种角色作为JavaScript中的一种非阻塞编程风格,以及作为一种功能性语言的中间形式(简要介绍)。
内容包括:
◆JavaScript中的CPS
◆CPS用于Ajax编程
◆用在非阻塞式编程(node.js)中的CPS
◆CPS用于分布式编程
◆如何使用CPS来实现异常
◆极简Lisp的一个CPS转换器
◆如何用Lisp实现call/cc
◆如何用JavaScript实现call/cc
请往下阅读以了解更多内容。
什么是持续传送风格?
如果一种语言支持后续(continuation)的话,编程者就可以添加诸如异常、回溯、线程以及构造函数一类的控制构造。
可惜的是,许多关于后续的解释(我的也包括在内)给人的感觉是含糊不清,令人难以满意。
后续传递风格是那么的基础。
后续传递风格赋予了后续在代码方面的意义。
更妙的是,编程者可以自我发掘出后续传递风格来,如果其受限于下面这样的一个约束的话:
没有过程被允许返回到它的调用者中永远如此。
存在的一个启示使得以这种风格编程成为可能:
过程可以在它们返回值时调用一个回调方法。
当一个过程(procedure)准备要返回到它的调用者中时,它在返回值时调用当前后续(current continuation)这一回调方法(由它的调用者提供)
一个后续是一个初始类型(first-class)返回点。
例子:标识函数
考虑这个正常写法的标识函数:
function
id(x) {
return
x ;
}
然后是后续传递风格的:
1
.
function
id(x,cc) {
2
. cc(x) ;
3
. }
有时候,把当前后续参数命名为ret会使得其目的更为明显一些:
1
.
function
id(x,ret) {
2
. ret(x) ;
3
. }
例子:朴素阶乘
下面是标准的朴素阶乘:
1
.
function
fact(n) {
2
.
if
(n
==
0
)
3
.
return
1
;
4
.
else
5
.
return
n
*
fact(n
-
1
) ;
6
. }
下面是CPS风格实现的:
1
.
function
fact(n,ret) {
2
.
if
(n
==
0
)
3
. ret(
1
) ;
4
.
else
5
. fact(n
-
1
,
function
(t0) {
6
. ret(n
*
t0) }) ;
7
. }
接下来,为了使用这一函数,我们把一个回调方法传给它:
1
. fact (
5
,
function
(n) {
2
. console.log(n) ;
//
在Firebug中输出120
3
. })
例子:尾递归阶乘
下面是尾递归阶乘:
1
.
function
fact(n) {
2
.
return
tail_fact(n,
1
) ;
3
. }
4
.
function
tail_fact(n,a) {
5
.
if
(n
==
0
)
6
.
return
a ;
7
.
else
8
.
return
tail_fact(n
-
1
,n
*
a) ;
9
. }
然后,是CPS实现方式的:
1
.
function
fact(n,ret) {
2
. tail_fact(n,
1
,ret) ;
3
. }
4
.
function
tail_fact(n,a,ret) {
5
.
if
(n
==
0
)
6
. ret(a) ;
7
.
else
8
. tail_fact(n
-
1
,n
*
a,ret) ;
9
. }
CPS和Ajax
Ajax是一种web编程技术,其使用JavaScript中的一个XMLHttpRequest对象来从服务器端(异步地)提取数据。(提取的数据不必是XML格式的。)CPS提供了一种优雅地实现Ajax编程的方式。使用XMLHttpRequest,我们可以写出一个阻塞式的过程fetch(url),该过程抓取某个url上的内容,然后把内容作为串返回。
这一方法的问题是,JavaScript是一种单线程语言,当JavaScript阻塞时,浏览器就被暂时冻结,不能动弹了。这会造成不愉快的用户体验。一种更好的做法是这样的一个过程fetch(url, callback),其允许执行(或是浏览器呈现工作)的继续,并且一旦请求完成就调用所提供的回调方法。在这种做法中,部分CPS转换变成了一种自然的编码方式。
实现fetch
1
.
/*
2. 对于客户端>服务器端的请求来说,
3. fetch是一个可选阻塞的过程。
4.
5. 只有在给出url的情况下,过程才会阻塞并返回该url上的内容。
6.
7. 如果提供了onSuccess回调方法,
8. 则过程是非阻塞的,并使用文件的
9. 内容来调用回调方法。
10.
11. 如果onFail回调方法也提供了的话,
12. 则过程在失败事件出现时调用onFail。
13.
14.
*/
15
.
16
.
function
fetch (url, onSuccess, onFail) {
17
.
//
只有在定义回调方法的情况下才是异步的
18
.
var
async
=
onSuccess
?
true
:
false
;
//
(别抱怨此行代码的效率低下,
19
.
20
.
//
否则你就是不明白关键所在。)
21
.
var
req ;
//
XMLHttpRequest对象.
22
.
23
.
//
XMLHttpRequest的回调方法:
24
.
function
processReqChange() {
25
.
if
(req.readyState
==
4
) {
26
.
if
(req.status
==
200
) {
27
.
if
(onSuccess)
28
. onSuccess(req.responseText, url, req) ;
29
. }
else
{
30
.
if
(onFail)
31
. onFail(url, req) ;
32
. }
33
. }
34
. }
35
.
36
.
//
创建XMLHttpRequest对象:
37
.
if
(window.XMLHttpRequest)
38
. req
=
new
XMLHttpRequest();
39
.
else
if
(window.ActiveXObject)
40
. req
=
new
ActiveXObject(
"
Microsoft.XMLHTTP
"
);
41
.
42
.
//
如果是异步的话,设定回调方法:
43
.
if
(async)
44
. req.onreadystatechange
=
processReqChange;
45
.
46
.
//
发起请求:
47
. req.open(
"
GET
"
, url, async);
48
. req.send(
null
);
49
.
50
.
//
如果是异步的话,
51
.
//
返回请求对象,否则
52
.
//
返回响应.
53
.
if
(async)
54
.
return
req ;
55
.
else
56
.
return
req.responseText ;
57
. }
例子:提取数据
考虑一个程序,该程序需要从UID中抓取一个名字
下面的两种做法都要用到fetch:
1
.
//
阻塞直到请求完成:
2
.
var
someName
=
fetch(
"
./1031/name
"
) ;
3
.
4
. document.write (
"
someName:
"
+
someName
+
"
5.
"
) ;
1
.
//
不做阻塞的:
2
. fetch(
"
./1030/name
"
,
function
(name) {
3
. document.getElementById(
"
name
"
).innerHTML
=
name ;
4
. }) ;
5
.
CPS和非阻塞式编程
node.js是一个高性能的JavaScript服务器端平台,在该平台上阻塞式过程是不允许的。
巧妙的是,通常会阻塞的过程(比如网络或是文件I/O)利用了通过结果来调用的回调方法。
对程序做部分CPS转换促成了自然而然的node.js编程。
例子:简单的web服务器
node.js中的一个简单的web服务器把一个后续传递给文件读取过程。相比于非阻塞式IO的基于select的方法,CPS使非阻塞I/O变得更加的简单明了。
1
.
var
sys
=
require(
'
sys
'
) ;
2
.
var
http
=
require(
'
http
'
) ;
3
.
var
url
=
require(
'
url
'
) ;
4
.
var
fs
=
require(
'
fs
'
) ;
5
.
6
.
//
Web服务器的根目录:
7
.
var
DocRoot
=
"
./www/
"
;
8
.
9
.
//
使用一个处理程序回调来创建web服务器:
10
.
var
httpd
=
http.createServer(
function
(req, res) {
11
. sys.puts(
"
request:
"
+
req.url) ;
12
.
13
.
//
解析url:
14
.
var
u
=
url.parse(req.url,
true
) ;
15
.
var
path
=
u.pathname.split(
"
/
"
) ;
16
.
17
.
//
去掉路径中的..:
18
.
var
localPath
=
u.pathname ;
19
.
//
"
20
.
/
.." => ""
21
.
var
localPath
=
22
. localPath.replace(
/
[^
/
]
+
\
/
+[.][.]
/
g,
""
) ;
23
.
//
".." => "."
24
.
var
localPath
=
DocRoot
+
25
. localPath.replace(
/
[.][.]
/
g,
"
.
"
) ;
26
.
27
.
//
读入被请求的文件,并把它发送回去.
28
.
//
注:readFile用到了当前后续(current continuation):
29
. fs.readFile(localPath,
function
(err,data) {
30
.
var
headers
=
{} ;
31
.
32
.
if
(err) {
33
. headers[
"
Content-Type
"
]
=
"
text/plain
"
;
34
. res.writeHead(
404
, headers);
35
. res.write(
"
404 File Not Found\n
"
) ;
36
. res.end() ;
37
. }
else
{
38
.
var
mimetype
=
MIMEType(u.pathname) ;
39
.
40
.
//
如果没有找出内容类型的话,
41
.
//
就由客户来猜测.
42
.
if
(mimetype)
43
. headers[
"
Content-Type
"
]
=
mimetype ;
44
. res.writeHead(
200
, headers) ;
45
.
46
. res.write(data) ;
47
. res.end() ;
48
. }
49
. }) ;
50
. }) ;
51
.
52
.
//
映射后缀名和MIME类型:
53
.
var
MIMETypes
=
{
54
.
"
html
"
:
"
text/html
"
,
55
.
"
js
"
:
"
text/javascript
"
,
56
.
"
css
"
:
"
text/css
"
,
57
.
"
txt
"
:
"
text/plain
"
58
. } ;
59
.
60
.
function
MIMEType(filename) {
61
.
var
parsed
=
filename.match(
/
[.](.*)$
/
) ;
62
.
if
(
!
parsed)
63
.
return
false
;
64
.
var
ext
=
parsed[
1
] ;
65
.
return
MIMEType[ext] ; }
66
.
67
.
//
启动服务器,监听端口8000:
68
. httpd.listen(
8000
) ;
CPS用于分布式计算
CPS简化了把计算分解成本地部分和分布部分的做法。
假设你编写了一个组合的choose函数;开始是一种正常的方式:
1
.
function
choose (n,k) {
2
.
return
fact(n)
/
3
. (fact(k)
*
fact(n
-
k)) ;
4
. }
现在,假设你想要在服务器端而不是本地计算阶乘。
你可以重新把fact写成阻塞的并等待服务器端的响应。
那样的做法很糟糕。
相反,假设你使用CPS来写choose的话:
1
.
function
choose(n,k,ret) {
2
. fact (n,
function
(factn) {
3
. fact (n
-
k,
function
(factnk) {
4
. fact (k,
function
(factk) {
5
. ret (factn
/
(factnk
*
factk)) }) }) })
6
. }
现在,重新把fact定义成在服务器端的异步计算阶乘就是一件很简单的事情了。
(有趣的练习:修改node.js服务器端以让这一做法生效。)
使用CPS来实现异常
一旦程序以CPS风格实现,其就破坏了语言中的普通的异常机制。 幸运的是,使用CPS来实现异常是一件很容易的事情。
异常是后续的一种特例。
通过把当前异常后续(current exceptional continuation)与当前后续一起做传递,你可以实现对try/catch代码块的脱糖处理。
考虑下面的例子,该例子使用异常来定义阶乘的一个完全版本:
1
.
function
fact (n) {
2
.
if
(n
<
0
)
3
.
throw
"
n < 0
"
;
4
.
else
if
(n
==
0
)
5
.
return
1
;
6
.
else
7
.
return
n
*
fact(n
-
1
) ; }
8
.
9
.
function
total_fact (n) {
10
.
try
{
11
.
return
fact(n) ;
12
. }
catch
(ex) {
13
.
return
false
;
14
. }
15
. }
16
.
17
. document.write(
"
total_fact(10):
"
+
total_fact(
10
)) ;
18
. document.write(
"
total_fact(-1):
"
+
total_fact(
-
1
)) ;
通过使用CPS来添加异常后续,我们就可以对throw、try和catch做脱糖处理:
1
.
function
fact (n,ret,thro) {
2
.
if
(n
<
0
)
3
. thro(
"
n < 0
"
)
4
.
else
if
(n
==
0
)
5
. ret(
1
)
6
.
else
7
. fact(n
-
1
,
8
.
function
(t0) {
9
. ret(n
*
t0) ;
10
. },
11
. thro)
12
. }
13
.
14
.
function
total_fact (n,ret) {
15
. fact (n,ret,
16
.
function
(ex) {
17
. ret(
false
) ;
18
. }) ;
19
. }
20
.
21
. total_fact(
10
,
function
(res) {
22
. document.write(
"
total_fact(10):
"
+
res)
23
. }) ;
24
.
25
. total_fact(
-
1
,
function
(res) {
26
. document.write(
"
total_fact(-1):
"
+
res)
27
. }) ;
CPS用于编译
三十年以来,CPS已经成为了功能性编程语言的编译器的一种强大的中间表达形式。
CPS脱糖处理了函数的返回、异常和初始类型后续;函数调用变成了单条的跳转指令。
换句话说,CPS在编译方面做了许多繁重的提升工作。
把lambda演算转写成CPS
lambda演算是Lisp的一个缩影,只需足够的表达式(应用程序、匿名函数和变量引用)来使得其对于计算是通用的。
1
. exp ::
=
(exp exp) ; 函数应用
2
.
|
(lambda (
var
) exp) ; 匿名函数
3
.
|
var
; 变量引用
下面的Racket代码把这一语言转换成CPS:
1
. (define (cps
-
convert term cont)
2
. (match term
3
. [`(,f ,e)
4
. ;
=>
5
. (let (($f (gensym
'
f))
6. ($e (gensym
'
e)))
7
. (cps
-
convert f `(lambda (,$f)
8
. ,(cps
-
convert e `(lambda (,$e)
9
. (,$f ,$e ,cont))))))]
10
.
11
. [`(lambda (,v) ,e)
12
. ;
=>
13
. (let (($k (gensym
'
k)))
14. `(,cont (lambda (,v ,$k)
15. ,(cps-convert e $k))))]
16.
17. [(? symbol?)
18. ; =>
19. `(,cont ,term)]))
20.
21. (define (cps-convert-program term)
22. (cps-convert term
'
(lambda (ans) ans)))
对于感兴趣的读者来说,Olivier Danvy有许多关于编写有效的CPS转换器的文章。
使用Lisp实现call/cc
原语call-with-current-continuation(通常称作call/cc)是现代编程中最强大的控制流结构。
CPS使得call/cc的实现成为了小菜一碟;这是一种语法上的脱糖:
call
/
cc => (lambda (f cc) (f (lambda (x k) (cc x)) cc))
这一脱糖处理(与CPS转换相结合)是准确理解call/cc所做工作的最好方式。
其所实现的正是其名称所说明的:其使用一个已经捕捉了当前后续的过程来调用被作为参数指定的过程。
当捕捉了后续的过程被调用时,其把计算返回给计算创建点。
使用JavaScript实现call/cc
如果有人要把JavaScript中的代码转写成后续传递风格的话,call/cc有一个很简单的定义:
function
callcc (f,cc) {
f(
function
(x,k) { cc(x) },cc)
}