寫在前面
從Java 8開始,Java語言添加了lambda表達式以及函數式接口等新特性。這意味着Java語言也開始逐步提供函數式編程的能力。
事實上,如果你熟悉Erlang、Scala、JavaScript或Python,那你或多或少對函數式編程相對熟悉。但如果你是一個通過常規路徑學習的Javaer,可能對函數式編程思想不甚了解,相對的,你可能對面向對象編程思想會更熟悉。
先熟悉一下幾個術語,有利於提升大家的逼格:
FP,Functional Programming,函數式編程;
OOP,Object Oriented Programming,面向對象編程;
雖然FP是在最近10年才流行起來的,但它的歷史和OOP幾乎等長。為什么FP突然流行起來了呢?
- 最主要原因是摩爾定律正在逐漸失效,單核CPU的計算能力在短期內無法有大的突破,計算機領域正在向“多核CPU和分布式計算”的方向發展。而FP則天然具備適合並發編程的優點:它不修改變量,不存在多線程間的競爭問題,因此也不需要考慮“鎖”和“線程阻塞”,易於並發編程。在未來的大數據時代,函數式編程思想將會越來越重要。
- 其次,在現實的編碼過程中,程序員們發現了即使在OOP的世界中,FP能更好的解決某些具體問題,是OOP的有益補充。因此,出現了許多混合式風格的代碼(混合式指 OOP + FP ),這也是為什么Java這個老牌的OOP編程語言要引入FP新特性的原因所在。
本系列文章的重點在於介紹Java中的函數式編程,Java作為一個經典的OOP編程語言,在實際應用中,大部分Java程序都是OOP+FP的混合式代碼。
因此,對於函數式編程中的一些高級特性和技巧,例如Currying、惰性求值、尾遞歸等,我們不做專門的闡述,感興趣的同學,可以搜索公眾號,員說,一起討論。
下面,我們先了解一下函數式編程的定義以及它的優點。
本文的示例代碼可從gitee上獲取:https://gitee.com/cnmemset/javafp
什么是函數式編程?
函數式編程是一種編程范式(programming paradigm),追求的目標是整個程序都由函數調用(function applying)以及函數組合(function composing)構成的。
函數調用大家容易理解,但在函數式編程中,函數調用有一個限制——它不會改變函數以外的其它狀態,換而言之,即函數調用不會改變在該函數之外定義的變量值。這種函數有個專門的術語——純函數(purely function)。純函數有個特點,當參數值不變的時候,多次運行純函數,得到的結果總是一樣的。這個特點特別有利於對純函數進行unit test和debugging。
函數組合指的是將一系列簡單函數組合起來形成一個復合函數。函數組合是一個相對復雜的概念,譬如在Python中:
from functools import reduce def compose(*funcs) -> int: """將一組簡單函數 [f, g, h] 組合為一個復合函數 (f(g(h(...)))) """ return reduce(lambda f, g: lambda x: f(g(x)), funcs) # 例子 f = lambda x: x + 1 g = lambda x: x * 2 h = lambda x: x - 3 # 調用復合函數 f(g(h(x)):[(x-3) * 2] + 1 print(compose(f, g, h)(10)) // print 15
default Consumer<T> andThen(Consumer<? super T> after) { Objects.requireNonNull(after); return (T t) -> { accept(t); after.accept(t); }; }
函數式編程的特性
1. 函數是“第一等公民”(first-class citizens)
函數是“第一等公民”,意味着函數和其它數據類型具備同等的地位——可以賦值給某個變量,可以作為另一個函數的參數,也可以作為另一個函數的返回值。
判斷某種開發語言對函數式編程支持程度高低,一個重要的標准就是該語言是否把函數作為“第一等公民”。
例如下面的Java代碼,print變量可以看做是一個匿名函數,它作為一個參數傳入了函數 ArrayList.forEach。更多的語言細節可以參考隨后的系列文章。
public static void simpleFunctinoProgramming() { List<String> l = Arrays.asList("a", "b", "c"); Consumer<String> print = s -> System.out.println(s); l.forEach(print); }
上述代碼會輸出:
a
b
c
2. 沒有“副作用(side effects)”
“副作用(side effects)”,指的是函數在執行的時候,除了得出計算結果之外,還會改變函數以外的狀態。“副作用”的典型場景就是修改了程序的全局變量(譬如Java中某個全局可見的類的屬性值、某個類的靜態變量等等);修改傳入的參數也屬於“副作用”之一;IO操作或調用其它有“副作用”的函數也屬於“副作用”。
函數式編程中要求函數都是“純函數(purely function)”。給定了參數后,多次運行純函數,總會得到相同的返回值,而且它不會修改函數以外的狀態或產生其它的“副作用”。
“副作用”的含義是如此苛刻,但有的時候我們需要在計算過程中保存狀態,然而我們又不能使用可變量,此時我們使用遞歸,利用保存在棧上的參數來記錄狀態。下面的代碼是一個經典的實例,它定義了一個將字符串反轉的函數reverse。可以看到,reverse在執行時的中間狀態,是通過它在遞歸時的參數來保存的。務必牢記,在函數式編程中,所有的參數和變量都是final的(只能賦值1次而且賦值后不可變):
public static String reverse(final String arg) { if (arg.length() == 0) { return arg; } else { return reverse(arg.substring(1)) + arg.substring(0, 1); } }
我們可以使用遞歸來解決類似的問題,雖然它的性能實在不敢恭維,但它確實完全符合函數式編程風格。此時,不免有一個疑問:函數式編程限制如此的多,性能看起來也不太好,我們為什么還要提倡函數式編程呢?原因就在於函數式編程有着許多的優點,尤其是在大數據 && 多核計算的時代。
3. 引用透明(Referential transparency)
引用透明(Referential transparency),指的是函數的運行不依賴於外部狀態或外部變量,只依賴於輸入的參數。任何時候只要參數相同,運行函數所得到的返回值總是相同的。
在其他的編程范式中,函數的返回值往往與系統狀態有關。不同的狀態之下,返回值可能不一樣。這很不利於對程序進行測試和調試。
函數式編程的優點
1. 便於單元測試和調試(Debugging)
由於函數式編程的“沒有副作用”和“引用透明”的特點,每個函數都是一個獨立的邏輯單元,不依賴於外部的狀態或變量,也不修改外部的狀態或變量。給定了參數后,多次運行函數,總會得到相同的返回值。這對單元測試者以及調試者來說,簡直是最理想的情形。
2. 易於“並發編程”
同樣的,函數式編程不依賴於也不會修改外部的狀態或變量,因此我們不需要考慮多線程的並發競爭、死鎖等問題,因為我們根本不需要加“鎖”。這樣,並發編程的復雜度將大大降低,部署也非常方便。
在大數據和多核時代,這個優點被更大的放大了,這也是函數式編程思想煥發活力的主要原因。
3. 便於熱部署
這個優點也很顯然——因為函數式編程不依賴於也不會修改外部的狀態或變量,所以只要保持接口不變,我們可以隨時升級代碼,不需要重啟服務。
結語
需要謹記,函數式編程(FP)是一種編程范式或者說是一種編程思想,和它可以類比的是面向對象編程(OOP)。
OOP的精髓在於“封裝對象”,而FP的精髓在於“不涉及外部狀態”。
一門開發語言,可以既支持OOP,同時也支持FP,兩者並不是矛盾的。
一門開發語言,即使它號稱真正支持函數式編程,也不可能100%符合函數式編程風格,因為IO操作是有“副作用”的,但實際上,不做I/O是不可能的。因此,在實際應用中,函數式編程只要求把I/O的影響限制到最小,並更專注於保持計算過程的單純性。