46. 优先考虑流中无副作用的函数

如果你是一个刚开始使用流的新手,那么很难掌握它们。仅仅将计算表示为流管道是很困难的。当你成功时,你的程序将运行,但对你来说可能没有意识到任何好处。流不仅仅是一个 API,它是基于函数式编程的范式(paradigm)。为了获得流提供的可表达性、速度和某些情况下的并行性,你必须采用范式和 API。

流范式中最重要的部分是将计算结构化为一系列转换,其中每个阶段的结果尽可能接近前一阶段结果的纯函数(pure function)。 纯函数的结果仅取决于其输入:它不依赖于任何可变状态,也不更新任何状态。 为了实现这一点,你传递给流操作的任何函数对象(中间操作和终结操作)都应该没有副作用。

有时,可能会看到类似于此代码片段的流代码,该代码构建了文本文件中单词的频率表:

// 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 和方法引用,并得到正确的答案。 简而言之,它根本不是流代码; 它是伪装成流代码的迭代代码。 它没有从流 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()));
}

此代码段与前一代码相同,但正确使用了流 API。 它更短更清晰。 那么为什么有人会用其他方式写呢? 因为它使用了他们已经熟悉的工具。 Java 程序员知道如何使用 for-each 循环,而 forEach 终结操作是类似的。 但 forEach 操作是终端操作中最不强大的操作之一,也是最不友好的流操作。 它是明确的迭代,因此不适合并行化。 forEach 操作应仅用于报告流计算的结果,而不是用于执行计算。 有时,将 forEach 用于其他目的是有意义的,例如将流计算的结果添加到预先存在的集合中。

改进后的代码使用了收集器(collector),这是使用流必须学习的新概念。Collectors 的 API 令人生畏:它有 39 个方法,其中一些方法有多达 5 个类型参数。好消息是,你可以从这个 API 中获得大部分好处,而不必深入研究它的全部复杂性。对于初学者来说,可以忽略收集器接口,将收集器看作是封装缩减策略(reduction strategy)的不透明对象。在此上下文中,reduction 意味着将流的元素组合为单个对象。 收集器生成的对象通常是一个集合(它代表名称收集器)。

将流的元素收集到真正的集合中的收集器非常简单。有三个这样的收集器:toList()toSet()toCollection(collectionFactory)。它们分别返回集合、列表和程序员指定的集合类型。有了这些知识,我们就可以编写一个流管道从我们的频率表中提取出现频率前 10 个单词的列表。

// 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 方法的类收集器进行限定。静态导入收集器的所有成员是一种惯例和明智的做法,因为它使流管道更易于阅读。

这段代码中唯一比较棘手的部分是我们把 comparing(freq::get).reverse() 传递给 sort 方法。comparing 是一种比较器构造方法(详见第 14 条),它具有一个 key 的提取方法。该函数接受一个单词,而“提取”实际上是一个表查找:绑定方法引用 freq::getfrequency 表中查找单词,并返回单词出现在文件中的次数。最后,我们在比较器上调用 reverse 方法,因此我们将单词从最频繁到最不频繁进行排序。然后,将流限制为 10 个单词并将它们收集到一个列表中就很简单了。

前面的代码片段使用 Scannerstream 方法在 scanner 实例上获取流。这个方法是在 Java 9 中添加的。如果正在使用较早的版本,可以使用类似于条目 47 中 (streamOf(Iterable<E>)) 的适配器将实现了 Iteratorscanner 序转换为流。

那么收集器中的其他 36 种方法呢?它们中的大多数都是用于将流收集到 map 中的,这比将流收集到真正的集合中要复杂得多。每个流元素都与一个键和一个值相关联,多个流元素可以与同一个键相关联。

最简单的映射收集器是 toMap(keyMapper、valueMapper),它接受两个函数,一个将流元素映射到键,另一个映射到值。在条目 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 更复杂的形式,以及 groupingBy 方法,提供了处理此类冲突 (collisions) 的各种方法。一种方法是向 toMap 方法提供除键和值映射器(mappers)之外的 merge 方法。merge 方法是一个 BinaryOperator<V>,其中 V是 map 的值类型。与键关联的任何附加值都使用 merge 方法与现有值相结合,因此,例如,如果 merge 方法是乘法,那么最终得到的结果是是值 mapper 与键关联的所有值的乘积。

toMap 的三个参数形式对于从键到与该键关联的选定元素的映射也很有用。例如,假设我们有一系列不同艺术家(artists)的唱片集(albums),我们想要一张从唱片艺术家到最畅销专辑的 map。这个收集器将完成这项工作。

