1. 程式人生 > >傻瓜函數語言程式設計

傻瓜函數語言程式設計


title: 傻瓜函數語言程式設計
data: 2018-9-25
tags: [函數語言程式設計,原文,學習]
categories: [學習]
grammar_cjkRuby: true
copyright: true

本文及翻譯摘自Github@justjavac(迷渡)

============================================
2006年6月19日,星期一

開篇

我們這些碼農做事都是很拖拉的。每天例行報到後,先來點咖啡,看看郵件還有RSS訂閱的文章。然後翻翻新聞還有那些技術網站上的更新,再過一遍程式設計論壇口水區裡那些無聊的論戰。最後從頭把這些再看一次以免錯過什麼精彩的內容。然後就可以吃午飯了。飯飽過後,回來盯著IDE發一會呆,再看看郵箱,再去搞杯咖啡。光陰似箭,可以回家了……
(在被眾人鄙視之前)我唯一想說的是,在這些拖拉的日子裡總會時不時讀到一些

不明覺厲的文章。如果沒有開啟不應該開啟的網站,每隔幾天你都可以看到至少一篇這樣的東西。它們的共性:難懂,耗時,於是這些文章就慢慢的堆積成山了。很快你就會發現自己已經累積了一堆的收藏連結還有數不清的PDF檔案,此時你只希望隱入一個杳無人煙的深山老林裡什麼也不做,用一年半載好好的消化這些私藏寶貝。當然,我是說最好每天還是能有人來給送吃的順帶幫忙打掃衛生倒垃圾,哇哈哈。

我不知道你都收藏了些什麼,我的閱讀清單裡面相當大部分都是函數語言程式設計相關的東東:基本上是最難啃的。這些文章充斥著無比枯燥的教科書語言,我想就連那些在華爾街浸淫10年以上的大牛都無法搞懂這些函數語言程式設計(簡稱FP)文章到底在說什麼。你可以去花旗集團或者德意志銀行找個專案經理來問問1

:你們為什麼要選JMS而不用Erlang?答案基本上是:我認為這個學術用的語言還無法勝任實際應用。可是,現有的一些系統不僅非常複雜還需要滿足十分嚴苛的需求,它們就都是用函數語言程式設計的方法來實現的。這,就說不過去了。
關於FP的文章確實比較難懂,但我不認為一定要搞得那麼晦澀。有一些歷史原因造成了這種知識斷層,可是FP概念本身並不難理解。我希望這篇文章可以成為一個“FP入門指南”,幫助你從指令式程式設計走向函數語言程式設計。先來點咖啡,然後繼續讀下去。很快你對FP的理解就會讓同事們刮目相看了。

什麼是函數語言程式設計(Functional Programming,FP)?它從何而來?可以吃嗎?倘若它真的像那些鼓吹FP的人說的那麼好,為什麼實際應用中那麼少見?為什麼只有那些在讀博士的傢伙想要用它?而最重要的是,它母親的怎麼就那麼難學?那些所謂的closure、continuation,currying,lazy evaluation還有no side effects都是什麼東東(譯者:本著保留專用術語的原則,此處及下文類似情形均不譯)?如果沒有那些大學教授的幫忙怎樣把它應用到實際工程裡去?為什麼它和我們熟悉的萬能而神聖的指令式程式設計那麼的不一樣?
我們很快就會解開這些謎團。剛才我說過實際工程和學術界之間的知識斷層是有其歷史原因的,那麼就先讓我來解釋一下這個問題。答案,就在接下來的一次公園漫步中:

公園漫步

時間機器啟動……我們來到公元前380年,也就是2000多年前的雅典城外。這是一個陽光明媚的久違的春天,柏拉圖和一個帥氣的小男僕走在一片橄欖樹蔭下。他們正準備前往一個學院。天氣很好,吃得很飽,漸漸的,兩人的談話轉向了哲學。

“你看那兩個學生,哪一個更高一些?”,柏拉圖小心的選擇用字,以便讓這個問題更好的引導眼前的這個小男孩。
小男僕望向水池旁邊的兩個男生,“他們差不多一樣高。”。
“‘差不多一樣高’是什麼意思?”柏拉圖問。
“嗯……從這裡看來他們是一樣高的,但是如果走近一點我肯定能看出差別來。”
柏拉圖笑了。他知道這個小孩已經朝他引導的方向走了。“這麼說來你的意思是世界上沒有什麼東西是完全相同的咯?”
思考了一會,小男孩回答:“是的。萬物之間都至少有一丁點差別,哪怕我們無法分辨出來。”
說到點子上了!“那你說,如果世界上沒有什麼東西是完全相等的,你怎麼理解‘完全相等’這個概念?”
小男僕看起來很困惑。“這我就不知道了。”

這是人類第一次試圖瞭解數學的本質。柏拉圖認為我們所在的世界中,萬事萬物都是完美模型的一個近似。他同時意識到雖然我們不能感受到完美的模型,但這絲毫不會阻止我們瞭解完美模型的概念。柏拉圖進而得出結論:完美的數學模型只存在於另外一個世界,而因為某種原因我們卻可以通過聯絡著這兩個世界的一個紐帶來認識這些模型。一個簡單的例子就是完美的圓形。沒有人見過這樣的一個圓,但是我們知道怎樣的圓是完美的圓,而且可以用公式把它描述出來。

