深入理解 Java 函數語言程式設計,第 4 部分: 使用 Vavr 進行函數語言程式設計
深入理解 Java 函數語言程式設計,第 4 部分
使用 Vavr 進行函數語言程式設計
成 富
2018 年 12 月 03 日釋出
系列內容:
此內容是該系列 5 部分中的第 # 部分: 深入理解 Java 函數語言程式設計,第 4 部分
https://www.ibm.com/developerworks/cn/views/global/libraryview.jsp?series_title_by=深入理解+java+函數語言程式設計
敬請期待該系列的後續內容。
此內容是該系列的一部分: 深入理解 Java 函數語言程式設計,第 4 部分
敬請期待該系列的後續內容。
在本系列的上一篇文章中對 Java 平臺提供的 Lambda 表示式和流做了介紹。受限於 Java 標準庫的通用性要求和二進位制檔案大小,Java 標準庫對函數語言程式設計的 API 支援相對比較有限。函式的宣告只提供了 Function 和 BiFunction 兩種,流上所支援的操作的數量也較少。為了更好地進行函數語言程式設計,我們需要第三方庫的支援。Vavr 是 Java 平臺上函數語言程式設計庫中的佼佼者。
Vavr 這個名字對很多開發人員可能比較陌生。它的前身 Javaslang 可能更為大家所熟悉。Vavr 作為一個標準的 Java 庫,使用起來很簡單。只需要新增對 io.vavr:vavr 庫的 Maven 依賴即可。Vavr 需要 Java 8 及以上版本的支援。本文基於 Vavr 0.9.2 版本,示例程式碼基於 Java 10。
元組
元組(Tuple)是固定數量的不同型別的元素的組合。元組與集合的不同之處在於,元組中的元素型別可以是不同的,而且數量固定。元組的好處在於可以把多個元素作為一個單元傳遞。如果一個方法需要返回多個值,可以把這多個值作為元組返回,而不需要建立額外的類來表示。根據元素數量的不同,Vavr 總共提供了 Tuple0、Tuple1 到 Tuple8 等 9 個類。每個元組類都需要宣告其元素型別。如 Tuple2<String, Integer>表示的是兩個元素的元組,第一個元素的型別為 String,第二個元素的型別為 Integer。對於元組物件,可以使用 _1、_2 到 _8 來訪問其中的元素。所有元組物件都是不可變的,在建立之後不能更改。
元組通過介面 Tuple 的靜態方法 of 來建立。元組類也提供了一些方法對它們進行操作。由於元組是不可變的,所有相關的操作都返回一個新的元組物件。在 清單 1 中,使用 Tuple.of 建立了一個 Tuple2 物件。Tuple2 的 map 方法用來轉換元組中的每個元素,返回新的元組物件。而 apply 方法則把元組轉換成單個值。其他元組類也有類似的方法。除了 map 方法之外,還有 map1、map2、map3 等方法來轉換第 N 個元素;update1、update2 和 update3 等方法用來更新單個元素。
清單 1. 使用元組
Tuple2<String, Integer> tuple2 = Tuple.of("Hello", 100); Tuple2<String, Integer> updatedTuple2 = tuple2.map(String::toUpperCase, v -> v * 5); String result = updatedTuple2.apply((str, number) -> String.join(", ", str, number.toString())); System.out.println(result);
雖然元組使用起來很方便,但是不宜濫用,尤其是元素數量超過 3 個的元組。當元組的元素數量過多時,很難明確地記住每個元素的位置和含義,從而使得程式碼的可讀性變差。這個時候使用 Java 類是更好的選擇。
函式
Java 8 中只提供了接受一個引數的 Function 和接受 2 個引數的 BiFunction。Vavr 提供了函式式介面 Function0、Function1 到 Function8,可以描述最多接受 8 個引數的函式。這些介面的方法 apply 不能丟擲異常。如果需要丟擲異常,可以使用對應的介面 CheckedFunction0、CheckedFunction1 到 CheckedFunction8。
Vavr 的函式支援一些常見特徵。
組合
函式的組合指的是用一個函式的執行結果作為引數,來呼叫另外一個函式所得到的新函式。比如 f 是從 x 到 y 的函式,g 是從 y 到 z 的函式,那麼 g(f(x))是從 x 到 z 的函式。Vavr 的函式式介面提供了預設方法 andThen 把當前函式與另外一個 Function 表示的函式進行組合。Vavr 的 Function1 還提供了一個預設方法 compose 來在當前函式執行之前執行另外一個 Function 表示的函式。
在清單 2 中,第一個 function3 進行簡單的數學計算,並使用 andThen 把 function3 的結果乘以 100。第二個 function1 從 String 的 toUpperCase 方法建立而來,並使用 compose 方法與 Object 的 toString 方法先進行組合。得到的方法對任何 Object 先呼叫 toString,再呼叫 toUpperCase。
清單 2. 函式的組合
Function3< Integer, Integer, Integer, Integer> function3 = (v1, v2, v3) -> (v1 + v2) * v3; Function3< Integer, Integer, Integer, Integer> composed = function3.andThen(v -> v * 100); int result = composed.apply(1, 2, 3); System.out.println(result); // 輸出結果 900 Function1< String, String> function1 = String::toUpperCase; Function1< Object, String> toUpperCase = function1.compose(Object::toString); String str = toUpperCase.apply(List.of("a", "b")); System.out.println(str); // 輸出結果[A, B]
部分應用
在 Vavr 中,函式的 apply 方法可以應用不同數量的引數。如果提供的引數數量小於函式所宣告的引數數量(通過 arity() 方法獲取),那麼所得到的結果是另外一個函式,其所需的引數數量是剩餘未指定值的引數的數量。在清單 3 中,Function4 接受 4 個引數,在 apply 呼叫時只提供了 2 個引數,得到的結果是一個 Function2 物件。
清單 3. 函式的部分應用
Function4< Integer, Integer, Integer, Integer, Integer> function4 = (v1, v2, v3, v4) -> (v1 + v2) * (v3 + v4); Function2< Integer, Integer, Integer> function2 = function4.apply(1, 2); int result = function2.apply(4, 5); System.out.println(result); // 輸出 27
柯里化方法
使用 curried 方法可以得到當前函式的柯里化版本。由於柯里化之後的函式只有一個引數,curried 的返回值都是 Function1 物件。在清單 4 中,對於 function3,在第一次的 curried 方法呼叫得到 Function1 之後,通過 apply 來為第一個引數應用值。以此類推,通過 3 次的 curried 和 apply 呼叫,把全部 3 個引數都應用值。
清單 4. 函式的柯里化
Function3<Integer, Integer, Integer, Integer> function3 = (v1, v2, v3) -> (v1 + v2) * v3; int result = function3.curried().apply(1).curried().apply(2).curried().apply(3); System.out.println(result);
記憶化方法
使用記憶化的函式會根據引數值來快取之前計算的結果。對於同樣的引數值,再次的呼叫會返回快取的值,而不需要再次計算。這是一種典型的以空間換時間的策略。可以使用記憶化的前提是函式有引用透明性。
在清單 5 中,原始的函式實現中使用 BigInteger 的 pow 方法來計算乘方。使用 memoized 方法可以得到該函式的記憶化版本。接著使用同樣的引數呼叫兩次並記錄下時間。從結果可以看出來,第二次的函式呼叫的時間非常短,因為直接從快取中獲取結果。
清單 5. 函式的記憶化
Function2<BigInteger, Integer, BigInteger> pow = BigInteger::pow; Function2<BigInteger, Integer, BigInteger> memoized = pow.memoized(); long start = System.currentTimeMillis(); memoized.apply(BigInteger.valueOf(1024), 1024); long end1 = System.currentTimeMillis(); memoized.apply(BigInteger.valueOf(1024), 1024); long end2 = System.currentTimeMillis(); System.out.printf("%d ms -> %d ms", end1 - start, end2 - end1);
注意,memoized 方法只是把原始的函式當成一個黑盒子,並不會修改函式的內部實現。因此,memoized 並不適用於直接封裝本系列第二篇文章中用遞迴方式計算斐波那契數列的函式。這是因為在函式的內部實現中,呼叫的仍然是沒有記憶化的函式。
值
Vavr 中提供了一些不同型別的值。
Option
Vavr 中的 Option 與 Java 8 中的 Optional 是相似的。不過 Vavr 的 Option 是一個介面,有兩個實現類 Option.Some 和 Option.None,分別對應有值和無值兩種情況。使用 Option.some 方法可以建立包含給定值的 Some 物件,而 Option.none 可以獲取到 None 物件的例項。Option 也支援常用的 map、flatMap 和 filter 等操作,如清單 6 所示。
清單 6. 使用 Option 的示例
Option<String> str = Option.of("Hello"); str.map(String::length); str.flatMap(v -> Option.of(v.length()));
Either
Either 表示可能有兩種不同型別的值,分別稱為左值或右值。只能是其中的一種情況。Either 通常用來表示成功或失敗兩種情況。慣例是把成功的值作為右值,而失敗的值作為左值。可以在 Either 上新增應用於左值或右值的計算。應用於右值的計算只有在 Either 包含右值時才生效,對左值也是同理。
在清單 7 中,根據隨機的布林值來建立包含左值或右值的 Either 物件。Either 的 map 和 mapLeft 方法分別對右值和左值進行計算。
清單 7. 使用 Either 的示例
import io.vavr.control.Either; import java.util.concurrent.ThreadLocalRandom; public class Eithers { private static ThreadLocalRandom random = ThreadLocalRandom.current(); public static void main(String[] args) { Either<String, String> either = compute() .map(str -> str + " World") .mapLeft(Throwable::getMessage); System.out.println(either); } private static Either<Throwable, String> compute() { return random.nextBoolean() ? Either.left(new RuntimeException("Boom!")) : Either.right("Hello"); } }
Try
Try 用來表示一個可能產生異常的計算。Try 介面有兩個實現類,Try.Success 和 Try.Failure,分別表示成功和失敗的情況。Try.Success 封裝了計算成功時的返回值,而 Try.Failure 則封裝了計算失敗時的 Throwable 物件。Try 的例項可以從介面 CheckedFunction0、Callable、Runnable 或 Supplier 中建立。Try 也提供了 map 和 filter 等方法。值得一提的是 Try 的 recover 方法,可以在出現錯誤時根據異常進行恢復。
在清單 8 中,第一個 Try 表示的是 1/0 的結果,顯然是異常結果。使用 recover 來返回 1。第二個 Try 表示的是讀取檔案的結果。由於檔案不存在,Try 表示的也是異常。
清單 8. 使用 Try 的示例
Try<Integer> result = Try.of(() -> 1 / 0).recover(e -> 1); System.out.println(result); Try<String> lines = Try.of(() -> Files.readAllLines(Paths.get("1.txt"))) .map(list -> String.join(",", list)) .andThen((Consumer<String>) System.out::println); System.out.println(lines);
Lazy
Lazy 表示的是一個延遲計算的值。在第一次訪問時才會進行求值操作,而且該值只會計算一次。之後的訪問操作獲取的是快取的值。在清單 9 中,Lazy.of 從介面 Supplier 中建立 Lazy 物件。方法 isEvaluated 可以判斷 Lazy 物件是否已經被求值。
清單 9. 使用 Lazy 的示例
Lazy<BigInteger> lazy = Lazy.of(() -> BigInteger.valueOf(1024).pow(1024)); System.out.println(lazy.isEvaluated()); System.out.println(lazy.get()); System.out.println(lazy.isEvaluated());
資料結構
Vavr 重新在 Iterable 的基礎上實現了自己的集合框架。Vavr 的集合框架側重在不可變上。Vavr 的集合類在使用上比 Java 流更簡潔。
Vavr 的 Stream 提供了比 Java 中 Stream 更多的操作。可以使用 Stream.ofAll 從 Iterable 物件中創建出 Vavr 的 Stream。下面是一些 Vavr 中新增的實用操作:
- groupBy:使用 Fuction 對元素進行分組。結果是一個 Map,Map 的鍵是分組的函式的結果,而值則是包含了同一組中全部元素的 Stream。
- partition:使用 Predicate 對元素進行分組。結果是包含 2 個 Stream 的 Tuple2。Tuple2 的第一個 Stream 的元素滿足 Predicate 所指定的條件,第二個 Stream 的元素不滿足 Predicate 所指定的條件。
- scanLeft 和 scanRight:分別按照從左到右或從右到左的順序在元素上呼叫 Function,並累積結果。
- zip:把 Stream 和一個 Iterable 物件合併起來,返回的結果 Stream 中包含 Tuple2 物件。Tuple2 物件的兩個元素分別來自 Stream 和 Iterable 物件。
在清單 10 中,第一個 groupBy 操作把 Stream 分成奇數和偶數兩組;第二個 partition 操作把 Stream 分成大於 2 和不大於 2 兩組;第三個 scanLeft 對包含字串的 Stream 按照字串長度進行累積;最後一個 zip 操作合併兩個流,所得的結果 Stream 的元素數量與長度最小的輸入流相同。
清單 10. Stream 的使用示例
Map<Boolean, List<Integer>> booleanListMap = Stream.ofAll(1, 2, 3, 4, 5) .groupBy(v -> v % 2 == 0) .mapValues(Value::toList); System.out.println(booleanListMap); // 輸出 LinkedHashMap((false, List(1, 3, 5)), (true, List(2, 4))) Tuple2<List<Integer>, List<Integer>> listTuple2 = Stream.ofAll(1, 2, 3, 4) .partition(v -> v > 2) .map(Value::toList, Value::toList); System.out.println(listTuple2); // 輸出 (List(3, 4), List(1, 2)) List<Integer> integers = Stream.ofAll(List.of("Hello", "World", "a")) .scanLeft(0, (sum, str) -> sum + str.length()) .toList(); System.out.println(integers); // 輸出 List(0, 5, 10, 11) List<Tuple2<Integer, String>> tuple2List = Stream.ofAll(1, 2, 3) .zip(List.of("a", "b")) .toList(); System.out.println(tuple2List); // 輸出 List((1, a), (2, b))
Vavr 提供了常用的資料結構的實現,包括 List、Set、Map、Seq、Queue、Tree 和 TreeMap 等。這些資料結構的用法與 Java 標準庫的對應實現是相似的,但是提供的操作更多,使用起來也更方便。在 Java 中,如果需要對一個 List 的元素進行 map 操作,需要使用 stream 方法來先轉換為一個 Stream,再使用 map 操作,最後再通過收集器 Collectors.toList 來轉換回 List。而在 Vavr 中,List 本身就提供了 map 操作。清單 11 中展示了這兩種使用方式的區別。
清單 11. Vavr 中資料結構的用法
List.of(1, 2, 3).map(v -> v + 10); //Vavr java.util.List.of(1, 2, 3).stream() .map(v -> v + 10).collect(Collectors.toList()); //Java 中 Stream
模式匹配
在 Java 中,我們可以使用 switch 和 case 來根據值的不同來執行不同的邏輯。不過 switch 和 case 提供的功能很弱,只能進行相等匹配。Vavr 提供了模式匹配的 API,可以對多種情況進行匹配和執行相應的邏輯。在清單 12 中,我們使用 Vavr 的 Match 和 Case 替換了 Java 中的 switch 和 case。Match 的引數是需要進行匹配的值。Case 的第一個引數是匹配的條件,用 Predicate 來表示;第二個引數是匹配滿足時的值。$(value) 表示值為 value 的相等匹配,而 $() 表示的是預設匹配,相當於 switch 中的 default。
清單 12. 模式匹配的示例
String input = "g"; String result = Match(input).of( Case($("g"), "good"), Case($("b"), "bad"), Case($(), "unknown") ); System.out.println(result); // 輸出 good
在清單 13 中,我們用 $(v -> v > 0) 建立了一個值大於 0 的 Predicate。這裡匹配的結果不是具體的值,而是通過 run 方法來產生副作用。
清單 13. 使用模式匹配來產生副作用
int value = -1; Match(value).of( Case($(v -> v > 0), o -> run(() -> System.out.println("> 0"))), Case($(0), o -> run(() -> System.out.println("0"))), Case($(), o -> run(() -> System.out.println("< 0"))) ); // 輸出<0
總結
當需要在 Java 平臺上進行復雜的函數語言程式設計時,Java 標準庫所提供的支援已經不能滿足需求。Vavr 作為 Java 平臺上流行的函數語言程式設計庫,可以滿足不同的需求。本文對 Vavr 提供的元組、函式、值、資料結構和模式匹配進行了詳細的介紹。下一篇文章將介紹函數語言程式設計中的重要概念 Monad。
參考資源
- 參考 Vavr 的 ofollow,noindex" target="_blank">官方文件 。
- 檢視 Vavr 的 Java API 文件 。