《Java的函数式》第六章:使用Stream进行数据处理

263 阅读10分钟

第六章:使用Stream进行数据处理

几乎任何程序都需要处理数据,通常是以集合的形式存在。命令式的处理方式使用循环来迭代元素,按顺序处理每个元素。而函数式语言更倾向于一种声明式的处理方式,有时甚至没有传统的循环语句。

流(Streams)API在Java 8中引入,提供了一种完全声明式和延迟求值的数据处理方式,通过利用高阶函数来实现大部分操作,充分利用了Java的函数式扩展功能。

本章将介绍命令式和声明式数据处理之间的差异。您将通过视觉化的介绍来了解Streams的基本概念,并学习如何充分利用其灵活性,实现更加函数式的数据处理方式。

迭代式数据处理

数据处理是一个日常任务,你可能已经遇到过无数次,将来也会继续进行。 从一个广泛的观点来看,任何类型的数据处理都类似于一个流水线,一个数据结构(如集合)提供元素,一个或多个操作(如过滤或转换元素),最后生成某种形式的结果。结果可以是另一个数据结构,甚至可以用它来运行另一个任务。 让我们从一个简单的数据处理示例开始。

外部迭代

假设我们需要从一组Book实例中找到1970年之前的三本科幻书,并按标题排序。示例6-1展示了如何使用典型的命令式方法和for循环来完成这个任务:

record Book(String title, int year, Genre genre) {
  // NO BODY
}

// DATA PREPARATION

List<Book> books = ...; 

Collections.sort(books, Comparator.comparing(Book::title)); 

// FOR-LOOP

List<String> result = new ArrayList<>();

for (var book : books) {
    if (book.year() >= 1970) { 
        continue;
    }

    if (book.genre() != Genre.SCIENCE_FICTION) { 
        continue;
    }

    var title = book.title(); 
    result.add(title);

    if (result.size() == 3) { 
        break;
    }
}

尽管该代码能够完成其所需的功能,但与其他方法相比,它存在一些缺点。最明显的缺点是迭代式循环需要大量的样板代码。 循环语句,无论是for循环还是while循环,都将其数据处理逻辑放在循环体中,为每次迭代创建一个新的作用域。

根据您的要求,循环体可能包含多个语句,包括决定迭代过程本身的continue和break形式的决策。总体而言,这些样板代码使得数据处理代码变得晦涩难懂,不够流畅,尤其是对于比之前示例更复杂的循环。 这些问题的根源在于将“您正在做什么”(处理数据)和“它是如何完成的”(迭代元素)混合在一起。这种类型的迭代称为外部迭代。

在幕后,for循环(在本例中是for-each变体)使用java.util.Iterator来遍历Collection。遍历过程通过调用hasNext和next来控制迭代,如图6-1所示。

image.png

在传统的for循环中,您必须管理元素的遍历,直到达到结束条件,这在某种程度上类似于Iterator和hasNext、next方法。 如果您计算一下与“您正在做什么”和“它是如何完成的”相关的代码行数,您会发现它在遍历管理方面花费的时间比在数据处理方面更多,详见表6-1。

截屏2023-05-26 22.50.36.png

然而,需要大量样板代码来进行遍历并不是与外部迭代关联的唯一缺点。另一个缺点是固有的串行遍历过程。如果需要并行数据处理,您需要重新设计整个循环,并且必须处理所有相关的问题,如令人害怕的Concurrent ModificationException异常。

内部迭代

与外部迭代相对的方法是,你猜对了,内部迭代。使用内部迭代,您放弃了对遍历过程的显式控制,而是让数据源本身处理“如何完成”,如图6-2所示。 不再使用迭代器来控制遍历,而是预先准备好数据处理逻辑以构建一个能够自主进行迭代的管道。

迭代过程变得更加不透明,但逻辑影响着哪些元素通过管道进行遍历。这样,您可以将精力和代码集中在“要做什么”上,而不是在“如何完成”的繁琐和常常重复的细节上。 Streams就是这样的具有内部迭代的数据管道。

image.png

函数式数据管道的流

作为一种数据处理方法,流(Streams)与其他方法一样能够完成工作,但由于具有内部迭代器,它们具有特定的优势,尤其从函数式的角度来看。这些优势包括:

声明式方法

使用单个流畅的调用链构建简洁易懂的多步骤数据处理管道。

可组合性

流操作提供了一个由高阶函数构成的框架,可以填充数据处理逻辑。它们可以根据需要进行混合使用。如果以函数式方式设计它们的逻辑,就会自动获得它们的所有优势,如可组合性。

惰性计算

它们在最后一个操作被附加到管道后,逐个地将元素通过管道拉取,将所需的操作数量减少到最小。

性能优化

根据数据源和所使用的不同操作,流会自动优化遍历过程,包括可能的短路操作。

并行数据处理

通过在调用链中简单地更改一个调用,即可使用内置的并行处理支持。