如此說來,什麼是數學呢?為什麼可以用數學法則來描述我們的這個宇宙?我們所處的這個世界中萬事萬物都可以用數學來描述嗎?2
數理哲學是一門很複雜的學科。它和其他多數哲學一樣,更著重於提出問題而不是給出答案。數學就像拼圖一樣,很多結論都是這樣推匯出來的:先是確立一些互不衝突的基礎原理,以及一些操作這些原理的規則,然後就可以把這些原理以及規則拼湊起來形成新的更加複雜的規則或是定理了。數學家把這種方法稱為“形式系統”或是“演算”。如果你想做的話,可以用形式系統描述俄羅斯方塊這個遊戲。而事實上,俄羅斯方塊這個遊戲的實現,只要它正確執行,就是一個形式系統。只不過它以一種不常見的形式表現出來罷了。

如果半人馬阿爾法上有文明存在的話,那裡的生物可能無法解讀我們的俄羅斯方塊形式系統甚至是簡單的圓形的形式系統,因為它們感知世界的唯一器官可能只有鼻子(譯者:偶的媽你咋知道?)也許它們是無法得知俄羅斯方塊的形式系統了,但是它們很有可能知道圓形。它們的圓形我們可能沒法解讀,因為我們的鼻子沒有它們那麼靈敏(譯者:那狗可以麼?)可是隻要越過形式系統的表示方式(比如通過使用“超級鼻子”之類的工具來感知這些用味道表示的形式系統,然後使用標準的解碼技術把它們翻譯成人類能理解的語言),那麼任何有足夠智力的文明都可以理解這些形式系統的本質。
有意思的是,哪怕宇宙中完全不存在任何文明,類似俄羅斯方塊還有圓形這樣的形式系統依舊是成立的:只不過沒有智慧生物去發現它們而已。這個時候如果忽然一個文明誕生了,那麼這些具有智慧的生物就很有可能發現各種各樣的形式系統,並且用它們發現的系統去描述各種宇宙法則。不過它們可能不會發現俄羅斯方塊這樣的形式系統,因為在它們的世界裡沒有俄羅斯方塊這種東西嘛。有很多像俄羅斯方塊這樣的形式系統是與客觀世界無關的,比如說自然數,很難說所有的自然數都與客觀世界有關,隨便舉一個超級大的數,這個數可能就和世界上任何事物無關,因為這個世界可能不是無窮大的。

歷史回眸3

再次啟動時間機……這次到達的是20世紀30年代,離今天近了很多。無論大陸,經濟大蕭條都造成了巨大的破壞。社會各階層幾乎每一個家庭都深受其害。只有極其少數的幾個地方能讓人們免於遭受窮困之苦。幾乎沒有人能夠幸運的在這些避難所裡度過危機,注意,我說的是幾乎沒有,還真的有這麼些幸運兒,比如說當時普林斯頓大學的數學家們。

新建成的哥特式辦公樓給普林斯頓大學帶來一種天堂般的安全感。來自世界各地的邏輯學者應邀來到普林斯頓,他們將組建一個新的學部。正當大部分美國人還在為找不到一片面包做晚餐而發愁的時候,在普林斯頓卻是這樣一番景象:高高的天花板和木雕包覆的牆,每天品茶論道,漫步叢林。
一個名叫阿隆佐·邱奇(Alonzo Church)的年輕數學家就過著這樣優越的生活。阿隆佐本科畢業於普林斯頓後被留在研究院。他覺得這樣的生活完全沒有必要,於是他鮮少出現在那些數學茶會中也不喜歡到樹林裡散心。阿隆佐更喜歡獨處:自己一個人的時候他的工作效率更高。儘管如此他還是和普林斯頓學者保持著聯絡,這些人當中有艾倫·圖靈約翰·馮·諾伊曼庫爾特·哥德爾
這四個人都對形式系統感興趣。相對於現實世界,他們更關心如何解決抽象的數學問題。而他們的問題都有這麼一個共同點:都在嘗試解答關於計算的問題。諸如:如果有一臺擁有無窮計算能力的超級機器,可以用來解決什麼問題?它可以自動的解決這些問題嗎?是不是還是有些問題解決不了,如果有的話,是為什麼?如果這樣的機器採用不同的設計,它們的計算能力相同嗎?
在與這些人的合作下,阿隆佐設計了一個名為lambda演算的形式系統。這個系統實質上是為其中一個超級機器設計的程式語言。在這種語言裡面,函式的引數是函式,返回值也是函式。這種函式用希臘字母lambda(λ),這種系統因此得名4。有了這種形式系統,阿隆佐終於可以分析前面的那些問題並且能夠給出答案了。
除了阿隆佐·邱奇,艾倫·圖靈也在進行類似的研究。他設計了一種完全不同的系統(後來被稱為圖靈機),並用這種系統得出了和阿隆佐相似的答案。到了後來人們證明了圖靈機和lambda演算的能力是一樣的。

