Vavr 用户指南

Daniel Dietrich, Robert Winkler, Grzegorz Piwowarek
版本 0.11.0,
2025-12-16

1. 引言

Vavr(原名 Javaslang)是一个用于 Java 8+ 的函数式库,提供持久化数据类型和函数式控制结构。

1.1. 在 Java 8 中使用 Vavr 的函数式数据结构

Java 8 的 lambda(λ)使我们能够创建出色的 API。它们极大地提高了语言的表达能力。

Vavr 利用 lambda 基于函数式模式创建了各种新特性。其中之一是一个旨在替代 Java 标准集合的函数式集合库。

1.2. 函数式编程

在我们深入探讨数据结构的细节之前,我想先谈谈一些基础知识。这将清楚地说明我为什么创建 Vavr,特别是新的 Java 集合。

1.2.1. 副作用

Java 应用程序通常充满了副作用。它们会改变某种状态,也许是外部世界。常见的副作用包括就地更改对象或变量、打印到控制台、写入日志文件或数据库。如果副作用以不良方式影响我们程序的语义,则被认为是有害的。

例如,如果一个函数抛出异常并且这个异常被解释,它就被认为是一个影响我们程序的副作用。此外,异常就像非局部的 goto 语句。它们破坏了正常的控制流。然而,现实世界的应用程序确实会执行副作用。

Java 复制代码
int divide(int dividend, int divisor) {    
	// 如果除数为零则抛出异常    
	return dividend / divisor;
}

在函数式环境中,我们处于有利地位,可以将副作用封装在 Try 中:

Java 复制代码
// = Success(result) 或 Failure(exception)
Try<Integer> divide(Integer dividend, Integer divisor) {    
	return Try.of(() -> dividend / divisor);
}

这个版本的 divide 不再抛出任何异常。我们通过使用 Try 类型使可能的失败变得明确。

1.2.2. 引用透明性

如果一个函数,或者更一般地说,一个表达式,其调用可以被其值替换而不影响程序的行为,则称其为引用透明的。简单来说,给定相同的输入,输出总是相同的。

Java 复制代码
// 不是引用透明的
Math.random();
// 引用透明的
Math.max(1, 2);

如果所有涉及的表达式都是引用透明的,则称该函数为纯函数。由纯函数组成的应用程序很可能在编译后就能正常工作。我们能够推理它。单元测试易于编写,调试成为过去式。

1.2.3. 值编程思想

Rich Hickey,Clojure 的创造者,做了一个关于《值的价值》的精彩演讲。最有趣的值是不可变值。主要原因是不可变值:

  • 本质上是线程安全的,因此不需要同步
  • equalshashCode 方面是稳定的,因此是可靠的哈希键
  • 不需要克隆
  • 在未检查的协变转换中使用时表现类型安全(Java 特有)

更好的 Java 的关键在于使用不可变值并结合引用透明的函数。 Vavr 提供了必要的控制和集合,以在日常 Java 编程中实现这一目标。

1.3. 数据结构概述

Vavr 的集合库包含一套丰富的基于 lambda 构建的函数式数据结构。它们与 Java 原始集合共享的唯一接口是 Iterable。主要原因是 Java 集合接口的修改器方法不返回底层集合类型的对象。

通过查看不同类型的数据结构,我们将明白为什么这一点如此重要。

1.3.1. 可变数据结构

Java 是一种面向对象的编程语言。我们将状态封装在对象中以实现数据隐藏,并提供修改器方法来控制状态。Java 集合框架(JCF)就是建立在这个思想之上的。

Java 复制代码
interface Collection<E> {    
	// 从此集合中移除所有元素    
	void clear();
}

如今,我将 void 返回类型视为一种代码异味。它表明发生了副作用,状态被改变了。共享的可变状态是失败的重要来源,不仅是在并发环境中。

1.3.2. 不可变数据结构

不可变数据结构在创建后不能被修改。在 Java 的上下文中,它们以集合包装器的形式被广泛使用。

Java 复制代码
List<String> list = Collections.unmodifiableList(otherList);
// 报错!list.add("why not?");

有各种库为我们提供了类似的工具方法。结果始终是特定集合的不可修改视图。通常,当我们调用修改器方法时,它会在运行时抛出异常。

1.3.3. 持久化数据结构

持久化数据结构在修改时会保留自身的先前版本,因此实际上是不可变的。完全持久化数据结构允许对任何版本进行更新和查询。

许多操作只进行小的更改。仅仅复制前一个版本效率不高。为了节省时间和内存,识别两个版本之间的相似性并尽可能共享数据至关重要。

这个模型没有强加任何实现细节。这时函数式数据结构就登场了。

1.4. 函数式数据结构

也称为纯函数式数据结构,它们是不可变且持久的。函数式数据结构的方法是引用透明的。

Vavr 提供了最常用的函数式数据结构。以下示例将深入解释。

1.4.1. 链表

最流行也是最简单的函数式数据结构之一是(单向)链表。它有一个头元素和一个尾链表。链表的行为类似于遵循后进先出(LIFO)方法的栈。

在 Vavr 中,我们这样实例化一个 List

Java 复制代码
// = List(1, 2, 3)
List<Integer> list1 = List.of(1, 2, 3);

每个 List 元素形成一个单独的链表节点。最后一个元素的尾部是 Nil,即空列表。 这使我们能够在 List 的不同版本之间共享元素。

