引言
Vavr(以前称为Javaslang)是一个Java 8+的函数库,它提供了持久的数据类型和函数控制结构。
使用Vavr实现Java 8中的功能数据结构
Java 8的λ(λ)赋予我们创造精彩的API。它们令人难以置信地提高了语言的表达能力。
Vavr利用lambdas创建基于功能模式的各种新特性。其中一个是用于替代Java标准集合的功能性集合库。
(这只是一个鸟瞰图,你会在下面找到一个人类可读的版本。)
函数式编程
在深入研究数据结构的细节之前,我想先谈谈一些基础知识。这将清楚地说明为什么我要创建Vavr,特别是新的Java集合。
副作用
Java应用程序通常有大量的副作用。他们改变了某种状态,也许是外部世界。常见的副作用是更改对象或变量、打印到控制台、写入日志文件或数据库。如果副作用以不受欢迎的方式影响程序的语义,则会被认为是有害的。
例如,如果一个函数抛出一个异常,并且解释了这个异常,那么它就会被认为是影响程序的副作用。此外,异常类似于非本地的goto-语句。它们打破了正常的控制流。然而,实际应用程序确实会执行副作用。
int divide(int dividend, int divisor) {
// throws if divisor is zero
return dividend / divisor;
}
在功能设置中,我们处于有利的情况,以封装的副作用,在一个尝试:
// = Success(result) or Failure(exception)
Try<Integer> divide(Integer dividend, Integer divisor) {
return Try.of(() -> dividend / divisor);
}
这个版本的divide不再抛出任何异常。通过使用类型Try,我们明确了可能的失败。
引用透明性
如果一个调用可以被它的值替换而不影响程序的行为,那么一个函数,或者更一般的表达式,就被称为引用透明的。简单地说,给定相同的输入,输出总是相同的。
// not referentially transparent
Math.random();
// referentially transparent
Math.max(1, 2);
如果涉及的所有表达式都是引用透明的,则称为纯函数。一个由纯函数组成的应用程序在编译后很可能可以正常工作。我们能够对它进行推理。单元测试很容易编写,调试成为过去的遗留物。
思考Value
Clojure的创建者里奇·希基(Rich Hickey)就Value的价值做了一次精彩的演讲。最有趣的值是不可变值。主要原因是不可变的值
- 本质上是线程安全的,因此不需要同步
- 对于equals和hashCode是稳定的,因此是可靠的散列键
- 不需要克隆
- 在未检查的协变类型强制转换(特定于java)中使用时表现为类型安全
更好的Java的关键是使用不可变值与引用透明函数相匹配。
Vavr提供了必要的控件和集合来实现日常Java编程中的这一目标。
简单地说就是数据结构
Vavr的集合库由构建在lambdas之上的一组功能丰富的数据结构组成。它们与Java原始集合共享的唯一接口是Iterable。主要原因是Java集合接口的mutator方法不返回底层集合类型的对象。
通过查看不同类型的数据结构,我们将了解为什么这一点如此重要。
可变的数据结构
Java是一种面向对象的编程语言。我们将状态封装在对象中以实现数据隐藏,并提供了一些mutator方法来控制状态。Java集合框架(JCF)就是基于这种思想构建的。
interface Collection<E> {
// removes all elements from this collection
void clear();
}
今天,我把void返回理解为一种气味。它是副作用发生的证据,状态是突变的。共享可变状态是失败的一个重要原因,不仅仅是在并发设置中。
不可变的数据结构
不可变数据结构创建后不能修改。在Java环境中,它们以集合包装器的形式被广泛使用。
List<String> list = Collections.unmodifiableList(otherList);
// Boom!
list.add("why not?");
有许多库为我们提供了类似的实用方法。结果总是特定集合的不可修改视图。通常,当我们调用mutator方法时,它会在运行时抛出。
持久数据结构
一个持久的数据结构在被修改时确实保留了它自身的前一个版本,因此它实际上是不可变的。完全持久的数据结构允许对任何版本进行更新和查询。
许多操作只执行很小的更改。仅仅复制以前的版本是没有效率的。为了节省时间和内存,确定两个版本之间的相似性并尽可能共享数据是至关重要的。
这个模型没有强加任何实现细节。功能性数据结构开始发挥作用了。
功能的数据结构
也称为纯功能性数据结构,它们是不可变的和持久的。函数数据结构的方法是引用透明的。
Vavr具有广泛的最常用的功能数据结构。下面将深入解释以下示例。
链表
最流行也是最简单的功能性数据结构之一是(单链表)。它有一个head元素和一个tail列表。链表的行为类似于堆栈,它遵循后进先出(LIFO)方法。
在Vavr
中,我们这样实例化一个列表:
// = List(1, 2, 3)
List<Integer> list1 = List.of(1, 2, 3);
每个列表元素构成一个单独的列表节点。最后一个元素的末尾是Nil,即空列表。
这使我们能够在列表的不同版本之间共享元素。
// = List(0, 2, 3)
List<Integer> list2 = list1.tail().prepend(0);
新的head元素0链接到原始列表的尾部。原始列表保持不变。
这些操作发生在常数时间内,换句话说,它们与列表大小无关。其他大多数操作都需要线性时间。在Vavr中,这是由接口LinearSeq表示的,我们可能已经从Scala中知道了。
如果我们需要在常数时间内查询的数据结构,Vavr提供了Array和Vector。两者都具有随机访问
能力。
Array类型由对象的Java数组支持。插入和删除操作需要线性时间。Vector在Array和List之间。它在随机访问和修改两个方面都表现良好。
实际上,链表还可以用来实现Queue数据结构。
队列
一个非常高效的功能队列可以基于两个链表来实现。前面的列表保存已出列的元素,后面的列表保存已入列的元素。在O(1)中,入队列和出队列操作都执行。
Queue<Integer> queue = Queue.of(1, 2, 3)
.enqueue(4)
.enqueue(5);
初始队列由三个元素创建。两个元素在后面的列表中排队。
如果前面的列表在退出队列时耗尽了元素,后面的列表将被反转并成为新的前面列表。
当将一个元素从队列中取出时,我们将得到第一个元素和剩余的队列的对。必须返回队列的新版本,因为函数数据结构是不可变的和持久的。原始队列不受影响。
Queue<Integer> queue = Queue.of(1, 2, 3);
// = (1, Queue(2, 3))
Tuple2<Integer, Queue<Integer>> dequeued =
queue.dequeue();
当队列为空时会发生什么?然后dequeue()将抛出一个NoSuchElementException。以函数的方式来做,我们宁愿期望一个可选的结果。
// = Some((1, Queue()))
Queue.of(1).dequeueOption();
// = None
Queue.empty().dequeueOption();
可选结果可以进一步处理,不管它是否为空。
// = 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);
有序集合
排序集是比队列更频繁使用的数据结构。我们使用二叉搜索树以函数的方式对它们进行建模。这些树由最多两个子节点和每个节点上的值组成。
我们在存在排序的情况下构建二叉搜索树,用元素比较器表示。任意给定节点的左子树的所有值都严格小于给定节点的值。右子树的所有值都是严格意义上的大。
// = TreeSet(1, 2, 3, 4, 6, 7, 8)
SortedSet<Integer> xs = TreeSet.of(6, 1, 3, 2, 4, 7, 8);
对这些树的搜索在O(log n)时间内运行。我们从根开始搜索,然后决定是否找到了元素。由于值的总排序,我们知道下一步在当前树的左分支或右分支中搜索什么。
// = 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)时间和空间。
// = TreeSet(1, 2, 3, 4, 5, 6, 7, 8)
SortedSet<Integer> ys = xs.add(5);
为了保持二叉搜索树的性能特征,需要保持平衡。从根到叶的所有路径都需要大致相同的长度。
在Vavr中,我们实现了一个基于红/黑树的二叉搜索树。它使用特定的着色策略来在插入和删除时保持树的平衡。要阅读关于这个主题的更多信息,请参阅Chris Okasaki的《纯函数数据结构》一书。
集合状态
一般来说,我们看到的是编程语言的融合。好的功能使它,其他消失。但Java是不同的,它注定永远是向后兼容的。这是一种优势,但也延缓了进化。
Lambda拉近了Java和Scala的距离,但它们仍然有很大的不同。Scala的创建者Martin Odersky最近在他的BDSBTB 2015主题演讲中提到了Java 8集合的状态。
他将Java流描述为迭代器的一种奇特形式。Java 8流API就是一个lifted集合的例子。它所做的是定义一个计算并在另一个显式步骤中将其链接到特定的集合。
// i + 1
i.prepareForAddition()
.add(1)
.mapBackToInteger(Mappers.toInteger())
这就是新的Java 8流API的工作方式。它是众所周知的Java集合之上的一个计算层。
// = ["1", "2", "3"] in Java 8
Arrays.asList(1, 2, 3)
.stream()
.map(Object::toString)
.collect(Collectors.toList())
Vavr深受Scala的启发。以上示例在Java 8中应该是这样的。
// = Stream("1", "2", "3") in Vavr
Stream.of(1, 2, 3).map(Object::toString)
在过去的一年里,我们花了很多精力来实现Vavr集合库。它包含最广泛使用的集合类型。
序列
我们通过实现顺序类型开始了我们的旅程。我们已经在上面描述了链表。随后是一个惰性链表Stream。它允许我们处理无限长的元素序列。
所有集合都是可迭代的,因此可以在增强的for语句中使用。
for (String s : List.of("Java", "Advent")) {
// side effects and mutation
}
我们可以通过内部化循环并使用lambda注入行为来完成相同的工作。
List.of("Java", "Advent").forEach(s -> {
// side effects and mutation
});
总之,正如我们前面看到的,我们更喜欢返回值的表达式,而不是什么都不返回的语句。通过看一个简单的例子,我们很快就会认识到,语句添加了噪音,并划分了属于它们的部分。
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集合为我们提供了许多操作底层元素的函数。这使我们能够以一种非常简洁的方式表达事物。
String join(String... words) {
return List.of(words)
.intersperse(", ")
.foldLeft(new StringBuilder(), StringBuilder::append)
.toString();
}
使用Vavr可以通过多种方式实现大多数目标。在这里,我们将整个方法体简化为一个列表实例上的连贯函数调用。我们甚至可以删除整个方法,直接使用我们的列表来获得计算结果。
List.of(words).mkString(", ");
在实际的应用程序中,我们现在能够极大地减少代码行数,从而降低bug的风险。
Set和Map
序列是伟大的。但是为了完成,集合库还需要不同类型的集合和映射。
我们描述了如何用二叉树结构来建模排序集。一个已排序的映射只不过是一个包含键-值对并对键进行排序的已排序集。
HashMap实现由一个哈希数组映射的Trie (HAMT)支持。因此,HashSet由包含键-键对的HAMT进行备份。
我们的映射没有表示键值对的特殊条目类型。相反,我们使用Tuple2,它已经是Vavr的一部分。元组的字段被枚举。
// = (1, "A")
Tuple2<Integer, String> entry = Tuple.of(1, "A");
Integer key = entry._1;
String value = entry._2;
整个Vavr都使用映射和元组。元组不可避免地要以常规方式处理多值返回类型。
// = 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个欧拉问题来探索和测试我们的库。这是一个很好的概念证明。请不要犹豫发送拉请求。
开始
包含Vavr的项目至少需要以Java 1.8为目标。
.jar在Maven Central
可用。
Gradle
dependencies {
compile "io.vavr:vavr:0.9.3"
}
Maven
<dependencies>
<dependency>
<groupId>io.vavr</groupId>
<artifactId>vavr</artifactId>
<version>0.9.3</version>
</dependency>
</dependencies>
Standalone
因为Vavr不依赖于任何库(除了JVM),所以您可以轻松地将它作为独立的.jar添加到类路径中。
Snapshots
开发者版本可以在这里
找到。
Gradle
将其他快照存储库添加到您的build.gradle:
repositories {
(...)
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
}
Maven
确保您的~/.m2/settings.xml包含以下内容:
<profiles>
<profile>
<id>allow-snapshots</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<repositories>
<repository>
<id>snapshots-repo</id>
<url>https://oss.sonatype.org/content/repositories/snapshots</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
</profile>
</profiles>
使用指南
Vavr附带了一些精心设计的最基本类型的表示,这些类型在Java中显然是缺失的或基本的: Tuple,Value和λ。
在Vavr中,一切都是建立在这三个基本模块之上的:
元组
Java缺少元组的一般概念。Tuple将固定数量的元素组合在一起,这样它们就可以作为一个整体传递。与数组或列表不同,tuple可以包含不同类型的对象,但它们也是不可变的。
元组的类型有Tuple1、Tuple2、Tuple3等等。目前有8个元素的上限。要访问元组t的元素,可以使用方法t。_1访问第一个元素t。_2访问第二个,依此类推。
创建一个元组
下面是一个如何创建包含字符串和整数的元组的示例:
// (Java, 8)
Tuple2<String, Integer> java8 = Tuple.of("Java", 8);
// "Java"
String s = java8._1;
// 8
Integer i = java8._2;
- tuple是通过静态工厂方法Tuple.of()创建的
- 得到这个元组的第1个元素。
- 得到这个元组的第二个元素。
映射元组组件
组件映射对元组中的每个元素求值一个函数,并返回另一个元组。
// (vavr, 1)
Tuple2<String, Integer> that = java8.map(
s -> s.substring(2) + "vr",
i -> i / 8
);
使用一个映射器映射一个元组
也可以使用一个映射函数来映射一个元组。
// (vavr, 1)
Tuple2<String, Integer> that = java8.map(
(s, i) -> Tuple.of(s.substring(2) + "vr", i / 8)
);
将一个元组
Transform根据元组的内容创建一个新类型。
// "vavr 1"
String that = java8.apply(
(s, i) -> s.substring(2) + "vr " + i / 8
);
函数
函数式编程都是关于值和使用函数转换值的。Java 8只提供了一个接受一个参数的函数和一个接受两个参数的双函数。Vavr提供最多8个参数的函数。这些功能接口分别称为Function0、Function1、Function2、Function3等。如果你需要一个抛出检查异常的函数,你可以使用CheckedFunction1, CheckedFunction2等等。
下面的lambda表达式创建一个函数来对两个整数求和:
// sum.apply(1, 2) = 3
Function2<Integer, Integer, Integer> sum = (a, b) -> a + b;
这是以下匿名类定义的简写:
Function2<Integer, Integer, Integer> sum = new Function2<Integer, Integer, Integer>() {
@Override
public Integer apply(Integer a, Integer b) {
return a + b;
}
};
您还可以使用静态工厂方法Function3.of(…)从任何方法引用创建一个函数。
Function3<String, String, String, String> function3 =
Function3.of(this::methodWhichAccepts3Parameters);
实际上,Vavr的功能接口是Java 8的功能接口。它们还提供以下功能:
- 组合
- 举起
- 柯里化
- 记忆化
组合
您可以组合函数。在数学中,函数组合是一个函数对另一个函数的结果的应用,从而产生第三个函数。例如,函数f: X→Y和g: Y→Z可以组合成一个映射X→Z的函数h: g(f(X))。
你可以使用任意一个,andThen:
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);
或组合:
Function1<Integer, Integer> add1AndMultiplyBy2 = multiplyByTwo.compose(plusOne);
then(add1AndMultiplyBy2.apply(2)).isEqualTo(6);
提升
您可以将部分函数提升为返回Option结果的总函数。偏函数一词来源于数学。X到Y的偏函数是f: X '→Y,对于X的某个子集X '。它通过不强制f将X的每个元素映射到Y的每个元素来概括函数f: X→Y的概念。这意味着分部函数只对某些输入值起作用。如果用不允许的输入值调用函数,它通常会抛出异常。
下面的方法除法是一个只接受非零因子的部分函数。
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而不是引发异常。
- 如果使用允许的输入值调用函数,则提升的函数将返回Some。
下面的方法sum是一个只接受正输入值的部分函数。
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方法。
Function2<Integer, Integer, Option<Integer>> sum = Function2.lift(this::sum);
// = None
Option<Integer> optionalResult = sum.apply(-1, 2);
被提升的函数捕获IllegalArgumentException并将其映射为None。
部分应用
部分应用允许通过固定某些值从现有函数派生新函数。您可以固定一个或多个参数,固定参数的数量定义了新函数的特性,如new arity =(original arity - fixed parameters)。这些参数是从左到右绑定的。
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。
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);
- a、b和c参数分别固定为值2、3和1。
部分应用不同于柯里化,这将在相关部分中进行探讨。
柯里化
柯里化是一种技术,通过为其中一个参数固定一个值来部分地应用一个函数,从而得到一个返回Function1的Function1函数。
当柯里化Function2时,结果与Function2的部分应用是无法区分的,因为两者都产生一个1-参数数量函数。
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-参数数量示例相同。对于高阶函数,区别就变得很明显了。
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。
记忆化
记忆是缓存的一种形式。缓存函数只执行一次,然后从缓存返回结果。下面的示例在第一次调用时计算一个随机数,并在第二次调用时返回缓存的数字。
Function0<Double> hashCache =
Function0.of(Math::random).memoized();
double randomValue1 = hashCache.apply();
double randomValue2 = hashCache.apply();
then(randomValue1).isEqualTo(randomValue2);
值
在函数设置中,我们将值视为一种范式,一种无法进一步求值的表达式。在Java中,我们通过使对象的状态为final并将其称为不可变来表达这一点。
Vavr的函数值对不可变对象进行抽象。通过在实例之间共享不可变内存来添加有效的写操作。我们得到的是免费线程安全!
Option
Option是一个表示可选值的一元容器类型。Option的实例要么是Some的实例,要么是None的实例。
// optional *value*, no more nulls
Option<T> option = Option.of(...);
如果您是在使用了Java的Optional类之后才使用Vavr,那么有一个重要的区别。在Optional中,调用.map会得到一个空的可选结果。在Vavr中,它会导致Some(null),然后导致NullPointerException。
使用Optional,这个场景是有效的。
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。
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) {
// this is clearly not the correct approach
}
- Option是Some(“foo”)
- 结果选项是Some(null)
- 对s.toUpperCase()的调用是在null上调用的
这看起来像是Vavr的实现被破坏了,但实际上它并没有——相反,它在调用.map时遵循单子的要求来维护计算上下文。对于一个选项,这意味着调用Some上的.map将导致Some,而调用None上的.map将导致None。在上面的Java可选示例中,上下文从Some更改为None。
这似乎使Option变得无用,但实际上它迫使您注意null可能出现的情况,并相应地处理它们,而不是在不知不觉中接受它们。处理null出现的正确方法是使用flatMap。
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共存的位置。
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博客对此有更详细的探讨。
Try
Try是一种一元容器类型,它表示可能导致异常或返回成功计算值的计算。它类似于,但在语义上与两者都不同。Try的实例,要么是Success的实例,要么是Failure的实例。
// no need to handle exceptions
Try.of(() -> bunchOfWork()).getOrElse(other);
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);
Lazy
Lazy是一种一元容器类型,它表示一个延迟计算值。与Supplier相比,Lazy是记忆的,也就是说它只评估一次,因此是引用透明的。
Lazy<Double> lazy = Lazy.of(Math::random);
lazy.isEvaluated(); // = false
lazy.get(); // = 0.123 (random generated)
lazy.isEvaluated(); // = true
lazy.get(); // = 0.123 (memoized)
因为2.0.0版本你也可以创建一个真正的延迟值(只对接口有效):
CharSequence chars = Lazy.val(() -> "Yay!", CharSequence.class);
Either
Either表示两种可能类型的值。Either就是Left或Right。如果Either是右值,并且投影到左值,则左操作对右值没有影响。如果给定的值是一个左值并映射到一个右值,则右操作对左值没有影响。如果将左投影到左或将右投影到右,则操作将产生效果。
示例:一个compute()函数,它的结果要么是一个整数值(在成功的情况下),要么是一个类型为String的错误消息(在失败的情况下)。按照惯例,成功的案例是Right,失败的案例是Left。
Either<String,Integer> value = compute().right().map(i -> i * 2).toEither();
如果compute()的结果是Right(1),那么值就是Right(2)。
如果compute()的结果为Left(“error”),则该值为Left(“error”)。
Future
未来是在某一时刻可用的计算结果。提供的所有操作都是非阻塞的。底层的ExecutorService用于执行异步处理程序,例如,通过onComplete(…)。
未来有两种状态:挂起状态和完成状态。
挂起:计算正在进行中。只有未完成的将来才可以完成或取消。
完成:计算成功地完成了一个结果,失败了一个异常或被取消。
回调可以在将来的每个时间点注册。一旦未来完成,就会立即执行这些操作。在已完成的Future上注册的操作将立即执行。该操作可以在单独的线程上运行,具体取决于底层的ExecutorService。在已取消的Future上注册的操作将与失败的结果一起执行。
// future *value*, result of an async calculation
Future<T> future = Future.of(...);
Validation
验证控制是一个应用函子,有助于错误的积累。当试图组成单子,组合过程将短路在第一次遇到错误。但是“验证”将继续处理组合函数,积累所有错误。这在对多个字段(比如web表单)进行验证时特别有用,您希望了解遇到的所有错误,而不是一次一个错误。
示例:我们从web表单获取字段’name’和’age’,并希望创建一个有效的Person实例,或者返回验证错误列表。
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实例中。
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);
}
}
如果验证成功,即输入数据有效,则根据给定字段名和年龄创建Person实例。
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 + ")";
}
}
收集
为满足函数式编程的要求,即不变性,设计了一个全新的Java集合库,付出了大量的努力。
Java的流将计算提升到一个不同的层,并在另一个显式步骤中链接到特定的集合。对于Vavr,我们不需要所有这些额外的样板文件。
新的集合基于java.lang.Iterable,所以他们利用了加糖的迭代风格。
// 1000 random numbers
for (double random : Stream.continually(Math::random).take(1000)) {
...
}
TraversableOnce有大量有用的函数可以对集合进行操作。它的API类似于java.util.stream.Stream但更成熟。
List
Vavr的List是一个不可变的链表。突变创造了新的实例。大多数操作是在线性时间内完成的。后续操作依次执行。
Java 8
Arrays.asList(1, 2, 3).stream().reduce((i, j) -> i + j);
IntStream.of(1, 2, 3).sum();
Vavr
// io.vavr.collection.List
List.of(1, 2, 3).sum();
Stream
io.vavr.collection.Stream实现是一个延迟链表。仅在需要时才计算值。由于它的惰性,大多数操作都是在常数时间内完成的。操作通常是中间的,并且在一次执行中执行。
流的惊人之处在于,我们可以用它们来表示(理论上)无限长的序列。
// 2, 4, 6, ...
Stream.from(1).filter(i -> i % 2 == 0);
性能特点
head() | tail() | get(int) | update(int, T) | prepend(T) | append(T) | |
---|---|---|---|---|---|---|
Array | const | linear | const | const | linear | linear |
CharSeq | const | linear | const | linear | linear | linear |
Iterator | const | const | – | – | – | – |
List | const | const | linear | linear | const | linear |
Queue | const | const^a | 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 |
contains/Key | add/put | remove | min | |
---|---|---|---|---|
HashMap | consteff | consteff | consteff | linear |
HashSet | consteff | consteff | consteff | linear |
LinkedHashMap | consteff | linear | linear | linear |
LinkedHashSet | consteff | linear | linear | linear |
Tree | log | log | log | log |
TreeMap | log | log | log | log |
TreeSet | log | log | log | log |
说明:
- const——常量时间
- const^a——恒定平摊时间,少数操作可能需要较长的时间
- const^eff——有效的常量时间,取决于像散列键的分布这样的假设
- const^lazy——常量时间,操作被延迟
- log——对数时间
- linear——线性时间
属性检验
属性检查(也称为属性测试)是一种以功能方式测试代码属性的真正强大的方法。它基于生成的随机数据,这些数据被传递给一个用户定义的检查函数。
Vavr在其io.vavr:vavr-test模块中提供了属性测试支持,所以要确保在测试中使用它。
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();
复杂数据结构的生成器由简单的生成器组成。
模式匹配
Scala具有原生模式匹配,这是与普通Java相比的优点之一。基本语法接近Java的开关:
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分支。它在关注相关部分的同时减少了代码量。
Java的基本匹配
Vavr提供了一个接近Scala的匹配API。它是通过添加以下导入到我们的应用程序:
import static io.vavr.API.*;
让静态方法Match,Case和原子模式
$()
-通配符模式$(value)
-相等模式$(predicate)
-条件模式
在scope中,最初的Scala示例可以这样表示:
String s = Match(i).of(
Case($(1), "one"),
Case($(2), "two"),
Case($(), "?")
);
我们使用统一的大写方法名,因为‘case’是Java中的一个关键字。这使得API很特别。
穷尽性
最后一个通配符模式$()将我们从匹配错误中解救出来,如果没有匹配的情况,则会抛出匹配错误。
因为我们不能像Scala编译器那样执行详尽性检查,所以我们提供了返回可选结果的可能性:
Option<String> s = Match(i).option(
Case($(0), "zero")
);
糖衣语法
如前所述,Case允许匹配条件模式。
Case($(predicate), ...)
Vavr提供一组默认谓词。
import static io.vavr.Predicates.*;
这些可以用来表达最初的Scala例子如下:
String s = Match(i).of(
Case($(is(1)), "one"),
Case($(is(2)), "two"),
Case($(), "?")
);
多重条件
我们使用isIn谓词来检查多个条件:
Case($(isIn("-h", "--help")), ...)
执行的副作用
Match就像一个表达式,它会产生一个值。为了执行副作用,我们需要使用帮助函数run,返回Void:
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体之外:
// Wrong!
Case($(isIn("-h", "--help")), run(this::displayHelp))
否则,将在模式匹配之前急切地求值,这会破坏整个匹配表达式。相反,我们在lambda体中使用它:
// Ok
Case($(isIn("-h", "--help")), o -> run(this::displayHelp))
我们可以看到,如果使用不当,run很容易出错。小心些而已。我们考虑在将来的版本中弃用它,也许我们还会提供一个更好的API来执行副作用。
具名参数
Vavr利用lambdas为匹配的值提供命名参数。
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分解为
- 年、月、日组件
- 表示相应瞬间的历元毫秒的长值
- 等等
模式
在Vavr中,我们使用模式来定义如何解析特定类型的实例。这些模式可以与Match API结合使用。
预定义的模式
对于许多Vavr类型,已经存在匹配模式。它们是通过
import static io.vavr.Patterns.*;
例如,我们现在可以匹配一个尝试的结果:
Match(_try).of(
Case($Success($()), value -> ...),
Case($Failure($()), x -> ...)
);
Vavr匹配API的第一个原型允许从匹配模式中提取用户定义的对象选择。没有适当的编译器支持,这是不现实的,因为生成的方法的数量呈指数级增长。当前API的折衷方案是匹配所有模式,但只分解根模式。
Match(_try).of(
Case($Success(Tuple2($("a"), $())), tuple2 -> ...),
Case($Failure($(instanceOf(Error.class))), error -> ...)
);
这里的根模式是Success和Failure。它们被分解为Tuple2和Error,具有正确的泛型类型。
深度嵌套类型是根据匹配参数推断的,而不是根据匹配的模式。
用户自定义模式
能够取消应用任意对象(包括final类的实例)是很重要的。Vavr通过提供编译时注释@Patterns和@Unapply以声明式风格实现这一点。
要启用注释处理器,需要将工件vavr-match作为项目依赖项添加。
注意:当然可以直接实现模式,而不需要使用代码生成器。有关更多信息,请查看生成的源代码。
import io.vavr.match.annotation.*;
@Patterns
class My {
@Unapply
static <T> Tuple1<T> Optional(java.util.Optional<T> optional) {
return Tuple.of(optional.orElse(null));
}
}
注释处理器将文件MyPatterns放在同一个包中(默认情况下在目标/生成源中)。内部类也受到支持。特殊情况:如果类名是$,则生成的类名只是模式,没有前缀。
守卫
现在我们可以使用警卫来匹配选项。
Match(optional).of(
Case($Optional($(v -> v != null)), "defined"),
Case($Optional($(v -> v == null)), "empty")
);
可以通过实现isNull和isNotNull来简化谓词。
是的,提取null很奇怪。与其使用Java的Optional,不如试试Vavr的Option!
Match(option).of(
Case($Some($()), "defined"),
Case($None(), "empty")
);