國外程序員用的火熱的Vavr是什麼鬼?讓函數式編程更簡單!

引言

相信很多人關注 Vavr 的原因,還是因爲 Hystrix 庫。Hystrix 不更新了,並在 GitHub 主頁上推薦了 Resilience4j,而 Vavr 作爲 Resilience4j 的唯一依賴被提及。對於 Resilience4j 這個以輕依賴作爲特色之一的容錯庫,爲什麼還會引用 Vavr 呢?

以下是 Resilience4j 官方原文:

Resilience4j is a lightweight fault tolerance library inspired by Netflix Hystrix, but designed for Java 8 and functional programming. Lightweight, because the library only uses Vavr, which does not have any other external library dependencies.

Resilience4j 除了輕量,另一特點是對 Java 8 函數式編程的支持,經過一番瞭解,Vavr 正是爲了提升 Java 函數式編程體驗而開發的,通過它可以幫助我們編寫出更簡潔、高效的函數風格代碼。

限於篇幅,該系列分爲上、下兩篇:上篇着重回顧函數式編程的一些基礎知識,以及 Vavr 總體介紹、Vavr 對元組、函數的支持,通過上篇的學習;下篇着重講述 Vavr 中對各種值類型、增強集合、參數檢查、模式匹配等特性。力求通過這兩篇文章,把 Vavr 的總體特性呈現給大家,讓大家對 Vavr 有個全面的認識。

簡介

Vavr是 Java 8+ 函數式編程的增強庫。提供了不可變數據類型和函數式控制結構,旨在讓 Java 函數編程更便捷高效。特別是功能豐富的集合庫,可以與Java的標準集合平滑集成。

Vavr 的讀音是 /ˈweɪ.vɚ/,早期版本叫 Javaslang,由於和 Java™ 商標衝突(類似國內的 JavaEye 改名),所以把 Java 倒過來取名。

函數式編程

學習 Vavr 之前,我們先回顧下 Java 函數式編程及 Lambda (λ)  表達式的一些相關內容。

Java 8 開始,在原有面向對象、命令式編程範式的基礎上,增加了函數式編程支持,其核心是行爲參數化,把行爲具體理解爲一個程序函數(方法),即是將函數作爲其它函數的參數傳遞,組成高階函數。

舉個例子,人(People)這個類存在一個年齡(age)屬性,我們想根據每個人的年齡進行排序比較。

首先看下 Java 8 之前的做法:

Comparator<People> comparator = new Comparator<People>() {
  @Override
  public int compare(People p1, People p2) {
    return Integer.compare(p1.getAge(), p2.getAge());
  }
};

再看 Java 8 之後的做法:

Comparator<People> comparator
    = (p1, p2) -> Integer.compare(p1.getAge(), p2.getAge());

利用 Lambda 表達式的語法,確實少了很多模板代碼,看起來更加簡潔了。關於 Java 的函數式編程及 Lambda 表達式語法,有以下需要掌握的知識點:

函數式接口

函數式接口 (Functional Interface) 就是一個有且僅有一個抽象方法,但是可以有多個非抽象方法的接口,通常會用 @FunctionalInterface 進行標註,但不是必須的。Java 8 自帶了常用的函數式接口,存放在 java.util.function 包下,包括 FunctionSupplierConsumerPredicate 等,此外在其它地方也用到很多函數式接口,比如前面演示的 Comparator

Lambda 表達式

Lambda 表達式是一種匿名函數,在 Java 中,定義一個匿名函數的實質依然是函數式接口的匿名實現類,它沒有名稱,只有參數列表、函數主體、返回類型,可能還有一個異常列表聲明。Lambda 表達式有以下重要特徵:

  • 可選類型聲明:不需要聲明參數類型,編譯器可以進行類型識別;

  • 可選的參數圓括號:一個參數無需定義圓括號,但多個參數需要定義圓括號;

  • 可選的花括號:如果主體包含了一個語句,就不需要使用花括號;

  • 可選的 return 關鍵字:如果主體只有一個表達式返回值,則編譯器會自動返回值,加了花括號需要指定表達式返回一個數值。

// 1. 不需要參數,返回值爲 1
() -> 1


// 2. 接收一個參數(數字類型),返回值爲 x + 1
x -> x + 1


// 3. 接受2個參數(數字),返回值爲 x + y 
(x, y) -> x + y


