深入理解 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。Function 接口只包含一個抽象方法 R apply(T t),也就是在類型為 T 的輸入 t 上應用該函數,得到類型為 R 的輸出。除了接受一個參數的 Function 之外,還有接受兩個參數的接口 BiFunction,T 和 U 分別是兩個參數的類型,R 是輸出類型。BiFunction 接口的抽象方法為 R apply(T t, U u)。超過 2 個參數的函數在 Java 標準庫中並沒有定義。如果函數需要 3 個或更多的參數,可以使用第三方庫,如 Vavr 中的 Function0 到 Function8。

除了 Function 和 BiFunction 之外,Java 標準庫還提供了幾種特殊類型的函數:

Consumer:接受一個輸入,沒有輸出。抽象方法為 void accept(T t)。

Supplier:沒有輸入,一個輸出。抽象方法為 T get()。

Predicate:接受一個輸入,輸出為 boolean 類型。抽象方法為 boolean test(T t)。

UnaryOperator:接受一個輸入,輸出的類型與輸入相同,相當於 Function

BinaryOperator:接受兩個類型相同的輸入,輸出的類型與輸入相同,相當於 BiFunction

BiPredicate:接受兩個輸入,輸出為 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 Predicate 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 Function partialLeft(BiFunction

U, R> biFunction, T t) {

return (u) -> biFunction.apply(t, u);

}

private static Function partialRight(BiFunction

U, R> biFunction, U u) {

return (t) -> biFunction.apply(t, u);

}

public static void main(String[] args) {

BiFunction<integer> biFunction = (v1, v2) -> v1/<integer>

- v2;

Function<integer> subtractFrom10 =/<integer>

partialLeft(biFunction, 10);

Function<integer> subtractBy10 = partialRight(biFunction,/<integer>

10);

System.out.println(subtractFrom10.apply(5)); // 5

System.out.println(subtractBy10.apply(5)); // -5

}

}

柯里化

柯里化(currying)是與λ演算相關的重要概念。通過柯里化,可以把有多個輸入的函數轉換成只有一個輸入的函數,從而可以在λ演算中來表示。柯里化的名稱來源於數學家 Haskell Curry。Haskell Curry 是一位傳奇性的人物,以他的名字命令了 3 種編程語言,Haskell、Brook 和 Curry。柯里化是把有多個輸入參數的求值過程,轉換成多個只包含一個參數的函數的求值過程。對於清單 6 的函數 f(a, b, c),在柯里化之後轉換成函數 g,則對應的調用方式是 g(a)(b)(c)。函數 (x, y) -> x + y 經過柯里化之後的結果是 x -> (y -> x + y)。

柯里化與部分函數存在一定的關聯,但兩者是有區別的。部分函數的求值結果永遠是實際的函數調用結果;而柯里化函數的求值結果則可能是另外一個函數。以清單 6 的部分函數 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. 閉包綁定值的演示頁面

<htmllang>

<title>Test/<title>

<button>Button 1/<button>

<button>Button 2/<button>

<button>Button 3/<button>

varbuttons = document.getElementsByTagName("button");

for(vari = 0; i < buttons.length; i++) {

buttons[i].addEventListener("click", function() {

alert(i);

});

}


分享到:


相關文章: