美文网首页
ITEM 46: 在流中使用无副作用的方法

ITEM 46: 在流中使用无副作用的方法

作者: rabbittttt | 来源:发表于2019-11-10 00:30 被阅读0次

ITEM 46: PREFER SIDE-EFFECT-FREE FUNCTIONS IN STREAMS
  如果你是流的新手,你可能很难掌握它们。仅仅将计算表示为流管道可能是困难的。当您成功时,您的程序将运行,但是您可能没有意识到任何好处。流不仅仅是一个 API,它是一个基于函数式编程的范例。为了获得流必须提供的表达性、速度和在某些情况下的并行性,您必须采用该范式和API。
  流范式最重要的部分是将计算结构转换为序列,其中每个阶段的结果尽可能接近前一阶段结果的纯函数。纯函数的结果只取决于其输入:它不依赖于任何可变状态,也不更新任何状态。为了实现这一点,您传递到流操作(中间和终端)的任何函数对象都应该没有副作用。
有时,你可能会看到类似于这个代码片段的流代码,它构建了一个文本文件中单词的频率表:

// Uses the streams API but not the paradigm--Don't do this!
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
  words.forEach(word -> { freq.merge(word.toLowerCase(), 1L, Long::sum); }); 
}

  这段代码有什么问题?它使用了流、lambdas和方法引用,并得到了正确的答案。简单地说,它根本不是流代码;它是伪装成流代码的迭代代码。它没有从 streams API中获得任何好处,并且它(有点)比相应的迭代代码更长、更难以阅读、更难以维护。这个问题源于这样一个事实:这段代码在一个终端 forEach 操作中执行所有的工作,使用一个改变外部状态的 lambda (频率表)。
  如果一个 forEach 操作所做的仅仅是呈现由流执行的计算结果,那么它就是“代码中的坏味道”,就像 lambda 改变状态一样。那么这段代码应该是什么样的呢?

// Proper use of streams to initialize a frequency table
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
   freq = words.collect(groupingBy(String::toLowerCase, counting()));
}

  这个代码段执行与前一个相同的操作,但是正确地使用了 streams API。它更短,更清晰。为什么会有人用另一种写法呢?因为它使用了他们已经熟悉的工具。Java 程序员知道如何使用 for-each 循环,而且 forEach 终端操作也类似。但是 forEach 操作是最不强大的终端操作之一,也是最不友好的流操作之一。它是显式迭代的,因此不适合并行化。forEach 操作应该只用于报告流计算的结果,而不用于执行计算。有时,将 forEach 用于其他目的是有意义的,比如将流计算的结果添加到预先存在的集合中。
  改进后的代码使用了一个收集器,这是一个必须学习才能使用流的新概念。收集器 API 令人生畏:它有39个方法,其中一些有多达5个类型参数。好消息是,您可以从这个 API 中获得大部分好处,而不必深入研究它的全部复杂性。对于初学者,您可以忽略收集器接口,将收集器看作封装了缩减策略的不透明对象。在此上下文中,约简意味着将流的元素组合成单个对象。收集器生成的对象通常是一个集合(它表示名称收集器)。
  用于将流的元素收集到真正的集合中的收集器非常简单。有三种这样的收集器: toList()、toSet() 和 toCollection(collectionFactory)。它们分别返回一个集合、一个列表和一个程序员指定的集合类型。有了这些知识,我们就可以编写一个流管道来从频率表中提取前十列表。