Java 复制代码
// = List(0, 2, 3)List<Integer> 
list2 = list1.tail().prepend(0);

新的头元素 0 链接到原始 List 的尾部。原始 List 保持不变。

这些操作在常数时间内完成,换句话说,它们与 List 的大小无关。大多数其他操作需要线性时间。在 Vavr 中,这通过接口 LinearSeq 表示,我们可能已经从 Scala 中了解它。

如果我们需要在常数时间内可查询的数据结构,Vavr 提供了 ArrayVector。两者都具有随机访问能力。

Array 类型由 Java 对象数组支持。插入和删除操作需要线性时间。Vector 介于 ArrayList 之间。它在随机访问和修改两个领域都表现良好。

事实上,链表也可以用来实现队列数据结构。

1.4.2. 队列

基于两个链表可以实现一个非常高效的函数式队列。前 List 保存要出列的元素,后 List 保存要入列的元素。入列和出列操作都在 O(1) 时间内执行。

Java 复制代码
Queue<Integer> queue = Queue.of(1, 2, 3)
							.enqueue(4)
							.enqueue(5);

初始队列由三个元素创建。两个元素被入列到后 List。 如果出列时前 List 的元素用完,后 List 会被反转并成为新的前 List。 出列一个元素时,我们得到第一个元素和剩余队列的配对。必须返回队列的新版本,因为函数式数据结构是不可变且持久的。原始队列不受影响。

Java 复制代码
Queue<Integer> queue = Queue.of(1, 2, 3);
// = (1, Queue(2, 3))
Tuple2<Integer, Queue<Integer>> dequeued = queue.dequeue();

当队列为空时会发生什么?那么 dequeue() 将抛出 NoSuchElementException。要以函数式的方式处理,我们更希望得到一个可选结果。

Java 复制代码
// = Some((1, Queue()))
Queue.of(1).dequeueOption();
// = None
Queue.empty().dequeueOption();

无论是否为空,可选结果都可以被进一步处理。

Java 复制代码
// = Queue(1)
Queue<Integer> queue = Queue.of(1);
// = Some((1, Queue()))
Option<Tuple2<Integer, Queue<Integer>>> dequeued = queue.dequeueOption();
// = Some(1)
Option<Integer> element = dequeued.map(Tuple2::_1);
// = Some(Queue())
Option<Queue<Integer>> remaining = dequeued.map(Tuple2::_2);

1.4.3. 有序集合

有序集合是比队列更常用的数据结构。我们使用二叉搜索树以函数式方式对它们进行建模。这些树由最多有两个子节点的节点组成,每个节点都有值。

我们在存在排序的情况下构建二叉搜索树,由元素 Comparator 表示。任何给定节点的左子树的所有值都严格小于给定节点的值。右子树的所有值都严格大于。

Java 复制代码
// = TreeSet(1, 2, 3, 4, 6, 7, 8)
SortedSet<Integer> xs = TreeSet.of(6, 1, 3, 2, 4, 7, 8);

在此类树上的搜索在 O(log n) 时间内运行。我们从根开始搜索,决定是否找到了元素。由于值的全序性,我们知道接下来要在哪里搜索,在当前树的左分支还是右分支。

Java 复制代码
// = TreeSet(1, 2, 3);
SortedSet<Integer> set = TreeSet.of(2, 3, 1, 2);
// = TreeSet(3, 2, 1);
Comparator<Integer> c = (a, b) -> b - a;
SortedSet<Integer> reversed = TreeSet.of(c, 2, 3, 1, 2);

大多数树操作本质上是递归的。插入函数的行为类似于搜索函数。当到达搜索路径的末端时,会创建一个新节点,并重建整个路径直到根节点。尽可能引用现有的子节点。因此,插入操作需要 O(log n) 的时间和空间。

Java 复制代码
// = TreeSet(1, 2, 3, 4, 5, 6, 7, 8)
SortedSet<Integer> ys = xs.add(5);

为了保持二叉搜索树的性能特征,需要保持其平衡。从根到叶子的所有路径需要大致相同的长度。

在 Vavr 中,我们基于红黑树实现了一个二叉搜索树。它使用特定的着色策略来在插入和删除时保持树的平衡。要了解更多关于这个主题的信息,请参阅 Chris Okasaki 的《纯函数式数据结构》一书。

1.5. 集合的现状

总的来说,我们观察到编程语言正在趋同。好的特性得以保留,其他的则消失。但 Java 不同,它永远需要向后兼容。这是一种优势,但也减缓了进化。

Lambda 使 Java 和 Scala 更加接近,但它们仍然如此不同。Scala 的创造者 Martin Odersky 最近在他 2015 年的 BDSBTB 主题演讲中提到了 Java 8 集合的现状。

他将 Java 的 Stream 描述为 Iterator 的一种高级形式。Java 8 Stream API 是提升集合的一个例子。它的作用是定义一个计算,并在另一个明确的步骤中将其链接到特定的集合。

Java 复制代码
// i + 1
i.prepareForAddition()
	.add(1)
	.mapBackToInteger(Mappers.toInteger())

这就是新的 Java 8 Stream API 的工作方式。它是众所周知的 Java 集合之上的一个计算层。

