许多方法都返回元素的序列。在Java8之前,这类方法明显的返回类型是集合接口Collection、Set和List;Iterable;以及数组类型。一般来说,很容易确定要返回这其中哪一种类型。标准是一个集合接口。如果某个方法只为for-each循环或者返回序列而存在,无法用它来实现一些Collection方法(一般是contains(Objetc)),那么就用Iterable接口吧。如果返回的元素是基本类型值,或者有严格的性能要求,就是用数组。在Java8中增加了Strema,本质上导致给序列化返回的方法选择适当返回类型的任务变得更复杂了。
或许你曾听说过,现在Stream是返回元素序列最明显的选择了,但如第45条所述,Stream并没有淘汰迭代:要编写出优秀的代码必须巧妙地将Stream与迭代结合起来使用。如果一个API只返回一个Stream,那么想要用for-each循环遍历返回序列地用户肯定会失望了。因为Stream接口只在Iterable接口中包含了唯一一个抽象方法,Stream对于该方法地规范也适用于Iterable的。唯一可以让程序员避免用for-each循环遍历Stream的是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语句遍历任何Stream:
for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
// Process the process
}
注意,第34条中Anagrams程序的Stream版本是使用Files.lines方法读取词典,而迭代版本则使用了扫描器(scanner)。Files.lines方法优于扫描器,因为后者默默的吞掉了在读取文件过程中遇到的所有异常。最理想的方式是在迭代版本中也使用Files.lines。这是程序员在特定情况下所做的一种妥协,比如当API只有Stream能访问序列,而他们想通过for-each语句遍历该序列的时候。
反过来说,想要利用Stream pipeline处理序列的程序员,也会被只提供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);
}
如果在编写一个返回对象序列的方法时,就知道它只在Stream pipeline中使用,当然就可以放心的返回Stream了。同样的,当返回序列的方法只在迭代中使用时,则应该返回Iterable。但如果是用公共的API返回序列,则应该为那些想要编写Stream pipeline,以及想要编写for-each语句的用户分别提供,除非有足够的理由相信大多数用户都想要使用相同的机制。
Collection接口时Iterable的一个子类型,它有一个stream方法,因此提供了迭代和stream访问。对于公共的、返回序列的方法,Collection或者适当的子类型通常是最佳的返回类型
。数组也通过Arrays.asList和Stream.of方法提供了简单的迭代和stream访问。如果返回的序列足够小,容易存储,或许最好返回标准的集合实现,如ArrayList或者HashSet。但是千万别在内存中保存巨大的序列,将它作为集合返回即可
。
如果返回的序列很大,但是能被准确表述,可以考虑实现一个专用的集合。假设想要返回一个指定集合的幂集,其中包括所有的子集。{a, b, c}的幂集是:{{}, {a}, {b}, {c}, {a、b}, {a, c}, {b, c}, {a, b, c}}。如果集合中有n个元素,它的幂集就有2n个。因此,不必考虑将幂集保存在标准的集合实现中。但是,有了AbstracList的协助,为此实现定制集合就很容易了。
技巧在于,用幂集中每个元素的索引作为位向量,在索引中排第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 抛出异常。这突出了使用集合作为返回类型而不是流或 Iterable 的缺点: 集合有一个 int 返回大小方法,它将返回序列的长度限制为整数。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, 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方法(item 45)用于生成由所有前缀的所有后缀组成的单一流。最后,请注意,我们通过映射 IntStream 返回的连续 int 值流来生成前缀和后缀。范围和 IntStream.rangeClosed。粗略地说,这个习惯用法相当于整数索引上的标准 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循环一样,这段代码也没有发出空列表。为了修正这个错误,也应该使用concat,如前一个版本中那样,或者用rangeClosed调用中的(int)Math.signum(start)代替1。
子列表的这些Stream实现都很好,但这两者都需要用户在任何更适合跌打的地方,采用Stream-to-Iterable适配器,或者用Stream。Stream-to-Iterable适配器不仅打乱了客户端代码,在我的机器上循环的速度还降低了2.3倍。专门构建的Collection实现(此处没有展示)相当烦琐,但是运行速度在我的机器上比基于Stream的实现快了1.4倍。
总而言之,在编写返回一系列元素方法时,要记住有些用户可能想要当作Stream处理,而其他用户可能想要使用迭代。要尽量两边兼顾。如果可以返回集合,就返回集合。如果集合中已经有元素,或者序列中的元素数量很少,足以创建一个新的集合,那么就返回一个标准的集合,如ArrayList。否则就要考虑实现一个定制的集合。如幂集范例中所示。如果无法返回集合,就返回Stream或者Iterable,感觉哪一种更自然即可。如果在未来的Java发行版本中,Stream接口声明被修改成扩展了Iterable接口,就可以放心的返回Stream了,因为他们允许进行Stream处理和迭代。