国外程序员用的火热的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
包下,包括 Function
、Supplier
、Consumer
、Predicate
等,此外在其它地方也用到很多函数式接口,比如前面演示的 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, λ 构建的:
引入依赖
这里使用 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 提供了 Tuple1
、Tuple2
到 Tuple8
等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个参数的函数式接口:Function0
、Function1
、Function2
...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))
的操作,可以使用 andThen
或 compose
方法实现函数组合:
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;
我们再利用lift
将divide
提升为可以接收所有输入的全函数:
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)
柯里化是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
当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)
推荐阅读