深入理解 Java 函數語言程式設計,第 2 部分: 函數語言程式設計中的重要概念
深入理解 Java 函數語言程式設計,第 2 部分
函數語言程式設計中的重要概念
成 富
2018 年 12 月 03 日釋出
系列內容:
此內容是該系列 5 部分中的第 # 部分: 深入理解 Java 函數語言程式設計,第 2 部分
https://www.ibm.com/developerworks/cn/views/global/libraryview.jsp?sort_by=&show_abstract=true&show_all=&search_flag=&contentarea_by=%E6%89%80%E6%9C%89%E4%B8%93%E5%8C%BA&search_by=%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3+Java+%E5%87%BD%E6%95%B0%E5%BC%8F%E7%BC%96%E7%A8%8B&product_by=-1&topic_by=-1&type_by=%E6%89%80%E6%9C%89%E7%B1%BB%E5%88%AB&ibm-search=%E6%90%9C%E7%B4%A2
敬請期待該系列的後續內容。
此內容是該系列的一部分: 深入理解 Java 函數語言程式設計,第 2 部分
敬請期待該系列的後續內容。
本系列的上一篇文章對函數語言程式設計思想進行了概述,本文將系統地介紹函數語言程式設計中的常見概念。這些概念對大多數開發人員來說可能並不陌生,在日常的程式設計實踐中也比較常見。
函數語言程式設計正規化的意義
在眾多的程式設計正規化中,大多數開發人員比較熟悉的是面向物件程式設計正規化。一方面是由於面向物件程式語言比較流行,與之相關的資源比較豐富;另外一方面是由於大部分學校和培訓機構的課程設定,都選擇流行的面向物件程式語言。面向物件程式設計正規化的優點在於其抽象方式與現實中的概念比較相近。比如,學生、課程、汽車和訂單等這些現實中的概念,在抽象成相應的類之後,我們很容易就能理解類之間的關聯關係。這些類中所包含的屬性和方法可以很直觀地設計出來。舉例來說,學生所對應的類 Student 就應該有姓名、出生日期和性別等基本的屬性,有方法可以獲取到學生的年齡、所在的班級等資訊。使用面向物件的程式設計思想,可以直觀地在程式要處理的問題和程式本身之間,建立直接的對應關係。這種從問題域到解決域的簡單對應關係,使得程式碼的可讀性很強。對於初學者來說,這極大地降低了上手的難度。
函數語言程式設計正規化則相對較難理解。這主要是由於函式所代表的是抽象的計算,而不是具體的實體。因此比較難通過類比的方式來理解。舉例來說,已知直角三角形的兩條直角邊的長度,需要通過計算來得到第三條邊的長度。這種計算方式可以使用函式來表示。length(a, b)=√a²+b² 就是具體的計算方式。這樣的計算方式與現實中的實體沒有關聯。
基於計算的抽象方式可以進一步提高程式碼的複用性。在一個學生資訊管理系統中,可能會需要找到一個班級的某門課程的最高分數;在一個電子商務系統中,也可能會需要計算一個訂單的總金額。看似風馬牛不相及的兩件事情,其實都包含了同樣的計算在裡面。也就是對一個可迭代的物件進行遍歷,同時在遍歷的過程中執行自定義的操作。在計算最高分數的場景中,在遍歷的同時需要儲存當前已知最高分數,並在遍歷過程中更新該值;在計算訂單總金額的場景中,在遍歷的同時需要儲存當前已累積的金額,並在遍歷過程中更新該值。如果用 Java 程式碼來實現,可以很容易寫出如下兩段程式碼。清單 1 計算學生的最高分數。
清單 1. 計算學生的最高分數的程式碼
int maxMark = 0; for (Student student : students) { if (student.getMark() > maxMark) { maxMark = student.getMark(); } }
清單 2 計算訂單的總金額。
清單 2. 計算訂單的總金額的程式碼
BigDecimal total = BigDecimal.ZERO; for (LineItem item : order.getLineItems()) { total = total.add(item.getPrice().multiply(new BigDecimal(item.getCount()))); }
在面向物件程式設計的實現中,這兩段程式碼會分別新增到課程和訂單所對應的類的某個方法中。課程對應的類 Course 會有一個方法叫 getMaxMark,而訂單對應的類 Order 會有一個方法叫 getTotal。儘管在實現上存在很多相似性和重複程式碼,由於課程和訂單是兩個完全不相關的概念,並沒有辦法通過面向物件中的繼承或組合機制來提高程式碼複用和減少重複。而函數語言程式設計可以很好地解決這個問題。
我們來進一步看一下清單 1 和清單 2 中的程式碼,嘗試提取其中的計算模式。該計算模式由 3 個部分組成:
- 儲存計算結果的狀態,有初始值。
- 遍歷操作。
- 遍歷時進行的計算,更新儲存計算結果的狀態值。
把這 3 個元素提取出來,用偽程式碼表示,就得到了清單 3 中用函式表示的計算模式。iterable 表示被迭代的物件,updateValue 是遍歷時進行的計算,initialValue 是初始值。
清單 3. 計算模式的虛擬碼
function(iterable, updateValue, initialValue) { value = initialValue loop(iterable) { value = updateValue(value, currentValue) } return value }
瞭解函數語言程式設計的讀者應該已經看出來了,這就是常用的 reduce 函式。使用 reduce 對清單 1 和清單 2 進行改寫,可以得到清單 4 中的兩段新的程式碼。
清單 4. 使用 reduce 函式改寫程式碼
reduce(students, (mark, student) -> { return Math.max(student.getMark(), mark); }, 0); reduce(order.lineItems, (total, item) -> { return total.add(item.getPrice().multiply(new BigDecimal(item.getCount()))) }, BigDecimal.ZERO);
函式型別與高階函式
對函數語言程式設計支援程度高低的一個重要特徵是函式是否作為程式語言的一等公民出現,也就是程式語言是否有內建的結構來表示函式。作為面向物件的程式語言,Java 中使用介面來表示函式。直到 Java 8,Java 才提供了內建標準 API 來表示函式,也就是 java.util.function 包。Function<T, R> 表示接受一個引數的函式,輸入型別為 T,輸出型別為 R。Function 介面只包含一個抽象方法 R apply(T t),也就是在型別為 T 的輸入 t 上應用該函式,得到型別為 R 的輸出。除了接受一個引數的 Function 之外,還有接受兩個引數的介面 BiFunction<T, U, R>,T 和 U 分別是兩個引數的型別,R 是輸出型別。BiFunction 介面的抽象方法為 R apply(T t, U u)。超過 2 個引數的函式在 Java 標準庫中並沒有定義。如果函式需要 3 個或更多的引數,可以使用第三方庫,如 Vavr 中的 Function0 到 Function8。
除了 Function 和 BiFunction 之外,Java 標準庫還提供了幾種特殊型別的函式:
- Consumer<T>:接受一個輸入,沒有輸出。抽象方法為 void accept(T t)。
- Supplier<T>:沒有輸入,一個輸出。抽象方法為 T get()。
- Predicate<T>:接受一個輸入,輸出為 boolean 型別。抽象方法為 boolean test(T t)。
- UnaryOperator<T>:接受一個輸入,輸出的型別與輸入相同,相當於 Function<T, T>。
- BinaryOperator<T>:接受兩個型別相同的輸入,輸出的型別與輸入相同,相當於 BiFunction<T,T,T>。
- BiPredicate<T, U>:接受兩個輸入,輸出為 boolean 型別。抽象方法為 boolean test(T t, U u)。
在本系列的第一篇文章中介紹 λ 演算時,提到了高階函式的概念。λ 項在定義時就支援以 λ 項進行抽象和應用。具體到實際的函式來說,高階函式以其他函式作為輸入,或產生其他函式作為輸出。高階函式使得函式的組合成為可能,更有利於函式的複用。熟悉面向物件的讀者對於物件的組合應該不陌生。在劃分物件的職責時,組合被認為是優於繼承的一種方式。在使用物件組合時,每個物件所對應的職責單一。多個物件通過組合的方式來完成複雜的行為。函式的組合類似物件的組合。上一節中提到的 reduce 就是一個高階函式的示例,其引數 updateValue 也是一個函式。通過組合,reduce 把一部分邏輯代理給了作為其輸入的函式 updateValue。不同的函式的巢狀層次可以很多,完成複雜的組合。
在 Java 中,可以使用函式型別來定義高階函式。上述函式介面都可以作為方法的引數和返回值。Java 標準 API 已經大量使用了這樣的方式。比如 Iterable 的 forEach 方法就接受一個 Consumer 型別的引數。
在清單 5 中,notEqual 返回值是一個 Predicate 物件,並使用在 Stream 的 filter 方法中。程式碼執行的輸出結果為 2 和 3。
清單 5. 高階函式示例
public class HighOrderFunctions { private static <T> Predicate<T> notEqual(T t) { return (v) -> !Objects.equals(v, t); } public static void main(String[] args) { List.of(1, 2, 3) .stream() .filter(notEqual(1)) .forEach(System.out::println); } }
部分函式
部分函式(partial function)是指僅有部分輸入引數被綁定了實際值的函式。清單 6 中的函式 f(a, b, c) = a + b +c 有 3 個引數 a、b 和 c。正常情況下呼叫該函式需要提供全部 3 個引數的值。如果只提供了部分引數的值,如只提供了 a 值,就得到了一個部分函式,其中引數 a 被繫結成了給定值。假設給定的引數 a 的值是 1,那新的部分函式的定義是 fa(b, c) = 1 + b + c。由於 a 的實際值可以有無窮多,也有對應的無窮多種可能的部分函式。除了只對 a 繫結值之外,還可以繫結引數 b 和 c 的值。
清單 6. 部分函式示例
function f(a, b, c) { return a + b + c; } function fa(b, c) { return f(1, b, c); }
部分函式可以用來為函式提供快捷方式,也就是預先繫結一些常用的引數值。比如函式 add(a, b) = a + b 用來對 2 個引數進行相加操作。可以在 add 基礎上建立一個部分函式 increase,把引數 b 的值繫結為 1。increase 相當於進行加 1 操作。同樣的,把引數值 b 繫結為 -1 可以得到函式 decrease。
Java 標準庫並沒有提供對部分函式的支援,而且由於只提供了 Function 和 BiFunction,部分函式只對 BiFunction 有意義。不過我們可以自己實現部分函式。部分函式在繫結引數時有兩種方式:一種是按照從左到右的順序繫結引數,另外一種是按照從右到左的順序繫結引數。這兩個方式分別對應於 清單 7 中的 partialLeft 和 partialRight 方法。這兩個方法把一個 BiFunction 轉換成一個 Function。
清單 7. 部分函式的 Java 實現
public class PartialFunctions { private static<T, U, R> Function<U, R> partialLeft(BiFunction<T, U, R> biFunction, T t) { return (u) -> biFunction.apply(t, u); } private static<T, U, R> Function<T, R> partialRight(BiFunction<T, U, R> biFunction, U u) { return (t) -> biFunction.apply(t, u); } public static void main(String[] args) { BiFunction<Integer, Integer, Integer> biFunction = (v1, v2) -> v1 - v2; Function<Integer, Integer> subtractFrom10 = partialLeft(biFunction, 10); Function<Integer, Integer> subtractBy10 = partialRight(biFunction, 10); System.out.println(subtractFrom10.apply(5)); // 5 System.out.println(subtractBy10.apply(5));// -5 } }
柯里化
柯里化(currying)是與λ演算相關的重要概念。通過柯里化,可以把有多個輸入的函式轉換成只有一個輸入的函式,從而可以在λ演算中來表示。柯里化的名稱來源於數學家 Haskell Curry。Haskell Curry 是一位傳奇性的人物,以他的名字命令了 3 種程式語言,Haskell、Brook 和 Curry。柯里化是把有多個輸入引數的求值過程,轉換成多個只包含一個引數的函式的求值過程。對於的函式 f(a, b, c),在柯里化之後轉換成函式 g,則對應的呼叫方式是 g(a)(b)(c)。函式 (x, y) -> x + y 經過柯里化之後的結果是 x -> (y -> x + y)。
柯里化與部分函式存在一定的關聯,但兩者是有區別的。部分函式的求值結果永遠是實際的函式呼叫結果;而柯里化函式的求值結果則可能是另外一個函式。以的部分函式 fa 為例,每次呼叫 fa 時都必須提供剩餘的 2 個引數。求值的結果都是具體的值;而呼叫柯里化之後的函式 g(a) 得到的是另外的一個函式。只有通過遞迴的方式依次求值之後,才能得到最終的結果。
閉包
閉包(closure)是函數語言程式設計相關的一個重要概念,也是很多開發人員比較難以理解的概念。很多程式語言都有閉包或類似的概念。
在上一篇文章介紹 λ 演算的時候提到過 λ 項的自由變數和繫結變數,如 λx.x+y 中的 y 就是自由變數。在對λ項求值時,需要一種方式可以獲取到自由變數的實際值。由於自由變數不在輸入中,其實際值只能來自於執行時的上下文環境。實際上,閉包的概念來源於 1960 年代對 λ 演算中表達式求值方式的研究。
閉包的概念與高階函式密切相關。在很多程式語言中,函式都是一等公民,也就是存在語言級別的結構來表示函式。比如 Python 中就有函式型別,JavaScript 中有 function 關鍵詞來建立函式。對於這樣的語言,函式可以作為其他函式的引數,也可以作為其他函式的返回值。當一個函式作為返回值,並且該函式內部使用了出現在其所在函式的詞法域(lexical scope)的自由變數時,就建立了一個閉包。我們首先通過一段簡單的 JavaScript 程式碼來直觀地瞭解閉包。
清單 8 中的函式 idGenerator 用來建立簡單的遞增式的 ID 生成器。引數 initialValue 是遞增的初始值。返回值是另外一個函式,在呼叫時會返回並遞增 count 的值。這段程式碼就用到了閉包。idGenerator 返回的函式中使用了其所在函式的詞法域中的自由變數 count。count 不在返回的函式中定義,而是來自包含該函式的詞法域。在實際呼叫中,雖然 idGenerator 函式的執行已經結束,其返回的函式 genId 卻仍然可以訪問 idGenerator 詞法域中的變數 count。這是由閉包的上下文環境提供的。
清單 8. JavaScript 中的閉包示例
function idGenerator(initialValue) { let count = initialValue; return function() { return count++; }; } let genId = idGenerator(0); genId(); // 0 genId(); // 1
從上述簡單的例子中,可以得出來構成閉包的兩個要件:
- 一個函式
- 負責繫結自由變數的上下文環境
函式是閉包對外的呈現部分。在閉包建立之後,閉包的存在與否對函式的使用者是透明的。比如清單 8 中的 genId 函式,使用者只需要呼叫即可,並不需要了解背後是否有閉包的存在。上下文環境則是閉包背後的實現機制,由程式語言的執行時環境來提供。該上下文環境需要為函式建立一個對映,把函式中的每個自由變數與閉包建立時的對應值關聯起來,使得閉包可以繼續訪問這些值。在 idGenerator 的例子中,上下文環境負責關聯變數 count 的值,該變數可以在返回的函式中繼續訪問和修改。
從上述兩個要件也可以得出閉包這個名字的由來。閉包是用來封閉自由變數的,適合用來實現內部狀態。比如清單 8 中的 count 是無法被外部所訪問的。一旦 idGenerator 返回之後,唯一的引用就來自於所返回的函式。在 JavaScript 中,閉包可以用來實現真正意義上的私有變數。
從閉包的使用方式可以得知,閉包的生命週期長於建立它的函式。因此,自由變數不能在堆疊上分配;否則一旦函式退出,自由變數就無法繼續訪問。因此,閉包所訪問的自由變數必須在堆上分配。也正因為如此,支援閉包的程式語言都有垃圾回收機制,來保證閉包所訪問的變數可以被正確地釋放。同樣,不正確地使用閉包可能造成潛在的記憶體洩漏。
閉包的一個重要特性是其中的自由變數所繫結的是閉包建立時的值,而不是變數的當前值。清單 9 是一個簡單的 HTML 頁面的程式碼,其中有 3 個按鈕。用瀏覽器開啟該頁面時,點選 3 個按鈕會發現,所彈出的值全部都是 3。這是因為當點選按鈕時,迴圈已經執行完成,i 的當前值已經是 3。所以按鈕的 click 事件處理函式所得到是 i 的當前值 3。
清單 9. 閉包繫結值的演示頁面
<!DOCTYPE html> <html lang="en"> <head> <title>Test</title> </head> <body> <button>Button 1</button> <button>Button 2</button> <button>Button 3</button> </body> <script> var buttons = document.getElementsByTagName("button"); for (var i = 0; i < buttons.length; i++) { buttons[i].addEventListener("click", function() { alert(i); }); } </script> </html>
如果把 JavaScript 程式碼改成清單 10 所示,就可以得到所期望的結果。我們建立了一個匿名函式並馬上呼叫該函式來返回真正的事件處理函式。處理函式中訪問的變數 i 現在成為了閉包的自由變數,因此 i 的值被繫結到閉包建立時的值,也就是每個迴圈執行過程中的實際值。
清單 10. 使用閉包解決繫結值的問題
var buttons = document.getElementsByTagName("button"); for (var i = 0; i < buttons.length; i++) { buttons[i].addEventListener("click", function(i) { return function() { alert(i); } }(i)); }
在 Java 中有與閉包類似的概念,那就是匿名內部類。在匿名內部類中,可以訪問詞法域中宣告為 final 的變數。不是 final 的變數無法被訪問,會出現編譯錯誤。匿名內部類提供了一種方式來共享區域性變數。不過並不能對該變數的引用進行修改。在清單 11 中,變數 latch 被兩個匿名內部類所使用。
清單 11. Java 中的匿名內部類
public class InnerClasses { public static void main(String[] args) { final CountDownLatch latch = new CountDownLatch(1); final Future<?> task1 = ForkJoinPool.commonPool().submit(() -> { try { Thread.sleep(ThreadLocalRandom.current().nextInt(2000)); } catch (InterruptedException e) { e.printStackTrace(); } finally { latch.countDown(); } }); final Future<?> task2 = ForkJoinPool.commonPool().submit(() -> { final long start = System.currentTimeMillis(); try { latch.await(); } catch (InterruptedException e) { e.printStackTrace(); } finally { System.out.println("Done after " + (System.currentTimeMillis() - start) + "ms"); } }); try { task1.get(); task2.get(); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } } }
可以被共享的變數必須宣告為 final。這個限制只對變數引用有效。只要物件本身是可變的,仍然可以修改該物件的內容。比如一個 List 型別的變數,雖然對它的引用是 final 的,仍然可以通過其方法來修改 List 內部的值。
遞迴
遞迴(recursion)是程式設計中的一個重要概念。很多程式語言,不僅限於函數語言程式設計語言,都有遞迴這樣的結構。從程式碼上來說,遞迴允許一個函式在其內部呼叫該函式自身。有些函數語言程式設計語言並沒有迴圈這樣的結構,而是通過遞迴來實現迴圈。遞迴和迴圈在表達能力上是相同的,只不過指令式程式設計語言偏向於使用迴圈,而函數語言程式設計語言偏向於使用遞迴。遞迴的優勢在於天然適合於那些需要用分治法(divide and conquer)解決的問題,把一個大問題劃分成小問題,以遞迴的方式解決。經典的通過遞迴解決的問題包括階乘計算、計算最大公約數的歐幾里德演算法、漢諾塔、二分查詢、樹的深度優先遍歷和快速排序等。
遞迴分為單遞迴和多遞迴。單遞迴只包含一個對自身的引用;而多遞迴則包含多個對自身的引用。單遞迴的例子包括列表遍歷和計算階乘等;多遞迴的例子包括樹遍歷等。在具體的程式設計實踐中,單遞迴可以比較容易改寫成使用迴圈的形式,而多遞迴則一般保持遞迴的形式。清單 12 給出了 JavaScript 實現的計算階乘的遞迴寫法。
清單 12. 遞迴方式計算階乘
int fact(n) { if (n === 0) { return 1; } else { return n * fact(n - 1); } }
而下面的清單 13 則是 JavaScript 實現的使用迴圈的寫法。
清單 13. 迴圈方式計算階乘
int fact_i(n) { let result = 1; for (let i = n; i > 0; i--) { result = result * i; } return result; }
有一種特殊的遞迴方式叫尾遞迴。如果函式中的遞迴呼叫都是尾呼叫,則該函式是尾遞迴函式。尾遞迴的特性使得遞迴呼叫不需要額外的空間,執行起來也更快。不少程式語言會自動對尾遞迴進行優化。
下面我們以歐幾里德演算法來說明一下尾遞迴。該演算法的 Java 實現比較簡單,如清單 14 所示。函式 gcd 的尾呼叫是遞迴呼叫 gcd 本身。
清單 14. 尾遞迴的方式實現歐幾里德演算法
int gcd(x, y) { if (y == 0) { return x; } return gcd(y, x % y); }
尾遞迴的特性在於實現時可以複用函式的當前呼叫棧的幀。當函式執行到尾呼叫時,只需要簡單的 goto 語句跳轉到函式開頭並更新引數的值即可。相對於迴圈,遞迴的一個大的問題是層次過深的函式呼叫棧導致佔用記憶體空間過大。對尾遞迴的優化,使得遞迴只需要使用與迴圈相似大小的記憶體空間。
記憶化
記憶化(memoization)也是函數語言程式設計中的重要概念,其核心思想是以空間換時間,提高函式的執行效能,尤其是使用遞迴來實現的函式。使用記憶化要求函式具有引用透明性,從而可以把函式的呼叫結果與呼叫時的引數關聯起來。通常是做法是在函式內部維護一個查詢表。查詢表的鍵是輸入的引數,對應的值是函式的返回結果。在每次呼叫時,首先檢查內部的查詢表,如果存在與輸入引數對應的值,則直接返回已經記錄的值。否則的話,先進行計算,再用得到的值更新查詢表。通過這樣的方式可以避免重複的計算。
最典型的展示記憶化應用的例子是計算斐波那契數列 (Fibonacci sequence)。該數列的表示式是 F[n]=F[n-1]+F[n-2](n>=2,F[0]=0,F[1]=1)。清單 15 是斐波那契數列的一個簡單實現,直接體現了斐波那契數列的定義。函式 fib 可以正確完成數列的計算,但是效能極差。當輸入 n 的值稍微大一些的時候,計算速度就非常之慢,甚至會出現無法完成的情況。這是因為裡面有太多的重複計算。比如在計算 fib(4) 的時候,會計算 fib(3) 和 fib(2)。在計算 fib(3) 的時候,也會計算 fib(2)。由於 fib 函式的返回值僅由引數 n 決定,當第一次得出某個 n 對應的結果之後,就可以使用查詢表把結果儲存下來。這裡需要使用 BigInteger 來表示值,因為 fib 函式的值已經超出了 Long 所能表示的範圍。
清單 15. 計算斐波那契數列的樸素實現
import java.math.BigInteger; public class Fib { public static void main(String[] args) { System.out.println(fib(40)); } private static BigInteger fib(int n) { if (n == 0) { return BigInteger.ZERO; } else if (n == 1) { return BigInteger.ONE; } return fib(n - 1).add(fib(n - 2)); } }
清單 16 是使用記憶化之後的計算類 FibMemoized。已經計算的值儲存在查詢表 lookupTable 中。每次計算之前,首先檢視查詢表中是否有值。改進後的函式的效能有了數量級的提升,即便是 fib(100) 也能很快完成。
清單 16. 使用記憶化的斐波那契數列計算
import java.math.BigInteger; import java.util.HashMap; import java.util.Map; public class FibMemoized { public static void main(String[] args) { System.out.println(fib(100)); } private static Map<Integer, BigInteger> lookupTable = new HashMap<>(); static { lookupTable.put(0, BigInteger.ZERO); lookupTable.put(1, BigInteger.ONE); } private static BigInteger fib(int n) { if (lookupTable.containsKey(n)) { return lookupTable.get(n); } else { BigInteger result = fib(n - 1).add(fib(n - 2)); lookupTable.put(n, result); return result; } } }
很多函數語言程式設計庫都提供了對記憶化的支援,會在本系列後續的文章中介紹。