如果二戰沒有發生,這個故事到這裡就應該結束了,我的這篇小文沒什麼好說的了,你們也可以去看看有什麼其他好看的文章。可是二戰還是爆發了,整個世界陷於火海之中。那時的美軍空前的大量使用炮兵。為了提高轟炸的精度,軍方聘請了大批數學家夜以繼日的求解各種差分方程用於計算各種火炮發射資料表。後來他們發現單純手工計算這些方程太耗時了,為了解決這個問題,各種各樣的計算裝置應運而生。IBM製造的Mark一號就是用來計算這些發射資料表的第一臺機器。Mark一號重5噸,由75萬個零部件構成,每一秒可以完成3次運算。
戰後,人們為提高計算能力而做出的努力並沒有停止。1949年第一臺電子離散變數自動計算機誕生並取得了巨大的成功。它是馮·諾伊曼設計架構的第一個例項,也是一臺現實世界中實現的圖靈機。相比他的這些同事,那個時候阿隆佐的運氣就沒那麼好了。
到了50年代末,一個叫John McCarthy的MIT教授(他也是普林斯頓的碩士)對阿隆佐的成果產生了興趣。1958年他發明了一種列表處理語言(Lisp),這種語言是一種阿隆佐lambda演算在現實世界的實現,而且它能在馮·諾伊曼計算機上執行!很多電腦科學家都認識到了Lisp強大的能力。1973年在MIT人工智慧實驗室的一些程式設計師研發出一種機器,並把它叫做Lisp機。於是阿隆佐的lambda演算也有自己的硬體實現了!

函數語言程式設計

函數語言程式設計是阿隆佐思想的在現實世界中的實現。不過不是全部的lambda演算思想都可以運用到實際中,因lambda演算在設計的時候就不是為了在各種現實世界中的限制下工作的。所以,就像面向物件的程式設計思想一樣,函數語言程式設計只是一系列想法,而不是一套嚴苛的規定。有很多支援函數語言程式設計的程式語言,它們之間的具體設計都不完全一樣。在這裡我將用Java寫的例子介紹那些被廣泛應用的函數語言程式設計思想(沒錯,如果你是受虐狂你可以用Java寫出函式式程式)。在下面的章節中我會在Java語言的基礎上,做一些修改讓它變成實際可用的函數語言程式設計語言。那麼現在就開始吧。

Lambda演算在最初設計的時候就是為了研究計算相關的問題。所以函數語言程式設計主要解決的也是計算問題,而出乎意料的是,是用函式來解決的!(譯者:請理解原作者的苦心,我想他是希望加入一點調皮的風格以免讀者在中途睡著或是轉檯……)。函式就是函數語言程式設計中的基礎元素,可以完成幾乎所有的操作,哪怕最簡單的計算,也是用函式完成的。我們通常理解的變數在函數語言程式設計中也被函式代替了:在函數語言程式設計中變數僅僅代表某個表示式(這樣我們就不用把所有的程式碼都寫在同一行裡了)。所以我們這裡所說的‘變數’是不能被修改的。所有的變數只能被賦一次初值。在Java中就意味著每一個變數都將被宣告為final(如果你用C++,就是const)。在FP中,沒有非final的變數。

final int i = 5;
final int j = i + 3;

既然FP中所有的變數都是final的,可以引出兩個規定:一是變數前面就沒有必要再加上final這個關鍵字了,二是變數就不能再叫做‘變數’了……於是現在開始對Java做兩個改動:所有Java中宣告的變數預設為final,而且我們把所謂的‘變數’稱為‘符號’。
到現在可能會有人有疑問:這個新創造出來的語言可以用來寫什麼有用的複雜一些的程式嗎?畢竟,如果每個符號的值都是不能修改的,那麼我們就什麼東西都不能改變了!別緊張,這樣的說法不完全正確。阿隆佐在設計lambda演算的時候他並不想要保留狀態的值以便稍後修改這些值。他更關心的是基於資料之上的操作(也就是更容易理解的“計算”)。而且,lambda演算和圖靈機已經被證明了是具有同樣能力的系統,因此指令式程式設計能做到的函數語言程式設計也同樣可以做到。那麼,怎樣才能做到呢?
事實上函式式程式是可以儲存狀態的,只不過它們用的不是變數,而是函式。狀態儲存在函式的引數中,也就是說在棧上。如果你需要儲存一個狀態一段時間並且時不時的修改它,那麼你可以編寫一個遞迴函式。舉個例子,試著寫一個函式,用來反轉一個Java的字串。記住咯,這個程式裡的變數都是預設為final的5

String reverse(String arg) {
    if(arg.length == 0) {
        return arg;
    }
    else {
        return reverse(arg.substring(1, arg.length)) + arg.substring(0, 1);
    }
}

這個方程執行起來會相對慢一些,因為它重複呼叫自己6。同時它也會大量的消耗記憶體,因為它會不斷的分配建立記憶體物件。無論如何,它是用函數語言程式設計思想寫出來的。這時候可能有人要問了,為什麼要用這種奇怪的方式編寫程式呢?嘿,我正準備告訴你。

FP之優點

你大概已經在想:上面這種怪胎函式怎麼也不合理嘛。在我剛開始學習FP的時候我也這樣想的。不過後來我知道我是錯的。使用這種方式程式設計有很多好處。其中一些是主觀的。比如說有人認為函式式程式更容易理解。這個我就不說了,哪怕街上隨便找個小孩都知道‘容易理解’是多麼主觀的事情。幸運的是,客觀方面的好處還有很多。