// 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 静态导入的。 此方法将 Comparator<T> 转换为 BinaryOperator<T>,用于计算指定比较器隐含的最大值。 在这种情况下,比较器由比较器构造方法 comparing 返回,它采用 key 提取器函数 Album::sales。 这可能看起来有点复杂,但代码可读性很好。 简而言之,它说,「将专辑(albums)流转换为地 map,将每位艺术家(artist)映射到销售量最佳的专辑。」这与问题陈述出奇得接近。

toMap 的三个参数形式的另一个用途是产生一个收集器,当发生冲突时强制执行 last-write-wins 策略。 对于许多流,结果是不确定的,但如果映射函数可能与键关联的所有值都相同,或者它们都是可接受的,则此收集器的行为可能正是您想要的:

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

toMap 的第三个也是最后一个版本采用第四个参数,它是一个 map 工厂,用于指定特定的 map 实现,例如 EnumMapTreeMap

toMap 的前三个版本也有变体形式,名为 toConcurrentMap,它们并行高效运行并生成 ConcurrentHashMap 实例。

除了 toMap 方法之外,Collectors API 还提供了 groupingBy 方法,该方法返回收集器以生成基于分类器函数 (classifier function)将元素分组到类别中的 map。 分类器函数接受一个元素并返回它所属的类别。 此类别来用作元素的 map 的键。 groupingBy 方法的最简单版本仅采用分类器并返回一个 map,其值是每个类别中所有元素的列表。 这是我们在条目 45 中的 Anagram 程序中使用的收集器,用于生成从按字母顺序排列的单词到单词列表的 map:

Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));

groupingBy 的第三个版本允许指定除 downstream 收集器之外的 map 工厂。 请注意,这种方法违反了标准的可伸缩参数列表模式 (standard telescoping argument list pattern):mapFactory 参数位于 downStream 参数之前,而不是之后。 此版本的 groupingBy 可以控制包含的 map 以及包含的集合,因此,例如,可以指定一个收集器,它返回一个 TreeMap,其值是 TreeSet

groupingByConcurrent 方法提供了 groupingBy 的所有三个重载的变体。 这些变体并行高效运行并生成 ConcurrentHashMap 实例。 还有一个很少使用的 grouping 的亲戚称为 partitioningBy。 代替分类器方法,它接受 predicate 并返回其键为布尔值的 map。 此方法有两种重载,除了 predicate 之外,其中一种方法还需要 downstream 收集器。

通过 counting 方法返回的收集器仅用作下游收集器。 Stream 上可以通过 count 方法直接使用相同的功能,因此没有理由说 collect(counting())。 此属性还有十五种收集器方法。 它们包括九个方法,其名称以 summingaveragingsummarizing 开头(其功能在相应的原始流类型上可用)。 它们还包括 reduce 方法的所有重载,以及 filter,mappingflatMappingcollectingAndThen 方法。 大多数程序员可以安全地忽略大多数这些方法。 从设计的角度来看,这些收集器代表了尝试在收集器中部分复制流的功能,以便下游收集器可以充当「迷你流(ministreams)」。

我们还有三种收集器方法尚未提及。 虽然他们在收 Collectors 类中,但他们不涉及集合。 前两个是 minBymaxBy,它们取比较器并返回比较器确定的流中的最小或最大元素。 它们是 Stream 接口中 minmax 方法的次要总结,是 BinaryOperator 中类似命名方法返回的二元运算符的类似收集器。 回想一下,我们在最畅销的专辑中使用了 BinaryOperator.maxBy 方法。

最后的 Collectors 中方法是 join,它仅对 CharSequence 实例(如字符串)的流进行操作。 在其无参数形式中,它返回一个简单地连接元素的收集器。 它的一个参数形式采用名为 delimiter 的单个 CharSequence 参数,并返回一个连接流元素的收集器,在相邻元素之间插入分隔符。 如果传入逗号作为分隔符,则收集器将返回逗号分隔值字符串(但请注意,如果流中的任何元素包含逗号,则字符串将不明确)。 除了分隔符之外,三个参数形式还带有前缀和后缀。 生成的收集器会生成类似于打印集合时获得的字符串,例如[came, saw, conquered]

总之,编程流管道的本质是无副作用的函数对象。 这适用于传递给流和相关对象的所有许多函数对象。 终结操作 forEach 仅应用于报告流执行的计算结果,而不是用于执行计算。 为了正确使用流,必须了解收集器。 最重要的收集器工厂是 toListtoSettoMapgroupingByjoin

