在Java8中增加了Stream API,简化了串行或并发的大批量操作。这个API提供了两个关键抽象:Stream(流)代表数据元素有限或无限的顺序,Stream pipeline(流管道)则代表这些元素的一个多级计算。Stream中的元素可能来自任何位置。常见的来源包括集合、数组、文件、正则表达式模式匹配器、伪随机生成器,以及其他Stream。Stream中的数据元素可以是对象引用,或者基本类型值。它支持三种基本类型:int、long和double。
一个Stream pipeline中包含一个源Stream,接着是0个或者多个中间操作和一个终止操作。每个中间操作都会通过某种方式对Stream进行转换,例如将每个元素映射到该元素的函数,或者过滤掉不满足某些条件的所有元素。所有的中间操作都是将一个Stream转换成另一个Stream,其元素类型可能与输入的Stream一样,也可能不同。终止操作会在最后一个中间操作产生的Stream上执行一个最终的计算,例如将其元素保持到一个集合中,并返回某个一元素,或者打印出所有元素等。
Stream pipeline通常是lazy的:直到调用终止操作时才会开始计算,对于完成终止操作不需要的数据元素,将永远不会被计算。这是这种lazy计算,时无限Stream成为可能。注意,没有终止操作的Stream pipline将是一个静默的无操作指令,因此千万不能忘记终止操作。
Stream API 是流式的:所有包含pipline的调用可以链接成一个表达式。事实上,多个pipline也可以链接在一起,成为一个表达式。
在默认情况下,Stream pipline事按顺序运行的。要使pipline并发运行,只需在该pipline的任何Stream上调用parallel方法即可,但是通常不建议这么做(详见第48条)。
Stream API包罗万象,足以用Stream执行任何计算,但是“可以”并不意味着“应该”。如果使用得当,Stream可以使程序变得更加简洁、清晰;如果使用不当,会使程序变得混乱难以维护。对于什么时候应该使用Stream,并没有硬性的规定,但是可以有所启发。
以下面的程序为例,它的作用是从词典文件中读取单词,并打印出单词长度符合用户指定的最低值的所有换位词。记住,包含相同的字母,但是顺序不同的两个词,称作换位词。该程序会从用户指定的词典文件中读取每一个词,并将符格条件的单词放入一个映射,这个映射是按字母顺序排列的单词,因此”straple“的键是”aelpst“,”petals“的键也是”aelpst“:这两个词就是换位词,所有换位词的字母排列形式是一样的(有时候也叫alphagram)。映射值是包含了字母排列形式一致的所有单词。词典读取完成之后,每一个列表就是一个完整的换位词组。随后,程序会遍历映射的values(),预览并打印出单词长度符合极限值的所有列表。
// Prints all large anagram groups in a dictionary iteratively
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner s = new Scanner(dictionary)) {
while (s.hasNext()) {
String word = s.next();
groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word);
}
}
for (Set<String> group : groups.values())
if (group.size() >= minGroupSize)
System.out.println(group.size() + ": " + group);
}
private static String alphabetize(String s) {
char[] a = s.toCharArray(); Arrays.sort(a);
return new String(a);
}
}
这个程序中有一个步骤值得注意。被插入到映射中的每一个单词都以粗体显式,这是使用了Java8中新增的computeIfAbsent方法。这个方法会在映射中查找一个键:如果这个键存在,该方法只会返回与之关联的值。如果键不存在,该方法就会对该键运用指定的函数对象算出一个值,将这个值与键关联起来,并返回计算得到的值。computeIfAbsent方法简化了将多个值与每个键关联起来的映射实现。
下面举个例子,它也能解决上述问题,只不过大量使用了Stream。注意,它的所有程序都是包含在一个表达式中,除了打开词典文件的那部分代码之外。之所以要在另一个表达式中打开词典文件,只是为了使用try-with-resources语句,它可以确保关闭词典文件:
// Overuse of streams - don't do this!
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(groupingBy(word -> word.chars().sorted()
.collect(StringBuilder::new,(sb, c) -> sb.append((char) c), StringBuilder::append).toString()))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
}
如果你发现这段代码好难懂,别担心,你并不是唯一有此想法的人。他虽然简单,但是难以读懂,对于那些使用Stream还不熟练的程序员而言更是如此。滥用Stream会使程序代码更加难以读懂和维护
。
好在还有一种舒适的中间方案。下面的程序解决了同样的问题,它使用了Stream,但是没有过渡使用。结构,与原来的程序相比,这个版本变得即简短又清晰:
// Tasteful use of streams enhances clarity and conciseness
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(groupingBy(word -> alphabetize(word)))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(g -> System.out.println(g.size() + ": " + g));
}
}
// alphabetize method is the same as in original version
}
即使你之前没怎么接触过Stream,这段程序也不难理解。它在try-with-resources块中打开词典文件,获得一个包含了文件中所有代码的Stream。Stream变量名为words,是建议Stream中的每个元素均为单词。这个Stream中的pipline没有中间操作;它的终止操作将所有的单词集合到一个映射中,按照它们的字母排序形式对单词进行分组(详见第46条)。这个映射与前面两个版本中的完全相同。随后,在映射的values()试图中打开了一个新的Stream<List<String>>。当然,这个Stream中的元素都是换位词分组。Stream进行了过滤,把所有单词长度小于minGroupSize的单词都去掉了,最后,通过终止操作的forEach打印出剩下的分组。
注意,Lambda参数的名称都是经过精心挑选的。实际上参数应当以group命名,只是这样得到的代码行对于本书而言太宽了。在没有显式类型的情况下,仔细命名Lambda参数,这对于Stream pipeline的可读性至关重要
。
还要注意单词的字母排序是在一个单独的alphabetize方法中完成的。给操作命名,并且不要在主程序中保留实现细节,这都是增强了程序的可读性。在Stream pipeline中使用helper方法,对于可读性而言,比在迭代化代码中使用更加重要
,因为pipeline缺乏显式的类型信息和命名临时变量。
可以重新实现alphabetize方法使用Stream,只是基于Stream的alphabetize方法没那么清晰,难以正确编写,速度也可能变慢。这些不足是因为Java不支持基本类型的char Stream(这并不意味着Java应该支持char Stream;也不可能支持)。为了证明用Stream处理char值的各种危险,请看以下代码:
"Hello world!".chars().forEach(System.out::print);
或许你以为它会输出Hello world!,但是运行之后发现,它输出的是721011081081113211911111410810033。这是因为”Hello world!“.chars()返回Stream中的元素,并不是char值,而是int值,因此调用了print的int覆盖。名为chars的方法,却返回int值的Stream,这固然会造成困惑。修正方法是利用转换强制调用正确的覆盖:
"Hello world!".chars().forEach(x -> System.out.print((char) x));
但是。最好避免利用Stream来处理char值
。
刚开始使用Stream时,可能会冲动到恨不得将所有的循环都转换成Stream,但是切记,千万别冲动。这可能会破坏代码的可读性和易维护性。一般来说,即使是相当复杂的任务,最好也结合Stream和迭代来一起完成,如上面的Anagrams程序范例所示。因此,重构现有代码来使用Stream,并且在必要的使用才在新代码中使用
。
本条目中的范例程序所示,Stream pipeline利用函数对象(一般是Lambda或者方法引用)来描述重复的计算,而迭代版代码利用代码来描述重复的计算。下列工作只能通过代码块,而不能通过函数对象来完成:
1、从代码块中,可以读取或者修改范围内的任意局部变量;从Lambda则只能读取final或者有效的final变量,并且不能修改任何local变量。
2、从代码块中,可以从外围方法中return、break或continue外围循环,或者抛出该方法声明要抛出的任何受检异常;从Lambda中则完全无法完成这些事情。
1、统一转换元素的序列
2、过滤元素的序列
3、利用单个操作(如添加、连接或者计算其最小值)合并元素的顺序
4、将元素的序列放到一个集合中,比如根据某些公共属性进行分组
5、收缩满足某些条件的元素的序列
如果某个计算最好是利用这些方法来完成,它就非常适合使用Stream。
利用Stream很难完成的一件事情就是,同时从一个pipeline的多个阶段去访问相应的元素:一旦将一个值映射到某个其他值,原来的值就丢失了。一种解决办法是将每个值都映射带包含原始值和新值的一个对象,不过这并非万全之策,当pipeline的多个阶段都需要这些对象时尤其如此。这样得到的代码将是混乱、繁杂的,违背了Stream的初衷。最好的解决办法是,当需要访问较早阶段的值时,将映射颠倒过来。
例如,编写一个打印出前20个梅森素数的程序。解释一下,梅森素数是一个形式为2p-1的数字。如果p是一个素数,相应的梅森数字也是素数;那么它就是一个梅森素数。作为pipeline的第一个Stream,我们想要的所有素数。下面的方法将返回(无限)Stream。假设使用的是静态导入,便于访问BigInteger的静态成员:
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
方法的名称(primes)是一个复数名词,它描述了Stream的元素。强烈建议返回Stream的所有方法都采用这种命名惯例,因为可以增强Stream pipeline的可读性。该方法使用静态工厂Stream.iterate,它有两个参数:Stream中的第一个元素,以及从前一个元素中生成下一个元素的一个函数。下面的程序用于打印出前20个梅森素数。
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
这段程序是对上述的简单编码示范:它从素数开始,计算出相应的梅森素数,过滤掉所有不是素数的数字(其中50是个神奇的数字,他控制着这个概率素性测试),限制最终得到的Stream为20个元素,并打印出来。
现在假设想要在每个梅森素数之前加上其指数(p)。这个值只出现在第一个 tream 中,因此在负责输出结果的终止操作中是访问不到的 所幸将发生在第一个中间操作中的映射颠倒过来,便可以很容易地计算出梅森数字的指数 该指数只不过是一个以二进制表示的位数,因此终止操作可以产生所要的结果:
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
在许多任务中,使用流还是迭代并不明显。例如,考虑初始化一副新纸牌的任务。假设 Card 是一个不可变的值类,它封装了 Rank 和 Suit,它们都是 enum 类型。此任务代表需要计算可从两个集合中选择的所有元素对的任何任务。数学家称之为两个集合的笛卡尔积。这是一个迭代实现嵌套的 for-each 循环,你应该很熟悉:
// Iterative Cartesian product computation
private static List<Card> newDeck() {
List<Card> result = new ArrayList<>();
for (Suit suit : Suit.values())
for (Rank rank : Rank.values())
result.add(new Card(suit, rank));
return result;
}
这是一个基于Stream的实现,利用了中间操作flatMap。这个操作是将Stream中的每个元素都映射到一个Stream中,然后将这些新的Stream全部合并到一个Stream(或者将它们扁平化)。注意,这个实现包含一个嵌入式的Lambda,如下粗体部分所示:
// Stream-based Cartesian product computation
private static List<Card> newDeck() {
return Stream.of(Suit.values())
.flatMap(suit -> Stream.of(Rank.values())
.map(rank -> new Card(suit, rank)))
.collect(toList());
}
两个版本的 newDeck 哪个更好?这可以归结为个人偏好和编程环境。第一个版本更简单,可能感觉更自然。大部分 Java 程序员将能够理解并维护它,但是有些程序员对第二个(基于流的)版本会感到更舒服。如果您相当精通流和函数式编程,那么它会更简明一些,也不会太难理解。如果您不确定您更喜欢哪个版本,迭代版本可能是更安全的选择。如果您更喜欢流版本,并且相信其他使用该代码的程序员也会与您有相同的偏好,那么您应该使用它。
总之,有些任务最好用Stream完成,有些则要用迭代。而有许多任务则最好是结合使用这两种方法一起完成。具体选择用哪一种方法,并没有硬性、速成的规则,但是可以参考一些有意义的启发。在很多时候,会很清楚应该使用哪一种方法;有些时候,则不太明显。如果实在不确定用Stream还是用迭代比较好,那么就两种都试试,看看哪一种更好
。
网友评论