傻瓜函数式编程
開篇
我們這些碼農(nóng)做事都是很拖拉的。每天例行報到后,先來點咖啡,看看郵件還有RSS訂閱的文章。然后翻翻新聞還有那些技術網(wǎng)站上的更新,再過一遍編程論壇口水區(qū)里那些無聊的論戰(zhàn)。最后從頭把這些再看一次以免錯過什么精彩的內容。然后就可以吃午飯了。飯飽過后,回來盯著IDE發(fā)一會呆,再看看郵箱,再去搞杯咖啡。光陰似箭,可以回家了……
(在被眾人鄙視之前)我唯一想說的是,在這些拖拉的日子里總會時不時讀到一些不明覺厲的文章。如果沒有打開不應該打開的網(wǎng)站,每隔幾天你都可以看到至少一篇這樣的東西。它們的共性:難懂,耗時,于是這些文章就慢慢的堆積成山了。很快你就會發(fā)現(xiàn)自己已經(jīng)累積了一堆的收藏鏈接還有數(shù)不清的PDF文件,此時你只希望隱入一個杳無人煙的深山老林里什么也不做,用一年半載好好的消化這些私藏寶貝。當然,我是說最好每天還是能有人來給送吃的順帶幫忙打掃衛(wèi)生倒垃圾,哇哈哈。
我不知道你都收藏了些什么,我的閱讀清單里面相當大部分都是函數(shù)式編程相關的東東:基本上是最難啃的。這些文章充斥著無比枯燥的教科書語言,我想就連那些在華爾街浸淫10年以上的大牛都無法搞懂這些函數(shù)式編程(簡稱FP)文章到底在說什么。你可以去花旗集團或者德意志銀行找個項目經(jīng)理來問問1:你們?yōu)槭裁匆xJMS而不用Erlang?答案基本上是:我認為這個學術用的語言還無法勝任實際應用。可是,現(xiàn)有的一些系統(tǒng)不僅非常復雜還需要滿足十分嚴苛的需求,它們就都是用函數(shù)式編程的方法來實現(xiàn)的。這,就說不過去了。
關于FP的文章確實比較難懂,但我不認為一定要搞得那么晦澀。有一些歷史原因造成了這種知識斷層,可是FP概念本身并不難理解。我希望這篇文章可以成為一個“FP入門指南”,幫助你從指令式編程走向函數(shù)式編程。先來點咖啡,然后繼續(xù)讀下去。很快你對FP的理解就會讓同事們刮目相看了。
什么是函數(shù)式編程(Functional Programming,FP)?它從何而來?可以吃嗎?倘若它真的像那些鼓吹FP的人說的那么好,為什么實際應用中那么少見?為什么只有那些在讀博士的家伙想要用它?而最重要的是,它母親的怎么就那么難學?那些所謂的closure、continuation,currying,lazy evaluation還有no side effects都是什么東東(譯者:本著保留專用術語的原則,此處及下文類似情形均不譯)?如果沒有那些大學教授的幫忙怎樣把它應用到實際工程里去?為什么它和我們熟悉的萬能而神圣的指令式編程那么的不一樣?
我們很快就會解開這些謎團。剛才我說過實際工程和學術界之間的知識斷層是有其歷史原因的,那么就先讓我來解釋一下這個問題。答案,就在接下來的一次公園漫步中:
公園漫步
時間機器啟動……我們來到公元前380年,也就是2000多年前的雅典城外。這是一個陽光明媚的久違的春天,柏拉圖和一個帥氣的小男仆走在一片橄欖樹蔭下。他們正準備前往一個學院。天氣很好,吃得很飽,漸漸的,兩人的談話轉向了哲學。
“你看那兩個學生,哪一個更高一些?”,柏拉圖小心的選擇用字,以便讓這個問題更好的引導眼前的這個小男孩。
小男仆望向水池旁邊的兩個男生,“他們差不多一樣高。”。
“‘差不多一樣高’是什么意思?”柏拉圖問。
“嗯……從這里看來他們是一樣高的,但是如果走近一點我肯定能看出差別來。”
柏拉圖笑了。他知道這個小孩已經(jīng)朝他引導的方向走了。“這么說來你的意思是世界上沒有什么東西是完全相同的咯?”
思考了一會,小男孩回答:“是的。萬物之間都至少有一丁點差別,哪怕我們無法分辨出來。”
說到點子上了!“那你說,如果世界上沒有什么東西是完全相等的,你怎么理解‘完全相等’這個概念?”
小男仆看起來很困惑。“這我就不知道了。”
這是人類第一次試圖了解數(shù)學的本質。柏拉圖認為我們所在的世界中,萬事萬物都是完美模型的一個近似。他同時意識到雖然我們不能感受到完美的模型,但這絲毫不會阻止我們了解完美模型的概念。柏拉圖進而得出結論:完美的數(shù)學模型只存在于另外一個世界,而因為某種原因我們卻可以通過聯(lián)系著這兩個世界的一個紐帶來認識這些模型。一個簡單的例子就是完美的圓形。沒有人見過這樣的一個圓,但是我們知道怎樣的圓是完美的圓,而且可以用公式把它描述出來。
如此說來,什么是數(shù)學呢?為什么可以用數(shù)學法則來描述我們的這個宇宙?我們所處的這個世界中萬事萬物都可以用數(shù)學來描述嗎?2 數(shù)理哲學是一門很復雜的學科。它和其他多數(shù)哲學一樣,更著重于提出問題而不是給出答案。數(shù)學就像拼圖一樣,很多結論都是這樣推導出來的:先是確立一些互不沖突的基礎原理,以及一些操作這些原理的規(guī)則,然后就可以把這些原理以及規(guī)則拼湊起來形成新的更加復雜的規(guī)則或是定理了。數(shù)學家把這種方法稱為“形式系統(tǒng)”或是“演算”。如果你想做的話,可以用形式系統(tǒng)描述俄羅斯方塊這個游戲。而事實上,俄羅斯方塊這個游戲的實現(xiàn),只要它正確運行,就是一個形式系統(tǒng)。只不過它以一種不常見的形式表現(xiàn)出來罷了。
如果半人馬阿爾法上有文明存在的話,那里的生物可能無法解讀我們的俄羅斯方塊形式系統(tǒng)甚至是簡單的圓形的形式系統(tǒng),因為它們感知世界的唯一器官可能只有鼻子(譯者:偶的媽你咋知道?)也許它們是無法得知俄羅斯方塊的形式系統(tǒng)了,但是它們很有可能知道圓形。它們的圓形我們可能沒法解讀,因為我們的鼻子沒有它們那么靈敏(譯者:那狗可以么?)可是只要越過形式系統(tǒng)的表示方式(比如通過使用“超級鼻子”之類的工具來感知這些用味道表示的形式系統(tǒng),然后使用標準的解碼技術把它們翻譯成人類能理解的語言),那么任何有足夠智力的文明都可以理解這些形式系統(tǒng)的本質。
有意思的是,哪怕宇宙中完全不存在任何文明,類似俄羅斯方塊還有圓形這樣的形式系統(tǒng)依舊是成立的:只不過沒有智慧生物去發(fā)現(xiàn)它們而已。這個時候如果忽然一個文明誕生了,那么這些具有智慧的生物就很有可能發(fā)現(xiàn)各種各樣的形式系統(tǒng),并且用它們發(fā)現(xiàn)的系統(tǒng)去描述各種宇宙法則。不過它們可能不會發(fā)現(xiàn)俄羅斯方塊這樣的形式系統(tǒng),因為在它們的世界里沒有俄羅斯方塊這種東西嘛。有很多像俄羅斯方塊這樣的形式系統(tǒng)是與客觀世界無關的,比如說自然數(shù),很難說所有的自然數(shù)都與客觀世界有關,隨便舉一個超級大的數(shù),這個數(shù)可能就和世界上任何事物無關,因為這個世界可能不是無窮大的。
歷史回眸3
再次啟動時間機……這次到達的是20世紀30年代,離今天近了很多。無論新舊大陸,經(jīng)濟大蕭條都造成了巨大的破壞。社會各階層幾乎每一個家庭都深受其害。只有極其少數(shù)的幾個地方能讓人們免于遭受窮困之苦。幾乎沒有人能夠幸運的在這些避難所里度過危機,注意,我說的是幾乎沒有,還真的有這么些幸運兒,比如說當時普林斯頓大學的數(shù)學家們。
新建成的哥特式辦公樓給普林斯頓大學帶來一種天堂般的安全感。來自世界各地的邏輯學者應邀來到普林斯頓,他們將組建一個新的學部。正當大部分美國人還在為找不到一片面包做晚餐而發(fā)愁的時候,在普林斯頓卻是這樣一番景象:高高的天花板和木雕包覆的墻,每天品茶論道,漫步叢林。
一個名叫阿隆佐·邱奇(Alonzo Church)的年輕數(shù)學家就過著這樣優(yōu)越的生活。阿隆佐本科畢業(yè)于普林斯頓后被留在研究院。他覺得這樣的生活完全沒有必要,于是他鮮少出現(xiàn)在那些數(shù)學茶會中也不喜歡到樹林里散心。阿隆佐更喜歡獨處:自己一個人的時候他的工作效率更高。盡管如此他還是和普林斯頓學者保持著聯(lián)系,這些人當中有艾倫·圖靈、約翰·馮·諾伊曼、庫爾特·哥德爾。
這四個人都對形式系統(tǒng)感興趣。相對于現(xiàn)實世界,他們更關心如何解決抽象的數(shù)學問題。而他們的問題都有這么一個共同點:都在嘗試解答關于計算的問題。諸如:如果有一臺擁有無窮計算能力的超級機器,可以用來解決什么問題?它可以自動的解決這些問題嗎?是不是還是有些問題解決不了,如果有的話,是為什么?如果這樣的機器采用不同的設計,它們的計算能力相同嗎?
在與這些人的合作下,阿隆佐設計了一個名為lambda演算的形式系統(tǒng)。這個系統(tǒng)實質上是為其中一個超級機器設計的編程語言。在這種語言里面,函數(shù)的參數(shù)是函數(shù),返回值也是函數(shù)。這種函數(shù)用希臘字母lambda(λ),這種系統(tǒng)因此得名4。有了這種形式系統(tǒng),阿隆佐終于可以分析前面的那些問題并且能夠給出答案了。
除了阿隆佐·邱奇,艾倫·圖靈也在進行類似的研究。他設計了一種完全不同的系統(tǒng)(后來被稱為圖靈機),并用這種系統(tǒng)得出了和阿隆佐相似的答案。到了后來人們證明了圖靈機和lambda演算的能力是一樣的。
如果二戰(zhàn)沒有發(fā)生,這個故事到這里就應該結束了,我的這篇小文沒什么好說的了,你們也可以去看看有什么其他好看的文章。可是二戰(zhàn)還是爆發(fā)了,整個世界陷于火海之中。那時的美軍空前的大量使用炮兵。為了提高轟炸的精度,軍方聘請了大批數(shù)學家夜以繼日的求解各種差分方程用于計算各種火炮發(fā)射數(shù)據(jù)表。后來他們發(fā)現(xiàn)單純手工計算這些方程太耗時了,為了解決這個問題,各種各樣的計算設備應運而生。IBM制造的Mark一號就是用來計算這些發(fā)射數(shù)據(jù)表的第一臺機器。Mark一號重5噸,由75萬個零部件構成,每一秒可以完成3次運算。
戰(zhàn)后,人們?yōu)樘岣哂嬎隳芰Χ龀龅呐Σ]有停止。1949年第一臺電子離散變量自動計算機誕生并取得了巨大的成功。它是馮·諾伊曼設計架構的第一個實例,也是一臺現(xiàn)實世界中實現(xiàn)的圖靈機。相比他的這些同事,那個時候阿隆佐的運氣就沒那么好了。
到了50年代末,一個叫John McCarthy的MIT教授(他也是普林斯頓的碩士)對阿隆佐的成果產(chǎn)生了興趣。1958年他發(fā)明了一種列表處理語言(Lisp),這種語言是一種阿隆佐lambda演算在現(xiàn)實世界的實現(xiàn),而且它能在馮·諾伊曼計算機上運行!很多計算機科學家都認識到了Lisp強大的能力。1973年在MIT人工智能實驗室的一些程序員研發(fā)出一種機器,并把它叫做Lisp機。于是阿隆佐的lambda演算也有自己的硬件實現(xiàn)了!
函數(shù)式編程
函數(shù)式編程是阿隆佐思想在現(xiàn)實世界中的實現(xiàn)。不過不是全部的lambda演算思想都可以運用到實際中,因lambda演算在設計的時候就不是為了在各種現(xiàn)實世界中的限制下工作的。所以,就像面向對象的編程思想一樣,函數(shù)式編程只是一系列想法,而不是一套嚴苛的規(guī)定。有很多支持函數(shù)式編程的程序語言,它們之間的具體設計都不完全一樣。在這里我將用Java寫的例子介紹那些被廣泛應用的函數(shù)式編程思想(沒錯,如果你是受虐狂你可以用Java寫出函數(shù)式程序)。在下面的章節(jié)中我會在Java語言的基礎上,做一些修改讓它變成實際可用的函數(shù)式編程語言。那么現(xiàn)在就開始吧。
Lambda演算在最初設計的時候就是為了研究計算相關的問題。所以函數(shù)式編程主要解決的也是計算問題,而出乎意料的是,是用函數(shù)來解決的!(譯者:請理解原作者的苦心,我想他是希望加入一點調皮的風格以免讀者在中途睡著或是轉臺……)。函數(shù)就是函數(shù)式編程中的基礎元素,可以完成幾乎所有的操作,哪怕最簡單的計算,也是用函數(shù)完成的。我們通常理解的變量在函數(shù)式編程中也被函數(shù)代替了:在函數(shù)式編程中變量僅僅代表某個表達式(這樣我們就不用把所有的代碼都寫在同一行里了)。所以我們這里所說的‘變量’是不能被修改的。所有的變量只能被賦一次初值。在Java中就意味著每一個變量都將被聲明為final(如果你用C++,就是const)。在FP中,沒有非final的變量。
final int i = 5; final int j = i + 3;既然FP中所有的變量都是final的,可以引出兩個規(guī)定:一是變量前面就沒有必要再加上final這個關鍵字了,二是變量就不能再叫做‘變量’了……于是現(xiàn)在開始對Java做兩個改動:所有Java中聲明的變量默認為final,而且我們把所謂的‘變量’稱為‘符號’。
到現(xiàn)在可能會有人有疑問:這個新創(chuàng)造出來的語言可以用來寫什么有用的復雜一些的程序嗎?畢竟,如果每個符號的值都是不能修改的,那么我們就什么東西都不能改變了!別緊張,這樣的說法不完全正確。阿隆佐在設計lambda演算的時候他并不想要保留狀態(tài)的值以便稍后修改這些值。他更關心的是基于數(shù)據(jù)之上的操作(也就是更容易理解的“計算”)。而且,lambda演算和圖靈機已經(jīng)被證明了是具有同樣能力的系統(tǒng),因此指令式編程能做到的函數(shù)式編程也同樣可以做到。那么,怎樣才能做到呢?
事實上函數(shù)式程序是可以保存狀態(tài)的,只不過它們用的不是變量,而是函數(shù)。狀態(tài)保存在函數(shù)的參數(shù)中,也就是說在棧上。如果你需要保存一個狀態(tài)一段時間并且時不時的修改它,那么你可以編寫一個遞歸函數(shù)。舉個例子,試著寫一個函數(shù),用來反轉一個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。同時它也會大量的消耗內存,因為它會不斷的分配創(chuàng)建內存對象。無論如何,它是用函數(shù)式編程思想寫出來的。這時候可能有人要問了,為什么要用這種奇怪的方式編寫程序呢?嘿,我正準備告訴你。
FP之優(yōu)點
你大概已經(jīng)在想:上面這種怪胎函數(shù)怎么也不合理嘛。在我剛開始學習FP的時候我也這樣想的。不過后來我知道我是錯的。使用這種方式編程有很多好處。其中一些是主觀的。比如說有人認為函數(shù)式程序更容易理解。這個我就不說了,哪怕街上隨便找個小孩都知道‘容易理解’是多么主觀的事情。幸運的是,客觀方面的好處還有很多。
單元測試
因為FP中的每個符號都是final的,于是沒有什么函數(shù)會有副作用。誰也不能在運行時修改任何東西,也沒有函數(shù)可以修改在它作用域之外的值給其他函數(shù)繼續(xù)使用(在指令式編程中可以用類成員或是全局變量做到)。這意味著決定函數(shù)執(zhí)行結果的唯一因素就是它的返回值,而影響其返回值的唯一因素就是它的參數(shù)。
這正是單元測試工程師夢寐以求的啊。現(xiàn)在測試程序中的函數(shù)時只需要關注它的參數(shù)就可以了。完全不需要擔心函數(shù)調用的順序,也不用費心設置外部某些狀態(tài)值。唯一需要做的就是傳遞一些可以代表邊界條件的參數(shù)給這些函數(shù)。相對于指令式編程,如果FP程序中的每一個函數(shù)都能通過單元測試,那么我們對這個軟件的質量必將信心百倍。反觀Java或者C++,僅僅檢查函數(shù)的返回值是不夠的:代碼可能修改外部狀態(tài)值,因此我們還需要驗證這些外部的狀態(tài)值的正確性。在FP語言中呢,就完全不需要。
調試查錯
如果一段FP程序沒有按照預期設計那樣運行,調試的工作幾乎不費吹灰之力。這些錯誤是百分之一百可以重現(xiàn)的,因為FP程序中的錯誤不依賴于之前運行過的不相關的代碼。而在一個指令式程序中,一個bug可能有時能重現(xiàn)而有些時候又不能。因為這些函數(shù)的運行依賴于某些外部狀態(tài), 而這些外部狀態(tài)又需要由某些與這個bug完全不相關的代碼通過某個特別的執(zhí)行流程才能修改。在FP中這種情況完全不存在:如果一個函數(shù)的返回值出錯了,它一直都會出錯,無論你之前運行了什么代碼。
一旦問題可以重現(xiàn),解決它就變得非常簡單,幾乎就是一段愉悅的旅程。中斷程序的運行,檢查一下棧,就可以看到每一個函數(shù)調用時使用的每一個參數(shù),這一點和指令式代碼一樣。不同的是指令式程序中這些數(shù)據(jù)還不足夠,因為函數(shù)的運行還可能依賴于成員變量,全局變量,還有其他類的狀態(tài)(而這些狀態(tài)又依賴于類似的變量)。FP中的函數(shù)只依賴于傳給它的參數(shù),而這些參數(shù)就在眼前!還有,對指令式程序中函數(shù)返回值的檢查并不能保證這個函數(shù)是正確運行的。還要逐一檢查若干作用域以外的對象以確保這個函數(shù)沒有對這些牽連的對象做出什么越軌的行為(譯者:好吧,翻譯到這里我自己已經(jīng)有點激動了)。對于一個FP程序,你要做的僅僅是看一下函數(shù)的返回值。
把棧上的數(shù)據(jù)過一遍就可以得知有哪些參數(shù)傳給了什么函數(shù),這些函數(shù)又返回了什么值。當一個返回值看起來不對頭的那一刻,跳進這個函數(shù)看看里面發(fā)生了什么。一直重復跟進下去就可以找到bug的源頭!
并發(fā)執(zhí)行
不需要任何改動,所有FP程序都是可以并發(fā)執(zhí)行的。由于根本不需要采用鎖機制,因此完全不需要擔心死鎖或是并發(fā)競爭的發(fā)生。在FP程序中沒有哪個線程可以修改任何數(shù)據(jù),更不用說多線程之間了。這使得我們可以輕松的添加線程,至于那些禍害并發(fā)程序的老問題,想都不用想!
既然是這樣,為什么沒有人在那些高度并行的那些應用程序中采用FP編程呢?事實上,這樣的例子并不少見。愛立信開發(fā)了一種FP語言,名叫Erlang,并應用在他們的電信交換機上,而這些交換機不僅容錯度高而且拓展性強。許多人看到了Erlang的這些優(yōu)勢也紛紛開始使用這一語言。在這里提到的電信交換控制系統(tǒng)遠遠要比華爾街上使用的系統(tǒng)具有更好的擴展性也更可靠。事實上,用Erlang搭建的系統(tǒng)并不具備可擴展性和可靠性,而Java可以提供這些特性。Erlang只是像巖石一樣結實不容易出錯而已。
FP關于并行的優(yōu)勢不僅于此。就算某個FP程序本身只是單線程的,編譯器也可以將其優(yōu)化成可以在多CPU上運行的并發(fā)程序。以下面的程序為例:
String s1 = somewhatLongOperation1(); String s2 = somewhatLongOperation2(); String s3 = concatenate(s1, s2);如果是函數(shù)式程序,編譯器就可以對代碼進行分析,然后可能分析出生成字符串s1和s2的兩個函數(shù)可能會比較耗時,進而安排它們并行運行。這在指令式編程中是無法做到的,因為每一個函數(shù)都有可能修改其外部狀態(tài),然后接下來的函數(shù)又可能依賴于這些狀態(tài)的值。在函數(shù)式編程中,自動分析代碼并找到適合并行執(zhí)行的函數(shù)十分簡單,和分析C的內聯(lián)函數(shù)沒什么兩樣。從這個角度來說用FP風格編寫的程序是“永不過時”的(雖然我一般不喜歡說大話空話,不過這次就算個例外吧)。硬件廠商已經(jīng)沒辦法讓CPU運行得再快了。他們只能靠增加CPU核的數(shù)量然后用并行來提高運算的速度。這些廠商故意忽略一個事實:只有可以并行的軟件才能讓你花大價錢買來的這些硬件物有所值。指令式的軟件中只有很小一部分能做到跨核運行,而所有的函數(shù)式軟件都能實現(xiàn)這一目標,因為FP的程序從一開始就是可以并行運行的。
熱部署
在Windows早期,如果要更新系統(tǒng)那可是要重啟電腦的,而且還要重啟很多次。哪怕只是安裝一個新版本的播放器。到了XP的時代這種情況得到比較大的改善,盡管還是不理想(我工作的時候用的就是Windows,就在現(xiàn)在,我的系統(tǒng)托盤上就有個討厭的圖標,我不重啟機子就不消失)。這一方面Unix好一些,曾經(jīng)。只需要暫停一些相關的部件而不是整個操作系統(tǒng),就可以安裝更新了。雖然是要好一些了,對很多服務器應用來說這也還是不能接受的。電信系統(tǒng)要求的是100%的在線率,如果一個救急電話因為系統(tǒng)升級而無法撥通,成千上萬的人就會因此喪命。同樣的,華爾街的那些公司怎么也不能說要安裝軟件而在整個周末停止他們系統(tǒng)的服務。
最理想的情況是更新相關的代碼而不用暫停系統(tǒng)的其他部件。對指令性程序來說是不可能的。想想看,試著在系統(tǒng)運行時卸載掉一個Java的類然后再載入這個類的新的實現(xiàn),這樣做的話系統(tǒng)中所有該類的實例都會立刻不能運行,因為該類的相關狀態(tài)已經(jīng)丟失了。這種情況下可能需絞盡腦汁設計復雜的版本控制代碼,需要將所有這種類正在運行的實例序列化,逐一銷毀它們,然后創(chuàng)建新類的實例,將現(xiàn)有數(shù)據(jù)也序列化后裝載到這些新的實例中,最后希望負責裝載的程序可以正確的把這些數(shù)據(jù)移植到新實例中并正常的工作。這種事很麻煩,每次有新的改動都需要手工編寫裝載程序來完成更新,而且這些裝載程序還要很小心,以免破壞了現(xiàn)有對象之間的聯(lián)系。理論上是沒問題,可是實際上完全行不通。
FP的程序中所有狀態(tài)就是傳給函數(shù)的參數(shù),而參數(shù)都是儲存在棧上的。這一特性讓軟件的熱部署變得十分簡單。只要比較一下正在運行的代碼以及新的代碼獲得一個diff,然后用這個diff更新現(xiàn)有的代碼,新代碼的熱部署就完成了。其它的事情有FP的語言工具自動完成!如果還有人認為這只存在于科幻小說中,他需要再想想:多年來Erlang工程師已經(jīng)使用這種技術對它們的系統(tǒng)進行升級而完全不用暫停運行了。
機器輔助證明及優(yōu)化
FP語言有一個特性很有意思,那就是它們是可以用數(shù)學方法來分析的。FP語言本身就是形式系統(tǒng)的實現(xiàn),只要是能在紙上寫出來的數(shù)學運算就可以用這種語言表述出來。于是只要能夠用數(shù)學方法證明兩段代碼是一致的,編譯器就可以把某段代碼解析成在數(shù)學上等同的但效率又更高的另外一段代碼7。 關系數(shù)據(jù)庫已經(jīng)用這種方法進行優(yōu)化很多年了。沒有理由在常規(guī)的軟件行業(yè)就不能應用這種技術。
另外,還可以用這種方法來證明代碼的正確性,甚至可以設計出能夠自動分析代碼并為單元測試自動生成邊緣測試用例的工具出來!對于那些對缺陷零容忍的系統(tǒng)來說,這一功能簡直就是無價之寶。例如心臟起搏器,例如飛行管控系統(tǒng),這幾乎就是必須滿足的需求。哪怕你正在開發(fā)的程序不是為了完成什么重要核心任務,這些工具也可以幫助你寫出更健壯的程序,直接甩競爭對手n條大街。
高階函數(shù)
我還記得在了解到FP以上的各種好處后想到:“這些優(yōu)勢都很吸引人,可是,如果必須非要用這種所有變量都是final的蹩腳語言,估計還是不怎么實用吧”。其實這樣的想法是不對的。對于Java這樣的指令式語言來說,如果所有的變量都是必須是final的,那么確實很束手束腳。然而對函數(shù)式語言來說,情況就不一樣了。函數(shù)式語言提供了一種特別的抽象工具,這種工具將幫助使用者編寫FP代碼,讓他們甚至都沒想到要修改變量的值。高階函數(shù)就是這種工具之一。
FP語言中的函數(shù)有別于Java或是C。可以說這種函數(shù)是一個全集:Java函數(shù)可以做到的它都能做,同時它還有更多的能力。首先,像在C里寫程序那樣創(chuàng)建一個函數(shù):
int add(int i, int j) {return i + j; }看起來和C程序沒什么區(qū)別,但是很快你就可以看出區(qū)別來。接下來我們擴展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并不是一個函數(shù),它是只有一個函數(shù)作為其成員的簡單的類。這樣做有很多好處,可以在程序中把add當成參數(shù)傳給其他的函數(shù),也可以把add賦給另外一個符號,還可以在運行時創(chuàng)建add_function_t的實例然后在不再需要這些實例的時候由系統(tǒng)回收機制處理掉。這樣做使得函數(shù)成為和integer或是string這樣的第一類對象。對其他函數(shù)進行操作(比如說把這些函數(shù)當成參數(shù))的函數(shù),就是所謂的高階函數(shù)。別讓這個看似高深的名字嚇倒你(譯者:好死不死起個這個名字,初一看還準備搬出已經(jīng)塵封的高數(shù)教材……),它和Java中操作其他類(也就是把一個類實例傳給另外的類)的類沒有什么區(qū)別。可以稱這樣的類為“高階類”,但是沒人會在意,因為Java圈里就沒有什么很強的學術社團。(譯者:這是高級黑嗎?)
那么什么時候該用高階函數(shù),又怎樣用呢?我很高興有人問這個問題。設想一下,你寫了一大堆程序而不考慮什么類結構設計,然后發(fā)現(xiàn)有一部分代碼重復了幾次,于是你就會把這部分代碼獨立出來作為一個函數(shù)以便多次調用(所幸學校里至少會教這個)。如果你發(fā)現(xiàn)這個函數(shù)里有一部分邏輯需要在不同的情況下實現(xiàn)不同的行為,那么你可以把這部分邏輯獨立出來作為一個高階函數(shù)。搞暈了?下面來看看我工作中的一個真實的例子。
假設有一段Java的客戶端程序用來接收消息,用各種方式對消息做轉換,然后發(fā)給一個服務器。
class MessageHandler {void handleMessage(Message msg) {// ...msg.setClientCode("ABCD_123");// ...sendMessage(msg);}// ... }再進一步假設,整個系統(tǒng)改變了,現(xiàn)在需要發(fā)給兩個服務器而不再是一個了。系統(tǒng)其他部分都不變,唯獨客戶端的代碼需要改變:額外的那個服務器需要用另外一種格式發(fā)送消息。應該如何處理這種情況呢?我們可以先檢查一下消息要發(fā)送到哪里,然后選擇相應的格式把這個消息發(fā)出去:
class MessageHandler {void handleMessage(Message msg) {// ...if(msg.getDestination().equals("server1") {msg.setClientCode("ABCD_123");} else {msg.setClientCode("123_ABC");}// ...sendMessage(msg);}// ... }可是這樣的實現(xiàn)是不具備擴展性的。如果將來需要增加更多的服務器,上面函數(shù)的大小將呈線性增長,使得維護這個函數(shù)最終變成一場噩夢。面向對象的編程方法告訴我們,可以把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";} }這樣一來就可以為每一個接收消息的服務器生成一個相應的類對象,添加服務器就變得更加容易維護了。可是,這一個簡單的改動引出了很多的代碼。僅僅是為了支持不同的客戶端行為代碼,就要定義兩種新的類型!現(xiàn)在來試試用我們剛才改造的語言來做同樣的事情,注意,這種語言支持高階函數(shù):
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);在上面的程序里,我們沒有創(chuàng)建任何新的類型或是多層類的結構。僅僅是把相應的函數(shù)作為參數(shù)進行傳遞,就做到了和用面向對象編程一樣的事情,而且還有額外的好處:一是不再受限于多層類的結構。這樣做可以做運行時傳遞新的函數(shù),可以在任何時候改變這些函數(shù),而且這些改變不僅更加精準而且觸碰的代碼更少。這種情況下編譯器其實就是在替我們編寫面向對象的“粘合”代碼(譯者:又稱膠水代碼,粘接代碼)!除此之外我們還可以享用FP編程的其他所有優(yōu)勢。函數(shù)式編程能提供的抽象服務還遠不止于此。高階函數(shù)只不過是個開始。
Currying
我遇見的大多數(shù)碼農(nóng)都讀過“四人幫”的那本《設計模式》。任何稍有自尊心的碼農(nóng)都會說這本書和語言無關,因此無論你用什么編程語言,當中提到的那些模式大體上適用于所有軟件工程。聽起來很厲害,然而事實卻不是這樣。
函數(shù)式語言的表達能力很強。用這種語言編程的時候基本不需要設計模式,因為這種語言層次已經(jīng)足夠高,使得使用者可以以概念編程,從而完全不需要設計模式了。以適配器模式為例(有人知道這個模式和外觀模式有什么區(qū)別嗎?怎么覺得有人為了出版合同的要求而硬生生湊頁數(shù)?)(譯者:您不愧是高級黑啊)。對于一個支持currying技術的語言來說,這個模式就是多余的。
在Java中最有名的適配器模式就是在其“默認”抽象單元中的應用:類。在函數(shù)式語言中這種模式其實就是函數(shù)。在這個模式中,一個接口被轉換成另外一個接口,讓不同的用戶代碼調用。接下來就有一個適配器模式的例子:
int pow(int i, int j); int square(int i) {return pow(i, 2); }上面的代碼中square函數(shù)計算一個整數(shù)的平方,這個函數(shù)的接口被轉換成計算一個整數(shù)的任意整數(shù)次冪。在學術圈里這種簡單的技術就被叫做currying(因為邏輯學家哈斯凱爾·加里用其數(shù)學技巧將這種技術描述出來,于是就以他的名字來命名了)。在一個FP語言中函數(shù)(而不是類)被作為參數(shù)進行傳遞,currying常常用于轉化一個函數(shù)的接口以便于其他代碼調用。函數(shù)的接口就是它的參數(shù),于是currying通常用于減少函數(shù)參數(shù)的數(shù)量(見前例)。
函數(shù)式語言生來就支持這一技術,于是沒有必要為某個函數(shù)手工創(chuàng)建另外一個函數(shù)去包裝并轉換它的接口,這些函數(shù)式語言已經(jīng)為你做好了。我們繼續(xù)拓展Java來支持這一功能。
square = int pow(int i, 2);上面的語句實現(xiàn)了一個平方計算函數(shù),它只需要一個參數(shù)。它會繼而調用pow函數(shù)并且把第二個參數(shù)置為2。編譯過后將生成以下Java代碼:
class square_function_t {int square(int i) {return pow(i, 2);} }square_function_t square = new square_function_t();
從上面的例子可以看到,很簡單的,函數(shù)pow的封裝函數(shù)就創(chuàng)建出來了。在FP語言中currying就這么簡單:一種可以快速且簡單的實現(xiàn)函數(shù)封裝的捷徑。我們可以更專注于自己的設計,編譯器則會為你編寫正確的代碼!什么時候使用currying呢?很簡單,當你想要用適配器模式(或是封裝函數(shù))的時候,就是用currying的時候。
惰性求值
惰性求值(或是延遲求值)是一種有趣的技術,而當我們投入函數(shù)式編程的懷抱后這種技術就有了得以實現(xiàn)的可能。前面介紹并發(fā)執(zhí)行的時候已經(jīng)提到過如下代碼:
String s1 = somewhatLongOperation1(); String s2 = somewhatLongOperation2(); String s3 = concatenate(s1, s2);在指令式語言中以上代碼執(zhí)行的順序是顯而易見的。由于每個函數(shù)都有可能改動或者依賴于其外部的狀態(tài),因此必須順序執(zhí)行。先是計算somewhatLongOperation1,然后到somewhatLongOperation2,最后執(zhí)行concatenate。函數(shù)式語言就不一樣了。
在前面討論過,somewhatLongOperation1和somewhatLongOperation2是可以并發(fā)執(zhí)行的,因為函數(shù)式語言保證了一點:沒有函數(shù)會影響或者依賴于全局狀態(tài)。可是萬一我們不想要這兩個函數(shù)并發(fā)執(zhí)行呢?這種情況下是不是也還是要順序執(zhí)行這些函數(shù)?答案是否定的。只有到了執(zhí)行需要s1、s2作為參數(shù)的函數(shù)的時候,才真正需要執(zhí)行這兩個函數(shù)。于是在concatenate這個函數(shù)沒有執(zhí)行之前,都沒有需要去執(zhí)行這兩個函數(shù):這些函數(shù)的執(zhí)行可以一直推遲到concatenate()中需要用到s1和s2的時候。假如把concatenate換成另外一個函數(shù),這個函數(shù)中有條件判斷語句而且實際上只會需要兩個參數(shù)中的其中一個,那么就完全沒有必要執(zhí)行計算另外一個參數(shù)的函數(shù)了!Haskell語言就是一個支持惰性求值的例子。Haskell不能保證任何語句會順序執(zhí)行(甚至完全不會執(zhí)行到),因為Haskell的代碼只有在需要的時候才會被執(zhí)行到。
除了這些優(yōu)點,惰性求值也有缺點。這里介紹了它的優(yōu)點,我們將在下一章節(jié)介紹這些缺點以及如何克服它們。
代碼優(yōu)化
惰性求值使得代碼具備了巨大的優(yōu)化潛能。支持惰性求值的編譯器會像數(shù)學家看待代數(shù)表達式那樣看待函數(shù)式程序:抵消相同項從而避免執(zhí)行無謂的代碼,安排代碼執(zhí)行順序從而實現(xiàn)更高的執(zhí)行效率甚至是減少錯誤。在此基礎上優(yōu)化是不會破壞代碼正常運行的。嚴格使用形式系統(tǒng)的基本元素進行編程帶來的最大的好處,是可以用數(shù)學方法分析處理代碼,因為這樣的程序是完全符合數(shù)學法則的。
抽象化控制結構
惰性求值技術提供了更高階的抽象能力,這提供了實現(xiàn)程序設計獨特的方法。比如說下面的控制結構:
unless(stock.isEuropean()) {sendToSEC(stock); }程序中除了在stock為European的時候都會執(zhí)行sendToSEC。如何實現(xiàn)例子中的unless?如果沒有惰性求值就需要求助于某種形式的宏(譯者:用if不行么?),不過在像Haskell這樣的語言中就不需要那么麻煩了。直接實現(xiàn)一個unless函數(shù)就可以!
void unless(boolean condition, List code) {if(!condition)code; }請注意,如果condition值為真,那就不會計算code。在其他嚴格語言(見嚴格求值)中這種行為是做不到的,因為在進入unless這個函數(shù)之前,作為參數(shù)的code已經(jīng)被計算過了。
無窮數(shù)據(jù)結構
惰性求值技術允許定義無窮數(shù)據(jù)結構,這要在嚴格語言中實現(xiàn)將非常復雜。例如一個儲存Fibonacci數(shù)列數(shù)字的列表。很明顯這樣一個列表是無法在有限的時間內計算出這個無窮的數(shù)列并存儲在內存中的。在像Java這樣的嚴格語言中,可以定義一個Fibonacci函數(shù),返回這個序列中的某個數(shù)。而在Haskell或是類似的語言中,可以把這個函數(shù)進一步抽象化并定義一個Fibonacci數(shù)列的無窮列表結構。由于語言本身支持惰性求值,這個列表中只有真正會被用到的數(shù)才會被計算出來。這讓我們可以把很多問題抽象化,然后在更高的層面上解決它們(比如可以在一個列表處理函數(shù)中處理無窮多數(shù)據(jù)的列表)。
不足之處
俗話說天下沒有免費的午餐?。惰性求值當然也有其缺點。其中最大的一個就是,嗯,惰性。現(xiàn)實世界中很多問題還是需要嚴格求值的。比如說下面的例子:
System.out.println("Please enter your name: "); System.in.readLine();在惰性語言中沒人能保證第一行會在第二行之前執(zhí)行!這也就意味著我們不能處理IO,不能調用系統(tǒng)函數(shù)做任何有用的事情(這些函數(shù)需要按照順序執(zhí)行,因為它們依賴于外部狀態(tài)),也就是說不能和外界交互了!如果在代碼中引入支持順序執(zhí)行的代碼原語,那么我們就失去了用數(shù)學方式分析處理代碼的優(yōu)勢(而這也意味著失去了函數(shù)式編程的所有優(yōu)勢)。幸運的是我們還不算一無所有。數(shù)學家們研究了不同的方法用以保證代碼按一定的順序執(zhí)行(in a functional setting?)。這一來我們就可以同時利用到函數(shù)式和指令式編程的優(yōu)點了!這些方法有continuations,monads以及uniqueness typing。這篇文章僅僅介紹了continuations,以后再討論monads和uniqueness typing。有意思的是呢,coutinuations處理強制代碼以特定順序執(zhí)行之外還有其他很多用處,這些我們在后面也會提及。
Continuation
continuation對于編程,就像是達芬奇密碼對于人類歷史一樣:它揭開了人類有史以來最大的謎團。好吧,也許沒有那么夸張,不過它們的影響至少和當年發(fā)現(xiàn)負數(shù)有平方根不相上下。
我們對函數(shù)的理解只有一半是正確的,因為這樣的理解基于一個錯誤的假設:函數(shù)一定要把其返回值返回給調用者。按照這樣的理解,continuation就是更加廣義的函數(shù)。這里的函數(shù)不一定要把返回值傳回給調用者,相反,它可以把返回值傳給程序中的任意代碼。continuation就是一種特別的參數(shù),把這種參數(shù)傳到函數(shù)中,函數(shù)就能夠根據(jù)continuation將返回值傳遞到程序中的某段代碼中。說得很高深,實際上沒那么復雜。直接來看看下面的例子好了:
int i = add(5, 10); int j = square(i);add這個函數(shù)將返回15然后這個值會賦給i,這也是add被調用的地方。接下來i的值又會被用于調用square。請注意支持惰性求值的編譯器是不能打亂這段代碼執(zhí)行順序的,因為第二個函數(shù)的執(zhí)行依賴于第一個函數(shù)成功執(zhí)行并返回結果。這段代碼可以用Continuation Pass Style(CPS)技術重寫,這樣一來add的返回值就不是傳給其調用者,而是直接傳到square里去了。
int j = add(5, 10, square);在上例中,add多了一個參數(shù):一個函數(shù),add必須在完成自己的計算后,調用這個函數(shù)并把結果傳給它。這時square就是add的一個continuation。上面兩段程序中j的值都是225。
這樣,我們學習到了強制惰性語言順序執(zhí)行兩個表達式的第一個技巧。再來看看下面IO程序(是不是有點眼熟?):
System.out.println("Please enter your name: "); System.in.readLine();這兩行代碼彼此之間沒有依賴關系,因此編譯器可以隨意的重新安排它們的執(zhí)行順序。可是只要用CPS重寫它,編譯器就必須順序執(zhí)行了,因為重寫后的代碼存在依賴關系了。
System.out.println("Please enter your name: ", System.in.readLine);這段新的代碼中println需要結合其計算結果調用readLine,然后再返回readLine的返回值。這使得兩個函數(shù)得以保證按順序執(zhí)行而且readLine總被執(zhí)行(這是由于整個運算需要它的返回值作為最終結果)。Java的println是沒有返回值的,但是如果它可以返回一個能被readLine接受的抽象值,問題就解決了!(譯者:別忘了,這里作者一開始就在Java的基礎上修改搭建自己的語言)當然,如果一直把函數(shù)按照這種方法串下去,代碼很快就變得不可讀了,可是沒有人要求你一定要這樣做。可以通過在語言中添加語法糖的方式來解決這個問題,這樣程序員只要按照順序寫代碼,編譯器負責自動把它們串起來就好了。于是就可以任意安排代碼的執(zhí)行順序而不用擔心會失去FP帶來的好處了(包括可以用數(shù)學方法來分析我們的程序)!如果到這里還有人感到困惑,可以這樣理解,函數(shù)只是有唯一成員的類的實例而已。試著重寫上面兩行程序,讓println和readLine變成這種類的實例,所有問題就都搞清楚了。
到這里本章基本可以結束了,而我們僅僅了解到continuation的一點皮毛,對它的用途也知之甚少。我們可以用CPS完成整個程序,程序里所有的函數(shù)都有一個額外的continuation作為參數(shù)接受其他函數(shù)的返回值。還可以把任何程序轉換為CPS的,需要做的只是把當中的函數(shù)看作是特殊的continuation(總是將返回值傳給調用者的continuation)就可以了,簡單到完全可以由工具自動完成(史上很多編譯器就是這樣做的)。
一旦將程序轉為CPS的風格,有些事情就變得顯而易見了:每一條指令都會有一些continuation,都會將它的計算結果傳給某一個函數(shù)并調用它,在一個普通的程序中這個函數(shù)就是該指令被調用并且返回的地方。隨便找個之前提到過的代碼,比如說add(5,10)好了。如果add屬于一個用CPS風格寫出的程序,add的continuation很明顯就是當它執(zhí)行結束后要調用的那個函數(shù)。可是在一個非CPS的程序中,add的continuation又是什么呢?當然我們還是可以把這段程序轉成CPS的,可是有必要這樣做嗎?
事實上沒有必要。注意觀察整個CPS轉換過程,如果有人嘗試要為CPS程序寫編譯器并且認真思考過就會發(fā)現(xiàn):CPS的程序是不需要棧的!在這里完全沒有函數(shù)需要做傳統(tǒng)意義上的“返回”操作,函數(shù)執(zhí)行完后僅需要接著調用另外一個函數(shù)就可以了。于是就不需要在每次調用函數(shù)的時候把參數(shù)壓棧再將它們從中取出,只要把這些參數(shù)存放在一片內存中然后使用跳轉指令就解決問題了。也完全不需要保留原來的參數(shù):因為這種程序里的函數(shù)都不返回,所以它們不會被用第二次!
簡單點說呢,用CPS風格寫出來的程序不需要棧,但是每次調用函數(shù)的時候都會要多加一個參數(shù)。非CPS風格的程序不需要額外的參數(shù)但又需要棧才能運行。棧里面存的是什么?僅僅是參數(shù)還有一個供函數(shù)運行結束后返回的程序指針而已。這個時候你是不是已經(jīng)恍然大悟了?對啊,棧里面的數(shù)據(jù)實際上就是continuation的信息!棧上的程序返回指針實質上就是CPS程序中需要調用的下一個函數(shù)!想要知道add(5, 10)的continuation是什么?只要看它運行時棧的內容就可以了。
接下來就簡單多了。continuation和棧上指示函數(shù)返回地址的指針其實是同一樣東西,只是continuation是顯式的傳遞該地址并且因此代碼就不局限于只能返回到函數(shù)被調用的地方了。前面說過,continuation就是函數(shù),而在我們特制的語言中函數(shù)就是類的實例,那么可以得知棧上指向函數(shù)返回地址的指針和continuation的參數(shù)是一樣的,因為我們所謂的函數(shù)(就像類的一個實例)其實就是指針。這也意味著在程序運行的任何時候,你都可以得到當前的continuation(就是棧上的信息)。
好了,我們已經(jīng)搞清楚當前的continuation是什么了。接下來要弄明白它的存在有什么意義。只要得到了當前的continuation并將它保存起來,就相當于保存了程序的當前狀態(tài):在時間軸上把它凍結起來了。這有點像操作系統(tǒng)進入休眠狀態(tài)。continuation對象保存了足夠的信息隨時可以從指定的某個狀態(tài)繼續(xù)運行程序。在切換線程的時候操作系統(tǒng)也是這樣做的。唯一的區(qū)別在于它保留了所有的控制權利。當請求某個continuation對象時(在Scheme語言中是通過調用call-with-current-continuation函數(shù)實現(xiàn)的)得到的是一個存有當前continuation的對象,也就是棧對象(在CPS中也就是下一個要執(zhí)行的函數(shù))。可以把這個對象保存做一個變量中(或者是存在磁盤上)。當以該continuation對象“重啟”該程序時,程序的狀態(tài)就會立即“轉換”為該對象中保存的狀態(tài)。這一點和切換回一個被暫停的線程或是從系統(tǒng)休眠中喚醒很相像,唯一不同的是continuatoin對象可以反復的這樣使用。當系統(tǒng)喚醒后,休眠前保存的信息就會銷毀,否則你也可以反復的從該點喚醒系統(tǒng),就像乘時光機回到過去一樣。有了continuation你就可以做到這一點!
那么continuation在什么情況下有用呢?有一些應用程序天生就沒有狀態(tài),如果要在這樣的系統(tǒng)中模擬出狀態(tài)以簡化工作的時候,就可以用到continuation。最合適的應用場合之一就是網(wǎng)頁應用程序。微軟的ASP.NET為了讓程序員更輕松的編寫應用程序,花了大量的精力去模擬各種狀態(tài)。假如C#支持continuation的話,那么ASP.NET的復雜度將減半:因為只要把某一時刻的continuation保存起來,下次用戶再次發(fā)起同樣請求的時候,重新載入這個continuation即可。對于網(wǎng)絡應用的程序員來說就再也沒有中斷了:輕輕松松程序就從下一行開始繼續(xù)運行了!對于一些實際問題來說,continuation是一種非常有用的抽象工具。如今大量的傳統(tǒng)胖客戶端(見瘦客戶端)正紛紛走進網(wǎng)絡,continuation在未來將扮演越來越重要的角色。
模式匹配
模式匹配并不是什么新功能。而事實上它和函數(shù)式編程也沒有什么太大的關系。它之所以常常被認為是FP的一個特性,是因為在函數(shù)式語言已經(jīng)支持模式匹配很長一段時間后的今天,指令式語言是還沒有這個功能。
還是直接用例子來看看什么是模式匹配吧,這是一個用Java寫的Fibonacci函數(shù):
int fib(int n) {if(n == 0) return 1;if(n == 1) return 1;return fib(n - 2) + fib(n - 1); }再看看用我們基于Java修改過的新語言寫出來的Fibonacci函數(shù),這種新語言就支持模式匹配:
int fib(0) {return 1; } int fib(1) {return 1; } int fib(int n) {return fib(n - 2) + fib(n - 1); }區(qū)別在哪里呢?在于后者的編譯器替我們實現(xiàn)了程序的分支。
這有什么了不起的?確實也沒什么。只是有人注意到很多函數(shù)中有非常復雜的switch結構(對于函數(shù)式程序而言更是如此),于是想到如果能把這層結構也抽象化就更好了。然后就把這個復雜的函數(shù)拆分成若干新的函數(shù),并在這些函數(shù)的某些參數(shù)中應用模式(這和重載有點類似)。當這個函數(shù)被調用的時候,編譯器會在運行時將調用者傳入的參數(shù)與各個新函數(shù)的參數(shù)定義進行比較,找出合適的那個函數(shù)來執(zhí)行。合適的函數(shù)往往是參數(shù)定義上最具體最接近傳入?yún)?shù)的那個函數(shù)。在這個例子中,當n為1時,可以用函數(shù)int fib(int n),不過真正調用的是int fib(1)因為這個函數(shù)更具體更接近調用者的要求。
模式匹配一般來說要比這里舉的例子更加復雜。比如說,高級模式匹配系統(tǒng)可以支持下面的操作:
int f(int n < 10) { ... } int f(int n) { ... }那么什么情況下模式匹配會有用呢?在需要處理一大堆程序分支的時候!每當需要實現(xiàn)復雜的嵌套if語句的時候,模式匹配可以幫助你用更少的代碼更好的完成任務。我所知道的一個這樣的函數(shù)是標準的WndProc函數(shù),該函數(shù)是所有Win32應用程序必須具備的(盡管它經(jīng)常會被抽象化)。模式匹配系統(tǒng)一般都可以像匹配簡單數(shù)值一樣匹配數(shù)據(jù)集合。舉個例子,對于一個接受數(shù)組作為參數(shù)的函數(shù),可以通過模式匹配數(shù)組中第一個數(shù)字為1并且第三個數(shù)字大于3的輸入。
模式匹配的另外一個好處是每當需要添加或者修改程序分支時,再也不用面對那個龐大臃腫的函數(shù)了。只要添加(或者修改)相關的函數(shù)定義即可。有了模式匹配就不再需要四人幫的很多設計模式了。程序分支越多越復雜,模式匹配就越有用。而在習慣使用這一技術之后,你可能會懷疑沒有它你一天都過不下去了。
Closure
目前為止關于函數(shù)式編程各種功能的討論都只局限在“純”函數(shù)式語言范圍內:這些語言都是lambda演算的實現(xiàn)并且都沒有那些和阿隆佐形式系統(tǒng)相沖突的特性。然而,很多函數(shù)式語言的特性哪怕是在lambda演算框架之外都是很有用的。確實,如果一個公理系統(tǒng)的實現(xiàn)可以用數(shù)學思維來看待程序,那么這個實現(xiàn)還是很有用的,但這樣的實現(xiàn)卻不一定可以付諸實踐。很多現(xiàn)實中的語言都選擇吸收函數(shù)式編程的一些元素,卻又不完全受限于函數(shù)式教條的束縛。很多這樣的語言(比如Common Lisp)都不要求所有的變量必須為final,可以修改他們的值。也不要求函數(shù)只能依賴于它們的參數(shù),而是可以讀寫函數(shù)外部的狀態(tài)。同時這些語言又包含了FP的特性,如高階函數(shù)。與在lambda演算限制下將函數(shù)作為參數(shù)傳遞不同,在指令式語言中要做到同樣的事情需要支持一個有趣的特性,人們常把它稱為lexical closure。還是來看看例子。要注意的是,這個例子中變量不是final,而且函數(shù)也可以讀寫其外部的變量:
Function makePowerFn(int power) {int powerFn(int base) {return pow(base, power);}return powerFn; }Function square = makePowerFn(2);
square(3); // returns 9
makePowerFn函數(shù)返回另一個函數(shù),這個新的函數(shù)需要一個整數(shù)參數(shù)然后返回它的平方值。執(zhí)行square(3)的時候具體發(fā)生了什么事呢?變量power并不在powerFn的域內,因為makePowerFn早就運行結束返回了,所以它的棧也已經(jīng)不存在了。那么square又是怎么正常工作的呢?這個時候需要語言通過某種方式支持繼續(xù)存儲power的值,以便square后面繼續(xù)使用。那么如果再定義一個函數(shù),cube,用來計算立方,又應該怎么做呢?那么運行中的程序就必須存儲兩份power的值,提供給makePowerFn生成的兩個函數(shù)分別使用。這種保存變量值的方法就叫做closure。closure不僅僅保存宿主函數(shù)的參數(shù)值,還可以用在下例的用法中:
Function inc1 = makeIncrementer();
Function inc2 = makeIncrementer();
inc1(); // returns 1;
inc1(); // returns 2;
inc1(); // returns 3;
inc2(); // returns 1;
inc2(); // returns 2;
inc2(); // returns 3;
運行中的程序負責存儲n的值,以便incrementer稍后可以訪問它。與此同時,程序還會保存多份n的拷貝,雖然這些值應該在makeIncrementer返回后就消失,但在這個情況下卻繼續(xù)保留下來給每一個incrementer對象使用。這樣的代碼編譯之后會是什么樣子?closure幕后的真正工作機理又是什么?這次運氣不錯,我們有一個后臺通行證,可以一窺究竟。
一點小常識往往可以幫大忙。乍一看這些本地變量已經(jīng)不再受限于基本的域限制并擁有無限的生命周期了。于是可以得出一個很明顯的結論:它們已經(jīng)不是存在棧上,而是堆上了8。這么說來closure的實現(xiàn)和前面討論過的函數(shù)差不多,只不過closure多了一個額外的引用指向其外部的變量而已:
class some_function_t {SymbolTable parentScope;// ...}
當closure需要訪問不在它本地域的變量時,就可以通過這個引用到更外一層的父域中尋找該變量。謎底揭開了!closure將函數(shù)編程與面向對象的方法結合了起來。下一次為了保存并傳遞某些狀態(tài)而創(chuàng)建類的時候,想想closure。它能在運行時從相應的域中獲得變量,從而可以把該變量當成“成員變量”來訪問,也因為這樣,就不再需要去創(chuàng)建一個成員變量了。
原文:戳
總結
- 上一篇: 解决steam下载速度过慢的问题
- 下一篇: RabbitMQ 延迟队列详解