欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

vavr-用户手册

程序员文章站 2022-06-14 23:27:57
...

引言

Vavr(以前称为Javaslang)是一个Java 8+的函数库,它提供了持久的数据类型和函数控制结构。

使用Vavr实现Java 8中的功能数据结构

Java 8的λ(λ)赋予我们创造精彩的API。它们令人难以置信地提高了语言的表达能力。

Vavr利用lambdas创建基于功能模式的各种新特性。其中一个是用于替代Java标准集合的功能性集合库。

vavr-用户手册
(这只是一个鸟瞰图,你会在下面找到一个人类可读的版本。)

函数式编程

在深入研究数据结构的细节之前,我想先谈谈一些基础知识。这将清楚地说明为什么我要创建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,即空列表。
vavr-用户手册

这使我们能够在列表的不同版本之间共享元素。

// = List(0, 2, 3)
List<Integer> list2 = list1.tail().prepend(0);

新的head元素0链接到原始列表的尾部。原始列表保持不变。
vavr-用户手册
这些操作发生在常数时间内,换句话说,它们与列表大小无关。其他大多数操作都需要线性时间。在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);

初始队列由三个元素创建。两个元素在后面的列表中排队。
vavr-用户手册
如果前面的列表在退出队列时耗尽了元素,后面的列表将被反转并成为新的前面列表。

当将一个元素从队列中取出时,我们将得到第一个元素和剩余的队列的对。必须返回队列的新版本,因为函数数据结构是不可变的和持久的。原始队列不受影响。

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);

vavr-用户手册
对这些树的搜索在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-用户手册
为了保持二叉搜索树的性能特征,需要保持平衡。从根到叶的所有路径都需要大致相同的长度。

在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。它允许我们处理无限长的元素序列。
vavr-用户手册
所有集合都是可迭代的,因此可以在增强的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

序列是伟大的。但是为了完成,集合库还需要不同类型的集合和映射。
vavr-用户手册
我们描述了如何用二叉树结构来建模排序集。一个已排序的映射只不过是一个包含键-值对并对键进行排序的已排序集。

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中,一切都是建立在这三个基本模块之上的:
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; 
  1. tuple是通过静态工厂方法Tuple.of()创建的
  2. 得到这个元组的第1个元素。
  3. 得到这个元组的第二个元素。

映射元组组件

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

// (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); 
  1. 如果使用不允许的输入值调用函数,则提升的函数将返回None而不是引发异常。
  2. 如果使用允许的输入值调用函数,则提升的函数将返回Some。

下面的方法sum是一个只接受正输入值的部分函数。

int sum(int first, int second) {
    if (first < 0 || second < 0) {
        throw new IllegalArgumentException("Only positive integers are allowed"); 
    }
    return first + second;
}
  1. 函数sum对负的输入值抛出IllegalArgumentException。

我们可以通过提供方法参考来提升sum方法。

Function2<Integer, Integer, Option<Integer>> sum = Function2.lift(this::sum);

// = None
Option<Integer> optionalResult = sum.apply(-1, 2); 

被提升的函数捕获IllegalArgumentException并将其映射为None。

部分应用