函數式編程與面向對象的設計方法在思路和手段上都各有千秋,在這裡,我將簡要介紹一下函數式編程與面向對象相比的一些特點和差異。
在理解函數作為一等公民這句話時,讓我們先來看一下一種非常常用的互聯網語言JavaScript,相信大家對它都不會陌生。JavaScript並不是嚴格意義上的函數式編程,不過,它也不是屬於嚴格的面向對象。但是,如果你願意,你既可以把它當做面向對象語言,也可以把它當做函數式語言,因此,稱之為多范式語言,可能更加合適。
如果你使用jQuery,你可能會經常使用如下的代碼:
$("button").click(function(){ $("li").each(function(){ alert($(this).text()) }); });
注意這裡each()函數的參數,這是一個匿名函數,在遍歷所有的li節點時,會彈出li節點的文本內容。將函數作為參數傳遞給另外一個函數,這是函數式編程的特性之一。
再來考察另外一個案例:
function f1(){ var n=1; function f2(){ alert(n); } return f2; } var result=f1(); result(); // 1
這也是一段JavaScript代碼,在這段代碼中,注意函數f1的返回值,它返回了函數f2。在倒數第2行,返回的f2函數並賦值給result,實際上,此時的result就是一個函數,並且指向f2。對result的調用,就會打印n的值。
函數可以作為另外一個函數的返回值,也是函數式編程的重要特點。
函數的副作用指的是函數在調用過程中,除了給出了返回值外,還修改了函數外部的狀態,比如,函數在調用過程中,修改了某一個全局狀態。函數式編程認為,函數的副用作應該被盡量避免。可以想象,如果一個函數肆意修改全局或者外部狀態,當系統出現問題時,我們可能很難判斷究竟是哪個函數引起的問題。這對於程序的調試和跟蹤是沒有好處的。如果函數都是顯式函數,那麼函數的執行顯然不會受到外部或者全局信息的影響,因此,對於調試和排錯是有益的。
注意:顯式函數指函數與外界交換數據的唯一渠道就是參數和返回值,顯式函數不會去讀取或者修改函數的外部狀態。與之相對的是隱式函數,隱式函數除了參數和返回值外,還會讀取外部信息,或者可能修改外部信息。
然而,完全的無副作用實際上做不到的。因為系統總是需要獲取或者修改外部信息的。同時,模塊之間的交互也極有可能是通過共享變量進行的。如果完全禁止副作用的出現,也是一件讓人很不愉快的事情。因此,大部分函數式編程語言,如Clojure等,都允許副作用的存在。但是與面向對象相比,這種函數調用的副作用,在函數式編程裡,需要進行有效的限制。
函數式編程是申明式的編程方式。相對於命令式(imperative)而言,命令式的程序設計喜歡大量使用可變對象和指令。我們總是習慣於創建對象或者變量,並且修改它們的狀態或者值,或者喜歡提供一系列指令,要求程序執行。這種編程習慣在申明式的函數式編程中有所變化。對於申明式的編程范式,你不在需要提供明確的指令操作,所有的細節指令將會更好的被程序庫所封裝,你要做的只是提出你要的要求,申明你的用意即可。
請看下面一段程序,這一段傳統的命令式編程,為了打印數組中的值,我們需要進行一個循環,並且每次需要判斷循環是否結束。在循環體內,我們要明確地給出需要執行的語句和參數。
public static void imperative(){ int[]iArr={1,3,4,5,6,9,8,7,4,2}; for(int i=0;i<iArr.length;i++){ System.out.println(iArr[i]); } }
與之對應的申明式代碼如下:
public static void declarative(){ int[]iArr={1,3,4,5,6,9,8,7,4,2}; Arrays.stream(iArr).forEach(System.out::println); }
可以看到,變量數組的循環體居然消失了!println()函數似乎在這裡也沒有指定任何參數,在此,我們只是簡單的申明了我們的用意。有關循環以及判斷循環是否結束等操作都被簡單地封裝在程序庫中。
遞歸是一種常用的編程技巧。使用遞歸通常可以簡化程序編碼,大幅減少代碼行數。但是遞歸有一個很大的弊病——它總是使用棧空間。但是,程序的棧空間是非常有限的,與堆空間相比,可能相差幾個數量級(棧空間大小通常只有幾百K,而堆空間則通常達到幾百M甚至上百G)。因此,大規模的遞歸操作有可能發生棧空間溢出錯誤,這也限制了遞歸函數的使用,並給系統帶來了一定的風險。
而尾遞歸優化可以有效地避免這種狀況。尾遞歸指遞歸操作處於函數的最後一步。在這種情況下,該函數的工作其實已經完成(剩余的工作就是再次調用它自己),此時,只需要簡單得將中間結果傳遞給後繼調用的遞歸函數即可。此時,編譯器就可以進行一種優化,使當前的函數調用返回,或者用新函數的幀棧覆蓋老函數的幀棧。總之,當遞歸處於函數操作的最後一步時,我們總是可以想方設法避免遞歸操作不斷申請棧空間。
大部分函數式編程語言直接或者間接支持尾遞歸優化。
如果讀者熟悉多線程程序設計,那麼一定對不變模式有所有了解。所謂不變,是指對象在創建後,就不再發生變化。比如,java.lang.String就是不變模式的典型。如果你在Java中創建了一個String實例,無論如何,你都不可能改變整個String的值。比如,當你使用String.replace()函數試圖進行字符串替換時,實際上,原有的字符串對象並不會發生變化,函數本身會返回一個新的String對象,作為給定字符替換後的返回值。不變的對象在函數式編程中被大量使用。
請看以下代碼:
static int[] arr={1,3,4,5,6,7,8,9,10}; Arrays.stream(arr).map((x)->x=x+1).forEach(System.out::println); System.out.println(); Arrays.stream(arr).forEach(System.out::println);
代碼第2行看似對每一個數組成員執行了加1的操作。但是在操作完成後,在最後一行,打印arr數組所有的成員值時,你還是會發現,數組成員並沒有變化!在使用函數式編程時,這種狀態是一種常態,幾乎所有的對象都拒絕被修改。
由於對象都處於不變的狀態,因此函數式編程更加易於並行。實際上,你甚至完全不用擔心線程安全的問題。我們之所以要關注線程安全,一個很大的原因是當多個線程對同一個對象進行寫操作時,容易將這個對象“寫壞”,更專業的說法是“使得對象狀態不一致”。但是,由於不變模式的存在,對象自創建以來,就不可能發生改變,因此,在多線程環境下,也就沒有必要進行任何同步操作。這樣不僅有利於並行化,同時,在並行化後,由於沒有同步和鎖機制,其性能也會比較好。讀者可以關注一下java.lang.String對象。很顯然,String對象可以在多線程中很好的工作,但是,它的每一個方法都沒有進行同步處理。
通常情況下,函數式編程更加簡明扼要,Clojure語言(一種運行於JVM的函數式語言)的愛好者就宣稱,使用Clojure可以將Java代碼行數減少到原有的十分之一。一般說來,精簡的代碼更易於維護。而Java代碼的冗余性也是出了名的,大部分對於Java語言的攻擊都會直接針對Java繁瑣,而且死板的語法(但我認為這也是Java的優點之一,正如本書第一段提到的“保守的設計思想是Java最大的優勢”),然而,引入函數式編程范式後,這種情況發生了改變。我們可以讓Java用更少的代碼完成更多的工作。
請看下面這個例子,對於數組中每一個成員,首先判斷是否是奇數,如果是奇數,則執行加1,並最終打印數組內所有成員。
數組定義:
使用函數式方式:
Arrays.stream(arr).map(x->(x%2==0?x:x+1)).forEach(System.out::println);
可以看到,函數式范式更加緊湊而且簡潔。
感興趣的朋友可以看看這本電子書《Java8函數式編程入門》