Java 复制代码
// = ["1", "2", "3"] in Java 8
Arrays.asList(1, 2, 3)
	.stream()
	.map(Object::toString)
	.collect(Collectors.toList())

Vavr 深受 Scala 的启发。这应该是 Java 8 中上述例子的样子。

Java 复制代码
// = Stream("1", "2", "3") in Vavr
Stream.of(1, 2, 3).map(Object::toString)

在过去的一年里,我们投入了大量精力来实现 Vavr 集合库。它包含了最广泛使用的集合类型。

1.5.1. Seq

我们通过实现顺序类型开始了我们的旅程。上面我们已经描述了链表。接着是 Stream,一个惰性链表。它允许我们处理可能无限长的元素序列。

所有集合都是 Iterable 的,因此可以在增强的 for 语句中使用。

Java 复制代码
for (String s : List.of("Java", "Advent")) {    
	// 副作用和突变
}

我们也可以通过内化循环并使用 lambda 注入行为来实现同样的效果。

Java 复制代码
List.of("Java", "Advent").forEach(s -> {    
	// 副作用和突变
});

无论如何,正如我们之前看到的,我们更喜欢返回值的表达式,而不是什么都不返回的语句。通过看一个简单的例子,我们很快就会发现语句增加了噪音,并分割了本应在一起的东西。

Java 复制代码
String join(String... words) {
	StringBuilder builder = new StringBuilder();
	for(String s : words) {
		if (builder.length() > 0) {
			builder.append(", ");
		}
		builder.append(s);
	}
	return builder.toString();
}

Vavr 集合为我们提供了许多函数来操作底层元素。这使我们能够以非常简洁的方式表达事物。

Java 复制代码
String join(String... words) {
	return List.of(words)
			.intersperse(", ")
			.foldLeft(new StringBuilder(), StringBuilder::append)
			.toString();
}

大多数目标都可以使用 Vavr 以各种方式实现。在这里,我们将整个方法体简化为对 List 实例的流畅函数调用。我们甚至可以删除整个方法,直接使用我们的 List 来获取计算结果。

Java 复制代码
List.of(words).mkString(", ");

在现实世界的应用程序中,我们现在能够大幅减少代码行数,从而降低错误风险。

1.5.2. Set 和 Map

序列很棒。但为了完整,一个集合库还需要不同类型的 SetMap

我们描述了如何使用二叉树结构来建模有序集合。有序 Map 不过是一个包含键值对并对键有排序的有序 Set

HashMap 的实现由哈希数组映射字典树(HAMT)支持。相应地,HashSet 由包含键-键对的 HAMT 支持。

我们的 Map 没有特殊的 Entry 类型来表示键值对。相反,我们使用已经是 Vavr 一部分的 Tuple2。元组的字段是枚举的。

Java 复制代码
// = (1, "A")
Tuple2<Integer, String> entry = Tuple.of(1, "A");
Integer key = entry._1;
String value = entry._2;

MapTuple 在 Vavr 中被广泛使用。Tuple 对于以通用方式处理多值返回类型是必不可少的。

Java 复制代码
// = HashMap((0, List(2, 4)), (1, List(1, 3)))
List.of(1, 2, 3, 4).groupBy(i -> i % 2);
// = List((a, 0), (b, 1), (c, 2))
List.of('a', 'b', 'c').zipWithIndex();

在 Vavr,我们通过实现 99 个欧拉问题来探索和测试我们的库。这是一个很好的概念验证。请不要犹豫发送拉取请求。

3. 使用指南

Vavr 附带了一些最基本类型的精心设计的表示,这些类型在 Java 中显然缺失或简陋:TupleValueλ。 在 Vavr 中,一切都建立在这三个基本构建块之上。

3.1. 元组

Java 缺少元组的一般概念。元组将固定数量的元素组合在一起,以便它们可以作为一个整体传递。与数组或列表不同,元组可以容纳不同类型的对象,但它们也是不可变的。

元组的类型是 Tuple1Tuple2Tuple3 等等。 目前最多有 8 个元素的上限。要访问元组 t 的元素,可以使用方法 t._1 访问第一个元素,t._2 访问第二个,依此类推。

3.1.1. 创建元组

以下是如何创建一个包含 StringInteger 的元组的示例:

Java 复制代码
// (Java, 8)
Tuple2<String, Integer> java8 = Tuple.of("Java", 8); 
// "Java"
String s = java8._1; 
// 8
Integer i = java8._2; 

元组是通过静态工厂方法 Tuple.of() 创建的。获取此元组的第 1 个元素。获取此元组的第 2 个元素。

3.1.2. 按组件映射元组

按组件映射对元组中的每个元素求值一个函数,返回另一个元组。

java 复制代码
// (vavr, 1)
Tuple2<String, Integer> that = java8.map(
	s -> s.substring(2) + "vr",
	i -> i / 8
);

3.1.3. 使用单个映射器映射元组

也可以使用一个映射函数来映射元组。

Java 复制代码
// (vavr, 1)
Tuple2<String, Integer> that = java8.map(
	(s, i) -> Tuple.of(s.substring(2) + "vr", i / 8)
);

3.1.4. 转换元组

转换基于元组的内容创建一个新类型。

Java 复制代码
// "vavr 1"
String that = java8.apply(
	(s, i) -> s.substring(2) + "vr " + i / 8
);

