原文連結:http://www.defmacro.org/ramblings/fp.html
單元測試
因為函數式編程的每一個符號都是 final
的,沒有函數產生過副作用。因為從未在某個地方修改過值,也沒有函數修改過在其範圍之外的量並被其他函數使用(如類成員或全域變數)。這意味著函數求值的結果只是其傳回值,而惟一影響其傳回值的就是函數的參數。
這是單元測試者的夢中仙境(wet
dream)。對被測試程式中的每個函數,你只需在意其參數,而不必考慮函數調用順序,不用謹慎地設定外部狀態。所有要做的就是傳遞代表了邊際情況的參數。如果程式中的每個函數都通過了單元測試,你就對這個軟體的品質有了相當的自信。而命令式編程就不能這樣樂觀了,在
Java 或 C++ 中只檢查函數的傳回值還不夠——我們還必須驗證這個函數可能修改了的外部狀態。
調試
如果一個函數式程式不如你期望地運行,調試也是輕而易舉。因為函數式程式的 bug
不依賴於執行前與其無關的代碼路徑,你遇到的問題就總是可以再現。在命令式程式中,bug 時隱時現,因為在那裡函數的功能依賴與其他函數的副作用,你可能會在和 bug
的產生無關的方向探尋很久,毫無收穫。函數式程式就不是這樣——如果一個函數的結果是錯誤的,那麼無論之前你還執行過什麼,這個函數總是返回相同的錯誤結果。
一旦你將那個問題再現出來,尋其根源將毫不費力,甚至會讓你開心。中斷那個程式的執行然後檢查堆棧,和命令式編程一樣,棧裡每一次函數調用的參數都呈現在你眼前。但是在命令式程式中只有這些參數還不夠,函數還依賴於成員變數,全域變數和類的狀態(這反過來也依賴著這許多情況)。函數式程式裡函數只依賴於它的參數,而那些資訊就在你注視的目光下!還有,在命令式程式裡,只檢查一個函數的傳回值不能夠讓你確信這個函數已經正常工作了,你還要去查看那個函數範圍外數十個對象的狀態來確認。對函數式程式,你要做的所有事就是查看其傳回值!
沿著堆棧檢查函數的參數和傳回值,只要發現一個不盡合理的結果就進入那個函數然後一步步跟蹤下去,重複這一個過程,直到它讓你發現了 bug 的產生點。
並行
函數式程式無需任何修改即可並存執行。不用擔心死結和臨界區,因為你從未用鎖!函數式程式裡沒有任何資料被同一線程修改兩次,更不用說兩個不同的線程了。這意味著可以不假思索地簡單增加線程而不會引發折磨著並行應用程式的傳統問題。
事實既然如此,為什麼並不是所有人都在需要高度並行作業的應用中採用函數式程式?嗯,他們正在這樣做。愛立信公司設計了一種叫作 Erlang
的函數式語言並將它使用在需要極高抗錯性和可擴充性的電信交換器上。還有很多人也發現了 Erlang
的優勢並開始使用它。我們談論的是電信通訊控制系統,這與設計華爾街的典型系統相比對可靠性和可升級性要求高了得多。實際上,Erlang
系統並不可靠和易擴充,Java 才是。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)或空中交通控制系統,這種工具就不可或缺。如果你編寫的應用程式不是產業的核心任務,這類工具也是你強於競爭者的殺手鐧。
高階函數
我記得自己在瞭解了上面列出的種種優點後曾想:“那都是非常好的特性,可是如果我不得不用天生就不健全的語言編程,把一切變數聲明為
final
產生的代碼將是垃圾一堆。” 這其實是誤解。在如Java 這般的命令式語言環境裡,將所有變數聲明為 final
沒有用,但是在函數式語言裡不是這樣。函數式語言提供了不同的抽象工具它會使你忘記你曾經習慣於修改變數。高階函數就是這樣一種工具。
函數式語言中的函數不同於 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_function_t
的執行個體如果不再被使用就將會被記憶體回收掉。這些使得函數成為第一級的對象無異於整數或字串。(作為參數)操作函數的函數被稱為高階函數。別讓這個術語嚇著你,這和
Java 的 class 操作其它 class(把它們作為參數)沒有什麼區別。我們本可以把它們稱為“高階類”但沒有人注意到這個,因為 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);
沒有建立新的類型和新的class層次,只是傳入合適的函數作為參數,完成了物件導向方式同樣的功能,同時還有一些額外的優點。沒有使自己囿於類的層次之中:可以在運行時傳入函數並在任何時候以更高的粒度更少的代碼修改他們。編譯器高效地為我們產生了物件導向的“粘合”代碼!除此之外,我們還獲得了所有函數式編程的其他好處。當然函數式語言提供的抽象不只這些,高階函數只是一個開始:
currying
我認識的大多數人都讀過“四人幫”的那本設計模式,任何自重的程式員都會告訴你那本書是語言中立的(agnostic),模式在軟體工程中是通用的,和使用的語言無關。這個說法頗為高貴,故而不幸的是,有違現實。
函數式編程極具表達能力。在函數式語言中,語言既已達此高度,設計模式就不再是必需,最終你將設計模式徹底消除而以概念編程。適配器(Adapter)模式就是這樣的一個例子。(究竟適配器和
Facade 模式區別在哪裡?可能有些人需要在這裡再多費些篇章)。一旦語言有了叫作 currying 的技術,這一模式就可以被消除。
currying.
適配器模式最有名的是被應用在 Java 的“預設”抽象單元——class
上。在函數式編程裡,模式被應用到函數。模式帶有一個介面並將它轉換成另一個對他人有用的介面。這有一個適配器模式的例子:
int pow(int i, int j);
int square(int i)
{
return pow(i,
2);
}
上面的代碼把一個整數冪運算介面轉換成為了一個平方介面。在學術文章裡,這個雕蟲小技被叫作currying(得名於邏輯學家Haskell
Curry,他曾將相關的數學理論形式化
)。因為在函數式編程中函數(反之如class)被作為參數來回傳遞,currying 很頻繁地被用來把函數調整為更適宜的介面。因為函數的介面是他的參數,使用
currying 可以減少參數的數目(如上例所示)。
函數式語言內建了這一技術。不用手動地建立一個封裝了原函數的函數,函數式語言可以為你代勞。同樣地,擴充我們的語言,讓他支援這個技術:
square = int pow(int i, 2);
這將為我們自動建立出一個有一個參數的函數 square。他把第二個參數設定為 2 再調用函數 pow。這行代碼會被編譯為如下的 Java 代碼:
class square_function_t {
int square(int i) {
return pow(i,
2);
}
}
square_function_t square = new square_function_t();
正如你所見,通過簡單地建立一個對原函數的封裝,在函數式編程中,這就是 currying ——
快速簡易建立封裝的捷徑。把精力集中在你的業務上,讓編譯器為你寫出必要的代碼!什麼時候使用
currying?這很簡單,任何時候你想要使用適配器模式(封裝)時。
惰性求值
惰性(或延遲)求值這一技術可能會變得非常有趣一旦我們採納了函數式哲學。在討論並行時已經見過下面的代碼片斷:
String s1 = somewhatLongOperation1();
String s2 =
somewhatLongOperation2();
String s3 = concatenate(s1, s2);
在一個命令式語言中求值順序是確定的,因為每個函數都有可能會變更或依賴於外部狀態,所以就必須有序的執行這些函數:首先是
somewhatLongOperation1,然後
somewhatLongOperation2,最後 concatenate,在函數式語言裡就不盡然了。
前面提到只要確保沒有函數修改或依賴於全域變數,somewhatLongOperation1 和 somewhatLongOperation2
可以被並存執行。但是如果我們不想同時運行這兩個函數,還有必要保證有序的執行他們呢?答案是不。我們只在其他函數依賴於s1和s2時才需要執行這兩個函數。我們甚至在concatenate調用之前都不必執行他們——可以把他們的求值延遲到concatenate函數內實際用到他們的位置。如果用一個帶有條件分支的函數替換concatenate並且只用了兩個參數中的一個,另一個參數就永遠沒有必要被求值。在
Haskell 語言中,不確保一切都(完全)按順序執行,因為 Haskell 只在必要時才會對其求值。
惰性求值優點眾多,但缺點也不少。我們會在這裡討論它的優點而在下一節中解釋其缺點。
最佳化
惰性求值有客觀的最佳化潛力。惰性編譯器看函數式代碼就像數學家面對的代數運算式————可以登出一部分而完全不去運行它,重新調整程式碼片段以求更高的效率,甚至重整代碼以降低出錯,所有確定性最佳化(guaranteeing
optimizations)不會破壞代碼。這是嚴格用形式原語描述程式的巨大優勢————代碼固守著數學定律並可以數學的方式進行推理。
抽象控制結構
惰性求值提供了更高一級的抽象,它使得不可能的事情得以實現。例如,考慮實現如下的控制結構:
unless(stock.isEuropean()) {
sendToSEC(stock);
}
我們希望只在祖先不是歐洲人時才執行sendToSEC。如何?
unless?如果沒有惰性求值,我們需要某種形式的宏(macro)系統,但
Haskell 這樣的語言不需要它。把他實現為一個函數即可:
void unless(boolean condition, List code)
{
if(!condition)
code;
}
注意如果條件為真代碼將不被執行。我們不能在一個嚴格(strict)的語言中再現這種求值,因為 unless 調用之前會先對參數進行求值。