CommonLisp函式呼叫的CPS變換
之前在新浪部落格上已經發表了一篇同樣主題的文章了,不過心血來潮想重寫一份發到這邊的部落格上來,順便看看自己能不能夠在自己重寫這篇的東西的過程中再發掘出一些有價值的東西,充實一下內容。
什麼是CPS
首先雙手奉上來自維基百科的Continuation-passing-style條目 。CPS一般譯作續延傳遞風格 ,這裡最不好理解的詞語莫過於續延 了。維基百科上關於續延也有詳細的介紹,不過我也可以簡單地介紹一下。續延 是一種表示接下來所要進行的計算的物件,也就是你在計算了這一步之後需要繼續做的事情的一個描述。舉個例子,對於Common Lisp程式碼(+ (+ 1 2) 3)而言,最左邊的引數首先會被計算,也就是Lisp實現先計算的是(+ 1 2) 這個表示式,於是可以得到返回值為3。那麼計算完了最左邊的引數之後,Lisp實現接下來要做什麼呢?顯然,接下來會計算作為直接值的3得到3本身,然後再將第一次計算得到的3和第二次求值得到的3進行相加。而上面的這兩個步驟,其實可以用Lisp中的物件進行描述,只需要用一個匿名函式就可以了,寫成
(lambda (v) (+ v 3))。
那麼我們就可以思考,在這一個匿名函式被計算之後,再一次的接下來的計算 又是什麼呢?對於在REPL中輸入的程式碼而言,Lisp實現會把表示式最終計算得到的返回值列印在終端上,所以我們可以認為上面的匿名函式,也就是一個計算過程,所對應的續延是
(lambda (u) (print u))。
那麼原來的表示式(+ (+ 1 2) 3) 就可以寫成下面這樣的風格的程式碼:
(+& 1 2 (lambda (v) (+& v 3 (lambda (u) (print u)))))
你或許會感到奇怪:這裡的+& 是個什麼東西啊?+&是一個和加法類似的函式,只是它除了會把前兩個引數相加之外,還會將這個引數傳遞給作為第三個引數傳入的匿名函式,並進行函式呼叫。按照維基百科上的說明,CPS程式碼有兩個規範: 1. 每一個函式都要接受一個續延作為額外的引數; 2. 每一個函式呼叫中的引數都只能是一個變數或者λ表示式。
可以說,+&這樣的函式是為了將程式碼變換成CPS才創造出來的。當然了,除了+函式需要進行變換之外,其它的普通函式(regular function)也是需要進行變換的。例如對程式碼(* (+ 1 2) 3) 進行變換的話,就需要為*函式提供相應的變體,結果應該是:
(+& 1 2 (lambda (v) (*& v 3 (lambda (u) (print u)))))。
上面的程式碼中有個可以稍微琢磨的地方:為什麼在兩個不同的匿名函式的引數列表中偏偏要使用不同的引數名呢?事實上,在上面的兩個變換出來的結果中,完全可以統一使用v或者u來作為引數名,那是因為引數v不需要在第二層巢狀的匿名函式中被使用。可是有時候一開始就進行求值的表示式不一定是馬上就被使用的,例如程式碼(- (+ 1 2) (* 3 4)) 的變換結果就是
(+& 1 2 (lambda (v) (*& 3 4 (lambda (u) (-& v u)))))。
可以說,當引數列表表示式中不止一個複雜 表示式時,就需要至少兩個不重名的引數了。講了這麼多手動變換的事情,現在可以討論最重要的問題了:
如何對Lisp程式碼進行CPS變換
如果我們可以對一類簡單的程式碼進行CPS變換了,那麼如何在這個基礎上對複雜的程式碼進行CPS變換呢?例如我們有一段簡單的程式碼(op3 (op2 (op1 a b) c) d) ,並且我們已經有了一個通用的功能F可以對其中的巢狀的表示式(op2 (op1 a b) c) 進行變換,那麼要如何將其和外層的對op3函式的呼叫結合在一起呢?顯然,我們可以先把(op2 (op1 a b) c) 換掉,換成一個普通的符號v,以表示一個變數。那麼接下來我們的處理物件就是一個稍微簡單的表示式(op3 v c) 了,對於這種引數列表均由變數所構成的表示式,要將其變換成CPS程式碼只需要修改其函式為加&的形式 ,並且添上一個額外的引數——續延就可以了。至於這個續延應該是由呼叫者提供,而又可以使用預設的(lambda (XXX) (print XXX)) 的。所以,上面的程式碼最終會變成
(經過F處理的程式碼 (lambda (v) (op3& v c (lambda (XXX) (print XXX)))))。
對內層的表示式應用F處理完之後還要再計算外層表示式的續延,而計算續延正是函式F所做的事情,所以實際上對一個複雜的表示式做CPS變換,是一個遞迴的過程。用兩段程式碼進行變換可以有更直觀的體驗:假設我們有一個正確實現的變換函式F,它接受一個程式碼和一個續延,併產生出對程式碼進行CPS變換所得到的結果。那麼如果應用在程式碼(+ (+ 1 1) 3) 上,就是
(F (+ (+ 1 1) 3) (lambda (v) (print v)))。
顯然,這段程式碼應該和下面的這段程式碼是等價的:
(F (+ 1 1) (lambda (u) (+& u 3 (lambda (v) (print v)))))。
並且上面這段程式碼的續延引數部分,和下面的這段程式碼是等價的:
(F (+ u 3) (lambda (v) (print v)))。
是不是就是說,對於一個複雜的表示式expr0,如果想要在提供一個額外續延cont0的情況下,將其正確變換成CPS形式,那麼可以先提取出它的第一個被求值的子表示式expr1,將原來子表示式出現的地方替換為一個變數,那麼原本的表示式就變成了expr1對應的續延cont1。此時先將cont1和cont0傳入函式F進行CPS變換,得到的結果cont將會成為和expr1一起傳入函式F進行變換,並最終得到對expr0和cont0進行變換的正確結果呢?顯然這是對的,因為cont0緊隨expr0求值,而expr0可以拆分為兩部分:expr1以及緊隨其求值的cont1。那麼顯然cont0緊隨cont1求值,所以cont0是cont1的續延,同樣需要進行CPS變換,然後得到新的程式碼作為expr1的續延繼續進行變換,那麼最終就會得到程式碼,它反映的求值順序正是expr1 -> cont1 -> expr0。
那麼重點就在於怎麼把原本的expr0給拆分成expr1和cont1,以及具體結合程式碼和續延的技術細節了。假設實現拆分功能的函式叫做split-funcall,那麼它需要在什麼情況下做什麼事情呢?一般而言,split-funcall處理的是有複雜的子表示式的程式碼,那麼它需要能夠在整個表示式expr0中找出儘量左的一個複雜子表示式,也就是expr1。如果找到這樣的expr1了,那麼就需要同時找出對應的續延cont1並將兩者都返回;如果沒有這樣的expr1,那麼直接返回邏輯假和整個expr0就可以了。說到需要返回多個值的情況,正是Common Lisp的values函式派上大用場的時候了。
對於含有複雜子表示式的情況,split-funcall函式會返回提取到的子表示式和對應的續延,那麼這裡的續延應該用什麼樣的形式來表示呢?為了方便使用從split-funcall內部所gensym出來的符號,所以直接返回一個經過構造的lambda表示式就可以了,但是在遞迴呼叫函式F時,不能直接對其進行變換處理,因為lambda表示式不是一個會對引數求值的操作符,所以真正需要變換的是lambda表示式的函式體。因此必須先拆開lambda表示式,對其函式體進行變換並再次構造成lambda表示式,以傳遞給函式F使用。
最後,貼一下自己放在Gist上的程式碼:
(defpackage :cps (:use :cl)) (in-package :cps) (defun append1 (list obj) (append list (list obj))) (defun cps-symbol (op) (intern (format nil "~S&" op))) (defun split-funcall (form) (labels ((aux (expr args) (cond ((null args) (values nil expr)) ((atom (first args)) (aux (append1 expr (first args)) (rest args))) (t (let ((id (gensym))) (values (first args) `(lambda (,id) ,(append expr (cons id (rest args)))))))))) (aux (list (first form)) (rest form)))) (defun cont-trans (expr &optional (cont (let ((id (gensym))) `(lambda (,id) (print ,id))))) (if (every #'atom expr) (append1 (cons (cps-symbol (car expr)) (cdr expr)) cont) (multiple-value-bind (subexpr k) (split-funcall expr) (cont-trans subexpr (destructuring-bind (l args body) k `(,l ,args ,(cont-trans body cont)))))))