47. 优先使用 Collection 而不是 Stream 来作为方法的返回类型
许多方法返回元素序列(sequence)。在 Java 8 之前,通常方法的返回类型是 Collection
,Set
和 List
这些接口;还包括 Iterable
和数组类型。通常,很容易决定返回哪一种类型。规范(norm)是返回 Collection
接口。如果该方法仅用于启用 for-each 循环,或者返回的序列不能实现某些 Collection
方法 (通常是 contains(Object)
),则使用迭代(Iterable
)接口。如果返回的元素是基本类型或有严格的性能要求,则使用数组。在 Java 8 中,将流(Stream)添加到平台中,这使得为序列返回方法选择适当的返回类型的任务变得非常复杂。
你可能听说过,流现在是返回元素序列的明显的选择,但是正如第 45 条所讨论的,流不会使迭代过时:编写好的代码需要明智地将流和迭代结合起来。如果一个 API 只返回一个流,并且一些用户想用 for-each 循环遍历返回的序列,那么这些用户肯定会感到不安。尤其令人沮丧的是, Stream
接口有一个和 Iterable
接口中一样的抽象方法,并且 Stream
的方法规范与 Iterable
中的一致。阻止程序员使用 for-each 循环在流上迭代的唯一原因是 Stream
无法继承 Iterable
。
遗憾的是,这个问题没有好的解决方法。 乍一看,似乎可以将方法引用传递给 Stream
的 iterator 方法。 结果代码可能有些乱,但并非不合理:
// Won't compile, due to limitations on Java's type inference
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
// Process the process
}
不幸的是,如果你试图编译这段代码,会得到一个错误信息:
Test.java:6: error: method reference not expected here
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
为了使代码编译,必须将方法引用强制转换为对应参数的 Iterable
类型:
// Hideous workaround to iterate over a stream
for (ProcessHandle ph : (Iterable<ProcessHandle>)ProcessHandle.allProcesses()::iterator)
此代码可以工作,但在实践中使用它太乱。 更好的解决方法是使用适配器方法。 JDK 没有提供这样的方法,但是使用上面的代码片段中的相同技术,很容易编写一个方法。 请注意,在适配器方法中不需要强制转换,因为 Java 的类型推断在此上下文中能够正常工作:
// Adapter from Stream<E> to Iterable<E>
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
通过这个适配器,你可以使用 for-each 语句迭代任何流:
for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
// Process the process
}
注意,第 34 条中的 Anagrams
程序的流版本使用 Files.lines
方法读取字典,而迭代版本使用了 scanner。Files.lines
方法优于 scanner,scanner 在读取文件时无声地吞噬所有异常。理想情况下,我们也会在迭代版本中使用 Files.lines
。如果 API 只提供对序列的流访问,而程序员希望使用 for-each 语句遍历序列,那么他们就要做出这种妥协。
相反,如果一个程序员想要使用流管道来处理一个序列,那么一个只提供 Iterable
的 API 会让他感到不安。JDK 同样没有提供适配器,但是编写这个适配器非常简单:
// Adapter from Iterable<E> to Stream<E>
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
如果你正在编写一个返回对象序列的方法,并且它只会在流管道中使用,那么当然可以自由地返回流。类似地,返回仅用于迭代的序列的方法应该返回一个 Iterable
。但是如果你写一个公共 API,它返回一个序列,你应该为用户提供哪些想写流管道,哪些想写 for-each 语句,除非你有充分的理由相信大多数用户想要使用相同的机制。
Collection
接口是 Iterable
的子类型,并且具有 stream
方法,因此它可以同时提供迭代和流访问的能力。 因此,Collection
或适当的子类型通常是公共序列返回方法的最佳返回类型。 数组也使用 Arrays.asList
和 Stream.of
方法提供简单的迭代和流访问能力。 如果返回的序列小到足以容易地放入内存中,那么最好返回一个标准集合实现,例如 ArrayList
或 HashSet
。 但是不要在只是为了将它作为集合返回,而在内存中存储很大的序列。
如果你需要返回一很大但可以简洁地表示的序列,请考虑实现一个专用集合。 例如,假设返回给定集合的幂集(power set:就是原集合中所有的子集(包括全集和空集)构成的集族),该集包含其所有子集。 {a,b,c} 的幂集为 {{},{a},{b},{c},{a,b},{a,c},{b,c},{a,b, c}}。 如果一个集合具有 n 个元素,则幂集具有 2n 个。 因此,你甚至不应考虑将幂集存储在标准集合实现中。 但是,在 AbstractList
的帮助下,很容易为此实现自定义集合。
诀窍是使用幂集中每个元素的索引作为位向量(bit vector),其中索引中的第 n 位指示源集合中是否存在第 n 个元素。 本质上,从 0 到 2n-1 的二进制数和 n 个元素集和的幂集之间存在自然映射。 这是代码:
// Returns the power set of an input set as custom collection
public class PowerSet {
public static final <E> Collection<Set<E>> of(Set<E> s) {
List<E> src = new ArrayList<>(s);
if (src.size() > 30)
throw new IllegalArgumentException("Set too big " + s);
return new AbstractList<Set<E>>() {
@Override
public int size() {
return 1 << src.size(); // 2 to the power srcSize
}
@Override
public boolean contains(Object o) {
return o instanceof Set && src.containsAll((Set)o);
}
@Override
public Set<E> get(int index) {
Set<E> result = new HashSet<>();
for (int i = 0; index != 0; i++, index >>= 1)
if ((index & 1) == 1)
result.add(src.get(i));
return result;
}
};
}
}
请注意,如果输入集合超过 30 个元素,则 PowerSet.of
方法会引发异常。 这突出了使用 Collection
作为返回类型而不是 Stream
或 Iterable
的缺点:Collection
有 int 返回类型的 size 的方法,该方法将返回序列的长度限制为 Integer.MAX_VALUE
或 231-1。Collection
规范允许 size 方法返回 231 - 1,如果集合更大,甚至无限,但这不是一个完全令人满意的解决方案。
为了在 AbstractCollection
上编写 Collection
实现,除了 Iterable
所需的方法之外,只需要实现两种方法:contains
和 size
。 通常,编写这些方法的有效实现很容易。 如果不可行,可能是因为在迭代发生之前未预先确定序列的内容,返回 Stream
还是 Iterable
的,无论哪种感觉更自然。 如果选择,可以使用两种不同的方法分别返回。
有时,你会仅根据实现的易用性选择返回类型。例如,假设希望编写一个方法,该方法返回输入列表的所有 (连续的) 子列表。生成这些子列表并将它们放到标准集合中只需要三行代码,但是保存这个集合所需的内存是源列表大小的二次方。虽然这没有指数幂集那么糟糕,但显然是不可接受的。实现自定义集合 (就像我们对幂集所做的那样) 会很乏味,因为 JDK 缺少一个框架 Iterator
实现来帮助我们。
然而,实现输入列表的所有子列表的流是直截了当的,尽管它确实需要一点的洞察力(insight)。 让我们调用一个子列表,该子列表包含列表的第一个元素和列表的前缀。 例如,(a,b,c)的前缀是(a),(a,b)和(a,b,c)。 类似地,让我们调用包含后缀的最后一个元素的子列表,因此(a,b,c)的后缀是(a,b,c),(b,c)和(c)。 洞察力是列表的子列表只是前缀的后缀(或相同的后缀的前缀)和空列表。 这一观察直接展现了一个清晰,合理简洁的实现:
// Returns a stream of all the sublists of its input list
public class SubLists {
public static <E> Stream<List<E>> of(List<E> list) {
return Stream.concat(Stream.of(Collections.emptyList()),
prefixes(list).flatMap(SubLists::suffixes));
}
private static <E> Stream<List<E>> prefixes(List<E> list) {
return IntStream.rangeClosed(1, list.size())
.mapToObj(end -> list.subList(0, end));
}
private static <E> Stream<List<E>> suffixes(List<E> list) {
return IntStream.range(0, list.size())
.mapToObj(start -> list.subList(start, list.size()));
}
}
请注意,Stream.concat
方法用于将空列表添加到返回的流中。 还有,flatMap
方法(条目 45)用于生成由所有前缀的所有后缀组成的单个流。 最后,通过映射 IntStream.range
和 IntStream.rangeClosed
返回的连续 int 值流来生成前缀和后缀。这个习惯用法,粗略地说,流等价于整数索引上的标准 for 循环。因此,我们的子列表实现似于明显的嵌套 for 循环:
for (int start = 0; start < src.size(); start++) for (int end = start + 1; end <= src.size(); end++) System.out.println(src.subList(start, end));
可以将这个 for 循环直接转换为流。结果比我们以前的实现更简洁,但可能可读性稍差。它类似于条目 45 中的笛卡尔积的使用流的代码:
// Returns a stream of all the sublists of its input listpublic static <E> Stream<List<E>> of(List<E> list) { return IntStream.range(0, list.size()) .mapToObj(start -> IntStream.rangeClosed(start + 1, list.size()) .mapToObj(end -> list.subList(start, end))) .flatMap(x -> x);}
与之前的 for 循环一样,此代码不会包换空列表。 为了解决这个问题,可以使用 concat
方法,就像我们在之前版本中所做的那样,或者在 rangeClosed
调用中用 (int) Math.signum(start)
替换 1。
这两种子列表的流实现都可以,但都需要一些用户使用流-迭代适配器 ( Stream-to-Iterable adapte),或者在更自然的地方使用流。流-迭代适配器不仅打乱了客户端代码,而且在我的机器上使循环速度降低了 2.3 倍。一个专门构建的 Collection 实现 (此处未显示) 要冗长,但运行速度大约是我的机器上基于流的实现的 1.4 倍。
总之,在编写返回元素序列的方法时,请记住,某些用户可能希望将元素序列作为流处理,而其他用户可能希望迭代方式来处理。 尽量满足两个群体。 如果返回集合是可行的,请执行此操作。 如果已经拥有集合中的元素,或者序列中的元素数量足够小到可以创造一个新的序列,那么返回一个标准集合,比如 ArrayList
。 否则,请考虑实现自定义集合,就像我们为幂集程序里所做的那样。 如果返回集合是不可行的,则返回流或可迭代的,无论哪个看起来更自然。 如果在将来的 Java 版本中,Stream
接口声明被修改为继承 Iterable
,那么你就应该返回 Stream
,因为它可以同时被流和迭代处理。
No Comments