牢記:函數式程式設計不是用函數來程式設計! ! !
23.4函數式編程
23.4.1 什麼是函數式程式設計
什麼是函數式程式設計?如果你這麼直白地詢問,會發現它竟是一個不太容易解釋的概念。許多在程式設計領域有著多年經驗的老手,也無法很明白地說清楚函數式程式設計到底在研究些什麼。函數式程式設計對於熟悉過程式設計的程式設計師來說的確是一個陌生的領域,閉包(closure),延續(continuation),和柯里化(currying)這些概念看起來是這麼的陌生,同我們熟悉的if、else、while沒有任何的相似之處。儘管函數式程式設計有著程式無法比擬的優美的數學原型,但它又是那麼的高深莫測,似乎只有拿著博士學位的人才玩得轉它。
提示:這一節有點難,但它並不是掌握JavaScript所必需的技能,如果你不想用JavaScript來完成那些用Lisp來完成活兒,或者不想學函數式編程這種深奧的技巧,你完全可以跳過它們,進入下一章的旅程。
那麼回到這個問題,什麼是函數式程式設計?答案很長…
函數式程式設計第一定律:函數是第一型。
這句話本身該如何理解?什麼才是真正的第一型?我們看下面的數學概念:
二元方程式F(x, y) = 0,x, y 是變量, 把它寫成y = f(x), x是參數,y是返回值,f是由x到y的映射關係,稱為函數。如果又有,G(x, y, z) = 0,或記為z = g(x, y),g是x、y到z的映射關係,也是函數。若g的參數x, y又滿足前面的關係y = f(x), 那麼得到z = g(x, y) = g(x, f(x)),這裡有兩重意義,一是f( x)是x上的函數,又是函數g的參數,二是g是比f更高階的函數。
這樣我們就用z = g(x, f(x)) 來表示方程式F(x, y) = 0和G(x, y, z) = 0的關聯解,它是一個迭代的函數。我們也可以用另一種形式來表示g,記z = g(x, y, f),這樣我們將函數g一般化為一個高階函數。同前面相比,後面這種表示方式的好處是,它是一種更泛化的模型,例如T(x,y) = 0和G(x,y,z) = 0的關聯解,我們也可以用同樣的形式來表示(只要令f=t)。在這種支持把問題的解轉換成高階函數迭代的語言體系中,函數就被稱為「第一型」。
JavaScript中的函數顯然是「第一型」。下面就是一個典型的例子:
Array.prototype.each = function(closure)
{
return this.length ? [closure(this[0])].concat(this.slice(1).each(closure)) : [];
}
這真是個神奇的魔法程式碼,它充分發揮了函數式的魅力,在整個程式碼中只有函數(function)和符號(Symbol)。它形式簡潔且威力無窮。
[1,2,3,4].each(function(x){return x * 2})得到[2,4,6,8],而[1,2,3,4].each(function(x ){return x-1})得到[0,1,2,3]。
函數式和物件導向的本質都是「道法自然」。如果說,物件導向是一種真實世界的模擬的話,那麼函數式就是數學世界的模擬,從某種意義上說,它的抽象程度比物件導向更高,因為數學系統本來就具有自然界所無法比擬的抽象性。
函數式程式設計第二定律:閉包是函數式程式設計的摯友。
閉包,在前面的章節中我們已經解釋過了,它對於函數式程式設計非常重要。它最大的特點是不需要透過傳遞變數(符號)的方式就可以從內層直接存取外層的環境,這為多重嵌套下的函數式程式帶來了極大的便利性,以下是一個例子:
(function outerFun(x)
{
return function innerFun(y)
{
return x * y;
}
})(2)(3);
函數式程式設計第三定律:函數可以被科里化(Currying)。
什麼是Currying? 它是一個有趣的概念。還是從數學開始:我們說,考慮一個三維空間方程式F(x, y, z) = 0,如果我們限定z = 0,於是得到F(x, y, 0) = 0 記為F'(x, y)。這裡F'顯然是一個新的方程式,它代表三維空間曲線F(x, y, z)在z = 0平面上的兩維投影。記y = f(x, z), 設z = 0, 得到y = f(x, 0),記為y = f'(x), 我們說函數f'是f的一個Currying解。
下面給出了JavaScript的Currying的範例:
function add(x, y)
{
if(x!=null && y!=null) return x + y;
else if(x!=null && y==null) return function(y)
{
return x + y;
}
else if(x==null && y!=null) return function(x)
{
return x + y;
}
}
var a = add(3, 4);
var b = add(2);
var c = b(10);
上面的例子中,b=add(2)得到的是一個add()的Currying函數,它是當x = 2時,關於參數y的函數,注意到上面也用到了閉包的特性。
有趣的是,我們可以給任意函數一般化Currying,例如:
function Foo(x, y, z, w)
{
var args = arguments;
if(Foo.length < args.length)
return function()
{
return
args.callee.apply(Array.apply([], args).concat(Array.apply([], arguments)));
}
else
return x + y – z * w;
}
函數式程式設計第四定律:延遲求值與延續。
//TODO:這裡再考慮下
23.4.2 函數式程式設計的優點
單元測試
嚴格函數式程式設計的每一個符號都是對直接量或表達式結果的引用,沒有函數產生副作用。因為從未在某個地方修改過值,也沒有函數修改過在其作用域之外的量並被其他函數使用(如類別成員或全域變數)。這表示函數求值的結果只是其回傳值,而惟一影響其回傳值的就是函數的參數。
這是單元測試者的夢中仙境(wet dream)。對被測試程式中的每個函數,你只需在意其參數,而不必考慮函數呼叫順序,不用謹慎地設定外部狀態。所有要做的就是傳遞代表了邊際情況的參數。如果程式中的每個函數都通過了單元測試,你就對這個軟體的品質有了相當的自信。而命令式程式設計就不能這樣樂觀了,在Java 或C++ 中只檢查函數的回傳值還不夠——我們還必須驗證這個函數可能修改了的外部狀態。
調試
如果一個函數式程式不如你期望地運行,調試也是輕而易舉。因為函數式程式的bug 不依賴執行前與其無關的程式碼路徑,你遇到的問題總是可以重現。在命令式程式中,bug 時隱時現,因為在那裡函數的功能依賴與其他函數的副作用,你可能會在和bug 的產生無關的方向探索很久,毫無收穫。函數式程式就不是這樣——如果函數的結果是錯誤的,那麼無論之前你還執行過什麼,這個函數總是會回傳相同的錯誤結果。
一旦你將那個問題再現出來,尋其根源將毫不費力,甚至會讓你開心。中斷那個程式的執行然後檢查堆疊,和命令式程式設計一樣,棧裡每一次函數呼叫的參數都呈現在你眼前。但是在命令式程式中只有這些參數還不夠,函數還依賴成員變量,全域變數和類別的狀態(這反過來也依賴這許多情況)。函數式程式裡函數只依賴它的參數,而那些資訊就在你注視的目光下!還有,在命令式程式裡,只檢查一個函數的回傳值不能夠讓你確信這個函數已經正常運作了,你還要去查看那個函數作用域外數十個物件的狀態來確認。對函數式程序,你要做的所有事情就是查看其返回值!
沿著堆疊檢查函數的參數和回傳值,只要發現一個不盡合理的結果就進入那個函數然後一步步追蹤下去,重複這一個過程,直到它讓你發現了bug 的生成點。
並行函數式程式無需任何修改即可並行執行。不用擔心死鎖和臨界區,因為你從未用鎖!函數式程式裡沒有任何資料被同一線程修改兩次,更不用說兩個不同的線程了。這意味著可以不假思索地簡單增加線程而不會引發折磨著平行應用程式的傳統問題。
事實既然如此,為什麼並不是所有人都在需要高度平行作業的應用中採用函數式程式?嗯,他們正在這樣做。愛立信公司設計了一種稱為Erlang 的函數式語言並將它使用在需要極高抗錯性和可擴展性的電信交換機上。還有很多人也發現了Erlang 的優勢並開始使用它。我們談論的是電信通訊控制系統,這與設計華爾街的典型系統相比對可靠性和可升級性要求高了得多。實際上,Erlang 系統並不可靠且容易擴展,JavaScript 才是。 Erlang 系統只是堅如磐石。
關於並行的故事還沒有就此停止,即使你的程式本身就是單線程的,那麼函數式程式的編譯器仍然可以優化它使其運行於多個CPU上。請看下面這段程式碼:
String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);
在函數程式語言中,編譯器會分析程式碼,辨認出潛在耗時的建立字串s1和s2的函數,然後並行地執行它們。這在命令式語言中是不可能的,因為在那裡,每個函數都有可能修改了函數作用域以外的狀態並且其後續的函數又會依賴這些修改。在函數式語言裡,自動分析函數並找出適合併行執行的候選函數簡單的像自動進行的函數內聯化!在這個意義上,函數式風格的程式是「不會過時的技術(future proof)」(即使不喜歡用行業術語,但這回要破例一次)。硬體廠商已經無法讓CPU運作得更快了,於是他們增加了處理器核心的速度並因並行而獲得了四倍的速度提升。當然他們也順便忘記提及我們的多花的錢只是用在了解決平行問題的軟體上了。一小部分的命令式軟體和100% 的函數式軟體都可以直接並行運作於這些機器上。
程式碼熱部署
過去要在Windows上安裝更新,重啟電腦是難免的,而且還不止一次,即使是安裝了一個新版的媒體播放器。 Windows XP 大大改進了這一狀態,但仍不理想(我今天工作時運行了Windows Update,現在一個煩人的圖標總是顯示在托盤裡除非我重啟一次機器)。 Unix系統一直以更好的模式運行,安裝更新時只需停止系統相關的元件,而不是整個作業系統。即使如此,對一個大規模的伺服器應用這還是不能令人滿意的。電信系統必須100%的時間運行,因為如果在系統更新時緊急撥號失效,就可能造成生命的損失。華爾街的公司也沒有理由必須在周末停止服務以安裝更新。
理想的情況是完全不停止系統任何元件來更新相關的程式碼。在命令式的世界裡這是不可能的。考慮運行時上載一個Java類別並重載一個新的定義,那麼所有這個類別的實例都將不可用,因為它們被保存的狀態丟失了。我們可以著手寫些繁瑣的版本控製程式碼來解決這個問題,然後將這個類別的所有實例序列化,再銷毀這些實例,繼而用這個類別新的定義來重新建立這些實例,然後載入先前被序列化的資料並希望載入程式碼可以恰當地將這些資料移植到新的實例。在此之上,每次更新都要重新手動編寫這些用來移植的程式碼,而且要相當謹慎地防止破壞物件間的相互關係。理論簡單,但實務並不容易。
對函數式的程序,所有的狀態即傳遞給函數的參數都被保存在了堆疊上,這使得的熱部署輕而易舉!實際上,所有我們需要做的就是對工作中的程式碼和新版本的程式碼做一個差異比較,然後部署新程式碼。其他的工作將由一個語言工具自動完成!如果你認為這是個科幻故事,請再思考一下。多年來Erlang工程師一直更新著他們的運作的系統,而無需中斷它。
機器輔助的推理和最佳化
函數式語言的一個有趣的屬性就是他們可以用數學方式推理。因為一種函數式語言只是一個形式系統的實現,所有在紙上完成的運算都可以應用在用這種語言書寫的程式。編譯器可以用數學理論將轉換一段程式碼轉換成等價的但卻更有效率的程式碼[7]。多年來關係資料庫一直在進行這類優化。沒有理由不能把這項技術應用在常規軟體上。
另外,還能使用這些技術來證明部分程式的正確,甚至可能創建工具來分析程式碼並為單元測試自動產生邊界用例!對穩固的系統這種功能沒有價值,但如果你要設計心房脈衝產生器(pace maker)或空中交通控制系統,這種工具就不可或缺。如果你寫的應用程式不是產業的核心任務,這類工具也是你強於競爭對手的殺手鐧。
23.4.3 函數式程式設計的缺點
閉包的副作用
非嚴格函數式程式設計中,閉包可以改寫外在環境(在上一章我們已經看過了),這帶來了副作用,當這種副作用頻繁出現並經常改變程式運行環境時,錯誤就變得難以追蹤。
//TODO:
遞迴的形式
儘管遞歸通常是一種最簡潔的表達形式,但它確實不如非遞歸的循環來的直觀。
//TODO:
延遲取值的弱點
//TODO: