深刻理解 Java 函數式編程(4): 使用 Vavr 進行函數式編程

深刻理解 Java 函數式編程(4): 使用 Vavr 進行函數式編程

在本系列的上一篇文章中對 Java 平臺提供的 Lambda 表達式和流作了介紹。受限於 Java 標準庫的通用性要求和二進制文件大小,Java 標準庫對函數式編程的 API 支持相對比較有限。函數的聲明只提供了 Function 和 BiFunction 兩種,流上所支持的操做的數量也較少。爲了更好地進行函數式編程,咱們須要第三方庫的支持。Vavr 是 Java 平臺上函數式編程庫中的佼佼者。html

Vavr 這個名字對不少開發人員可能比較陌生。它的前身 Javaslang 可能更爲你們所熟悉。Vavr 做爲一個標準的 Java 庫,使用起來很簡單。只須要添加對 io.vavr:vavr 庫的 Maven 依賴便可。Vavr 須要 Java 8 及以上版本的支持。本文基於 Vavr 0.9.2 版本,示例代碼基於 Java 10。java

元組

元組(Tuple)是固定數量的不一樣類型的元素的組合。元組與集合的不一樣之處在於,元組中的元素類型能夠是不一樣的,並且數量固定。元組的好處在於能夠把多個元素做爲一個單元傳遞。若是一個方法須要返回多個值,能夠把這多個值做爲元組返回,而不須要建立額外的類來表示。根據元素數量的不一樣,Vavr 總共提供了 Tuple0、Tuple1 到 Tuple8 等 9 個類。每一個元組類都須要聲明其元素類型。如 Tuple2<String, Integer>表示的是兩個元素的元組,第一個元素的類型爲 String,第二個元素的類型爲 Integer。對於元組對象,可使用 _一、_2 到 _8 來訪問其中的元素。全部元組對象都是不可變的,在建立以後不能更改。編程

元組經過接口 Tuple 的靜態方法 of 來建立。元組類也提供了一些方法對它們進行操做。因爲元組是不可變的,全部相關的操做都返回一個新的元組對象。在 清單 1 中,使用 Tuple.of 建立了一個 Tuple2 對象。Tuple2 的 map 方法用來轉換元組中的每一個元素,返回新的元組對象。而 apply 方法則把元組轉換成單個值。其餘元組類也有相似的方法。除了 map 方法以外,還有 map一、map二、map3 等方法來轉換第 N 個元素;update一、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。app

Vavr 的函數支持一些常見特徵。框架

組合

函數的組合指的是用一個函數的執行結果做爲參數,來調用另一個函數所獲得的新函數。好比 f 是從 x 到 y 的函數,g 是從 y 到 z 的函數,那麼 g(f(x))是從 x 到 z 的函數。Vavr 的函數式接口提供了默認方法 andThen 把當前函數與另一個 Function 表示的函數進行組合。Vavr 的 Function1 還提供了一個默認方法 compose 來在當前函數執行以前執行另一個 Function 表示的函數。dom

在清單 2 中,第一個 function3 進行簡單的數學計算,並使用 andThen 把 function3 的結果乘以 100。第二個 function1 從 String 的 toUpperCase 方法建立而來,並使用 compose 方法與 Object 的 toString 方法先進行組合。獲得的方法對任何 Object 先調用 toString,再調用 toUpperCase。ide

清單 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); //Vavrjava.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 進行函數式編程
原出處:IBM Developer

64669c7e6712295f10a8c0382d616e16.jpeg

相關文章
相關標籤/搜索