// Pipeline to get a top-ten list of words from a frequency table
List<String> topTen = freq.keySet().stream()
                                    .sorted(comparing(freq::get).reversed()) 
                                    .limit(10)
                                    .collect(toList());

  注意,我们还没有对 toList 方法及其类 collector 进行限定。通常,静态导入收集器的所有成员是明智的,因为这样可以使流管道更具可读性。
  这段代码中惟一需要技巧的部分是传递给已排序的比较器 compare (freq::get).reverse()。比较方法是一种取关键提取函数的比较器构造方法(item 14)。该函数接受一个单词,而“提取”实际上是一个表查找:绑定方法引用 freq::get 在频率表中查找该单词,并返回该单词在文件中出现的次数。最后,我们在比较器上调用 reverse,所以我们将单词从最频繁到最不频繁排序。然后,很简单的一件事就是把单词限制在10个以内,并把它们收集到一个列表中。
  前面的代码片段使用扫描器的流方法获得扫描器上的流。这个方法是在 Java 9中添加的。如果您使用的是较早的版本,那么您可以使用类似于 item 47 (streamOf(Iterable)) 中的适配器将实现 Iterator 的扫描程序转换成流。
  那么收集器中的其他36个方法呢?它们中的大多数是为了让您将流收集到地图中,这比将它们收集到真正的集合要复杂得多。每个流元素与一个键和一个值相关联,多个流元素可以与同一个键相关联。
  最简单的映射收集器是 toMap(keyMapper, valueMapper),它接受两个函数,一个函数将流元素映射到键,另一个映射到值。我们在 item 34的 fromString 实现中使用了这个收集器,将 enum 的字符串形式映射到 enum 本身:

// Using a toMap collector to make a map from string to enum
private static final Map<String, Operation> stringToEnum = Stream.of(values()).collect( toMap(Object::toString, e -> e));

  如果流中的每个元素都映射到一个惟一的键,那么这种简单形式的 toMap 是完美的。如果多个流元素映射到同一个键,则管道将使 IllegalStateException 终止。
  比较复杂的 toMap 形式,以及按方法分组,为您提供了处理这种冲突的各种策略。一种方法是为 toMap 方法提供一个合并函数,以及它的键映射器和值映射器。merge 函数是一个 BinaryOperator,其中 V 是映射的值类型。与键关联的任何附加值都将使用 merge 函数与现有值组合,因此,例如,如果 merge 函数是乘法,则最终得到的值是与键关联的所有值的乘积,这些值由值映射程序生成。
  toMap 的三参数形式对于从键到与该键关联的所选元素的映射也很有用。例如,假设我们有一个由不同艺术家录制的唱片流,我们想要一个从唱片艺术家到畅销唱片的映射。这个收集器将完成这项工作。

// Collector to generate a map from key to chosen element for key
Map<Artist, Album> topHits = albums.collect(toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));

  注意,比较器使用静态工厂方法 maxBy,该方法是从 BinaryOperator 静态导入的。此方法将比较器转换为 BinaryOperator,该操作符计算指定比较器所隐含的最大值。在这种情况下,比较器是通过比较器构造方法比较返回的,该方法取从 key 中获取字段 Album::sales。这看起来有点复杂,但是代码读起来很好。简单地说,它是这样说的:“将专辑流转换成一张地图,将每个艺人映射到销量最好的专辑。”这与问题陈述惊人地接近。
  toMap 的三参数形式的另一种用法是产生一个收集器,当发生冲突时,该收集器强制实施最后写赢策略。对于许多流,结果将是不确定的,但如果映射函数可能与键关联的所有值都是相同的,或者如果它们都是可接受的,则此收集器的s行为可能正是您想要的:

// Collector to impose last-write-wins policy
toMap(keyMapper, valueMapper, (v1, v2) -> v2)

  toMap的第三个也是最后一个版本采用了第四个参数,这是一个映射工厂,当您希望指定特定的映射实现(如 EnumMap 或 TreeMap)时,可以使用它。
  还有前三个版本的 toMap 的变体形式,称为 toConcurrentMap,它们可以有效地并行运行并生成 ConcurrentHashMap 实例。
  除了toMap方法之外,collector API 还提供 groupingBy 方法,该方法返回收集器,以生成根据分类器函数将元素分组到类别中的映射。分类器函数接受一个元素并返回它所属的类别。这个类别充当元素的映射键。最简单的 groupingBy 方法只接受一个分类器并返回一个 map,其值是每个类别中所有元素的列表。这是我们在 item 45 的字谜程序中使用的收集器,用于生成从按字母顺序排列的单词到共享字母顺序的单词列表的映射:
