《Java的函数式》第七章:使用流(Streams)进行操作

214 阅读9分钟

流(Streams)利用了Java 8引入的许多函数式特性,提供了一种声明性的方式来处理数据。Stream API涵盖了许多用例,但是你需要了解不同的操作和可用的辅助类如何工作,以充分利用它们。

第6章着重介绍了流的基础知识。本章将在此基础上进一步讲解创建和处理流的不同方法,以应对各种用例。

原始流(Primitive Streams)

在Java中,泛型只能用于基于对象的类型(至少在Java 8之前是如此)。这就是为什么Stream不能用于原始类型(primitive types)(如int)的序列。在使用原始类型与流进行操作时,只有两个选择:

  1. 自动装箱(Autoboxing)
  2. 专门的流变体(Specialized Stream variants)

Java的自动装箱支持——即原始类型(如int)与其基于对象的对应类型(如Integer)之间的自动转换——可能看起来像一个简单的解决方法,因为它自动地工作,如下所示: Stream<Long> longStream = Stream.of(5L, 23L, 42L);

然而,自动装箱会引入多个问题。首先,与直接使用原始类型相比,从原始值转换为对象的开销会增加。通常情况下,这种开销是可以忽略的。然而,在数据处理流水线中,频繁创建包装类型的开销会累积起来,可能会降低整体性能。

另一个与原始包装类型相关的问题是可能出现null元素的情况。直接从原始类型转换为对象类型永远不会产生null,但是在流水线中的任何操作,如果必须处理包装类型而不是原始类型,则可能返回null。

为了解决这个问题,Stream API(与JDK的其他函数式特性一样)针对原始类型int、long和double提供了专门的变体,而不依赖于自动装箱,如表7-1所示。

截屏2023-06-16 14.36.00.png

原始流(Primitive Streams)上可用的操作与它们的通用类型(Generic)对应操作相似,但使用原始功能接口(primitive functional interfaces)。例如,IntStream提供了map操作来转换元素,就像Stream一样。与Stream不同,用于执行此操作的高阶函数是专门的IntUnaryOperator变体,它接受并返回一个int,如下面简化的接口声明所示:

@FunctionalInterface
public interface IntUnaryOperator {

    int applyAsInt(int operand);

    // ...
}

在原始流(Primitive Streams)上接受高阶函数的操作使用了专门的功能接口,例如IntConsumer或IntPredicate,以保持在原始流的限制范围内。与Stream相比,这减少了可用操作的数量。

不过,你可以通过映射到另一种类型或将原始流转换为其包装类型的方式轻松地在原始流和相应的Stream之间切换:

  • Stream boxed()
  • Stream mapToObj(IntFunction<? extends U> mapper)

另一方面,从Stream到原始流的转换也是支持的,在Stream上提供了mapTo-和flatMapTo-操作:

  • IntStream mapToInt(ToIntFunction<? super T> mapper)
  • IntStream flatMapToInt(Function<? super T, ? extends IntStream> mapper)

除了常见的中间操作外,原始流还具有一组自解释的算术终端操作,用于常见任务:

  • int sum()
  • OptionalInt min()
  • OptionalInt max()
  • OptionalDouble average()

这些操作不需要任何参数,因为它们对于数字的行为是不可协商的。返回的类型是你从类似的Stream操作中期望的原始等价类型。 与一般的原始流一样,在流中进行算术操作具有其使用场景,例如高度优化的大规模数据的并行处理。然而,对于更简单的用例来说,与现有的处理结构相比,切换到原始流通常并不值得。

迭代式流(Iterative Streams)

流(Stream)管道及其内部迭代通常处理现有的元素序列或可轻松转换为元素序列的数据结构。与传统的循环结构相比,你必须放弃对迭代过程的控制权,让流接管。然而,如果你需要更多的控制,流 API 仍然提供了相应的解决方案,其中包括 Stream 类型及其原始类型的静态 iterate 方法:

  • Stream iterate(T seed, UnaryOperator f)
  • IntStream iterate(int seed, IntUnaryOperator f)

