流(Streams)是与任何特定存储机制无关的元素序列——实际上,我们说流是“没有存储”的。
取代了在集合迭代元素的做法,使用流即可以从管道中提取元素并对其操作。这些管道通常被串联在一起形成一整套的管线,来对流进行操作。
在大多数情况下,将对象存储在集合中就是为了处理它们,因此你会发现你把编程的主要焦点从集合转移到了流上。流的好处是,它使得程序更加短小并且更易理解。当Lambda表达式和方法引用(method references)和流一起使用的时候会让人感觉自成一体。流使得Java8更具吸引力。
举个例子,假如你要随机展示5至20之间不重复的整数并进行排序。你要对它们进行排序的事实,会使得你首先关注选用哪个有序集合,然后围绕这个集合进行后续的操作。但是使用流式编程,你可以简单陈述你要做什么:
1 | // streams/Randoms.java |
首先,我们给Random对象一个种子值(以便程序再次运行时产生相同的输出)。ints()方法产生一个流并且ints()方法有多种方式的重载——两个参数限定了产生的数值的边界。这将生成一个随机整数流。我们用流的中间操作(intermediate stream operation)distinct()使流中的整数不重复,然后使用limit()方法获取前7个元素。接下来使用sorted()方法排序。最终使用forEach()方法遍历输出,它根据传递给它的函数对流中的每个对象执行操作。在这里,我们传递了一个可以在控制台显示每个元素的方法引用:System.out::println。
注意Randoms.java中没有声明任何变量。流可以在不曾使用赋值或可变数据的情况下,对有状态的系统建模,这非常有用。
声明式编程(Declarative programming)是一种编程风格——它声明了要做什么,而不是指明如何做。而这正是我们在函数式编程中所看到的编程风格。你会注意到,命令式(Imperative)编程的形式会更难理解:
1 | // streams/ImperativeRandoms.java |
在Randoms.java中,我们无需定义任何变量,但在这里我们定义了3个变量:rand、rints、r。由于nextInt()方法没有下限的原因,这段代码实现起来更复杂。所以我们要生成额外的值来过滤小于5的结果。
注意,你必须研究代码才能搞清楚ImperativeRandoms.java程序在做什么。而在Randoms.java中,代码会直接告诉你它在做什么。这种语义的清晰性是使用Java8流式编程的重要原因之一。
像在ImperativeRandoms.java中那样显示地编写迭代过程中的方式为外部迭代(external iteration)。而在Randoms.java中,你看不到任何上述的迭代过程,所以它被称为内部迭代(internal iteration),这是流式编程的一个核心特征。内部迭代产生的代码可读性更强,而且能更简单的使用多核处理器。通过放弃对迭代过程的控制,可以把控制权交给并行化机制。
另一个重要方面,流是懒加载的。这代表着它只在绝对必要时才计算。你可以将流看作“延迟列表”。由于计算延迟,流使我们能够表示非常大的序列,而不需要考虑内存问题。
流支持
Java设计者面临着这样一个难题:现存的大量类库不仅为Java所用,同时也被应用在整个Java生态圈数百万行的代码中。如何将一个全新的流的概念融入到现有类库中呢?
比如在Random中添加更多的方法。只要不改变原有的方法,现有代码就不会受到干扰。
一个大的挑战来自于使用接口的库。集合类是其中关键的一部分,因为你想把集合转为流。但是如果你将一个新方法添加到接口,那就破坏了每一个实现接口的类,因为这些类都没有实现你添加的新方法。
Java8采用的解决方案是:在接口中添加被default(默认)修饰的方法。通过这种方案,设计者可以将流式(stream)方法平滑地嵌入到现有类中。流方法预置的操作几乎已满足了我们平常所有的需求。流操作的类型有三种:创建流、修改流元素(中间操作,Intermediate Operations),消费流元素(终端操作,Terminal Operations)。最后一种类型通常意味着收集流元素(通常是汇入一个集合)。
下面来看下每种类型的流操作。
流创建
你可以通过Stream.of()很容易地将一组元素转化成为流:
1 | // streams/StreamOf.java |
除此之外,每个集合都可以通过调用stream()方法来产生一个流。代码示例:
1 | // streams/CollectionToStream.java |
在创建List\
我们通过调用字符串的split()来获取元素用于定义变量w。在这里我们只是根据空格来分割字符串。
为了从Map集合中产生流数据,我们首先调用entrySet()产生一个对象流,每个对象都包含一个key键以及与其相关联的value值。然后分别调用getKey()和getValue()获取值。
随机数流
Random类被一组生成流的方法增强了。代码示例:
1 | // streams/RandomGenerators.java |
为了消除冗余代码,我创建了一个泛型方法show(Stream\
我们可以使用Random为任意对象集合创建Supplier。从文本文件提供字符串对象的例子如下。
Cheese.dat文件内容:
1 | // streams/Cheese.dat |
我们通过File类将Cheese.dat文件的所有行读取到List\
1 | // streams/RandomWords.java |
在这里可以看到split()更复杂的运用。在构造器里,每一行都被split()通过方括号内的空格或其他标点符号分割。在方括号后面+表示+前面的东西可以出现一次或者多次(正则表达式)。
你会发现构造函数使用命令行式编程(外部迭代)进行循环。在以后的例子中,你会看到我们如何去除命令行式编程。这种旧的形式不是特别糟糕,但使用流会让人感觉更好。
在toString()和main()方法中你看到了collect()操作,它根据参数来结合所有的流元素。当你用Collectors.joining()作为collect()的参数时,将得到一个String类型的结果,该结果是流中的所有元素被joining()的参数隔开。还有很多不同的Collections用于产生不同的结果。
在主方法这,我们提前看到了Stream.generate()的用法,它可以把任意Supplier\
int类型的范围
IntStream类提供了range()方法用于生成整型序列的流。编写循环时,这个方法会更加便利:
1 | // streams/Ranges.java |
在主方法中的第一种方式是我们编写for循环的方式;第二种方式,我们使用range()创建了流并将其转化为数组,然后在for-in代码块中使用。但是,如果你能像第三种方法那样使用流是更好的。我们对范围中的数字进行求和。在流中可以很方便的使用sum()操作求和。
注意IntStream.range()相比onjava.Range.range()受更多限制。这是由于其可选的第三个参数,后者允许步长大于1,并且可以从大到小来生成。
实用小功能repeat()可以用来替换简单的for循环。代码示例:
1 | // onjava/Repeat.java |
其产生的循环更加清晰:
1 | // streams/Looping.java |
原则上,在代码中包含和解释repeat()并不值得。诚然它是一个相当透明的工具,但这取决于你的团队和公司的运作方式。
generate()
参照RandomWords.java中Stream.generate()搭配Supplier\
1 | // streams/Generator.java |
使用Random.nextInt()方法来挑选字母表中的大写字母。Random.nextInt()的参数代表可以接受的最大的随机数范围,所以使用数组边界是经过慎重考虑的。
如果要创建包含相同对象的流,只需要传递一个生成那些对象的lambda到generate()中:
1 | // streams/Duplicator.java |
如下是本章之前例子中使用过的Bubble类。注意它包含了自己的静态生成器(Static generator)方法。
1 | // streams/Bubble.java |
由于bubbler()与Supplier\
1 | // streams/Bubbles.java |
这是创建单独工厂类(Separate Factory class)的另一种方式。在很多方面它更加整洁,但是这是一个关于代码组织和品味的问题——你总是可以创建一个完全不同的工厂类。
iterate()
Stream.iterate()产生的流的第一个元素是种子(iterate方法的第一个参数),然后将种子传递给方法(iterate方法的第二个参数)。方法运行的结果被添加到流(作为流的下一个元素),并被存储起来,作为下次调用iterate()方法时的第一个参数,以此类推。我们可以利用iterate()生成一个斐波那契数列。代码示例:
1 | // streams/Fibonacci.java |
斐波那契数列中最后两个元素进行求和以产生下一个元素。iterate()只能记忆结果,因此我们需要利用一个变量x追踪另外一个元素。
在主方法中,我们使用了一个之前没有见过的skip()操作。它根据参数丢弃指定数量的流元素。在这里,我们丢弃了前20个元素。
流的建造者模式
在建造者模式(Builder design pattern)中,首先创建一个builder对象,然后将创建流所需的多个信息传递给它,最后builder对象执行“创建”流的操作。Stream库提供了这样的Builder。在这里,我们重新审视文件读取并将其转换成为单词流的过程。代码示例:
1 | // streams/FileToWordsBuilder.java |
注意,构造器会添加文件中的所有单词(除了第一行,它是包含了文件路径信息的注释),但是其并没有调用build()。只要你不调用stream()方法,就可以继续向builder对象中添加单词。
在该类的更完整形式中,你可以添加一个标志位用于查看build()是否被调用,并且可能的话增加一个可以添加更多单词的方法。在Stream.Builder调用build()方法后继续尝试添加单词会产生一个异常。
Arrays
Arrays类中含有一个名为stream()的静态方法用于把数组转化成流。我们可以重写interfaces/Machine.java中的主方法用于创建一个流,并将execute()应用于每一个元素。代码示例:
1 | // streams/Machine2.java |
new Operations[]表达式动态创建了Operations对象的数组。
Stream()同样可以产生IntStream、LongStream和DoubleStream。
1 | // streams/ArrayStreams.java |
最后一次stream()的调用有额外的参数。第一个参数告诉stream()从数组的哪一个位置开始选择元素,第二个参数用于告诉在哪里停止。每种不同类型的stream()都拥有类似的操作。
正则表达式
Java的正则表达式将在字符串这一章中详细介绍。java8在java.util.regex.Pattern中增加了一个新的方法splitAsStream()。这个方法可以根据传入的公式将字符序列转化为流。但是有一个限制,输入只能是CharSequence,因此不能将流作为splitAsStream()的参数。
我们再一次查看将文件转化为单词的过程。这一次,我们使用流将文件转换为一个字符串,接着使用正则表达式将字符串转化为单词流。
1 | // streams/FileToWordsRegexp.java |
在构造器中我们读取了文件中的所有内容(跳过第一行注释,并将其转化为单行字符串)。现在,当你调用stream()的时候,就可以像往常一样获取一个流,但这回你可以多次调用stream(),每次从以存储的字符串中创建一个新的流。这里有一个限制,整个文件必须存储在内存中;大多数情况下这并不是什么问题,但这都掉了流操作非常重要的优势:
- 1.“不需要把流存储起来”。当然,流确实需要一些内部存储,但存储的只是序列的一小部分,和存储整个序列不同。
- 2.它们是懒加载计算的。
中间操作
中间操作用于从一个流中获取对象,并将对象作为另一个流从后端输出,已连接到其他操作。
跟踪和调试
peek()操作的目的是帮助调试。它允许你无需修改地查看流中的元素。代码示例:
1 | // streams/Peeking.java |
FileToWords稍后定义,但它的功能实现貌似和之前我们看到的差不多:产生字符串对象的流。之后在其通过管道时调用peek()进行处理。
因为peek()符合无返回值的Consumer函数式接口,所以我们只能观察,无法使用不同的元素来替换流中的对象。
流元素排序
在Randoms.java中,我们熟识了sorted()的默认比较器实现。其实它还有另一种形式的实现:传入一个Comparator参数。代码示例:
1 | // streams/SortedComparator.java |
sorted()预设了一些默认的比较器。这里我们使用的是反转“自然排序”。当然你也可以把Lambda函数作为参数传递给sorted()。