words.collect(groupingBy(word -> alphabetize(word)))
  如果希望 groupingBy 返回一个使用列表之外的值生成映射的收集器,则可以指定一个下游收集器和一个分类器。下游收集器从包含类别中的所有元素的流中生成一个值。这个参数最简单的用法是传递 toSet(),它会生成一个 map,其值是元素集,而不是列表。
  或者,您可以使用 toCollection(collectionFactory),它允许您创建集合,将每个类别的元素放入其中。这使您可以灵活地选择所需的任何集合类型。groupingBy 的两参数形式的另一个简单用法是将 count() 作为下游收集器传递。这将产生一个映射,该映射将每个类别与类别中的元素数量关联起来,而不是一个包含元素的集合。这是你看到的频率表的例子在这一项的开始:
Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));
  groupingBy 的第三个版本允许您指定一个映射工厂和一个下游收集器。注意,这个方法违反了标准的伸缩参数列表模式: mapFactory 参数在下游参数之前,而不是在其之后。groupingBy 的这个版本让您可以控制包含的映射和包含的集合,因此,例如,您可以指定一个收集器,它返回一个树形图,其值是树形集。
  groupingByConcurrent 方法提供了 groupingBy 的所有三种重载的变体。这些变体可以有效地并行运行,并生成 ConcurrentHashMap 实例。还有一个与 groupingBy 关系不大的方法,叫做partitioningBy。它使用一个谓词并返回一个键值为布尔值的映射来代替分类器方法。此方法有两个重载,其中一个除了谓词外还接受下游收集器。
  通过计数方法返回的收集器仅用于作为下游收集器。相同的功能可以通过 count 方法直接在流中使用,因此没有理由说 collect(counting())。还有15个具有此属性的收集器方法。它们包括以求和、平均和汇总(其功能在相应的原始流类型中可用)开头的9个方法。它们还包括还原方法的所有重载,以及过滤、映射、平面映射和收集方法。
  大多数程序员可以安全地忽略这些方法中的大多数。从设计的角度来看,这些收集器代表了在收集器中部分复制流功能的尝试,以便下游收集器可以充当“迷你流”。
  有三种收集器方法我们还没有提到。虽然它们是在收集器中,但它们不涉及收集。前两个是minBy 和 maxBy,它们接受一个比较器并返回由比较器确定的流中的最小或最大元素。它们是流接口中最小和最大方法的次要泛化,是 BinaryOperator 中同名方法返回的二进制操作符的收集器类似物。回想一下,我们使用了 BinaryOperator.maxBy在我们畅销专辑的例子中。
  最后的收集器方法是 join,它只对 CharSequence 实例流(如字符串)进行操作。在它的无参数形式中,它返回一个简单连接元素的收集器。它的一个参数形式接受一个名为 delimiter 的CharSequence 参数,并返回一个连接流元素的收集器,在相邻元素之间插入分隔符。如果传递一个逗号作为分隔符,收集器将返回一个逗号分隔的值字符串(但是要注意,如果流中的任何元素包含逗号,该字符串将是不明确的)。除了分隔符外,这三种参数形式还带有前缀和后缀。生成的收集器生成的字符串与打印集合时得到的字符串类似,例如[came, saw, conquered]。
  总之,流管道编程的本质是无副作用的函数对象。这适用于传递给流和相关对象的所有函数对象。终端操作 forEach 只能用于报告由流执行的计算结果,而不能用于执行计算。为了正确地使用流,您必须了解收集器。最重要的回收工厂有 toList, toSet, toMap, groupingBy 和 joining。

相关文章

网友评论

      本文标题:ITEM 46: 在流中使用无副作用的方法

      本文链接:https://www.haomeiwen.com/subject/lcxnvctx.html