Java 9 还添加了两个附加方法,其中包括一个带有结束条件的 Predicate 变体:

  • Stream iterate(T seed, Predicate hasNext, UnaryOperator next)
  • IntStream iterate(int seed, IntPredicate hasNext, IntUnaryOperator next)

针对 int、long 和 double 类型的原始类型,相应的 iterate 变体可用于它们对应的流变体。

迭代式流的方法是通过将一个种子值应用于 UnaryOperator 来生成一个有序且可能无限的元素序列。换句话说,流的元素将是 [seed, f(seed), f(f(seed)), ...],依此类推。

如果这个概念感觉很熟悉,没错!这相当于使用流的方式实现了一个 for 循环的等价形式:

// FOR-LOOP
for (int idx = 1; 
     idx < 5; 
     idx++) { 
  System.out.println(idx);
}

// EQUIVALENT STREAM (Java 8)
IntStream.iterate(1, 
                  idx -> idx + 1) 
         .limit(4L) 
         .forEachOrdered(System.out::println);

// EQUIVALENT STREAM (Java 9+)
IntStream.iterate(1, 
                  idx -> idx < 5, 
                  idx -> idx + 1) 
         .forEachOrdered(System.out::println);

无论是循环还是流的变体,它们都会为循环体/后续流操作产生相同的元素。Java 9 引入了一种带有限制条件的 iterate 变体,因此不需要额外的操作来限制整体元素。 相对于 for 循环而言,迭代式流的最大优势在于你仍然可以使用类似循环的迭代方式,同时获得延迟计算的函数式流流水线的好处。 结束条件不必在流创建时定义。相反,后续的中间流操作(如 limit)或终端条件(如 anyMatch)可能提供结束条件。 迭代式流的特性是有序(ORDERED)、不可变(IMMUTABLE),对于原始类型的流来说,还是非空(NONNULL)的。如果迭代是基于数字的,并且范围在预先知道的情况下,你可以通过使用 IntStream 和 LongStream 上提供的静态 range- 方法来获得更多的流优化,比如短路计算:

  • IntStream range(int startInclusive, int endExclusive)
  • IntStream rangeClosed(int startInclusive, int endInclusive)
  • LongStream range(long startInclusive, long endExclusive)
  • LongStream rangeClosed(long startInclusive, long endInclusive)

尽管使用 iterate 可以获得相同的结果,但主要区别在于底层的 Spliterator。返回的流的特性是有序(ORDERED)、大小已知(SIZED)、子大小已知(SUBSIZED)、不可变(IMMUTABLE)、非空(NONNULL)、去重(DISTINCT)和已排序(SORTED)。 选择迭代式或范围式流创建取决于你想要实现的目标。迭代式方法可以在迭代过程中提供更多的自由度,但你会失去流的特性,从而限制了最优化的可能性,特别是在并行流中。

无限流(Infinite Streams)

流(Stream)的惰性特性使得可以处理无限序列的元素,因为它们是按需逐个处理的,而不是一次性全部处理。

在JDK中,所有可用的流接口——Stream及其原始类型的相关接口IntStream、LongStream和DoubleStream——都具有静态便捷方法来创建无限流,无论是基于迭代的方法还是基于无序生成的方法。 虽然前一节中的iterate方法从一个种子值开始,并依赖于将其UnaryOperator应用于当前迭代值,但静态生成方法仅依赖于一个Supplier来生成它们的下一个流元素:

  • Stream generate(Supplier s)
  • IntStream generate(IntSupplier s)
  • LongStream generate(LongSupplier s)
  • DoubleStream generate(DoubleSupplier s)

缺少起始种子值会影响流的特性,使其成为无序的,这在并行使用时可能是有益的。由Supplier创建的无序流对于常量的非相互依赖序列的元素非常有帮助,例如,创建UUID流的工厂就非常简单:

