scheme心得(3) 尾呼叫/尾遞迴與CPS
摘要:介紹了尾呼叫/尾遞迴呼叫(tail call/tail recursive call)的一般概念、形式、用法,以及CPS(continuation-passing style)的程式設計模式。
函數語言程式設計非常重要的一個概念是遞迴。在函數語言程式設計裡面,遞迴是原生的合乎情理的,是從lambda計算繼承而來的;而迭代(迴圈)是不良的,因為它的副作用,因為迭代(迴圈)意味著修改變數的值,違背了函數語言程式設計的本意。修改變數的值是某些純粹的函數語言程式設計語言所堅決擯棄的,比如haskell。而scheme採取了更務實的做法,支援修改變數的值,並且保留了迭代(迴圈)的語法。這是非常有趣和值得探討的。
我們知道,當一個函式的返回語句是另一個函式的直接呼叫,可稱為尾呼叫tail call。尾呼叫的優點是節省程式執行時間和空間。因為函式每一層的巢狀呼叫,都意味著一個新的棧幀stack frame。棧幀保留了諸如實參值,區域性變數,訪問鏈,返回地址等資訊,構造和銷燬棧幀是程式執行時的開銷。而尾呼叫,可以避免在記憶體中保留caller函式的棧幀,只需要直接把caller的返回地址複製到callee的返回地址。一般情況下的尾呼叫,優勢不明顯,但是如果是層次非常多的遞迴呼叫,則可以大大發揮作用。這種遞迴叫做尾遞迴。優化後的尾遞迴,即直接傳遞返回地址的尾遞迴,與程序式程式設計的迭代(迴圈)類同。換句話說,也可以編譯為goto或者jump語句。
以階乘函式為例。
(define (factorial n)
(if (= n 0) 1
(* n (factorial (- n 1)))))
這是非尾遞迴的階乘。可以看出,每次遞迴呼叫,都要保留caller函式的棧幀,因為callee的返回值還要繼續*n的操作才能返回caller的值。
(define (factorial n acc)
(if (= n 0) acc
(factorial (- n 1) acc*n)))
這是尾遞迴的階乘。其中factorial函式額外有一個引數來儲存累積的值。把累積的值傳遞到下次遞迴,遞迴函式返回值可以直接返回到最開始呼叫的位置。這樣階乘函式變成了尾遞迴的形式。
因為尾遞迴在編譯器層面可以優化為迭代,所以scheme除了顯式的定義尾遞迴函式,還有兩種獨立的語法形式,用於尾遞迴或迭代。一種是let proc ((var1 initial1)...) exp,(var1 initial1)...表示初始化各引數,exp中尾遞迴呼叫proc var1 ...;另一種是do ((var1 initial1 step1)...) exp,(var1 initial1 step1)...表示初始化各變數並定義迭代步長,exp中操作各變數。
(let loop
((numbers '(3 -2 1 6 -5))
(noneg '())
(neg '()))
(cond ((null? numbers)
(list nonneg neg))
((>=(car numbers) 0)
(loop (cdr numbers)
(cons (car numbers) nonneg)
neg))
(else (loop (cdr numbers)
nonneg
(cons (car numbers) neg)))))
;((6 1 3)(-5 -2))
以上是named let的用法。它初始化numbers、noneg、neg變數後,依次將numbers的頭元素取出,將非負值放入noneg,負值放入neg。
(let ((x '(1 3 5 7 9)))
(do ((x x (cdr x))
(sum 0 (+ sum (car x))))
((null? x) sum)))
;25
以上是do的用法。它初始化x、sum並定義迭代步長,將x的頭元素取出累加。最後得到25。
有一種特殊形式是CPS,continuation-passing style。CPS的含義是函式的引數包括一個顯式的continuation,以及一個tail call尾呼叫,前面的continuation傳遞給尾呼叫的函式作為其引數。CPS同樣可以進行尾呼叫優化。continuation的定義和用法在前文中有介紹。
CPS在一般程式設計中使用不多,更多是編譯器裡面使用到。每個函式都可以轉換為PCS形式,這叫做PCS transformation。舉個簡單的例子,函式(+ x y)轉換CPS形式則變成:
(define (+& x y k)
(k (+ x y)))
k是continuation,如果結合call/cc,有下面的例子,其中continuation是取出值並打印出來。
(define (+/k x y)
(display
(call/cc (lambda(k)(+& x y k)))))
對於一般的函式f,轉換為PCS形式則可以定義為:
(define (cps-prim f)
(lambda args
(let ((r (reverse args)))
((car r) (apply f
(reverse (cdr r)))))))
(cps-prim f)返回一個函式,其最後一個引數是continuation,其餘引數送到原函式f中求f的值。舉例如下:
(define *& (cps-prim *))
(define +& (cps-prim +))
(cps-prim *)和(cps-prim +)得到的都是PCS形式的*和+運算。
最後舉一個例子。仍然是階乘函式。尾遞迴的階乘函式如下:
(define (factorial n)
(f-aux n 1))
(define (f-aux n a)
(if (= n 0)
a
(f-aux (- n 1)(* n a))))
而CPS形式的階乘函式如下:(define (factorial& n k)
(=& n 0 (lambda(b)
(if b
(k 1)
(-& n 1 (lambda(nm1)
(factorial& nm1 (lambda(f)
(*& n f k)))))))))
(define (factorial& n k) (f-aux& n 1 k))
(define (f-aux& n a k)
(=& n 0 (lambda(b)
(if b
(k a)
(-& n 1 (lambda(nm1)
(*& n a (lambda(nta)
(f-aux& nm1 nta k)))))))))
兩個函式中,所有的函式和運算子都要先轉換為CPS形式。第一個函式中,每次呼叫CPS形式的子函式,上一層的continuation傳遞進去經過包裝,作為下一層巢狀的子函式的continuation引數。例如=&函式的continuation是lambda(b)...,(= n 0)的bool值作為引數b,計算lambda(b)...的值。(- n 1)的值作為nm1代入lambda(nm1)...計算factorial& nm1 (lambda(f)...),其中對新的factorial&,lambda(f)(*&
n f k)作為新的continuation k。這個函式雖然是CPS形式,但是因為continuation中記錄了層層巢狀的複雜的計算過程,並不能尾遞迴優化。第二個函式可以實現尾遞迴優化,因為用到了輔助的f-aux&函式。尾遞迴過程實際上就是(if (= n 0) (k a) (f-aux& (- n 1) (* n a) k)),如果將=&、-&、*&三個函式轉為普通形式,則階乘函式的程式碼如下:(define (factorial& n k) (f-aux& n 1 k))
(define (f-aux& n a k)
(if (= n 0) (k a)
(f-aux& (- n 1) (* n a) k)))
對比前面的(factorial n)函式,無非就是多了個continuation引數而已。這就是CPS。