Java流简介
您好!Java 和 Vavr 都提供了Stream 功能,这是一个非常实用的工具。结合前面提到的 Optional/Option 和 Try 语句,您可以实现应用程序的函数式编程风格。和往常一样,我们将从纯 Java 开始——首先介绍什么是 Stream 以及如何构建管道。之后,我们将深入探讨 Vavr Stream,并了解它与 Java 默认提供的 Stream 有何不同。
Java 中的流(stream)是什么?
流(Stream)是在 Java 8 中引入的,并在后续版本中不断更新。文档将流描述为支持顺序和并行聚合操作的元素序列。请不要混淆“流”这个词:即使在 Java 8 之前,Java 也已经有了InputStream和OutputStream,但这些概念与本文的重点——Java Stream——没有任何关系。Java Stream是在 Java 8 中引入的,它是monad 模式的一种实现——monad 模式的概念源自函数式语言。在函数式语言中,monad 代表被定义为一系列步骤的计算。
让我们来看一个用传统方式编写的简单案例:
List<String> names = Arrays.asList("Anna", "Bob", "Carolina", "Denis", "Anna", "Jack", "Marketa", "Simon", "Anna");
for (String name: names){
if (name.equalsIgnoreCase("Anna")){
System.out.println(name);
}
}
我们在这里所做的就是找出列表中所有名为 Anna 的元素并将它们打印出来。这是一个简单的操作,但即便如此,我们却需要为如此琐碎的任务编写大量的代码!再来看另一段代码:
List<String> names; // same names as before
names.stream().filter(name->name.equalsIgnoreCase("Anna")).forEach(System.out::println);
同样的任务,但现在只需要一串代码。我们做了什么?我们构建了一个流水线:
- 找出所有与Anna同名的名字
- 把它们全部打印出来
该管道由中间操作( fliter())和终止操作(forEach() )组成,我们将在本文后面部分进行观察。
创建流
Stream 是一种编程抽象概念,它并不等同于集合,但我们可以从集合创建 Stream。这些概念经常被从函数式 Java 入门的开发者混淆,但我们需要区分它们。在我们的示例中,我们之前从List创建了一个 Stream 。初始化 Stream 有几种方法:
来自收藏
这是最简单也最显而易见的。Java 的Collection接口有一个内置方法stream(),它会返回一个以该集合为数据源的顺序流。请看下面的代码片段:
List<Person> people;
Stream<Person> stream = people.stream();
// do something with stream...
生成流
如果您没有已定义的数据集,您可以生成流数据。这对于尝试使用流 API 方法非常有用。我们需要提供一个Supplier,用于生成随机元素序列。`generate` 方法返回一个无限的无序顺序流。以下是一个示例:
DoubleStream numbers = Stream.generate(Math::random);
在这种情况下,我们生成一个包含随机 Double 值的流。IntStream和LongStream也提供了一个特殊的range方法,我们也可以利用它来生成流。请看下面的代码片段:
IntStream integers = IntStream.range(1,20);
integers.forEach(System.out::println);
LongStream longs = LongStream.rangeClosed(1,20);
longs.forEach(System.out::println);
两种情况下,取值范围都是 1 到 20,但输出结果不同。这是因为`range`和`rangeClosed`返回的范围可能包含上限值,也可能不包含。`rangeClosed`方法返回的范围包含上限和下限,而`range`方法则从结果中排除第二个值。
可空
另一个用于创建流的静态方法是`ofNullable`。它允许我们创建一个包含单个元素或空元素(如果元素为null)的流。注意:此方法是在 Java 9 中引入的。
代码如下:
Person anna = null;
Stream<Person> personStream = Stream.ofNullable(anna);
的
另一种值得关注的创建流的方法是使用 ` .` 方法。该方法有两个重载版本:
- (T 元素)
- (T...元素)
第一种情况返回一个包含单个元素T 的顺序流。第二种情况返回一个顺序有序流,其元素为指定的值。注意,第二种情况使用可变参数作为参数。以下代码片段演示了此方法:
Stream<Car> cars = Stream.of(new Car("tesla"), new Car("skoda"), new Car("toyota"), new Car("mazda"));
cars.forEach(System.out::println);
Stream<Car> skoda = Stream.of(new Car("skoda"));
skoda.forEach(System.out::println);
迭代
与ofNullable类似,此方法在 Java 9 中引入。iterate方法接受两个参数:初始值(seed)和用于生成序列的一元运算符。该方法从初始值开始,迭代地应用给定的函数来获取下一个元素。以下是一个示例:
Stream.iterate(0, i -> i + 2);
空荡荡的溪流
最后,我们总是可以创建一个空流。注意,我们提到了`ofNullable`方法可以返回一个空流,但还有另一种方法可以显式地获取空流。`empty`方法返回一个空的顺序流:
Stream<Double> empty = Stream.empty();
那么Builder呢?
我们探讨了用于创建流的静态方法。但除了这些方法之外,还有另一种方法:使用 Builder。Stream.Builder允许通过单独生成元素并将其添加到构建器中来创建流,而无需使用临时集合或缓冲区。让我们来看一下:
// 1. create builder
Stream.Builder<String> builder = Stream.builder;
// 2. create stream
Stream<String> names = builder.add("anna").add("bob")
.add("carolina").add("david")
.build();
Builder 是构建流的另一种方法。我们初始化一个Stream.Builder实例,然后使用add方法向其中填充值。最后,我们使用build方法将Builder转换为Stream。
组装管道
我们对流的创建进行了广泛的介绍,并观察了创建流的关键方法。现在,既然我们已经获得了一个流实例,就可以构建一个管道来对该流进行一些有用的操作。从技术角度来看,管道由源(集合、生成器函数)、零个或多个中间操作以及一个终端操作组成。下图展示了管道的概念:
在本节中,我们将简要探讨中间操作和终端操作的作用,并观察其中最显著的一些操作。
中间操作
中间操作会返回一个新的流,并且是惰性的。惰性意味着只有在调用终端操作之后才会对源数据进行实际计算,并且源元素也仅在需要时才被使用。我们可以链接多个中间操作,因为每个操作都会返回一个新的Stream对象。请参见下图:
现在让我们快速了解一下最常用的中间操作。
筛选
在文章开头,我们已经使用过这个操作来筛选集合并查找匹配的名称。简而言之,它会返回一个新流,其中包含符合给定条件的元素。此方法接受一个谓词来指定条件。
names.stream().filter(name->name.equalsIgnoreCase("Joe"));
这段代码使用过滤器来查找所有包含“Joe”的名字。最终,我们将得到一个只包含“Joe”的新数据流。
地图
映射操作有多种,我决定将它们归为一类。我们先从通用的映射方法开始。它返回一个新的流,其中包含将映射函数应用于流中的元素的结果。以下是一个示例代码:
Stream.of("anna", "benjamin", "carol", "david", "eliska", "frank")
.map(String::toUpperCase)
.forEach(System.out::println);
这里还有一个源数据,它是一个名称列表。我们应用映射函数将名称转换为大写字符串。在所有情况下,映射器都是一个接受一个参数并产生一个结果的函数。还有其他一些特定的映射操作:
- mapToInt = 生成一个 IntStream,其中包含应用给定映射函数的结果。
- mapToDouble = 生成一个 DoubleStream,其中包含应用给定映射函数的结果
- mapToLong = 生成一个 LongStream,其中包含应用给定映射函数的结果
清楚的
Java Stream API 中另一个值得注意的中间操作是`distinct` 。它从数据中生成一个包含不同(唯一)元素的流。从技术角度来看, `distinct`方法使用实体的`equals` 属性来避免重复。对于有序流,不同元素的选择是稳定的;而对于无序流,Java 不提供稳定性保证。
List<Integer> numbers = Arrays.asList(1, 1, 2, 3, 3, 4, 5, 5);
numbers.stream().distinct().forEach(System.out::println);
这就是这种方法处理数字的方式。正如前面提到的,在你的自定义实体中,你需要重写equals和hashCode 方法才能区分不同的元素。我建议你在进行此操作之前,先阅读一下关于重写 hashCode 和 equals 方法的相关资料。
种类
排序是处理流时需要执行的另一项重要任务。`sorted`方法是一个中间操作,它提供一个流,该流中的元素已按照自然顺序排序。请看下面的代码片段:
List<Integer> numbers = Arrays.asList(-9, -18, 0, 25, 4);
numbers.stream().sorted().forEach(System.out::println);
同样,这就是数字排序的工作原理。对于自定义实体,您需要实现Comparable 接口,否则在执行终端操作时会抛出ClassCastException 异常。如果您没有实现此标记接口,则可以使用接受Comparator作为参数的重载排序版本:
Stream.of("barbora", "daria", "cristopher", "adam", "fritz")
.sorted((s1, s2) -> {
return s1.compareTo(s2);
}).forEach(System.out::println);
尽管
自 Java 9 发布以来,还新增了dropWhile和takeWhile这两个方法。它们都是中间操作,接受带条件的谓词。
- dropWhile = 生成一个流,该流由该流在删除与给定谓词匹配的最长前缀元素后剩余的元素组成。
- takeWhile = 生成一个流,该流由从该流中提取的与给定谓词匹配的元素的最长前缀组成。
注意:两者都适用于有序流。
请查看以下示例代码片段:
Set<Integer> numbers = Set.of(1,2,3,4,5,6,7,8);
numbers.stream()
.takeWhile(x-> x < 5)
.forEach(System.out::println);
限制
本文要介绍的最后一个中间操作是`limit`。它会生成一个长度不超过指定长度的元素流。此方法接受一个参数——表示所需长度的长整型值。
List<Integer> numbers = Arrays.asList(-9, -18, 0, 12, -5, 92, 13, 50, -75, 25, 4);
numbers.stream().sorted().limit(5).forEach(System.out::println);
码头作业
另一类操作称为终端操作。与中间操作不同,流上只会执行一个终端操作,因为终端操作执行完毕后,流管道就会被消耗掉,无法再使用。终端操作会产生结果,而不是流。
我们将在这里探讨几个值得关注的码头作业。
对于每个
我们在之前的大多数示例中都使用过这种操作。此方法接受一个 Consumer函数,该函数定义了要对流中的每个元素执行的操作。您可能还记得,在文章开头,我们比较了完成此任务的两种方法:
List<String> names = Arrays.asList("Anna", "Bob", "Carolina", "Denis", "Anna", "Jack", "Marketa", "Simon", "Anna");
for (String name: names){
if (name.equalsIgnoreCase("Anna")){
System.out.println(name);
}
}
names.stream().filter(name->name.equalsIgnoreCase("Anna")).forEach(System.out::println);
这里我们还使用了方法引用,使代码更简洁易读。完整的代码如下所示:
stream.forEach(name->System.out.println(name));
请注意,对于并行流管道,此操作并不保证遵循流的遭遇顺序,因为这样做会牺牲并行性的优势。对于任何给定的元素,该操作可以在库选择的任何时间、任何线程中执行。如果该操作访问共享状态,则库负责提供所需的同步机制。
收集
之前的终端操作没有返回值:它消耗数据,但不提供任何结果。然而,我们经常需要对集合执行一些流操作,然后获取更改后的集合。在这种情况下,我们使用`collect`方法。它使用`collection`对流中的元素执行可变归约操作。
collect方法有两个重载版本:一个返回单个结果,另一个返回一个集合。让我们详细了解一下:
Stream<Integer> numbers = Stream.of(1, 2, 3, 4, 5);
List<Integer> result = numbers.collect(Collectors.toList());
在这段代码片段中,我们使用内置的Collectors方法将流收集到列表中。Java 还提供了其他一些现成的实用方法:
- 收藏家.toMap
- 收藏家.toSet
寻找
最后,还有一些操作会返回可选对象。虽然它们是独立的方法,但我将它们归为一类。让我们先列出它们:
- 查找任何
- 查找第一个
它们都没有参数,所以你可能会问一个很合理的问题:它们究竟是如何找到数据的?这些方法需要结合我们之前介绍的过滤器使用。请看以下示例:
List<String> names = Arrays.asList("anna", "barbora", "andrew", "benjamin", "carol");
Optional<String> anna = names.stream().filter(name->name.equalsIgnoreCase("anna")).findFirst();
if (anna.isPresent){
System.out.println("Anna is here!");
} else {
System.out.println("No Anna there");
}
这里我们结合使用findFirst和filter来查找匹配结果。然而,这只是一个非常人为的例子:通常我们不会这样做,而是根据某种模式进行过滤。
// names list
names.stream().filter(name->name.startsWith("A")).findAny().ifPresent(System.out::println);
两种情况下我们都得到了 Anna。这两种方法有什么区别?顾名思义,`findFirst` 返回匹配元素首次出现的位置。在我们的例子中,它们都是 Anna。`findAny` 返回任意匹配元素,它可以是第一个出现的位置,也可以不是:此操作的行为是明确表示非确定性的;它可以自由选择流中的任何元素。
我们对 Java Stream 进行了全面的回顾,并介绍了每位开发者都应该了解的最重要方法(但这并非完整列表,您可以随时查阅Javadoc)。Stream 是一个借鉴自函数式编程语言的概念,它能更好地与其他 Java 函数式工具(例如 Option 类型)配合使用。然而,许多开发者认为它们功能不够强大。作为 Java 内置工具的替代方案,我们可以使用Vavr库。在本系列文章的前几部分中,我已经介绍了Option和Try类。
结论
本文探讨了流(Stream)这一概念——它源自函数式编程语言。从技术角度讲,Java 流是一系列元素,支持顺序和并行聚合操作。Java 从 JDK 8 开始引入 Streams API,并在后续版本中不断改进。此外,Java 流并非唯一。为函数式 Java 提供扩展的 Vavr 库也提供了与原生 Java 流略有不同的流。本文主要介绍了与 Java 流相关的核心任务:创建和构建管道。祝您愉快!
文章来源:https://dev.to/iuriimednikov/introduction-to-java-streams-4k20


