深入理解 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。柯里化是把有多個輸入參數的求值過程,轉換成多個只包含一個參數的函數的求值過程。對於清單 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. 閉包綁定值的演示頁面

<!DOCTYPE html>

<htmllang="en">

<head>

   <title>Test</title>

</head>

<body>

   <button>Button 1</button>

   <button>Button 2</button>

   <button>Button 3</button>

</body>

<script>

   varbuttons = document.getElementsByTagName("button");

   for(vari = 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;

   }

 }

}

很多函數式編程庫都提供了對記憶化的支持,會在本系列後續的文章中介紹。

總結

本文對函數式編程相關的一些重要概念做了系統性介紹。理解這些概念可以爲應用函數式編程實踐打下良好的基礎。本文首先介紹了函數式編程範式的意義,接着介紹了函數類型與高階函數、部分函數、柯里化、閉包、遞歸和記憶化等概念。下一篇文章將介紹 Java 8 所引入的 Lambda 表達式和流處理。

參考資源

瞭解更多關於柯里化的內容。

瞭解更多關於遞歸的內容。

瞭解更多關於記憶化的內容。


本文分享自微信公衆號 - 小技術君(gh_2fd927ba125d)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章