3.2. 函数

函数式编程就是关于值和使用函数对值进行转换。Java 8 只提供了一个接受一个参数的 Function 和一个接受两个参数的 BiFunction。 Vavr 提供了最多 8 个参数的函数。函数式接口称为 Function0Function1Function2Function3 等等。 如果你需要一个抛出受检异常的函数,可以使用 CheckedFunction1CheckedFunction2 等等。

以下 lambda 表达式创建一个函数来对两个整数求和:

java 复制代码
// sum.apply(1, 2) = 3
Function2<Integer, Integer, Integer> sum = (a, b) -> a + b;

这是以下匿名类定义的简写:

java 复制代码
Function2<Integer, Integer, Integer> sum = new Function2<Integer, Integer, Integer>() {
	@Override
	public Integer apply(Integer a, Integer b) {
		return a + b;    
	}
};

你也可以使用静态工厂方法 Function3.of(...) 从任何方法引用创建函数。

Java 复制代码
Function3<String, String, String, String> function3 = Function3.of(this::methodWhichAccepts3Parameters);

事实上,Vavr 函数式接口是增强版的 Java 8 函数式接口。它们还提供以下特性:

  • 组合
  • 提升
  • 柯里化
  • 记忆化

3.2.1. 组合

你可以组合函数。在数学中,函数组合是将一个函数应用于另一个函数的结果以产生第三个函数。例如,函数 f : X → Yg : Y → Z 可以组合产生一个函数 h: g(f(x)),它将 X 映射到 Z。

你可以使用 andThen

java 复制代码
Function1<Integer, Integer> plusOne = a -> a + 1;
Function1<Integer, Integer> multiplyByTwo = a -> a * 2;
Function1<Integer, Integer> add1AndMultiplyBy2 = plusOne.andThen(multiplyByTwo);
then(add1AndMultiplyBy2.apply(2)).isEqualTo(6);

或者 compose

Java 复制代码
Function1<Integer, Integer> add1AndMultiplyBy2 = multiplyByTwo.compose(plusOne);
then(add1AndMultiplyBy2.apply(2)).isEqualTo(6);

3.2.2. 提升

你可以将一个部分函数提升为一个返回 Option 结果的总函数。术语"部分函数"来自数学。从 X 到 Y 的部分函数是函数 f: X′ → Y,其中 X′ 是 X 的某个子集。它通过不强制 f 将 X 的每个元素映射到 Y 的元素来推广函数 f: X → Y 的概念。这意味着部分函数仅对某些输入值正常工作。如果使用不允许的输入值调用该函数,它通常会抛出异常。

以下方法 divide 是一个只接受非零除数的部分函数。

java 复制代码
Function2<Integer, Integer, Integer> divide = (a, b) -> a / b;

我们使用 liftdivide 转换为一个对所有输入都有定义的总函数。

Java 复制代码
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 而不是抛出异常。如果使用允许的输入值调用提升函数,则返回 Some

以下方法 sum 是一个只接受正整数的部分函数。

java 复制代码
int sum(int first, int second) {
	if (first < 0 || second < 0) {
		throw new IllegalArgumentException("Only positive integers are allowed");
	}
	return first + second;
}

函数 sum 对负输入值抛出 IllegalArgumentException。 我们可以通过提供方法引用来提升sum方法。

java 复制代码
Function2<Integer, Integer, Option<Integer>> sum = Function2.lift(this::sum);
// = None
Option<Integer> optionalResult = sum.apply(-1, 2); 

提升后的函数捕获 IllegalArgumentException 并将其映射到 None

3.2.3. 部分应用

部分应用允许你通过固定一些值从现有函数派生新函数。你可以固定一个或多个参数,固定参数的数量定义了新函数的元数,使得新元数 = (原始元数 - 固定参数)。参数从左到右绑定。

java 复制代码
Function2<Integer, Integer, Integer> sum = (a, b) -> a + b;
Function1<Integer, Integer> add2 = sum.apply(2); 
then(add2.apply(4)).isEqualTo(6);

第一个参数 a 被固定为值 2。

这可以通过固定 Function5 的前三个参数来演示,结果是一个 Function2

Java 复制代码
Function5<Integer, Integer, Integer, Integer, Integer, Integer> sum = (a, b, c, d, e) -> a + b + c + d + e;
Function2<Integer, Integer, Integer> add6 = sum.apply(2, 3, 1); 
then(add6.apply(4, 3)).isEqualTo(13);

abc 参数分别被固定为值 2、3 和 1。 部分应用与柯里化不同,将在相关章节中探讨。

3.2.4. 柯里化

柯里化是一种通过为其中一个参数固定值来部分应用函数的技术,产生一个返回 Function1Function1

Function2 被柯里化时,结果与 Function2 的部分应用无法区分,因为两者都产生一个 1 元函数。

Java 复制代码
Function2<Integer, Integer, Integer> sum = (a, b) -> a + b;
Function1<Integer, Integer> add2 = sum.curried().apply(2); 
then(add2.apply(4)).isEqualTo(6);

第一个参数 a 被固定为值 2。

你可能会注意到,除了使用 .curried() 之外,这段代码与部分应用中给出的 2 元示例完全相同。对于更高元数的函数,区别就变得清晰了。