Effective-Java 优先考虑流中无副作用的函数相关推荐

  1. Effective Java~46. 优先选择Stream 中无副作用的函数

    纯函数(pure function)的结果仅取决于其输入:它不依赖于任何可变状态,也不更新任何状态. 坏味道 // Uses the streams API but not the paradigm- ...

  2. java crud_Java 8流中的数据库CRUD操作

    java crud 在开始使用新工具时要克服的最大障碍是让您着手处理小事情. 到目前为止,您可能对新的Java 8 Stream API的工作方式充满信心,但是您可能尚未将其用于数据库查询. 为了帮助 ...

  3. java内联_JAVA中的内联函数

    在说内联函数之前,先说说函数的调用过程. 调用某个函数实际上将程序执行顺序转移到该函数所存放在内存中某个地址,将函数的程序内容执行完后,再返回到 转去执行该函数前的地方.这种转移操作要求在转去前要保护 ...

  4. java split函数的用法,java拆分字符串_java中split拆分字符串函数用法

    摘要 腾兴网为您分享:java中split拆分字符串函数用法,中信期货,掌上电力,星球联盟,淘集集等软件知识,以及韩剧精灵,每日英语听力vip,龙卷风收音机,优衣库,中国平煤神马集团协同办公系统,光晕 ...

  5. java strtotime_js模仿php中strtotime()与date()函数实现方法

    本文实例讲述了js模仿php中strtotime()与date()函数实现方法.分享给大家供大家参考.具体如下: 在js中没有像php中strtotime()与date()函数,可直接转换时间戳,下面 ...

  6. 黑马程序员-JAVA基础-IO流中的装饰设计模式

    ------- android培训.java培训.期待与您交流!------- 装饰设计模式: 当想要对已有的对象进行功能增强时,可以定义类,将已有的对象传入,基于已有的功能,并提供加强功能.那么,自 ...

  7. java嵌套对象,java – 从嵌套流中收集一组对象

    我有一个场景,我有两个for循环,一个嵌套在另一个.在内部循环中,对于每次迭代,我都有创建特定类型的新实例所需的信息.我想将代码从for循环更改为使用流,因此我可以将所有对象收集到ImmutableS ...

  8. java名词解释输入流_下列Java的IO流中,哪一个是输入流( )。

    [单选题]调整画布大小的快捷键是( ). [填空题]Room number: ______________ [单选题]秦简中以问答形式对秦律某些条文.术语及律文意图作解释的是( ). [多选题]秦朝的 ...

  9. [Java基础]字符流中的编码解码问题

最新文章

  1. 提权巧用RAR.EXE
  2. chrome浏览器不能录音:Uncaught TypeError: Cannot read property ‘getUserMedia‘ of undefined解决方法
  3. powerdesigner 新建按钮是灰色的
  4. [翻译]IE8下VML的变化
  5. 达内python第二阶段月考_oracle练习题 达内第二次月考题
  6. Python中Queue.get()方法阻塞,怎么办?
  7. TIA博途中如何设置不需要初始化DB块也可以下载?
  8. “绿坝—花季护航”使用全攻略
  9. 泛微 - eteams
  10. 人工智能产品经理是否需要懂技术
  11. 数据分析之描述性统计分析
  12. Flutter与原生混合开发
  13. 数据库表关系详解(一对多、一对一、多对多)
  14. 【Books系列】2022年:《拼职场》读书笔记
  15. JVM学习笔记(12) 垃圾回收-垃圾回收相关算法
  16. ccf201503-1 ccf 图像旋转-内存限制问题
  17. 每日一结(11.1)
  18. 在vue中将数据导出为excel文件file-saver+xlsx+script-loader
  19. 4.2 人工智能产业岗位分布
  20. 对接支付宝网站支付接口

热门文章

  1. 微信小程序使用 async , await
  2. JavaOne 2016——观众得以一睹JShell的威力
  3. java monogodb 图片 pdf 下载添加单个水印 铺满水印
  4. PP实施经验分享(13)——SAP中BOM查询技巧CS11/CS12/CS13/CS14/CS15/CSMB
  5. 《软件工程-理论、方法与实践》读书笔记二
  6. Chrome浏览器如何使用socket5代理?
  7. 西门子伺服分拣机西门子S7-1200 PLC程序,有自己录4平详细讲解项目程序
  8. Web前端学习第一周
  9. LVGL hal indev(porting evdev)
  10. STATA横向数据匹配