// 4. 接收2個int型整數,返回值爲 x + y 
(int x, int y) -> x + y


// 5. 接受一個 String 對象,並在控制檯打印,不返回任何值(返回 void)  
(String s) -> System.out.print(s)

副作用(Side-Effects)

如果一個操作、函數或表達式在其本地環境之外修改了某個狀態變量值,則會產生副作用,也就是說,除了向操作的調用者返回一個值(主要效果)之外,還會產生可觀察到的效果。

例如,一個函數產生異常,並且這個異常向上傳遞,就是一種影響程序的副作用,此外,異常就像一種非本地的 goto 語句,打斷了正常的程序流程。具體看以下代碼:

int divide(int dividend, int divisor) {
    // 如果除數爲0,會產生異常
    return dividend / divisor;
}

怎麼處理這種副作用呢?在 Vavr 中,可以把它封裝到一個 Try 實例,具體實現:

// = Success(result) or Failure(exception)
Try<Integer> safeDivide(Integer dividend, Integer divisor) {
    return Try.of(() -> divide(dividend, divisor));
}

這個版本的除法函數不再拋出異常,我們通過 Try 類型明確了可能的錯誤。

引用透明(Referential Transparency)

引用透明的概念與函數的副作用相關,且受其影響。如果程序中任意兩處具有相同輸入值的函數調用能夠互相置換,而不影響程序的動作,那麼該程序就具有引用透明性。

從以下示例可以看出,第一種實現,隨機數是根據可變的外部狀態生成的,所以每次調用產生的結果都不同,無法做到引用透明;第二種實現,我們給隨機對象指定一個隨機種子,這樣保證不受外部狀態的影響,達到引用透明的效果:

// 非引用透明
Math.random();


// 引用透明
new Random(1).nextDouble();

不可變對象

不可變對象是指其狀態在創建後不能修改的對象。它有以下好處:

  • 本質上是線程安全的,因此不需要同步;

  • 對於equals和hashCode來說是穩定的,因此是可靠的哈希鍵;

  • 不需要克隆;

  • 在未檢查的協變強制轉換(特定於java)中使用時表現爲類型安全。

使用 Vavr

受限於 Java 標準庫的通用性要求及體量大小考慮,JDK API 對函數式編程的支持比較有限,這時候可以引入 Vavr 來提供更便捷的安全集合類型、支持更多的 stream 流操作、豐富函數式接口類型……

在 Vavr 中,所有類型都是基於 Tuple, Value, λ 構建的:

圖片來自 Vavr 官網

引入依賴

這裏使用 Maven 項目構建,完整的 pom.xml 配置如下:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>demo-vavr</artifactId>
  <version>0.0.1</version>
  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <vavr.version>0.9.3</vavr.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>io.vavr</groupId>
      <artifactId>vavr</artifactId>
      <version>${vavr.version}</version>
    </dependency>
  </dependencies>
</project>

Java 必須是 1.8 以上版本,這是使用 Java 函數式編程的前提,另外 Vavr 使用的是 0.9.3 版本。Vavr 本身沒有其它外部依賴,Jar 包大小僅有 800+K,相當輕量。

元組(Tuple)

Java 自身並沒有元組的概念,元組是將固定數量的元素組合在一起,這樣它們就可以作爲一個整體傳遞,但它與數組或集合的區別是,元組能包含不同類型的對象,且是不可變的。

Vavr 提供了 Tuple1Tuple2Tuple8 等8個具體的元組類型,分別代表可存儲1~8個元素的元組,並可以通過 _1_2..._8 等屬性訪問對應元素。

以下是創建並獲取元組的示例:

// 通過 Tuple.of() 靜態方法創建一個二元組
Tuple2<String, Integer> people = Tuple.of("Bob", 18);


// 獲取第一個元素,名稱:Bob
String name = people._1;


// 獲取第二個元素,年齡:18
Integer age = people._2;

元組也提供了對元素映射處理的能力,以下兩種寫法效果是相同的:

// ("Hello, Bob", 9)
people.map(
  name -> "Hello, " + name,
  age-> age / 2
);


// ("Hello, Bob", 9)	
people.map(
  (name, age) -> Tuple.of("Hello, " + name, age / 2)
);

此外,元組還提供了基於元素內容轉換創建新的類型:

