從遞迴到 Y 組合子
我們再來回顧一下停機問題 :
是否存在一個函式 P,對於任意輸入的函式 w,能夠判斷 w 會在有限時間內結束或者死迴圈。
通過前文的論述,我們已經知道這樣的函式 P 是不存在的。由此可知,某些函式存在可以用語言描述卻無法被程式定義的情況,以 Scheme 語言為例,即無法用(define ...)
給函式命名。
遞迴
我們知道遞迴在 Scheme 中重要性是處在第一序列的。《The Little Schemer》書裡,幾乎所有函式都是圍繞著遞迴思想來建立的。下面來看一個用於獲取列表 l 的長度的例子:
(define length (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l)))))))
它定義了length
這個函式名稱並用其進行遞迴操作。
如果這時候告訴你,(define ...)
不能用了,那麼還能有什麼方法可以表示出length
函式呢?答案是有的,不過得讓我們一步步來接近它。
高階函式
高階函式的一個形式是:接受一個函式作為引數,返回另一個函式。在上述例子中,我們可以引入一個高階函式,把函式length
作為引數傳入:
(lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l)))))))
顯然它返回的也是length
函式本身,而且沒有再用到(define ...)
。因為這個高階函式建立了length
函式(make length),所以乾脆就叫它mk-length
。
事情看起來已經符合我們的要求了,接下來似乎只要
(mk-length length)
就能得到length
函式。不過傳入的length
引數依舊需要通過mk-length
來生成,也就是:
(mk-length (mk-length length))
可以預見接下來還會有:
(mk-length (mk-length (mk-length length)))
(mk-length (mk-length (mk-length ...)))
這樣下去就進入了無限迴圈。讓我們先回到(mk-length length)
。
從頭開始
上面length
被不停建立的過程觸發了無限遞迴,要解決這個問題,我們來做一個神奇的替換。
(define eternity (lambda (x) (eternity x)))
eternity
是我們之前創建出來的一個最簡單的偏函式,並且也是無限遞迴的。我們用它來替換length
,代入(mk-length eternity)
並展開:
((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) eternity)
再建立一個高階函式,將mk-length
本身作為引數提取出來:
((lambda (mk-length) (mk-length eternity)) (lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))))
那麼上面這個函式是否已經創建出了length
函式呢,我們代入具體的列表 l 來檢驗。
當 l 是空列表時,進入(null? l)
分支,返回值是 0,正確。
當 l 是(a)
這樣的非空列表時,則進入else
分支,但是(etertiny (quote ()))
並不會返回值,函式也就不會返回正確的答案。
這個函式看起來只能計算空列表的長度,稱之為length0
似乎更加貼切。
(define length0 (mk-length eternity))
接下來我們可以寫出length1
,它可以計算長度不超過 1 的列表:
((lambda (mk-length) (mk-length length0)) (lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))))
展開:
((lambda (mk-length) (mk-length (mk-length eternity))) (lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))))
可以寫出length2
了嗎?當然:
((lambda (mk-length) (mk-length (mk-length (mk-length eternity)))) (lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))))
它可以計算長度不大於 2 的列表。lengthN
呢?
((lambda (mk-length) (mk-length (mk-length (mk-length ...)))) (lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))))
要寫 N 個mk-length
了。
分析上面的推導過程可知,我們現在還無法定義一個通用的length
函式,使得它能夠計算任意列表的長度。從另一個角度考慮,如果在上面的函式形式中,我們能夠寫出足夠多個mk-length
,那麼就能計算任意列表的長度了。但是“足夠多”太抽象,不同列表所要求的個數有所不同,對於一個程式或者函式來講,這樣是寫不出來的。
我算我自己
我們再來看看mk-length
,因為它的引數是一個函式,所以理論上可以呼叫任意函式。我們把mk-length
傳給自己試試:
((lambda (mk-length) (mk-length mk-length)) (lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))))
length
作為入參可以用任意字元表示,甚至是mk-length
:
((lambda (mk-length) (mk-length mk-length)) (lambda (mk-length) (lambda (l) (cond ((null? l) 0) (else (add1 (mk-length (cdr l))))))))
現在我們得出了什麼呢?當傳入非空列表時,else 分支的mk-length
接收一個列表作為引數,但是它實際上需要的引數是函式,所以最後並不會計算出值,這就和length0
一樣了。同時也表明我們將mk-length
傳給自己其實沒有改變整個函式的意義。
如果我們用這個引數mk-length
繼續建立遞迴,就用eternity
好了:
; 函式一 ((lambda (mk-length) (mk-length mk-length)) (lambda (mk-length) (lambda (l) (cond ((null? l) 0) (else (add1 ((mk-length eternity) (cdr l))))))))
代入列表 l 看看,l 這裡是(apple)
:
; 第一步 (((lambda (mk-length) (lambda (l) (cond ((null? l) 0) (else (add1 ((mk-length eternity) (cdr l))))))) (lambda (mk-length) ; 代入展開 (lambda (l) (cond ((null? l) 0) (else (add1 ((mk-length eternity) (cdr l)))))))) l)
; 第二步 ((lambda (l) (cond ((null? l) 0) (else (add1 (((lambda (mk-length) (lambda (l) (cond ((null? l) 0) (else (add1 ((mk-length eternity) (cdr l))))))) eternity) ; 代入展開 (cdr l)))))) l)
; 第三步 ((lambda (l) (cond ((null? l) 0) (else (add1 ((lambda (l) (cond ((null? l) 0) (else (add1 ((eternity eternity) (cdr l)))))) (cdr l)))))) ; 代入展開,這裡 (cdr l) 是 (quote ()),返回 0 l)
現在能夠計算長度是 1 的列表了。那長度為 2 的呢?假設這時 l 是(apple banana)
,前三步是和上面一樣的,最後可以簡化成:
(add1 (add1 ((eternity eternity) (quote ()))))
其中(eternity eternity)
在無限遞迴,上述表示式沒有返回值,所以我們算不出長度為 2 的列表。那麼函式一其實就是length1
。問題來了,有沒有什麼辦法可以讓(eternity eternity)
這個位置的遞迴繼續下去,這樣我們好像就可以計算任意長度的列表了。
解決方案還是和這一節最開始用的相同方法,我們讓mk-length
來呼叫mk-length
:
((lambda (mk-length) (mk-length mk-length)) (lambda (mk-length) (lambda (l) (cond ((null? l) 0) (else (add1 ((mk-length mk-length) (cdr l))))))))
這麼做的目的是在遞迴即將結束的時候,又將mk-length
傳遞給自身,確保遞迴的不斷進行。
有點激動呀,我們終於能夠拋棄(define ...)
,僅用 lambda 表示式就寫出length
函數了不過先等一下,在為勝利歡呼之前,我們來驗證一下函式的正確性。
終極答案
首先我們用高階函式把(mk-length mk-length)
語句提取出來,讓函式更加清晰:
((lambda (mk-length) (mk-length mk-length)) (lambda (mk-length) ((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) (mk-length mk-length))))
第 4 到 8 行長得就很像一開始的mk-length
,這麼一來就舒服了。之後當然是用具體的列表來測試啦,依舊借用(apple)
。要計算(length (quote (apple)))
的值需要先展開length
也就是上面這個函式,我們來試試吧。
((lambda (mk-length) ((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) (mk-length mk-length))) (lambda (mk-length) ; 代入展開 ((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) (mk-length mk-length))))
((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) ((lambda (mk-length) ((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) (mk-length mk-length))) (lambda (mk-length) ; 代入展開 ((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) (mk-length mk-length)))))
((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) ((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) ((lambda (mk-length) ((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) (mk-length mk-length))) (lambda (mk-length) ; 代入展開... ((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) (mk-length mk-length))))))
咦等等,這麼一級級展開完全沒有盡頭,我們只是不停地把mk-length
應用到它自己身上,周而復始。(mk-length mk-length)
本來期待它返回一個函式的,現在再也返回不了了。這可怎麼辦,勝利道路嚴重受阻。
不要方,我們還有一個武器——惰性求值
,它的思想簡單來說就是使表示式在被用到的時候才求值。在我們的需求裡面就是對(mk-length mk-length)
惰性求值,前面的運算裡面不對它進行展開。
如何做?我們考慮到(mk-length mk-length)
返回的函式在理論條件下就是length
,它是一個單引數函式。那麼我們構造一個單引數函式,它可以做到惰性求值:
(lambda (x) ((mk-length mk-length) x))
Lambda 演算
中的第三條 η-變換規則規定到:對於任一給定的引數,當且僅當兩個函式得到的結果都一致,則它們將被視同為一個函式。即如果對於給定的 l,((mk-length mk-length) l)
和((lambda (x) ((mk-length mk-length) x)) l)
的值都相同,那麼就說這兩個函式是等價的。顯然他們就是同一個函式。那麼做完替換之後長這樣:
((lambda (mk-length) (mk-length mk-length)) (lambda (mk-length) ((lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))) (lambda (x) ((mk-length mk-length) x)))))
我們再構建一個高階函式,移出第 4 到 8 行長得像mk-length
的部分,作為引數:
((lambda (le) ((lambda (mk-length) (mk-length mk-length)) (lambda (mk-length) (le (lambda (x) ((mk-length mk-length) x)))))) (lambda (length) (lambda (l) (cond ((null? l) 0) (else (add1 (length (cdr l))))))))
:tada: 大功告成!以上就是我們不用(define ...)
而寫出的length
最終版本。不容易啊。
Y 組合子
我們把上面函式呼叫mk-length
的邏輯部分剝離出來:
(lambda (le) ((lambda (mk-length) (mk-length mk-length)) (lambda (mk-length) (le (lambda (x) ((mk-length mk-length) x))))))
簡化一下:
(lambda (le) ((lambda (f) (f f)) (lambda (f) (le (lambda (x) ((f f) x))))))
這玩意兒就叫應用序 Y 組合子(applicative-order Y combinator)!
仔細體會整個過程,在不給函式命名的情況下,我們最終卻實現了這個遞迴函式。實際上正是 Y 組合子做了這件極其偉大的事情——實現遞迴。
補充:不動點組合子
這裡我們先引入不動點的概念:對於函式 f,如果存在變數 x,有 f(x) = x,此時就說 x 是函式 f 的不動點。
我們來看看mk-length
的不動點fixed-point
是什麼。
(define fixed-point (mk-length fixed-point))
那麼可以替換函式體中的fixed-point
為(mk-length fixed-point)
:
(define fixed-point (mk-length (mk-length fixed-point)))
(define fixed-point (mk-length (mk-length (mk-length fixed-point))))
(define fixed-point (mk-length (mk-length (mk-length ...))))
看上去很眼熟對不對?所以mk-length
的不動點就是我們想要的length
。於是求length
的問題就轉化為:有沒有這樣一個函式calc-fixed-point
,代入引數mk-length
,然後得到mk-length
的不動點:
(calc-fixed-point mk-length) = length
又是一個眼熟的表示式,calc-fixed-point
看起來就是我們上面所得到的 Y 組合子,它可以計算引數的不動點。所以有時它又被稱為不動點組合子。
關於 Lambda 演算、組合子、由不動點推導 Y 組合子等知識點,本文暫不作討論。我光是看明白《The Little Schemer》第九章裡推導 Y 組合子的過程,頭就已經大了好幾圈,頭髮也多掉了好幾
根,就像書裡說得那樣。文章也無法避免由主觀臆斷而產生的概念性或者推導上的錯誤,歡迎指正。