许多方法返回元素序列。在Java 8之前,这些方法的明显返回类型是collection接口collection、Set和List;Iterable;以及数组类型。通常,很容易决定返回哪一种类型。规范是一个集合接口。如果方法的存在只是为了启用for-each循环,或者无法使返回的序列实现某些集合方法(通常是contains(Object)),则使用迭代接口。如果返回的元素是原始值或有严格的性能要求,则使用数组。在Java 8中,流被添加到平台中,这使得为序列返回方法选择合适的返回类型的任务变得非常复杂。
您可能听说过,流现在是返回元素序列的最佳选择,但是正如第45项中所讨论的,流不会使迭代过时:编写好的代码需要明智地组合流和迭代。如果一个API只返回一个流,而一些用户希望用for-each循环遍历返回的序列,那么这些用户将会有理由感到不安。尤其令人沮丧的是,Stream接口在Iterable接口中包含唯一的抽象方法,而且Stream对该方法的规范与Iterable兼容。阻止程序员使用for-each循环在流上迭代的惟一原因是流没有扩展Iterable。
遗憾的是,对于这个问题没有好的解决办法。乍一看,将方法引用传递给Stream的迭代器方法似乎是可行的。生成的代码可能有点嘈杂和不透明,但并非不合理:
不幸的是,如果你试图编译这段代码,你会得到一个错误信息:
为了使代码编译,您必须将方法引用转换为适当的参数化迭代:
这个客户机代码可以工作,但是它太吵,而且不透明,不能在实践中使用。更好的解决方法是使用适配器方法。JDK没有提供这样的方法,但是使用上面代码片段中使用的内联技术很容易编写这样的方法。注意,适配器方法中不需要强制转换,因为Java的类型推断在这种上下文中正常工作:
使用这个适配器,您可以使用for-each语句遍历任何流:
注意,第34项中的字谜程序的流版本使用了这些Files.lines 方法读取字典,而迭代版使用scanner。这些 Files.lines 方法优于scanner,scanner可以在读取文件时无声地处理遇到的任何异常。我们会使用 Files.lines 版本中的行也是如此。如果一个API只提供对序列的流访问,并且希望用for-each语句遍历序列,那么程序员就会做出这种妥协。
相反,想要使用流管道处理序列的程序员会被只提供可迭代的API打乱,这是可以理解的。同样,JDK没有提供适配器,但是很容易编写适配器:
如果您正在编写一个返回对象序列的方法,并且您知道它只会在流管道中使用,那么您当然应该可以随意返回流。类似地,返回只用于迭代的序列的方法应该返回一个Iterable.但是如果你写一个公共API,它返回一个序列,你应该提供用户想写流管道以及那些想写for - each语句,除非你有充分的理由相信大多数用户想要使用相同的机制。
Collection 接口是Iterable的子类型,并且有一个流方法,因此它同时提供了迭代和流访问。所以, Collection 或适当的子类型通常是公共的返回序列方法的最佳返回类型。数组还提供了简单的迭代和流访问数组还提供了简单的迭代和流访问Arrays.asList和 Stream.of的方法。如果返回的序列足够小,可以轻松地放入内存,那么最好返回标准集合实现之一,比如ArrayList或HashSet。但是不要将一个大序列存储在内存中,只是将它作为一个集合返回。
如果返回的序列很大,但是可以用简洁的方式表示,那么可以考虑实现一个特殊用途的集合。例如,假设您想返回一个给定集合的幂集,它由它的所有子集组成。幂集为{a, b, c} {{}, {}, {b}, {c}, {a、b}, {a, c}, {b, c}, {a, b, c}}。如果一个集合有n个元素,它的幂集就是2^n。因此,您甚至不应该考虑将power集存储在标准集合实现中。但是,在AbstractList的帮助下,很容易实现作业的自定义集合。
技巧是使用幂集中每个元素的下标作为位向量,其中索引中的第n位表示源集中第n个元素的存在或不存在。本质上,0到2n - 1的二进制数与n元素集的幂集之间存在一种自然的映射关系:
注意,PowerSet,如果输入集有30多个元素,则会引发异常。这突出了使用Collection作为返回类型而不是Stream或Iterable的缺点:Collection有一个int-return size方法,它将返回序列的长度限制为Integer.MAX_VALUE,或者2^31 - 1。集合规范允许size方法返回2^31 - 1(如果集合更大,甚至是无穷大),但这不是一个完全令人满意的解决方案。
为了在AbstractCollection之上编写集合实现,除了Iterable所需的方法之外,您只需要实现两个方法:contains和size。通常很容易编写这些方法的有效实现。如果这是不可行的,可能是因为序列的内容在迭代发生之前没有预先确定,那么返回一个流或iterable,无论哪个更自然。如果选择,可以使用两个单独的方法返回。
有时候,您只会根据实现的简单程度来选择返回类型。例如,假设您想编写一个方法来返回输入列表的所有(连续的)子列表。只需要三行代码就可以生成这些子列表并将它们放入一个标准集合中,但是保存这个集合所需的内存是源列表大小的两倍。虽然这没有幂集那么糟糕,幂集是指数的,但显然是不可接受的。实现自定义集合(就像我们为power集所做的那样)将会非常单调乏味,因为JDK缺少一个骨架迭代器实现来帮助我们。
然而,实现一个输入列表的所有子列表的流是很简单的,尽管它需要一个小的洞察力。让我们将包含列表第一个元素的子列表称为列表的前缀。例如,(a, b, c)的前缀是(a), (a, b)和(a, b, c)。类似地,我们调用包含最后一个元素a后缀的子列表,所以(a, b, c)的后缀是(a, b, c) (b, c)和(c).我们的理解是,列表的子列表仅仅是前缀的后缀(或后缀的前缀相同)和空列表。这一观察直接导致了一个清晰、合理简洁的实现:
注意Stream.concat 方法用于将空列表添加到返回的流中。还要注意,flatMap方法(第45项)用于生成一个由所有前缀的所有后缀组成的流。最后,注意,我们通过映射IntStream返回的连续int值流来生成前缀和后缀 IntStream.range 和IntStream.rangeClosed。这个习惯用法大致相当于整数索引上的标准for循环。因此,我们的子列表实现在本质上类似于明显的嵌套for循环:
可以将这个for循环直接转换为流。结果比我们之前的实现更简洁,但可读性可能略差。它在本质上类似于项目45中笛卡尔积的streams代码:
与前面的for循环一样,这段代码不会发出空列表。为了修复这个缺陷,您可以像在前一个版本中那样使用concat,或者在rangeClosed调用中使用(int) Math.signum(start)替换1
子列表的这两种流实现都很好,但是都需要一些用户使用流到迭代的适配器,或者在迭代更自然的地方使用流。流到迭代适配器不仅使客户机代码变得混乱,而且还将我的机器上的循环速度降低了2.3倍。专门构建的集合实现(这里没有显示)要详细得多,但是运行速度大约是基于流的实现在我的机器上运行速度的1.4倍。
总之,在编写返回元素序列的方法时,请记住,您的一些用户可能希望将它们作为一个流来处理,而其他用户可能希望对它们进行迭代。尽量容纳两组人。如果返回集合是可行的,那么就这样做。如果集合中已经有了元素,或者序列中的元素数量足够小,可以创建一个新的元素,那么返回一个标准集合,比如ArrayList。否则,请考虑像我们为power set所做的那样实现自定义集合。如果无法返回集合、返回流或iterable,则选择看起来更自然的方法。如果在将来的Java版本中,流接口声明被修改为扩展Iterable,那么您应该可以随意返回流,因为它们同时支持流处理和迭代。
本文写于2019.7.15,历时1天