原文。
https://en.wikibooks.org/wiki/Write_Yourself_a_Scheme_in_48_Hours/Towards_a_Standard_Library
現在我們的Scheme基本是完成了,但目前它還有點難以使用。至少,我們希望能夠有一個標準的列表處理的函數庫供我們使用來進行一些基本的計算操作。
與其使用一個傳統的遞歸方式來實現Scheme中的列表函數,我們會首先定義兩個基本的遞歸操作符(fold和unfold)然后在它們的基礎上定義出我們的整個函數庫。這是Haskell中Prelude庫的風格:更加簡潔的定義,更少出錯的可能,使用fold在控制循環是很好的習慣。
我們從定義一些簡單有用的輔助函數開始,我們將no和null函數通過if語句定義出來:
(define (not x) (if x #f #t))
(define (null? obj) (if (eqv? obj '()) #t #f))
我們可以通過可變參數的功能來定義list函數,它會將輸入參數作為一個列表返回:
(define (list . objs) objs)
我們還需要一個id函數,僅僅將輸入參數完全不變的返回給我們。這雖然看起來好像完全沒用-如果你已經持有這個值了,為什么還需要一個函數來將它再次返回給你呢?然而,我們需要的算法都會讀取一個對給定值進行轉換的函數作為參數。而通過定義id函數,我們能在調用那些高階函數的時候達到不對值進行任何改變的效果。
(define (id obj) obj)
類似的我們也同樣需要flip函數來在傳入參數順序不對的時候允許我們進行調整:
(define (flip func) (lambda (arg1 arg2) (func arg2 arg1)))
最后,我們添加curry和compose函數,它們會我們在Haskell里已經熟悉的概念完全一樣(分別對應部分應用以及dot操作符的概念)。
(define (curry func arg1) (lambda (arg) (apply func (cons arg1 (list arg)))))
(define (compose f g) (lambda (arg) (f (apply g arg))))
再來一些Scheme標準庫中應該包含的簡單函數:
(define zero? (curry = 0))
(define positive? (curry < 0))
(define negative? (curry > 0))
(define (odd? num) (= (mod num 2) 1))
(define (even? num) (= (mod num 2) 0))
這些定義基本上和想象中的一樣。注意我們是怎么使用curry來定義zero?
,positive?
和negative?
的。我們將curry返回的函數綁定給zero?
,從而得到一個如果參數等于0就返回True的一元函數。
接下來,我們要定義一個能夠捕獲列表的基本遞歸形式的fold函數。最好的想象這個fold函數的方法就是以中綴構造的方式來思考整個列表:Haskell中是[1, 2, 3, 4] = 1:2:3:4:[]
而Scheme中則是(1 . (2 . (3 . (4 . NIL))))
。我們的fold函數會把每一個構造器都替換成一個二元操作符,然后把Nil替代成一個累計值。舉例來說(fold + 0 '(1 2 3 4)) = (1 + (2 + (3 + (4 + 0))))
。
有了以上的定義,我們就可以寫出我們的fold函數了。首先通過右結合的方式來模擬上面的例子:
(define (foldr func end lst)
(if (null? lst)
end
(func (car lst) (foldr func end (cdr lst)))))
這個函數基本上就完全模擬我們之前對它的定義。如果列表是空的,就用一個終止符取代它。如果不是,我們對列表的car部分以及剩余部分和一個end值一起被fold之后的結果應用這個函數。因為列表右側的部分會首先被fold,因此這里你首先得到了一個右結合的fold函數。
同樣我們也想要一個左結合的版本。對于大部分像+
或是*
這樣的操作符,這兩者是完全一樣的。然而,我們至少有一個重要的二元操作符是沒法這樣結合的:cons。因此對于列表操作函數,我們需要在左右兩種方式結合的fold中有所選擇.
(define (foldl func accum lst)
(if (null? lst)
accum
(foldl func (func accum (car lst)) (cdr lst))))
開頭的部分和我們在右結合的時候一樣,通過判斷來決定是否直接將累計值返回。不過接下來有點不同,我們首先對累計值和列表的頭元素應用我們的函數,而不是像之前那樣分別對頭元素和剩下元素fold的結果進行應用。這意味著我們這次會首先處理列表的頭部部分,提供給了我們左結合的功能。當到達列表的結尾'()
的時候,我們就將之前一路處理得到累計值作為結果返回。
注意傳入兩個fold的函數func
讀入參數的順序是相反的。foldr中,累計值代表的是當前位置右邊遞歸計算直到列表結束的結果。而在foldl中,它代表了列表左邊部分已經完成了的計算。因此為了保證我們隊操作符交換性能夠有一個直觀的認識,在foldl中累計值會作為函數的前一個參數,而在foldr中會作為后一個。
定義好了基本的fold函數,我們再根據它們在Scheme中的典型用途為它們定義一些別名:
(define fold foldl)
(define reduce foldr)
這里沒有定義新函數,只是把定義過的函數綁定到了新的變量上。大部分Scheme會把fold稱作reduce或是傳統的fold,并且不對foldl和foldr進行區分。我們這里把它和foldl綁定,這里foldl是一個尾遞歸的函數因此它會比foldr的運行效率更高(它不需要在開始計算之前將整個列表遞歸展開)。但是不是所有操作都支持結合律的。我們接下來會看到幾個需要使用foldr才能正確運行的例子。
接下來,我們來定義一個與fold相反的函數。讀入一個一元函數,一個初始值和一個一元斷言,它會不斷將函數應用于之前的計算結果直到斷言為真,然后將所有得到的計算值組成一個列表。
(define (unfold func init pred)
(if (pred init)
(cons init '())
(cons init (unfold func (func init) pred))))
我們的函數一如既往的和上面給出的定義一致。如果斷言得到True,那就將一個'()
和最后的值cons起來,將列表終止。否則的話,將當前值和接下來的unfold結果值組合起來。
在學院派的函數式編程著作當中,folds一般會被稱作catamorphisms,unfold則是anamorphisms,而它們兩者的組合被叫做hylomorphisms。有趣的地方就是事實上所有for-each循環都可以以catamorphisms的形式表達。如果我們想要將一個循環轉換成一個foldl形式,我們只需要把所有循環中會操作的變量組成一個數據結構就可以了(例如Record類型,或者你也可以自己專門定義一個代數類型)。我們把初始狀態作為累計值;循環體作為傳入fold的func參數,循環體中的變量是一個參數而被遍歷的變量是第二個;最后,列表參數當然就是我們需要遍歷的列表。fold函數最后計算的結果就是所有變量被更新后的狀態。
相似的,所有for循環(沒有事先存在的遍歷對象)也都可以用hylomorphism來表示。初始值,終止值以及for循環中的每一步判斷條件定義了一個產生數據列表的anamorphism來供我們遍歷。接下來,你只需要把它當做一個for-each循環然后使用catamorphism 來將它分解并按照需要做你自己的計算并對狀態進行更新就可以了。
先來看點例子。我們會從典型的sum,product,and和or函數開始:
(define (sum . lst) (fold + 0 lst))
(define (product . lst) (fold * 1 lst))
(define (and . lst) (fold && #t lst))
(define (or . lst) (fold || #f lst))
這里是它們的定義:
- (sum 1 2 3 4) = 1 + 2 + 3 + 4 + 0 = (fold + 0 '(1 . (2 . (3 . (4 . NIL)))))
- (product 1 2 3 4) = 1 * 2 * 3 * 4 * 1 = (fold * 1 '(1 . (2 . (3 . (4 . NIL)))))
- (and #t #t #f) = #t && #t && #f && #t = (fold && #t '(#t . (#t . (#f . NIL))))
- (or #t #t #f) = #t || #t || #f || #f = (fold || #f '(#t . (#t . (#f . NIL))))
因為所有操作符都滿足結合律,因此它不在乎我們是使用foldr還是foldl。我們將cons替換為操作符,將Nil替換為操作符的幺元。
接下來,試試看一些更加復雜的操作符。max和min會分別將它們傳入參數中的最大和最小值找出來:
(define (max first . rest) (fold (lambda (old new) (if (> old new) old new)) first rest))
(define (min first . rest) (fold (lambda (old new) (if (< old new) old new)) first rest))
這里似乎沒辦法一眼看清楚到底我們對整個列表fold了什么操作,因為似乎并沒有任何比較合適的內置函數。這里我們將fold想象成foreach循環的一種形式。累計值可以代表我們在循環的上一次迭代中的任意狀態,因此我們這里把它當做目前為止發現的最大值。它的初始值就應該是整個列表的最左邊的元素(因為我們使用的是foldl)。注意我們的操作的結果是會變成下一步的累計值的,所以我們就這樣定義傳入的函數。如果前一個值更大那就保留它,而如果后一個值更大或是它們是相等的,那就返回新的值。min函數則是與之相反的過程。
length函數怎么樣?我們知道我們可以通過累加的方式來求出一個列表的長度,那么我們怎么用fold的形式來表達它呢:
(define (length lst) (fold (lambda (x y) (+ x 1)) 0 lst))
同樣我們把這個定義想象成一個循環。累計從0開始,然后沒迭代一次就增加1。因此我們得到了初始值0以及需要傳入fold的函數(lambda (x y) (+ x 1))
。另一種思考的方式就是“列表的的長度就是1加上它當前位置以左部分的子串的長度”。
讓我們來看點有趣的:reverse函數:
(define (reverse lst) (fold (flip cons) '() lst))
這個函數這里就非常明顯了:如果你想要翻轉兩個cons單元,你只需要使用flip函數,這樣它們就會將參數調換到相反的位置。然而,實際這里有一個很巧妙的地方。普通列表都是右結合的:(1 2 3 4) = (1 . (2 . (3 . (4 . NIL))))
。如果你想要將它翻轉,你需要讓你的fold能夠支持左結合(reverse '(1 2 3 4)) = (4 . (3 . (2 . (1 . NIL))))
。用foldr代替foldl試試看你會得到什么。
接下來是一大堆member和assoc函數,它們都可以用fold的形式來定義。雖然它們需要的lambda表達式都有點復雜,讓我們來提取它們中的規律:
(define (mem-helper pred op) (lambda (acc next) (if (and (not acc) (pred (op next))) next acc)))
(define (memq obj lst) (fold (mem-helper (curry eq? obj) id) #f lst))
(define (memv obj lst) (fold (mem-helper (curry eqv? obj) id) #f lst))
(define (member obj lst) (fold (mem-helper (curry equal? obj) id) #f lst))
(define (assq obj alist) (fold (mem-helper (curry eq? obj) car) #f alist))
(define (assv obj alist) (fold (mem-helper (curry eqv? obj) car) #f alist))
(define (assoc obj alist) (fold (mem-helper (curry equal? obj) car) #f alist))
這里我們讓輔助函數能夠讀取一個斷言和一個會對結果進行操作的函數作為參數。它的累計值代表著當前第一個被找到的匹配值:它的初始值是#f
并且會變成列表中第一個滿足斷言的值。我們通過一個非#f
的判斷來避免找到后續的值從而讓它在累計值已經被設置之后直接將當前值返回。我們還提供了一個在斷言檢測時每次都會應用到next值的操作:這讓我們可以自定義我們的mem-helper函數來選擇是檢查值本身(member函數)or是只是檢查鍵(assoc函數)。
剩下的部分就只是各種eq?
,eqv?
,equal?
,id
和car
的組合將整個列表從#f
開始fold起來的過程了。
接下來我們來定義map和filter函數。map會將函數應用到列表里的每一個元素,然后返回一個由被轉換后的元素組成的列表:
(define (map func lst) (foldr (lambda (x y) (cons (func x) y)) '() lst))
需要記住foldr的函數首先讀取的參數是當前的迭代值,這是和fold不同的。Map函數中的lambda表達式會首先將函數應用于當前值,然后再講它的結果和剩下部分的map之后的列表組合起來。這本質上來說就是將所有的中綴cons構造器都替代成一個,然后再將函數對cons左邊的參數進行應用。
filter函數會將列表里滿足條件的元素留下來,然后丟棄掉其它的:
(define (filter pred lst) (foldr (lambda (x y) (if (pred x) (cons x y) y)) '() lst))
它會將每個值代入斷言里計算然后如果結果為True,用cons代替cons,即什么也不做。如果是False,將cons丟棄掉,僅僅將列表剩下的部分返回。這樣我們就刪除掉了列表中所有不滿足斷言的元素,然后用cons將剩下滿足的元素組成一個新的列表。
我們可以在Lisp解釋器啟動以后通過(load "stdlib.scm")
來加載我們的標準庫:
$ ./lisp
Lisp>>> (load "stdlib.scm")
(lambda ("pred" . lst) ...)
Lisp>>> (map (curry + 2) '(1 2 3 4))
(3 4 5 6)
Lisp>>> (filter even? '(1 2 3 4))
(2 4)
Lisp>>> quit
標準庫里還有很多其他有用的函數,例如list-tail,list-ref,append以及其他字符串操作函數。嘗試著通過folds來實現它們。記住,基于fold的編程成功的關鍵就是以迭代為單位進行思考。我們通過fold來捕捉列表中的遞歸的模式,然后一次一步的解決這個遞歸。