vlambda博客
学习文章列表

国外程序员用的火热的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 + 1x -> 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是什么鬼?让函数式编程更简单!
图片来自 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);
// 获取第一个元素,名称:BobString name = people._1;
// 获取第二个元素,年龄:18Integer 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: 18String 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) * 2Function1<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);
// = NoneOption<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);
// = 9Integer 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)

推荐阅读