// 返回 name: Bob, age: 18
String str = people.apply(
  (name, age) -> "name: " + name  + ", age: " + age
);

函數(Function)

Java 8 僅提供了接受一個參數的函數式接口 Function 和接受兩個參數的函數式接口 BiFunction,vavr 則提供了最多可以接受8個參數的函數式接口:Function0Function1Function2...Function8。如果需要拋出受檢異常的函數,可以使用 CheckedFunction{0...8} 版本。

以下是使用函數的示例:

// 聲明一個接收兩個參數的函數
Function2<String, Integer, String> description = (name, age) -> "name: " + name  + ", age: " + age;


// 返回 "name: Bob, age: 18"
String str = description.apply("Bob", 18);

Vavr 函數是 Java 8 函數的增強,它提供了以下特性:

組合(Composition)

組合是將一個函數 f(x) 的結果作爲另一個函數 g(y) 的參數,產生新函數 h: g(f(x)) 的操作,可以使用 andThencompose 方法實現函數組合:

Function1<Integer, Integer> plusOne = a -> a + 1;
Function1<Integer, Integer> multiplyByTwo = a -> a * 2;


// 以下兩種寫法結果一致,都是 z -> (z + 1) * 2
Function1<Integer, Integer> addOneAndMultiplyByTwo1 = plusOne.andThen(multiplyByTwo);
Function1<Integer, Integer> addOneAndMultiplyByTwo2 = plusOne.andThen(multiplyByTwo);


提升(Lifting)

提升是針對部分函數(partial function)的操作,如果一個函數 f(x) 的定義域是 x,另一個函數 g(y) 跟 f(x) 定義相同,只是定義域 y 是 x 的子集,就說 f(x) 是全函數(total function),g(y) 是部分函數。函數的提升會返回當前函數的全函數,返回類型爲 Option,以下是一個部分函數定義:

// 當除數 0 時,將導致程序異常
Function2<Integer, Integer, Integer> divide = (a, b) -> a / b;

我們再利用liftdivide提升爲可以接收所有輸入的全函數:

Function2<Integer, Integer, Option<Integer>> safeDivide = Function2.lift(divide);


// = None
Option<Integer> i1 = safeDivide.apply(1, 0);


// = Some(2)
Option<Integer> i2 = safeDivide.apply(4, 2);

通過以上示例可以看出,如果使用不允許的輸入值調用提升後的全函數,則返回None而不是引發異常。

部分應用(Partial application)

部分應用是通過固定函數的前n個參數值,產生一個新函數,該新函數參數爲原函數總參數個數減n,具體示例如下:

Function2<Integer, Integer, Integer> sum = (a, b) -> a + b;
Function1<Integer, Integer> add2 = sum.apply(2);

sum函數通過部分應用,第一個參數被固定爲2,併產生新的add2函數。

柯里化(Currying)

柯里化是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,並且返回接受餘下的參數而且返回結果的新函數的技術。

Function8 的 JavaDoc說明

Function2被柯里化時,結果與部分應用沒有區別,因爲兩者都會產生單參數的函數。但函數參數多於2個,就能明顯看出柯里化的不同:

Function3<Integer, Integer, Integer, Integer> sum = (a, b, c) -> a + b + c;
final Function1<Integer, Function1<Integer, Integer>> add2 = sum.curried().apply(2);


// = 9
Integer i = add2.apply(4).apply(3);

記憶(Memoization)

函數記憶是利用緩存技術,第一次執行後,將結果緩存起來,後續從緩存返回結果。以下函數在第一次調用時,生成隨機數並進行緩存,第二次調用直接從緩存返回結果,所以多次返回的隨機數是同一個:

Function0<Double> hashCache = Function0.of(Math::random).memoized();
double randomValue1 = hashCache.apply();
double randomValue2 = hashCache.apply();

總結

今天對 Vavr 的介紹先到這裏,下篇我們將會接着介紹另外一些特性:

  • 值類型(Values)

  • 集合(Collections)

  • 參數檢查(Property Checking)

  • 模式匹配(Pattern Matching)

推薦閱讀

原創推薦:日誌規範多重要,這篇文章告訴你!

原創推薦:我們已經不用AOP做操作日誌了!

ElasticSearch 使用 Logstash 從 MySQL 中同步數據

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