Stream<UUID> createStream(long count) {
  return Stream.generate(UUID::randomUUID)
               .limit(count);
}

无序流的缺点是,在并行环境中,它们无法保证限制操作会选择前n个元素。这可能会导致对生成元素的Supplier的调用次数超过实际需要的流结果。 看下面的例子:

Stream.generate(new AtomicInteger()::incrementAndGet)
      .parallel()
      .limit(1_000L)
      .mapToInt(Integer::valueOf)
      .max()
      .ifPresent(System.out::println);

预期的输出是1000。然而,输出很可能会大于1000。 这种行为在并行执行环境中的无序流中是可以预料的。在大多数情况下,这并不重要,但它强调了选择具有有利特性的正确流类型以获得最佳性能和尽可能少的调用的必要性。

随机数

Stream API对于生成无限随机数流有特殊的考虑。虽然可以使用Stream.generate和例如java.util.Random实例来创建这样的流,但有更简单的方法可用。 有三种不同的随机数生成类型可以创建随机元素流:

  • java.util.Random
  • java.util.concurrent.ThreadLocalRandom
  • java.util.SplittableRandom

这三种类型都提供了多种方法来创建随机元素流:

IntStream ints()
IntStream ints(long streamSize)
IntStream ints(int randomNumberOrigin,
               int randomNumberBound)
IntStream ints(long streamSize,
               int randomNumberOrigin,
               int randomNumberBound)

LongStream longs()
LongStream longs(long streamSize)
LongStream longs(long randomNumberOrigin,
                 long randomNumberBound)
LongStream longs(long streamSize,
                 long randomNumberOrigin,
                 long randomNumberBound)

DoubleStream doubles()
DoubleStream doubles(long streamSize)
DoubleStream doubles(double randomNumberOrigin,
                     double randomNumberBound)
DoubleStream doubles(long streamSize,
                     double randomNumberOrigin,
                     double randomNumberBound)

从技术上讲,这些流只是在文档中说明的情况下才有效地无限。如果未提供streamSize,生成的流将包含Long.MAX_VALUE个元素。上界和下界由randomNumberOrigin(包含)和randomNumberBound(不包含)设置。 一般使用方法和性能特征将在“示例:随机数”中讨论。

内存并非无限的

在使用无限流时,最重要的是记住你的内存是有限的。限制无限流的大小不仅仅是重要的,而是绝对必要的!如果忘记添加限制流大小的中间操作或终端操作,将不可避免地消耗JVM可用的所有内存,并最终引发OutOfMemoryError。

可以使用以下表格列出的操作来限制任何流的大小。

截屏2023-06-16 14.49.02.png

最直接的选择是使用limit操作。使用Predicate进行选择的操作,如takeWhile,必须谨慎地设计,否则可能会导致流消耗比所需更多的内存。对于终端操作,只有find-操作保证终止流的执行。

-match操作遇到与takeWhile相同的问题。如果谓词不符合其目的,流管道将处理无限数量的元素,从而消耗所有可用的内存。

正如在“操作的成本”中讨论的那样,限制操作在流中的位置也会影响通过的元素数量。即使最终结果可能相同,尽早限制流元素的流动将节省更多的内存和CPU周期。

从数组到流,再从流返回

数组是一种特殊类型的对象。它们类似于集合结构,可以保存基本类型的元素,并且除了继承自java.lang.Object的常规方法之外,它们还提供了一种通过索引访问特定元素以及获取数组长度的方法。在未来,直到项目Valhalla可用之前,它们也是拥有原始类型集合的唯一方式。

然而,有两个特点使得数组非常适合基于流进行处理。首先,它们在创建时就确定了长度,并且不会改变。其次,它们是有序的序列。这就是为什么java.util.Arrays提供了多个便利方法,用于为不同的基本类型创建合适的流。将流转换为数组可以通过适当的终端操作完成。