單元測試

因為FP中的每個符號都是final的,於是沒有什麼函式會有副作用。誰也不能在執行時修改任何東西,也沒有函式可以修改在它的作用域之外修改什麼值給其他函式繼續使用(在指令式程式設計中可以用類成員或是全域性變數做到)。這意味著決定函式執行結果的唯一因素就是它的返回值,而影響其返回值的唯一因素就是它的引數。
這正是單元測試工程師夢寐以求的啊。現在測試程式中的函式時只需要關注它的引數就可以了。完全不需要擔心函式呼叫的順序,也不用費心設定外部某些狀態值。唯一需要做的就是傳遞一些可以代表邊界條件的引數給這些函式。相對於指令式程式設計,如果FP程式中的每一個函式都能通過單元測試,那麼我們對這個軟體的質量必將信心百倍。反觀Java或者C++,僅僅檢查函式的返回值是不夠的:程式碼可能修改外部狀態值,因此我們還需要驗證這些外部的狀態值的正確性。在FP語言中呢,就完全不需要。

除錯查錯

如果一段FP程式沒有按照預期設計那樣執行,除錯的工作幾乎不費吹灰之力。這些錯誤是百分之一百可以重現的,因為FP程式中的錯誤不依賴於之前執行過的不相關的程式碼。而在一個指令式程式中,一個bug可能有時能重現而有些時候又不能。因為這些函式的執行依賴於某些外部狀態, 而這些外部狀態又需要由某些與這個bug完全不相關的程式碼通過某個特別的執行流程才能修改。在FP中這種情況完全不存在:如果一個函式的返回值出錯了,它一直都會出錯,無論你之前運行了什麼程式碼。
一旦問題可以重現,解決它就變得非常簡單,幾乎就是一段愉悅的旅程。中斷程式的執行,檢查一下棧,就可以看到每一個函式呼叫時使用的每一個引數,這一點和指令式程式碼一樣。不同的是指令式程式中這些資料還不足夠,因為函式的執行還可能依賴於成員變數,全域性變數,還有其他類的狀態(而這些狀態又依賴於類似的變數)。FP中的函式只依賴於傳給它的引數,而這些引數就在眼前!還有,對指令式程式中函式返回值的檢查並不能保證這個函式是正確執行的。還要逐一檢查若干作用域以外的物件以確保這個函式沒有對這些牽連的物件做出什麼越軌的行為(譯者:好吧,翻譯到這裡我自己已經有點激動了)。對於一個FP程式,你要做的僅僅是看一下函式的返回值。
把棧上的資料過一遍就可以得知有哪些引數傳給了什麼函式,這些函式又返回了什麼值。當一個返回值看起來不對頭的那一刻,跳進這個函式看看裡面發生了什麼。一直重複跟進下去就可以找到bug的源頭!

併發執行

不需要任何改動,所有FP程式都是可以併發執行的。由於根本不需要採用鎖機制,因此完全不需要擔心死鎖或是併發競爭的發生。在FP程式中沒有哪個執行緒可以修改任何資料,更不用說多執行緒之間了。這使得我們可以輕鬆的新增執行緒,至於那些禍害併發程式的老問題,想都不用想!
既然是這樣,為什麼沒有人在那些高度並行的那些應用程式中採用FP程式設計呢?事實上,這樣的例子並不少見。愛立信開發了一種FP語言,名叫Erlang,並應用在他們的電信交換機上,而這些交換機不僅容錯度高而且拓展性強。許多人看到了Erlang的這些優勢也紛紛開始使用這一語言。在這裡提到的電信交換控制系統遠遠要比華爾街上使用的系統具有更好的擴充套件性也更可靠。事實上,用Erlang搭建的系統並不具備可擴充套件性和可靠性,而Java可以提供這些特性。Erlang只是像岩石一樣結實不容易出錯而已。
FP關於並行的優勢不僅於此。就算某個FP程式本身只是單執行緒的,編譯器也可以將其優化成可以在多CPU上執行的併發程式。以下面的程式為例:

String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);

如果是函式式程式,編譯器就可以對程式碼進行分析,然後可能分析出生成字串s1和s2的兩個函式可能會比較耗時,進而安排它們並行執行。這在指令式程式設計中是無法做到的,因為每一個函式都有可能修改其外部狀態,然後接下來的函式又可能依賴於這些狀態的值。在函數語言程式設計中,自動分析程式碼並找到適合並行執行的函式十分簡單,和分析C的行內函數沒什麼兩樣。從這個角度來說用FP風格編寫的程式是“永不過時”的(雖然我一般不喜歡說大話空話,不過這次就算個例外吧)。硬體廠商已經沒辦法讓CPU執行得再快了。他們只能靠增加CPU核的數量然後用並行來提高運算的速度。這些廠商故意忽略一個事實:只有可以並行的軟體才能讓你花大價錢買來的這些硬體物有所值。指令式的軟體中只有很小一部分能做到跨核執行,而所有的函式式軟體都能實現這一目標,因為FP的程式從一開始就是可以並行執行的。

熱部署

