1. 程式人生 > >詳解Clojure的遞迴(上)—— 直接遞迴及優化

詳解Clojure的遞迴(上)—— 直接遞迴及優化

遞迴可以說是LISP的靈魂之一,通過遞迴可以簡潔地描述數學公式、函式呼叫,Clojure是LISP的方言,同樣需要遞迴來扮演重要作用。遞迴的價值在於可以讓你的思維以what的形式思考,而無需考慮how,你寫出來的程式碼就是數學公式,就是函式的描述,一切顯得直觀和透明。如果你不習慣遞迴,那只是因為命令式語言的思維根深蒂固,如x=x+1這樣的表示式,從數學的角度來看完全不合法,但是在命令式語言裡卻是合法的賦值語句。


   遞迴可以分為直接遞迴和間接遞迴,取決於函式是否直接或者間接地呼叫自身。如果函式的最後一個呼叫是遞迴呼叫,那麼這樣的遞迴呼叫稱為尾遞迴,針對此類遞迴呼叫,編譯器可以作所謂的尾遞迴優化(TCO),因為遞迴呼叫是最後一個,因此函式的區域性變數等沒有必要再儲存,本次呼叫的結果可以完全作為引數傳遞給下一個遞迴呼叫,清空當前的棧並複用,那麼就不需要為遞迴的函式呼叫儲存一長串的棧,因此不會有棧溢位的問題。在Erlang、LISP這樣的FP語言裡,都支援TCO,無論是直接遞迴或者間接遞迴。


   但是由於JVM自身的限制,Clojure和Scala一樣,僅支援直接的尾遞迴優化,將尾遞迴呼叫優化成迴圈語句。例如一個求階乘的例子:
    
;;第一個版本的階乘函式
(defn fac [n]
          (if (= 1 n)
              1
             (* n (fac (dec n)))))


   第一個版本的階乘並非尾遞迴,這是因為最後一個表示式的呼叫是一個乘法運算,而非(fac (dec n)),因此這個版本的階乘在計算大數的時候會導致棧溢位:
user=> (fac 10000)
java.lang.StackOverflowError (NO_SOURCE_FILE:0)


   將第一個版本改進一下,為了讓最後一個呼叫是遞迴呼叫,那麼我們需要將結果作為引數來傳遞,而不是倚靠棧來儲存,並且為了維持介面一樣,我們引入了一個內部函式fac0:
  
  ;;第二個版本,不是尾遞迴的“尾遞迴” 
  (defn fac [n]
           (defn fac0 [c r]
              (if (= 0 c)
                  r
                  (fac0 (dec c) (* c r))))
           (fac0 n 1))


   這個是第二個版本的階乘,通過將結果提取成引數來傳遞,就將fac0函式的遞迴呼叫修改為尾遞迴的形式,這是個尾遞迴嗎?這在Scala裡,在LISP裡,這都是尾遞迴,但是Clojure的TCO優化卻是要求使用recur這個特殊形式,而不能直接用函式名作遞迴呼叫,因此我們這個第二版本在計算大數的時候仍然將棧溢位:
user=> (fac 10000)
java.lang.StackOverflowError (NO_SOURCE_FILE:0)


   在Clojure里正確地TCO應該是什麼樣子的呢?其實只要用recur在最後呼叫那一下替代fac0即可,這就形成我們第三個版本的階乘:
  ;;第三個版本,TCO起作用了
  (defn fac [n]
           (defn fac0 [c r]
              (if (= 0 c)
                  r
                  (recur (dec c) (* c r))))
           (fac0 n 1))


    此時你再計算大數就沒有問題了,計算(fac 10000)可以正常執行(結果太長,我就不貼出來了)。recur只能跟函式或者loop結合在一起使用,只有函式和loop會形成遞迴點。我們第三個版本就是利用函式fac0做了尾遞迴呼叫的優化。
    
    loop跟let相似,只不過loop會在頂層形成一個遞迴點,以便recur重新繫結引數,使用loop改寫階乘函式,這時候就不需要定義內部函數了:
;;利用loop改寫的第四個版本的階乘函式
(defn fac [n]
           (loop [n n r 1]
                (if (= n 0)
                    r
                    (recur (dec n) (* n r)))))


   loop初始的時候將n繫結為傳入的引數n(由於作用域不同,同名沒有問題),將r繫結為1,最後recur就可以將新的引數值繫結到loop的引數列表並遞迴呼叫。


   Clojure的TCO是怎麼做到的,具體可以看看我前兩天寫的這篇部落格,本質上是在編譯的時候將最後的遞迴呼叫轉化成一條goto語句跳轉到開始的Label,也就是轉變成了迴圈呼叫。


   這個階乘函式仍然有優化的空間,可以看到,每次計算其實都有部分是重複計算的,如計算(fac 5)也就是1*2*3*4*5,計算(fac 6)的1*2*3*4*5*6,如果能將前面的計算結果快取下來,那麼計算(fac 6)的時候將更快一些,這可以通過memoize函式來包裝階乘函式:
;;第五個版本的階乘,快取中間結果
(def fac (memoize fac))


第一次計算(fac 10000)花費的時間長一些,因為還沒有快取:
user=> (time (fac 10000)) 
"Elapsed time: 170.489622 msecs"




第二次計算快了非常多(其實沒有計算,只是返回快取結果):
user=> (time (fac 10000))
"Elapsed time: 0.058737 msecs"


    可以看到,如果沒有預先快取,利用memoize包裝的階乘函式也是快不了。memoize的問題在於,計算(fac n)路徑上的沒有用到的值都不會快取,它只快取最終的結果,因此如果計算n前面的其他沒有計算過的數字,仍然需要重新計算。那麼怎麼儲存路徑上的值呢?這可以將求階乘轉化成另一個等價問題來解決。
    我們可以將所有的階乘結果組織成一個無窮集合,求階乘變成從這個集合裡取第n個元素,這是利用Clojure裡集合是lazy的特性,集合裡的元素如果沒有使用到,那麼就不會預先計算,而是等待要用到的時候才計算出來,定義一個階乘結果的無窮集合,可以利用map將fac作用在整數集合上,map、reduce這樣的高階函式返回的是LazySeq:
 (def fac-seq (map fac (iterate inc 0)))


   (iterate inc 0)定義了正整數集合包括0,0的階乘沒有意義。這個集合的第0項其實是多餘的。
   檢視fac-seq的型別,這是一個LazySeq:
user=> (class fac-seq)
clojure.lang.LazySeq


  求n的階乘,等價於從這個集合裡取第n個元素:
user=> (nth fac-seq 10)
3628800


  這個集合會比較耗記憶體,因為會快取所有計算路徑上的獨立的值,哪怕他們暫時不會被用到。但是這種採用LazySeq的方式來定義階乘函式的方式有個優點,那就是在定義fac-seq使用的fac函式無需一定是符合TCO的函式,我們的第一個版本的階乘函式稍微修改下也可以使用,並且不會棧溢位:
(defn fac [n]
          (if (<= n 1)
              1
              (* n (fac (dec n)))))


(def fac (memoize fac))
(def fac-seq (map fac (iterate inc 0)))
(nth fac-seq 10000)




  因為集合從0開始,因此只是修改了fac的if條件為n<=1的時候返回1。至於為什麼這樣就不會棧溢位,有興趣的朋友可以自己思考下。


    從這個例子也可以看出,一些無法TCO的遞迴呼叫可以轉化為LazySeq來處理,這算是彌補JVM缺陷的一個辦法。