从概念上看,流可以被视为传统循环结构的另一种选择,用于数据处理。然而,在现实中,流在提供这些数据处理功能方面是特殊的。首先要考虑的是整体的流工作流程。流可以被概括为惰性顺序数据管道。这些管道是一系列用于以流畅、表达性和函数式方式处理元素的高阶函数序列。其一般工作流程如图6-3所示,并在以下三个步骤中进行了解释。

image.png

(1)创建流(Creating a Stream) 第一步是从现有数据源创建一个流(Stream)。流不仅限于类似集合的类型。任何能够提供连续元素的数据源都可以成为流的数据源。

(2)进行操作(Doing the work) 所谓的中间操作是可以作为java.util.stream.Stream方法提供的高阶函数,它们对通过流传递的元素进行操作,执行不同的任务,如过滤、映射、排序等。每个中间操作都会返回一个新的流,可以连接多个中间操作。

(3)获取结果(Getting a result) 为了完成数据处理的管道,需要一个最终的终端操作,以获取结果而不是流。这样的终端操作完成了流管道的蓝图,并开始实际的数据处理过程。

为了看到这一点,让我们重新审视之前的任务,即查找1970年之前的三本科幻书的书名。这次,我们将使用一个流管道(Stream pipeline),而不是像在示例6-1中使用for循环。暂时不要太担心流代码,我将在稍后解释各种方法。先阅读一遍,你应该能够初步理解它的主要内容。

List<Book> books = ...; 

List<String> result =
  books.stream()
       .filter(book -> book.year() < 1970) 
       .filter(book -> book.genre() == Genre.SCIENCE_FICTION) 
       .map(Book::title) 
       .sorted() 
       .limit(3L) 
       .collect(Collectors.toList());

从高层次的观点来看,示例 6-1 和 6-2 中的实现都代表了元素可以遍历的流水线,其中有多个用于过滤不需要的数据的出口。但请注意,for 循环的多个语句的功能现在被压缩成了一个简洁的 Stream 调用。

这使得我们了解到 Streams 如何优化它们的元素流动。您无需显式地使用 continue 或 break 来管理遍历,因为元素将根据操作的结果在流水线中遍历。图 6-4 展示了示例 6-2 中不同的 Stream 操作如何影响元素的流动。

image.png

元素逐个通过流动到最少量的处理数据所需的位置。

与事先准备数据并将处理逻辑封装在循环语句的主体中不同,Stream 是由不同处理步骤组成的流畅类。与其他函数式方法一样,Stream 代码以更具表达性和声明性的方式反映了正在发生的“事情”,而不涉及典型的“如何”实际执行的废话。

流的特性

Streams是一个具有特定行为和预期的函数式API。从某种意义上说,这限制了它们的可能性,至少与传统循环的空白画布相比。然而,通过不是空白画布,它们为您提供了许多预定义的构建块和保证的属性,这些属性在其他方法中您将不得不自己创建。

惰性求值

与循环相比,Streams最显著的优势是它们的延迟性。每次在Stream上调用一个中间操作时,它不会立即应用。相反,该调用仅仅是将管道进一步“扩展”,并返回一个新的延迟求值的Stream。管道累积所有操作,并且在调用终端操作之前不会开始任何工作,终端操作将触发实际的元素遍历,如图6-5所示。

image.png

与循环提供所有元素给代码块不同,终端操作会根据需要请求更多的数据,而Stream会尽力满足这个需求。作为数据源,Stream不需要“过度提供”或缓冲任何元素,如果没有人请求更多的元素。如果你回顾一下图6-4,这意味着并不是每个元素都会经过每个操作。

Stream元素的流动遵循“深度优先”的方法,减少了所需的CPU周期、内存占用和堆栈深度。这样,即使是无限的数据源也是可能的,因为管道负责请求所需的元素并终止Stream。

你可以在第11章中了解更多关于惰性在函数式编程中的重要性的内容。

(大部分)无状态和非干扰性

正如你在第4章中学到的那样,不可变状态是函数式编程中的一个重要概念,而Stream尽力遵循这一概念。几乎所有的中间操作都是无状态的,并且与流水线的其余部分分离开来,只能访问它们正在处理的当前元素。然而,某些中间操作需要一些形式的状态来实现其目的,例如limit或skip。

使用流的另一个优点是它们将数据源和元素本身分离开来。这样,操作不会以任何方式影响底层数据源,流本身也不会存储任何元素。

Stream是非干扰的,并且是一种无干扰的传递管道,如果没有绝对必要,它们会尽可能地让元素自由地遍历。

包含优化

内部迭代和高阶函数的基本设计使得流能够进行相当高效的优化。它们利用多种技术来提高性能:

1.(无状态)操作的融合

  1. 删除冗余操作

  2. 短路管道路径

与此同时,与流相关的迭代代码优化并不限于流本身。如果可能的话,传统的循环也会被 JVM 进行优化。

此外,像 for 和 while 这样的循环是语言特性,因此它们可以进行更高程度的优化。而流是普通的类型,具有与之相关的所有开销。它们仍然需要通过包装数据源来创建,并且管道是一个调用链,每次调用都需要一个新的堆栈帧。在大多数实际场景中,相对于内置的 for 或 while 语句,流的通用优势超过了可能带来的性能开销。