在Windows早期,如果要更新系統那可是要重啟電腦的,而且還要重啟很多次。哪怕只是安裝一個新版本的播放器。到了XP的時代這種情況得到比較大的改善,儘管還是不理想(我工作的時候用的就是Windows,就在現在,我的系統托盤上就有個討厭的圖示,我不重啟機子就不消失)。這一方面Unix好一些,曾經。只需要暫停一些相關的部件而不是整個作業系統,就可以安裝更新了。雖然是要好一些了,對很多伺服器應用來說這也還是不能接受的。電信系統要求的是100%的線上率,如果一個救急電話因為系統升級而無法撥通,成千上萬的人就會因此喪命。同樣的,華爾街的那些公司怎麼也不能說要安裝軟體而在整個週末停止他們系統的服務。
最理想的情況是更新相關的程式碼而不用暫停系統的其他部件。對指令性程式來說是不可能的。想想看,試著在系統執行時解除安裝掉一個Java的類然後再載入這個類的新的實現,這樣做的話系統中所有該類的例項都會立刻不能執行,因為該類的相關狀態已經丟失了。這種情況下可能需絞盡腦汁設計複雜的版本控制程式碼,需要將所有這種類正在執行的例項序列化,逐一銷燬它們,然後建立新類的例項,將現有資料也序列化後裝載到這些新的例項中,最後希望負責裝載的程式可以正確的把這些資料移植到新例項中並正常的工作。這種事很麻煩,每次有新的改動都需要手工編寫裝載程式來完成更新,而且這些裝載程式還要很小心,以免破壞了現有物件之間的聯絡。理論上是沒問題,可是實際上完全行不通。
FP的程式中所有狀態就是傳給函式的引數,而引數都是儲存在棧上的。這一特性讓軟體的熱部署變得十分簡單。只要比較一下正在執行的程式碼以及新的程式碼獲得一個diff,然後用這個diff更新現有的程式碼,新程式碼的熱部署就完成了。其它的事情有FP的語言工具自動完成!如果還有人認為這隻存在於科幻小說中,他需要再想想:多年來Erlang工程師已經使用這種技術對它們的系統進行升級而完全不用暫停運行了。

機器輔助優化及證明

FP語言有一個特性很有意思,那就是它們是可以用數學方法來分析的。FP語言本身就是形式系統的實現,只要是能在紙上寫出來的數學運算就可以用這種語言表述出來。於是只要能夠用數學方法證明兩段程式碼是一致的,編譯器就可以把某段程式碼解析成在數學上等同的但效率又更高的另外一段程式碼7。 關係資料庫已經用這種方法進行優化很多年了。沒有理由在常規的軟體行業就不能應用這種技術。
另外,還可以用這種方法來證明程式碼的正確性,甚至可以設計出能夠自動分析程式碼併為單元測試自動生成邊緣測試用例的工具出來!對於那些對缺陷零容忍的系統來說,這一功能簡直就是無價之寶。例如心臟起搏器,例如飛行管控系統,這幾乎就是必須滿足的需求。哪怕你正在開發的程式不是為了完成什麼重要核心任務,這些工具也可以幫助你寫出更健壯的程式,直接甩競爭對手n條大街。

高階函式

我還記得在瞭解到FP以上的各種好處後想到:“這些優勢都很吸引人,可是,如果必須非要用這種所有變數都是final的蹩腳語言,估計還是不怎麼實用吧”。其實這樣的想法是不對的。對於Java這樣的指令式語言來說,如果所有的變數都是必須是final的,那麼確實很束手束腳。然而對函式式語言來說,情況就不一樣了。函式式語言提供了一種特別的抽象工具,這種工具將幫助使用者編寫FP程式碼,讓他們甚至都沒想到要修改變數的值。高階函式就是這種工具之一。
FP語言中的函式有別於Java或是C。可以說這種函式是一個全集:Java函式可以做到的它都能做,同時它還有更多的能力。首先,像在C裡寫程式那樣建立一個函式:

int add(int i, int j) {
    return i + j;
}

看起來和C程式沒什麼區別,但是很快你就可以看出區別來。接下來我們擴充套件Java的編譯器以便支援這種程式碼,也就是說,當我們寫下以上的程式編譯器會把它轉化成下面的Java程式(別忘了,所有的變數都是final的):

class add_function_t {
    int add(int i, int j) {
        return i + j;
    }
}

add_function_t add = new add_function_t();

在這裡,符號add並不是一個函式,它是隻有一個函式作為其成員的簡單的類。這樣做有很多好處,可以在程式中把add當成引數傳給其他的函式,也可以把add賦給另外一個符號,還可以在執行時建立add_function_t的例項然後在不再需要這些例項的時候由系統回收機制處理掉。這樣做使得函式成為和integer或是string這樣的第一類物件。對其他函式進行操作(比如說把這些函式當成引數)的函式,就是所謂的高階函式。別讓這個看似高深的名字嚇倒你(譯者:好死不死起個這個名字,初一看還準備搬出已經塵封的高數教材……),它和Java中操作其他類(也就是把一個類例項傳給另外的類)的類沒有什麼區別。可以稱這樣的類為“高階類”,但是沒人會在意,因為Java圈裡就沒有什麼很強的學術社團。(譯者:這是高階黑嗎?)
那麼什麼時候該用高階函式,又怎樣用呢?我很高興有人問這個問題。設想一下,你寫了一大堆程式而不考慮什麼類結構設計,然後發現有一部分程式碼重複了幾次,於是你就會把這部分程式碼獨立出來作為一個函式以便多次呼叫(所幸學校裡至少會教這個)。如果你發現這個函式裡有一部分邏輯需要在不同的情況下實現不同的行為,那麼你可以把這部分邏輯獨立出來作為一個高階函式。搞暈了?下面來看看我工作中的一個真實的例子。