java 复制代码
Function3<Integer, Integer, Integer, Integer> sum = (a, b, c) -> a + b + c;
final Function1<Integer, Function1<Integer, Integer>> add2 = sum.curried().apply(2);
then(add2.apply(4).apply(3)).isEqualTo(9);

注意参数中出现了额外的函数。除了最后一次调用外,对 apply 的进一步调用会返回另一个 Function1

3.2.5. 记忆化

记忆化是一种缓存形式。记忆化的函数只执行一次,然后从缓存返回结果。

以下示例在第一次调用时计算一个随机数,在第二次调用时返回缓存的数字。

Java 复制代码
Function0<Double> hashCache = Function0.of(Math::random).memoized();
double randomValue1 = hashCache.apply();
double randomValue2 = hashCache.apply();
then(randomValue1).isEqualTo(randomValue2);

3.3. 值类型

在函数式环境中,我们将值视为一种范式,一种无法进一步求值的表达式。在 Java 中,我们通过将对象的状态设为 final 并称之为不可变来表达这一点。

Vavr 的函数式 Value 抽象了不可变对象。通过在实例之间共享不可变内存来添加高效的写操作。我们免费获得了线程安全性!

3.3.1. Option

Option 是一个单子容器类型,表示一个可选值。Option 的实例要么是 Some 的实例,要么是 None

java 复制代码
// 可选值,不再有 null
Option<T> option = Option.of(...);

如果你在使用JavaOptional 类之后转到 Vavr,有一个关键的区别。在 Optional 中,导致 null.map 调用将导致空的 Optional。在 Vavr 中,它将导致 Some(null),然后可能导致 NullPointerException

使用 Optional,这个场景是有效的。

java 复制代码
Optional<String> maybeFoo = Optional.of("foo"); 
then(maybeFoo.get()).isEqualTo("foo");

Optional<String> maybeFooBar = maybeFoo.map(s -> (String)null)
                                         .map(s -> s.toUpperCase() + "bar");
then(maybeFooBar.isPresent()).isFalse();

该选项是 Some("foo")结果选项在这里变为空

使用 Vavr 的 Option,相同的场景将导致 NullPointerException

Java 复制代码
Option<String> maybeFoo = Option.of("foo"); then(maybeFoo.get()).isEqualTo("foo");
try {
	maybeFoo.map(s -> (String)null)
			.map(s -> s.toUpperCase() + "bar");
	Assert.fail();
} catch (NullPointerException e) {   
	 // 这显然不是正确的方法
 }

该选项是 Some("foo")结果选项是 Some(null)s.toUpperCase() 的调用是在 null 上调用的

这看起来像是 Vavr 的实现有问题,但事实上并非如此------相反,它遵循了单子在调用 .map 时保持计算上下文的要求。就 Option 而言,这意味着在 Some 上调用 .map 将导致 Some,在 None 上调用 .map 将导致 None。在上面的 Java Optional 示例中,上下文从 Some 更改为 None

这似乎使 Option 变得无用,但实际上它迫使你关注可能出现的 null 并相应地处理它们,而不是不知不觉地接受它们。处理 null 出现的正确方法是使用 flatMap

java 复制代码
Option<String> maybeFoo = Option.of("foo"); then(maybeFoo.get()).isEqualTo("foo");
Option<String> maybeFooBar = maybeFoo.map(s -> (String)null)                                      .flatMap(s -> Option.of(s)                                                          .map(t -> t.toUpperCase() + "bar"));then(maybeFooBar.isEmpty()).isTrue();

该选项是 Some("foo")结果选项是 Some(null)``s(即 null)变为 None

或者,将 .flatMap 移到与可能为 null 的值相同的位置。

java 复制代码
Option<String> maybeFoo = Option.of("foo"); 
then(maybeFoo.get()).isEqualTo("foo");
Option<String> maybeFooBar = maybeFoo.flatMap(s -> Option.of((String)null))
                                      .map(s -> s.toUpperCase() + "bar");
then(maybeFooBar.isEmpty()).isTrue();

该选项是 Some("foo")结果选项是 None 这在 Vavr 博客上有更详细的探讨。

3.3.2. Try

Try 是一个单子容器类型,表示一个可能产生异常或成功返回计算值的计算。它在语义上与 Either 相似但不同。Try 的实例要么是 Success 的实例,要么是 Failure 的实例。

Java 复制代码
// 无需处理异常
Try.of(() -> bunchOfWork()).getOrElse(other);
Java 复制代码
import static io.vavr.API.*;        // $, Case, Match
import static io.vavr.Predicates.*; // instanceOf
A result = Try.of(this::bunchOfWork).recover(x -> Match(x).of(
		Case($(instanceOf(Exception_1.class)), t -> somethingWithException(t)),
		Case($(instanceOf(Exception_2.class)), t -> somethingWithException(t)),
		Case($(instanceOf(Exception_n.class)), t -> somethingWithException(t))
)).getOrElse(other);

3.3.3. Lazy

Lazy 是一个单子容器类型,表示一个惰性求值的值。与 Supplier 相比,Lazy 是记忆化的,即它只求值一次,因此是引用透明的。

Java 复制代码
Lazy<Double> lazy = Lazy.of(Math::random);
lazy.isEvaluated(); // = false
lazy.get();         // = 0.123 (随机生成)
lazy.isEvaluated(); // = true
lazy.get();         // = 0.123 (已记忆)

