Write Yourself a Scheme in 48 Hours/Towards a Standard Library

原文。
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?,idcar的組合將整個列表從#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來捕捉列表中的遞歸的模式,然后一次一步的解決這個遞歸。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,663評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,125評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,506評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,614評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,402評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,934評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,021評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,168評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,690評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,596評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,288評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,027評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,404評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,662評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,398評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,743評論 2 370

推薦閱讀更多精彩內容