假設有一段Java的客戶端程式用來接收訊息,用各種方式對訊息做轉換,然後發給一個伺服器。

class MessageHandler {
    void handleMessage(Message msg) {
        // ...
        msg.setClientCode("ABCD_123");
        // ...

        sendMessage(msg);
    }

    // ...
}

再進一步假設,整個系統改變了,現在需要發給兩個伺服器而不再是一個了。系統其他部分都不變,唯獨客戶端的程式碼需要改變:額外的那個伺服器需要用另外一種格式傳送訊息。應該如何處理這種情況呢?我們可以先檢查一下訊息要傳送到哪裡,然後選擇相應的格式把這個訊息發出去:

class MessageHandler {
    void handleMessage(Message msg) {
        // ...
        if(msg.getDestination().equals("server1") {
            msg.setClientCode("ABCD_123");
        } else {
            msg.setClientCode("123_ABC");
        }
        // ...

        sendMessage(msg);
    }

    // ...
}

可是這樣的實現是不具備擴充套件性的。如果將來需要增加更多的伺服器,上面函式的大小將呈線性增長,使得維護這個函式最終變成一場噩夢。面向物件的程式設計方法告訴我們,可以把MessageHandler變成一個基類,然後將針對不同格式的訊息編寫相應的子類。

abstract class MessageHandler {
    void handleMessage(Message msg) {
        // ...
        msg.setClientCode(getClientCode());
        // ...

        sendMessage(msg);
    }

    abstract String getClientCode();

    // ...
}

class MessageHandlerOne extends MessageHandler {
    String getClientCode() {
        return "ABCD_123";
    }
}

class MessageHandlerTwo extends MessageHandler {
    String getClientCode() {
        return "123_ABCD";
    }
}

這樣一來就可以為每一個接收訊息的伺服器生成一個相應的類物件,新增伺服器就變得更加容易維護了。可是,這一個簡單的改動引出了很多的程式碼。僅僅是為了支援不同的客戶端行為程式碼,就要定義兩種新的型別!現在來試試用我們剛才改造的語言來做同樣的事情,注意,這種語言支援高階函式:

class MessageHandler {
    void handleMessage(Message msg, Function getClientCode) {
        // ...
        Message msg1 = msg.setClientCode(getClientCode());
        // ...

        sendMessage(msg1);
    }

    // ...
}

String getClientCodeOne() {
    return "ABCD_123";
}

String getClientCodeTwo() {
    return "123_ABCD";
}

MessageHandler handler = new MessageHandler();
handler.handleMessage(someMsg, getClientCodeOne);

在上面的程式裡,我們沒有建立任何新的型別或是多層類的結構。僅僅是把相應的函式作為引數進行傳遞,就做到了和用面向物件程式設計一樣的事情,而且還有額外的好處:一是不再受限於多層類的結構。這樣做可以做執行時傳遞新的函式,可以在任何時候改變這些函式,而且這些改變不僅更加精準而且觸碰的程式碼更少。這種情況下編譯器其實就是在替我們編寫面向物件的“粘合”程式碼(譯者:又稱膠水程式碼,粘接程式碼)!除此之外我們還可以享用FP程式設計的其他所有優勢。函數語言程式設計能提供的抽象服務還遠不止於此。高階函式只不過是個開始。

Currying

我遇見的大多數碼農都讀過“四人幫”的那本《設計模式》。任何稍有自尊心的碼農都會說這本書和語言無關,因此無論你用什麼程式語言,當中提到的那些模式大體上適用於所有軟體工程。聽起來很厲害,然而事實卻不是這樣。
函式式語言的表達能力很強。用這種語言程式設計的時候基本不需要設計模式,因為這種語言層次已經足夠高,使得使用者可以以概念程式設計,從而完全不需要設計模式了。以介面卡模式為例(有人知道這個模式和外觀模式有什麼區別嗎?怎麼覺得有人為了出版合同的要求而硬生生湊頁數?)(譯者:您不愧是高階黑啊)。對於一個支援currying技術的語言來說,這個模式就是多餘的。
在Java中最有名的介面卡模式就是在其“預設”抽象單元中的應用:類。在函式式語言中這種模式其實就是函式。在這個模式中,一個介面被轉換成另外一個介面,讓不同的使用者程式碼呼叫。接下來就有一個介面卡模式的例子:

int pow(int i, int j);
int square(int i)
{
    return pow(i, 2);
}

上面的程式碼中square函式計算一個整數的平方,這個函式的介面被轉換成計算一個整數的任意整數次冪。在學術圈裡這種簡單的技術就被叫做currying(因為邏輯學家哈斯凱爾·加里用其數學技巧將這種技術描述出來,於是就以他的名字來命名了)。在一個FP語言中函式(而不是類)被作為引數進行傳遞,currying常常用於轉化一個函式的介面以便於其他程式碼呼叫。函式的介面就是它的引數,於是currying通常用於減少函式引數的數量(見前例)。
函式式語言生來就支援這一技術,於是沒有必要為某個函式手工建立另外一個函式去包裝並轉換它的介面,這些函式式語言已經為你做好了。我們繼續拓展Java來支援這一功能。

square = int pow(int i, 2);

上面的語句實現了一個平方計算函式,它只需要一個引數。它會繼而呼叫pow函式並且把第二個引數置為2。編譯過後將生成以下Java程式碼:

class square_function_t {
    int square(int i) {
        return pow(i, 2);
    }
}
square_function_t square = new square_function_t();

從上面的例子可以看到,很簡單的,函式pow的封裝函式就創建出來了。在FP語言中currying就這麼簡單:一種可以快速且簡單的實現函式封裝的捷徑。我們可以更專注於自己的設計,編譯器則會為你編寫正確的程式碼!什麼時候使用currying呢?很簡單,當你想要用介面卡模式(或是封裝函式)的時候,就是用currying的時候。

惰性求值

惰性求值(或是延遲求值)是一種有趣的技術,而當我們投入函數語言程式設計的懷抱後這種技術就有了得以實現的可能。前面介紹併發執行的時候已經提到過如下程式碼:

String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);