你也可以创建一个真正的惰性值(仅适用于接口):

java 复制代码
CharSequence chars = Lazy.val(() -> "Yay!", CharSequence.class);

3.3.4. Either

Either 表示两种可能类型的值。一个 Either 要么是 Left,要么是 Right。如果给定的 EitherRight 并投影到 Left,则 Left 操作对 Right 值没有影响。如果给定的 EitherLeft 并投影到 Right,则 Right 操作对 Left 值没有影响。如果 Left 投影到 LeftRight 投影到 Right,则操作会生效。

示例:一个 compute() 函数,其结果要么是一个 Integer 值(成功时),要么是一个 String 类型的错误消息(失败时)。按照惯例,成功情况是 Right,失败是 Left

java 复制代码
Either<String,Integer> value = compute().right().map(i -> i * 2).toEither();

如果 compute() 的结果是 Right(1),则 valueRight(2)。 如果 compute() 的结果是 Left("error"),则 valueLeft("error")

3.3.5. Future

Future 是在某个时刻可用的计算结果。提供的所有操作都是非阻塞的。底层的 ExecutorService 用于执行异步处理程序,例如通过 onComplete(...)

Future 有两种状态:挂起和完成。

  • 挂起:计算正在进行中。只有挂起的 future 可以被完成或取消。
  • 完成:计算成功完成并产生结果、因异常失败或被取消。

可以在 Future 的任何时间点注册回调。这些操作在 Future 完成时立即执行。注册在已完成的 Future 上的操作会立即执行。该操作可能运行在单独的线程上,具体取决于底层的 ExecutorService。注册在已取消的 Future 上的操作会以失败结果执行。

Java 复制代码
// future 值,异步计算的结果
Future<T> future = Future.of(...);

3.3.6. Validation

Validation 控件是一个应用函子,有助于累积错误。当尝试组合单子时,组合过程将在第一个遇到的错误处短路。但是 Validation 将继续处理组合函数,累积所有错误。这在验证多个字段(例如 Web 表单)时特别有用,你希望知道遇到的所有错误,而不是一次一个。

示例:我们从 Web 表单获取字段 nameage,并希望创建一个有效的 Person 实例,或者返回验证错误列表。

Java 复制代码
PersonValidator personValidator = new PersonValidator();
// Valid(Person(John Doe, 30))
Validation<Seq<String>, Person> valid = personValidator.validatePerson("John Doe", 30);
// Invalid(List(Name contains invalid characters: '!4?', Age must be greater than 0))
Validation<Seq<String>, Person> invalid = personValidator.validatePerson("John? Doe!4", -1);

一个有效值包含在 Validation.Valid 实例中,一个验证错误列表包含在 Validation.Invalid 实例中。 以下验证器用于将不同的验证结果组合到一个 Validation 实例中。

Java 复制代码
class PersonValidator {
    private static final String VALID_NAME_CHARS = "[a-zA-Z ]";
		private static final int MIN_AGE = 0;
    
		public Validation<Seq<String>, Person> validatePerson(String name, int age) {
			return Validation.combine(validateName(name), validateAge(age)).ap(Person::new);
		}

    private Validation<String, String> validateName(String name) {
			return CharSeq.of(name)
				.replaceAll(VALID_NAME_CHARS, "")
				.transform(seq -> seq.isEmpty() ? 
					Validation.valid(name) : 
					Validation.invalid("Name contains invalid characters: '"+ seq.distinct().sorted() + "'")
			);
		}

    private Validation<String, Integer> validateAge(int age) {
			return age < MIN_AGE
						? Validation.invalid("Age must be at least " + MIN_AGE)
						: Validation.valid(age);
		}
}

如果验证成功,即输入数据有效,则使用给定的字段 nameage 创建一个 Person 实例。

java 复制代码
class Person {
    public final String name;
		public final int age;
		
    public Person(String name, int age) {
			this.name = name;
			this.age = age;
		}

    @Override
		public String toString() {
			return "Person(" + name + ", " + age + ")";
		}
}

3.4. 集合

我们投入了大量精力来设计一个全新的 Java 集合库,以满足函数式编程的要求,即不可变性。

Java 的 Stream 将计算提升到不同的层,并在另一个明确的步骤中链接到特定的集合。使用 Vavr,我们不需要所有这些额外的样板代码。

新集合基于 java.lang.Iterable,因此它们利用了语法糖迭代风格。

Java 复制代码
// 1000 个随机数
for (double random : Stream.continually(Math::random).take(1000)) {
	...
}

TraversableOnce 有大量有用的函数来操作集合。它的 API 类似于 java.util.stream.Stream 但更成熟。

3.4.1. List

Vavr 的 List 是一个不可变的链表。修改会创建新实例。大多数操作在线性时间内执行。连续操作一个接一个地执行。

Java 8

Java 复制代码
Arrays.asList(1, 2, 3).stream().reduce((i, j) -> i + j);IntStream.of(1, 2, 3).sum();

Vavr

Java 复制代码
// io.vavr.collection.List
List.of(1, 2, 3).sum();

3.4.2. Stream

io.vavr.collection.Stream 实现是一个惰性链表。值只在需要时计算。由于其惰性,大多数操作在常数时间内执行。操作通常是中间操作,并在一次遍历中执行。

