本系列的上一篇文章對函數式編程思想進行了概述,本文將系統地介紹函數式編程中的常見概念。這些概念對大多數開發人員來講可能並不陌生,在平常的編程實踐中也比較常見。html
在衆多的編程範式中,大多數開發人員比較熟悉的是面向對象編程範式。一方面是因爲面向對象編程語言比較流行,與之相關的資源比較豐富;另一方面是因爲大部分學校和培訓機構的課程設置,都選擇流行的面向對象編程語言。面向對象編程範式的優勢在於其抽象方式與現實中的概念比較相近。好比,學生、課程、汽車和訂單等這些現實中的概念,在抽象成相應的類以後,咱們很容易就能理解類之間的關聯關係。這些類中所包含的屬性和方法能夠很直觀地設計出來。舉例來講,學生所對應的類 Student 就應該有姓名、出生日期和性別等基本的屬性,有方法能夠獲取到學生的年齡、所在的班級等信息。使用面向對象的編程思想,能夠直觀地在程序要處理的問題和程序自己之間,創建直接的對應關係。這種從問題域到解決域的簡單對應關係,使得代碼的可讀性很強。對於初學者來講,這極大地下降了上手的難度。java
函數式編程範式則相對較難理解。這主要是因爲函數所表明的是抽象的計算,而不是具體的實體。所以比較難經過類比的方式來理解。舉例來講,已知直角三角形的兩條直角邊的長度,須要經過計算來獲得第三條邊的長度。這種計算方式可使用函數來表示。length(a, b)=√a²+b² 就是具體的計算方式。這樣的計算方式與現實中的實體沒有關聯。算法
基於計算的抽象方式能夠進一步提升代碼的複用性。在一個學生信息管理系統中,可能會須要找到一個班級的某門課程的最高分數;在一個電子商務系統中,也可能會須要計算一個訂單的總金額。看似風馬牛不相及的兩件事情,其實都包含了一樣的計算在裏面。也就是對一個可迭代的對象進行遍歷,同時在遍歷的過程當中執行自定義的操做。在計算最高分數的場景中,在遍歷的同時須要保存當前已知最高分數,並在遍歷過程當中更新該值;在計算訂單總金額的場景中,在遍歷的同時須要保存當前已累積的金額,並在遍歷過程當中更新該值。若是用 Java 代碼來實現,能夠很容易寫出以下兩段代碼。清單 1 計算學生的最高分數。編程
int maxMark = 0;for (Student student : students) { if (student.getMark() > maxMark) { maxMark = student.getMark(); }}
清單 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 個部分組成:app
把這 3 個元素提取出來,用僞代碼表示,就獲得了清單 3 中用函數表示的計算模式。iterable 表示被迭代的對象,updateValue 是遍歷時進行的計算,initialValue 是初始值。dom
function(iterable, updateValue, initialValue) { value = initialValue loop(iterable) { value = updateValue(value, currentValue) } return value}
瞭解函數式編程的讀者應該已經看出來了,這就是經常使用的 reduce 函數。使用 reduce 對清單 1 和清單 2 進行改寫,能夠獲得清單 4 中的兩段新的代碼。編程語言
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。ide
除了 Function 和 BiFunction 以外,Java 標準庫還提供了幾種特殊類型的函數:
在本系列的第一篇文章中介紹 λ 演算時,提到了高階函數的概念。λ 項在定義時就支持以 λ 項進行抽象和應用。具體到實際的函數來講,高階函數以其餘函數做爲輸入,或產生其餘函數做爲輸出。高階函數使得函數的組合成爲可能,更有利於函數的複用。熟悉面向對象的讀者對於對象的組合應該不陌生。在劃分對象的職責時,組合被認爲是優於繼承的一種方式。在使用對象組合時,每一個對象所對應的職責單一。多個對象經過組合的方式來完成複雜的行爲。函數的組合相似對象的組合。上一節中提到的 reduce 就是一個高階函數的示例,其參數 updateValue 也是一個函數。經過組合,reduce 把一部分邏輯代理給了做爲其輸入的函數 updateValue。不一樣的函數的嵌套層次能夠不少,完成複雜的組合。
在 Java 中,可使用函數類型來定義高階函數。上述函數接口均可以做爲方法的參數和返回值。Java 標準 API 已經大量使用了這樣的方式。好比 Iterable 的 forEach 方法就接受一個 Consumer 類型的參數。
在清單 5 中,notEqual 返回值是一個 Predicate 對象,並使用在 Stream 的 filter 方法中。代碼運行的輸出結果爲 2 和 3。
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 的值。
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。
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。柯里化是把有多個輸入參數的求值過程,轉換成多個只包含一個參數的函數的求值過程。對於清單 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。這是由閉包的上下文環境提供的。
function idGenerator(initialValue) {let count = initialValue;return function() { return count++;};}let genId = idGenerator(0);genId(); // 0genId(); // 1
從上述簡單的例子中,能夠得出來構成閉包的兩個要件:
函數是閉包對外的呈現部分。在閉包建立以後,閉包的存在與否對函數的使用者是透明的。好比清單 8 中的 genId 函數,使用者只須要調用便可,並不須要瞭解背後是否有閉包的存在。上下文環境則是閉包背後的實現機制,由編程語言的運行時環境來提供。該上下文環境須要爲函數建立一個映射,把函數中的每一個自由變量與閉包建立時的對應值關聯起來,使得閉包能夠繼續訪問這些值。在 idGenerator 的例子中,上下文環境負責關聯變量 count 的值,該變量能夠在返回的函數中繼續訪問和修改。
從上述兩個要件也能夠得出閉包這個名字的由來。閉包是用來封閉自由變量的,適合用來實現內部狀態。好比清單 8 中的 count 是沒法被外部所訪問的。一旦 idGenerator 返回以後,惟一的引用就來自於所返回的函數。在 JavaScript 中,閉包能夠用來實現真正意義上的私有變量。
從閉包的使用方式能夠得知,閉包的生命週期長於建立它的函數。所以,自由變量不能在堆棧上分配;不然一旦函數退出,自由變量就沒法繼續訪問。所以,閉包所訪問的自由變量必須在堆上分配。也正由於如此,支持閉包的編程語言都有垃圾回收機制,來保證閉包所訪問的變量能夠被正確地釋放。一樣,不正確地使用閉包可能形成潛在的內存泄漏。
閉包的一個重要特性是其中的自由變量所綁定的是閉包建立時的值,而不是變量的當前值。清單 9 是一個簡單的 HTML 頁面的代碼,其中有 3 個按鈕。用瀏覽器打開該頁面時,點擊 3 個按鈕會發現,所彈出的值所有都是 3。這是由於當點擊按鈕時,循環已經執行完成,i 的當前值已是 3。因此按鈕的 click 事件處理函數所獲得是 i 的當前值 3。
<!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 的值被綁定到閉包建立時的值,也就是每一個循環執行過程當中的實際值。
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 被兩個匿名內部類所使用。
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 實現的計算階乘的遞歸寫法。
int fact(n) { if (n === 0) { return 1; } else { return n * fact(n - 1); }}
而下面的清單 13 則是 JavaScript 實現的使用循環的寫法。
int fact_i(n) { let result = 1; for (let i = n; i > 0; i--) { result = result * i; } return result;}
有一種特殊的遞歸方式叫尾遞歸。若是函數中的遞歸調用都是尾調用,則該函數是尾遞歸函數。尾遞歸的特性使得遞歸調用不須要額外的空間,執行起來也更快。很多編程語言會自動對尾遞歸進行優化。
下面咱們以歐幾里德算法來講明一下尾遞歸。該算法的 Java 實現比較簡單,如清單 14 所示。函數 gcd 的尾調用是遞歸調用 gcd 自己。
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 所能表示的範圍。
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) 也能很快完成。
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; } }}
不少函數式編程庫都提供了對記憶化的支持,會在本系列後續的文章中介紹。
本文對函數式編程相關的一些重要概念作了系統性介紹。理解這些概念能夠爲應用函數式編程實踐打下良好的基礎。本文首先介紹了函數式編程範式的意義,接着介紹了函數類型與高階函數、部分函數、柯里化、閉包、遞歸和記憶化等概念。下一篇文章將介紹 Java 8 所引入的 Lambda 表達式和流處理。
原做者:成 富
原文連接: 函數式編程中的重要概念
原出處:IBM Developer