在指令式語言中以上程式碼執行的順序是顯而易見的。由於每個函式都有可能改動或者依賴於其外部的狀態,因此必須順序執行。先是計算somewhatLongOperation1,然後到somewhatLongOperation2,最後執行concatenate。函式式語言就不一樣了。
在前面討論過,somewhatLongOperation1和somewhatLongOperation2是可以併發執行的,因為函式式語言保證了一點:沒有函式會影響或者依賴於全域性狀態。可是萬一我們不想要這兩個函式併發執行呢?這種情況下是不是也還是要順序執行這些函式?答案是否定的。只有到了執行需要s1、s2作為引數的函式的時候,才真正需要執行這兩個函式。於是在concatenate這個函式沒有執行之前,都沒有需要去執行這兩個函式:這些函式的執行可以一直推遲到concatenate()中需要用到s1和s2的時候。假如把concatenate換成另外一個函式,這個函式中有條件判斷語句而且實際上只會需要兩個引數中的其中一個,那麼就完全沒有必要執行計算另外一個引數的函數了!Haskell語言就是一個支援惰性求值的例子。Haskell不能保證任何語句會順序執行(甚至完全不會執行到),因為Haskell的程式碼只有在需要的時候才會被執行到。
除了這些優點,惰性求值也有缺點。這裡介紹了它的優點,我們將在下一章節介紹這些缺點以及如何克服它們。

程式碼優化

惰性求值使得程式碼具備了巨大的優化潛能。支援惰性求值的編譯器會像數學家看待代數表示式那樣看待函式式程式:抵消相同項從而避免執行無謂的程式碼,安排程式碼執行順序從而實現更高的執行效率甚至是減少錯誤。在此基礎上優化是不會破壞程式碼正常執行的。嚴格使用形式系統的基本元素進行程式設計帶來的最大的好處,是可以用數學方法分析處理程式碼,因為這樣的程式是完全符合數學法則的。

抽象化控制結構

惰性求值技術提供了更高階的抽象能力,這提供了實現程式設計獨特的方法。比如說下面的控制結構:

unless(stock.isEuropean()) {
    sendToSEC(stock);
}

程式中除了在stock為European的時候都會執行sendToSEC。如何實現例子中的unless?如果沒有惰性求值就需要求助於某種形式的巨集(譯者:用if不行麼?),不過在像Haskell這樣的語言中就不需要那麼麻煩了。直接實現一個unless函式就可以!

void unless(boolean condition, List code) {
    if(!condition)
        code;
}

請注意,如果condition值為真,那就不會計算code。在其他嚴格語言(見嚴格求值)中這種行為是做不到的,因為在進入unless這個函式之前,作為引數的code已經被計算過了。

無窮資料結構

惰性求值技術允許定義無窮資料結構,這要在嚴格語言中實現將非常複雜。例如一個儲存Fibonacci數列數字的列表。很明顯這樣一個列表是無法在有限的時間內計算出這個無窮的數列並存儲在記憶體中的。在像Java這樣的嚴格語言中,可以定義一個Fibonacci函式,返回這個序列中的某個數。而在Haskell或是類似的語言中,可以把這個函式進一步抽象化並定義一個Fibonacci數列的無窮列表結構。由於語言本身支援惰性求值,這個列表中只有真正會被用到的數才會被計算出來。這讓我們可以把很多問題抽象化,然後在更高的層面上解決它們(比如可以在一個列表處理函式中處理無窮多資料的列表)。

不足之處

俗話說天下沒有免費的午餐™。惰性求值當然也有其缺點。其中最大的一個就是,嗯,惰性。現實世界中很多問題還是需要嚴格求值的。比如說下面的例子:

System.out.println("Please enter your name: ");
System.in.readLine();

在惰性語言中沒人能保證第一行會中第二行之前執行!這也就意味著我們不能處理IO,不能呼叫系統函式做任何有用的事情(這些函式需要按照順序執行,因為它們依賴於外部狀態),也就是說不能和外界互動了!如果在程式碼中引入支援順序執行的程式碼原語,那麼我們就失去了用數學方式分析處理程式碼的優勢(而這也意味著失去了函數語言程式設計的所有優勢)。幸運的是我們還不算一無所有。數學家們研究了不同的方法用以保證程式碼按一定的順序執行(in a functional setting?)。這一來我們就可以同時利用到函式式和指令式程式設計的優點了!這些方法有continuations,monads以及uniqueness typing。這篇文章僅僅介紹了continuations,以後再討論monads和uniqueness typing。有意思的是呢,coutinuations處理強制程式碼以特定順序執行之外還有其他很多用處,這些我們在後面也會提及。

Continuation

continuation對於程式設計,就像是達芬奇密碼對於人類歷史一樣:它揭開了人類有史以來最大的謎團。好吧,也許沒有那麼誇張,不過它們的影響至少和當年發現負數有平方根不相上下。

我們對函式的理解只有一半是正確的,因為這樣的理解基於一個錯誤的假設:函式一定要把其返回值返回給呼叫者。按照這樣的理解,continuation就是更加廣義的函式。這裡的函式不一定要把返回值傳回給呼叫者,相反,它可以把返回值傳給程式中的任意程式碼。continuation就是一種特別的引數,把這種引數傳到函式中,函式就能夠根據continuation將返回值傳遞到程式中的某段程式碼中。說得很高深,實際上沒那麼複雜。直接來看看下面的例子好了:

int i = add(5, 10);
int j = square(i);

add這個函式將返回15然後這個值會賦給i,這也是add被呼叫的地方。接下來i的值又會被用於呼叫square。請注意支援惰性求值的編譯器是不能打亂這段程式碼執行順序的,因為第二個函式的執行依賴於第一個函式成功執行並返回結果。這段程式碼可以用Continuation Pass Style(CPS)技術重寫,這樣一來add的返回值就不是傳給其呼叫者,而是直接傳到square裡去了。

int j = add(5, 10, square);

在上例中,add多了一個引數:一個函式,add必須在完成自己的計算後,呼叫這個函式並把結果傳給它。這時square就是add的一個continuation。上面兩段程式中j的值都是225。

這樣,我們學習到了強制惰性語言順序執行兩個表示式的第一個技巧。再來看看下面IO程式(是不是有點眼熟?):

System.out.println("Please enter your name: ");
System.in.readLine();

這兩行程式碼彼此之間沒有依賴關係,因此編譯器可以隨意的重新安排它們的執行順序。可是隻要用CPS重寫它,編譯器就必須順序執行了,因為重寫後的程式碼存在依賴關係了。

System.out.println("Please enter your name: ", System.in.readLine);

這段新的程式碼中println需要結合其計算結果呼叫readLine,然後再返回readLine的返回值。這使得兩個函式得以保證按順序執行而且readLine總被執行(這是由於整個運算需要它的返回值作為最終結果)。Java的println是沒有返回值的,但是如果它可以返回一個能被readLine接受的抽象值,問題就解決了!(譯者:別忘了,這裡作者一開始就在Java的基礎上修改搭建自己的語言)當然,如果一直把函式按照這種方法串下去,程式碼很快就變得不可讀了,可是沒有人要求你一定要這樣做。可以通過在語言中新增語法糖的方式來解決這個問題,這樣程式設計師只要按照順序寫程式碼,編譯器負責自動把它們串起來就好了。於是就可以任意安排程式碼的執行順序而不用擔心會失去FP帶來的好處了(包括可以用數學方法來分析我們的程式)!如果到這裡還有人感到困惑,可以這樣理解,函式只是有唯一成員的類的例項而已。試著重寫上面兩行程式,讓println和readLine變成這種類的例項,所有問題就都搞清楚了。
到這裡本章基本可以結束了,而我們僅僅瞭解到continuation的一點皮毛,對它的用途也知之甚少。我們可以用CPS完成整個程式,程式裡所有的函式都有一個額外的continuation作為引數接受其他函式的返回值。還可以把任何程式轉換為CPS的,需要做的只是把當中的函式看作是特殊的continuation(總是將返回值傳給呼叫者的continuation)就可以了,簡單到完全可以由工具自動完成(史上很多編譯器就是這樣做的)。

一旦將程式轉為CPS的風格,有些事情就變得顯而易見了:每一條指令都會有一些continuation,都會將它的計算結果傳給某一個函式並呼叫它,在一個普通的程式中這個函式就是該指令被呼叫並且返回的地方。隨便找個之前提到過的程式碼,比如說add(5,10)好了。如果add屬於一個用CPS風格寫出的程式,add的continuation很明顯就是當它執行結束後要呼叫的那個函式。可是在一個非CPS的程式中,add的continuation又是什麼呢?當然我們還是可以把這段程式轉成CPS的,可是有必要這樣做嗎?
事實上沒有必要。注意觀察整個CPS轉換過程,如果有人嘗試要為CPS程式寫編譯器並且認真思考過就會發現:CPS的程式是不需要棧的!在這裡完全沒有函式需要做傳統意義上的“返回”操作,函式執行完後僅需要接著呼叫另外一個函式就可以了。於是就不需要在每次呼叫函式的時候把引數壓棧再將它們從中取出,只要把這些引數存放在一片記憶體中然後使用跳轉指