关于流的一个惊人之处是我们可以使用它们来表示(理论上)无限长的序列。

Java 复制代码
// 2, 4, 6, ...
Stream.from(1).filter(i -> i % 2 == 0);

3.4.3. 性能特征

表 1. 顺序操作的时间复杂度

head() tail() get(int) update(int, T) prepend(T) append(T)
Array --- --- const const linear linear
CharSeq const linear const linear linear linear
Iterator const const --- --- --- ---
List const const linear linear const linear
Queue const const¹ linear linear const const
PriorityQueue log log --- --- log log
Stream const const linear linear const lazy const lazy
Vector const eff const eff const eff const eff const eff const eff

表 2. Map/Set 操作的时间复杂度

contains/Key add/put remove min
HashMap const eff const eff const eff linear
HashSet const eff const eff const eff linear
LinkedHashMap const eff linear linear linear
LinkedHashSet const eff linear linear linear
Tree log log log log
TreeMap log log log log
TreeSet log log log log

图例:

  • const --- 常数时间
  • const¹ --- 摊销常数时间,少数操作可能需要更长时间
  • const eff --- 有效常数时间,取决于假设,如哈希键的分布
  • const lazy --- 惰性常数时间,操作被延迟
  • log --- 对数时间
  • linear --- 线性时间

3.5. 属性检查

属性检查(也称为属性测试)是一种以函数式测试代码属性的真正强大的方式。它基于生成的随机数据,这些数据被传递给用户定义的检查函数。 Vavr 在其 io.vavr:vavr-test 模块中支持属性测试,因此请确保包含它以在测试中使用。

java 复制代码
Arbitrary<Integer> ints = Arbitrary.integer();
// square(int) >= 0: OK, passed 1000 
tests.Property.def("square(int) >= 0")
	.forAll(ints)
	.suchThat(i -> i * i >= 0)
	.check()
	.assertIsSatisfied();

复杂数据结构的生成器由简单生成器组成。

3.6. 模式匹配

Scala 有原生模式匹配,这是相对于普通 Java 的优势之一。基本语法接近 Java 的 switch

Scala 复制代码
val s = i match {  
	case 1 => "one"  
	case 2 => "two"  
	case _ => "?"
}

值得注意的是 match 是一个表达式,它产生一个结果。此外,它还提供:

  • 命名参数 case i: Int ⇒ "Int " + i
  • 对象解构 case Some(i) ⇒ i
  • 守卫 case Some(i) if i > 0 ⇒ "positive " + i`
  • 多条件 case "-h" | "--help" ⇒ displayHelp`
  • 编译时检查完备性

模式匹配是一个很棒的特性,它使我们免于编写一堆 if-then-else 分支。它减少了代码量,同时专注于相关部分。

3.6.1. Java 匹配基础

Vavr 提供了一个接近 Scala match 的匹配 API。通过将以下导入添加到我们的应用程序来启用它:

java 复制代码
import static io.vavr.API.*;

将静态方法 MatchCase 和原子模式

  • $() - 通配符模式
  • $(value) - 等于模式
  • $(predicate) - 条件模式

引入作用域后,最初的 Scala 示例可以这样表达:

Java 复制代码
String s = Match(i).of(
	Case($(1), "one"),
	Case($(2), "two"),
	Case($(), "?")
);

⚡ 我们使用统一的大写方法名,因为 case 是 Java 中的关键字。这使得 API 很特殊。

完备性

最后一个通配符模式 $() 使我们免于 MatchError,如果没有匹配项则会抛出此错误。 因为我们无法像 Scala 编译器那样执行完备性检查,所以我们提供了返回可选结果的可能性:

Java 复制代码
Option<String> s = Match(i).option(    Case($(0), "zero"));

语法糖

如前所示,Case 允许匹配条件模式。

Java 复制代码
Case($(predicate), ...)

Vavr 提供了一组默认谓词。

Java 复制代码
import static io.vavr.Predicates.*;

这些可以用来表达最初的 Scala 示例,如下所示:

Java 复制代码
String s = Match(i).of(
	Case($(is(1)), "one"),
	Case($(is(2)), "two"),
	Case($(), "?")
);

多条件

我们使用 isIn 谓词来检查多个条件:

Java 复制代码
Case($(isIn("-h", "--help")), ...)

执行副作用

Match 的行为类似于一个表达式,它产生一个值。为了执行副作用,我们需要使用返回 Void 的辅助函数 run

Java 复制代码
Match(arg).of(
	Case($(isIn("-h", "--help")), o -> run(this::displayHelp)),
	Case($(isIn("-v", "--version")), o -> run(this::displayVersion)),
	Case($(), o -> run(() -> {
		throw new IllegalArgumentException(arg);
		})
	)
);

run 用于解决歧义,并且因为 void 在 Java 中不是有效的返回值。 注意:run 不能用作直接返回值,即不能在 lambda 体外部使用:

Java 复制代码
// 错误!
Case($(isIn("-h", "--help")), run(this::displayHelp))

否则,Case 将在模式匹配之前被急切求值,这会破坏整个 Match 表达式。相反,我们在 lambda 体内使用它:

Java 复制代码
// 正确
Case($(isIn("-h", "--help")), o -> run(this::displayHelp))

正如我们所看到的,如果使用不当,run 容易出错。请小心。我们考虑在未来的版本中弃用它,也许我们还会为执行副作用提供更好的 API。

