示例:JavaScript中的后續傳遞風格
現在,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)返回點。
例子:標識函數
考慮這個正常寫法的標識函數:
return x ;
}
然后是后續傳遞風格的:
2. cc(x) ;
3. }
有時候,把當前后續參數命名為ret會使得其目的更為明顯一些:
2. ret(x) ;
3. }
例子:樸素階乘
下面是標準的樸素階乘:
2. if (n ==0)
3. return1 ;
4. else
5. return n * fact(n-1) ;
6. }
下面是CPS風格實現的:
2. if (n ==0)
3. ret(1) ;
4. else
5. fact(n-1, function (t0) {
6. ret(n * t0) }) ;
7. }
接下來,為了使用這一函數,我們把一個回調方法傳給它:
2. console.log(n) ; // 在Firebug中輸出120
3. })
例子:尾遞歸階乘
下面是尾遞歸階乘:
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實現方式的:
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
實現fetch過程并不難,至于其以非阻塞模式或是阻塞模式操作則取決于編程者是否提供回調方法:
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. elseif (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:
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變得更加的簡單明了。
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. returnfalse ;
64. var ext = parsed[1] ;
65. return MIMEType[ext] ; }
66.
67. // 啟動服務器,監聽端口8000:
68. httpd.listen(8000) ;
CPS用于分布式計算
CPS簡化了把計算分解成本地部分和分布部分的做法。
假設你編寫了一個組合的choose函數;開始是一種正常的方式:
2. return fact(n) /
3. (fact(k) * fact(n-k)) ;
4. }
現在,假設你想要在服務器端而不是本地計算階乘。
你可以重新把fact寫成阻塞的并等待服務器端的響應。
那樣的做法很糟糕。
相反,假設你使用CPS來寫choose的話:
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代碼塊的脫糖處理。
考慮下面的例子,該例子使用異常來定義階乘的一個完全版本:
2. if (n <0)
3. throw"n < 0" ;
4. elseif (n ==0)
5. return1 ;
6. else
7. return n * fact(n-1) ; }
8.
9. function total_fact (n) {
10. try {
11. return fact(n) ;
12. } catch (ex) {
13. returnfalse ;
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做脫糖處理:
2. if (n <0)
3. thro("n < 0")
4. elseif (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的一個縮影,只需足夠的表達式(應用程序、匿名函數和變量引用)來使得其對于計算是通用的。
2. | (lambda (var) exp) ; 匿名函數
3. |var ; 變量引用
下面的Racket代碼把這一語言轉換成CPS:
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的實現成為了小菜一碟;這是一種語法上的脫糖:
這一脫糖處理(與CPS轉換相結合)是準確理解call/cc所做工作的最好方式。
其所實現的正是其名稱所說明的:其使用一個已經捕捉了當前后續的過程來調用被作為參數指定的過程。
當捕捉了后續的過程被調用時,其把計算返回給計算創建點。
使用JavaScript實現call/cc
如果有人要把JavaScript中的代碼轉寫成后續傳遞風格的話,call/cc有一個很簡單的定義:
f(function(x,k) { cc(x) },cc)
}