命名参数

Vavr 利用 lambda 为匹配的值提供命名参数。

Java 复制代码
Number plusOne = Match(obj).of(
	Case($(instanceOf(Integer.class)), i -> i + 1),
	Case($(instanceOf(Double.class)), d -> d + 1),
	Case($(), o -> { throw new NumberFormatException(); }
));

到目前为止,我们直接使用原子模式匹配值。如果原子模式匹配,则匹配对象的正确类型将从模式的上下文中推断出来。 接下来,我们将看看能够匹配(理论上)任意深度的对象图的递归模式。

对象解构

在 Java 中,我们使用构造函数来实例化类。我们将对象解构理解为将对象分解为其组成部分。 构造函数是应用于参数并返回新实例的函数,而解构函数是接受实例并返回其组成部分的函数。我们说一个对象被解构了。 对象解构不一定是一个唯一的操作。例如,一个 LocalDate 可以被解构为:

  • 年、月和日组件
  • 表示相应 Instant 的纪元毫秒的长整型值
  • 等等。

3.6.2. 模式

在 Vavr 中,我们使用模式来定义如何解构特定类型的实例。这些模式可以与 Match API 结合使用。 预定义模式 对于许多 Vavr 类型,已经存在匹配模式。它们通过以下方式导入:

Java 复制代码
import static io.vavr.Patterns.*;

例如,我们现在能够匹配 Try 的结果:

Java 复制代码
Match(_try).of(
	Case($Success($()), value -> ...),
	Case($Failure($()), x -> ...)
);

⚡ Vavr 的 Match API 的第一个原型允许从匹配模式中提取用户选择的对象。没有适当的编译器支持,这是不切实际的,因为生成的方法数量呈指数级增长。当前的 API 做出了妥协,即匹配所有模式但只解构根模式。

Java 复制代码
Match(_try).of(
	Case($Success($Tuple2($("a"), $())), tuple2 -> ...),
	Case($Failure($(instanceOf(Error.class))), error -> ...)
);

这里的根模式是 SuccessFailure。它们被解构为 Tuple2Error,具有正确的泛型类型。

⚡ 深层嵌套的类型是根据 Match 参数推断的,而不是根据匹配的模式。

用户定义模式

能够解构任意对象(包括 final 类的实例)是至关重要的。Vavr 通过提供编译时注解 @Patterns@Unapply 以声明式风格实现这一点。

要启用注解处理器,需要将工件 vavr-match 添加为项目依赖项。

⚡ 注意:当然,模式可以直接实现而不使用代码生成器。更多信息请查看生成的源代码。

Java 复制代码
import io.vavr.match.annotation.*;
@Patternsclass My {
    @Unapply    
		static <T> Tuple1<T> Optional(java.util.Optional<T> optional) {
			return Tuple.of(optional.orElse(null));    
		}
}

注解处理器将文件 MyPatterns 放在同一个包中(默认在 target/generated-sources 中)。也支持内部类。特殊情况:如果类名是 $,则生成的类名只是 Patterns,没有前缀。

守卫

现在我们可以使用守卫来匹配 Optional

Java 复制代码
Match(optional).of(
	Case($Optional($(v -> v != null)), "defined"),
	Case($Optional($(v -> v == null)), "empty")
);

谓词可以通过实现 isNullisNotNull 来简化。 ⚡ 是的,提取 null 很奇怪。与其使用 Java 的 Optional,不如试试 Vavr 的 Option

Java 复制代码
Match(option).of(
	Case($Some($()), "defined"),
	Case($None(), "empty")
);

4. 许可证

版权所有 2014-2025 Vavr, vavr.io

根据 Apache 许可证 2.0 版("许可证")授权;

除非遵守许可证,否则你不得使用此文件。

你可以在以下网址获取许可证副本:www.apache.org/licenses/LI...

除非适用法律要求或书面同意,否则按"原样"分发的软件,没有任何明示或暗示的担保或条件。

请参阅许可证以了解许可证下的特定语言管理权限和限制。

版本 0.11.0

最后更新于 2025-12-16 18:50:22 UTC

相关推荐
PieroPC18 小时前
用FastAPI 一个 后端 和 两个前端 原生HTML/CSS/JS 、Vue3 写一个博客系统 例
前端·后端
Way2top18 小时前
Go语言动手写Web框架 - Gee第五天 中间件
后端·go
Way2top18 小时前
Go语言动手写Web框架 - Gee第四天 分组控制
后端·go
喵叔哟18 小时前
17.核心服务实现(上)
后端·.net
怦怦蓝18 小时前
IDEA 开发邮件发送功能:全流程报错解决方案汇总
java·ide·intellij-idea·发邮件
艾莉丝努力练剑18 小时前
【优选算法必刷100题:专题五】(位运算算法)第033~38题:判断字符是否唯一、丢失的数字、两整数之和、只出现一次的数字 II、消失的两个数字
java·大数据·运维·c++·人工智能·算法·位运算
大猫和小黄18 小时前
Java开发过程中的各种ID生成策略
java·开发语言·id
李梨同学丶18 小时前
好虫子周刊:1-bit LLM、物理 AI、DeepSeek-R1
后端
小罗和阿泽19 小时前
java [多线程基础 二】
java·开发语言·jvm