引文
这次主要介绍Java 8的Stream以及如何与lambda配合使用。Stream作为Java 8的一大亮点,它与java.io包里的InputStream和OutputStream是完全不同的概念。Java 8中的Stream是对集合对象功能的增强,它专注于对集合对象进行各种非常便利、高效的聚合操作,或者大批量数据操作。Stream API借助于同样新出现的lambda表达式,极大的提高编程效率和程序可读性。可以说,Stream的出现,完全改变了处理集合的方式。希望大家在看完这篇文章后,能抛弃之前对集合用Iterator遍历并完成相关的聚合操作那种笨拙的方式,改用流来处理。
先用一个例子让大家感受一下Stream的便捷。假设有这样一个Book类:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Book {
private Integer id;
private String name;
private String type;
private Double price;
}
复制代码
现在有这样一个业务场景:要发现种类为“计算机”的所有图书,然后返回以价格增序排序好的图书ID集合:
传统方式
public class StreamTest {
public static void main(String[] args) {
List<Book> bookList = new ArrayList<>();
bookList.addAll(Arrays.asList(new Book(1, "Java核心技术", "计算机", 90.0),
new Book(2, "Java编程思想", "计算机", 100.0),
new Book(3, "浮生六记", "文学", 50.0)));
List<Book> computerBooks = new ArrayList<>();
for (Book b : bookList) {
if (b.getType().equals("计算机")) {
computerBooks.add(b);
}
}
Collections.sort(computerBooks, new Comparator<Book>() {
@Override
public int compare(Book b1, Book b2) {
return b1.getPrice().compareTo(b2.getPrice());
}
});
List<Integer> bookIds = new ArrayList<>();
for (Book b : computerBooks) {
bookIds.add(b.getId());
}
}
}
复制代码
使用Stream
public class StreamTest {
public static void main(String[] args) {
List<Book> bookList = new ArrayList<>();
bookList.addAll(Arrays.asList(new Book(1, "Java核心技术", "计算机", 90.0),
new Book(2, "Java编程思想", "计算机", 100.0),
new Book(3, "浮生六记", "文学", 50.0)));
List<Integer> bookIds = bookList.stream()
.filter(b -> b.getType().equals("计算机"))
.sorted(Comparator.comparing(Book::getPrice))
.map(Book::getId)
.collect(Collectors.toList());
}
}
复制代码
可以看到,原先繁琐的操作,在使用了Stream后,只用一句话就解决了。那Stream究竟是什么呢?
Stream原理
Stream不是集合元素或者数据结构,它并不保存数据,而是有关数据的算法和计算的。可以把Stream理解成一个高级版本的Iterator。原始版本的Iterator,用户只能显式地一个一个遍历元素并对其执行某些操作;高级版本的Stream,用户只要给出需要对其包含的元素执行什么操作,比如 “过滤掉长度大于10的字符串”、“获取每个字符串的首字母”等,Stream会隐式地在内部进行遍历,做出相应的数据转换(ps:也可以把Stream理解成一种处理数据的风格,这种风格将要处理的元素集合看作一种流,流在管道中传输,并且可以在管道的节点上进行处理,比如筛选、排序、聚合等)。
可能上面说的有些抽象,下面给出一个具体的例子。有这样一段代码:
List<Integer> nums = new ArrayList<>();
nums.addAll(Arrays.asList(1, null, 3, 4, null, 6));
nums.stream().filter(num -> num != null)).count();
复制代码
上面这段代码的目的是获取一个List中不为null的元素的个数。通过这段代码,我们来剖析一下Stream的结构:
下面,我具体说一下这三个步骤:
创建Stream有很多方法,大家感兴趣的话可以自行去查。这里我重点说一下如何把一个Collection对象转换成Stream。通常情况下,调用Collection.stream()和Collection.parallelStream()分别产生序列化流(普通流)和并行流。并行和并发的概念,大家应该都清楚。并发是指多线程有竞争关系,在单核的情况下某一时刻只有一个线程运行;而并行是指在多核的情况下同时运行,单核谈并行是无意义的。使用并行方式去遍历时,数据会被分成多个段,其中每一个都在不同的线程中处理,然后将结果一起输出。Stream的并行操作依赖于Java 7中引入的Fork/Join框架来拆分任务和加速处理过程。需要注意的是,并行不一定快,尤其在数据量很小的情况下,可能比普通流更慢。只有在大数据量和多核的情况下才考虑并行流。
流的操作类型分为两种:
- 中间操作(Intermediate operation):一个流可以后面跟随零个或多个intermediate操作。其目的主要是打开流,做出某种程度的数据映射/过滤,然后返回一个新的流,交给下一个操作使用。这类操作都是惰性化的(lazy),就是说,仅仅调用到这类方法,并没有真正开始流的遍历。三个基本步骤中的转换属于中间操作,用于把一个Stream通过某些行为转换成一个新的Stream。
- 最终操作(Terminal Operation):一个流只能有一个terminal操作,当这个操作执行后,流就被使用“光”了,无法再被操作,所以这必定是流的最后一个操作。Terminal操作的执行,才会真正开始流的遍历,并且会生成一个结果。三个基本步骤中的聚合属于最终操作,用于接受一个元素序列作为输入,反复使用某个合并操作,把序列中的元素合并成一个汇总的结果。比如查找一个数字列表的总和或者最大值,或者把这些数合并成一个List对象。
需要强调的是,在对一个Stream进行多次Intermediate操作时,每次都对Stream的每个元素进行转换,但实质上并没有做N(转换次数)次for循环。转换操作都是lazy的,多个转换操作只会在Terminal操作的时候融合起来,一次循环完成。我们可以这样简单的理解,Stream里有个操作函数的集合,每次转换操作就是把转换函数放入这个集合中,在Terminal操作的时候循环Stream对应的集合,然后对每个元素执行所有的函数。
至于有哪些转换和聚合方法呢,大家可以自行查找。推荐一篇文章:ifeve.com/stream/ ,里面用示意图说明了几个典型的转换和聚合方法,很形象。
Stream用法示例
map/flatMap
map的作用是把input Stream的每一个元素,映射成output Stream的另外一个元素,例如:
// 大小写转换
List<String> wordList = Arrays.asList("a", "b", "c");
List<String> newWorldList = wordList.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
复制代码
map生成的是1:1映射,每个输入元素,都按照规则转换成为另外一个元素。还有一些场景,是一对多映射关系的,这时需要flatMap:
Stream<List<Integer>> inputStream = Stream.of(
Arrays.asList(1),
Arrays.asList(2, 3),
Arrays.asList(4, 5, 6)
);
Stream<Integer> outputStream = inputStream
.flatMap((childList) -> childList.stream());
复制代码
flatMap把input Stream中的层级结构扁平化,就是将最底层元素抽出来放到一起。最终output Stream里面已经没有List了,都是直接的数字。
reduce
这个方法的主要作用是把Stream元素组合起来。它提供一个起始值,然后依照运算规则(BinaryOperator),和前面Stream的第一个、第二个、第n个元素组合。从这个意义上说,字符串拼接、数值的sum、min、max、average都是特殊的reduce。例如Stream的sum就相当于以下两种写法:
Integer sum = integers.reduce(0, (a, b) -> a+b);
Integer sum = integers.reduce(0, Integer::sum);
复制代码
reduce的用例如下:
// 字符串连接,concat = "ABCD"
String concat = Stream.of("A", "B", "C", "D").reduce("", String::concat);
// 求最小值,minValue = -3.0
double minValue = Stream.of(-1.5, 1.0, -3.0, -2.0).reduce(Double.MAX_VALUE, Double::min);
// 求和,sumValue = 10, 有起始值
int sumValue = Stream.of(1, 2, 3, 4).reduce(0, Integer::sum);
// 求和,sumValue = 10, 无起始值
sumValue = Stream.of(1, 2, 3, 4).reduce(Integer::sum).get();
// 过滤、字符串连接,concat = "ace"
concat = Stream.of("a", "B", "c", "D", "e", "F")
.filter(x -> x.compareTo("Z") > 0)
.reduce("", String::concat);
复制代码
match
Stream有三个match方法,从语义上说:
- allMatch:Stream中全部元素符合传入的predicate,返回true
- anyMatch:Stream中只要有一个元素符合传入的predicate,返回true
- noneMatch:Stream中没有一个元素符合传入的predicate,返回true
它们都不是要遍历全部元素才能返回结果。例如allMatch只要一个元素不满足条件,就skip剩下的所有元素,返回false。match的用例如下:
List<Person> persons = new ArrayList();
persons.add(new Person(1, "name" + 1, 10));
persons.add(new Person(2, "name" + 2, 21));
persons.add(new Person(3, "name" + 3, 34));
persons.add(new Person(4, "name" + 4, 6));
persons.add(new Person(5, "name" + 5, 55));
boolean isAllAdult = persons.stream()
.allMatch(p -> p.getAge() > 18);
System.out.println("All are adult? " + isAllAdult);
boolean isThereAnyChild = persons.stream()
.anyMatch(p -> p.getAge() < 12);
System.out.println("Any child? " + isThereAnyChild);
复制代码
emmm就先写这几个吧,其实Stream另一个便捷的地方在于它的一些方法可以直接根据方法名来判断用途。当然,想要掌握Stream的用法,还是要——多用。
最后,说几个lambda表达式需要注意的地方:
- lambda表达式内可以使用方法引用(即那个“::”),仅当该方法不修改lambda表达式提供的参数。然而,若对参数有任何修改,则不能使用方法引用,而需键入完整的lambda表达式。
- lambda内部可以使用静态、非静态和局部变量,但只能是final的。这就是说不能在lambda内部修改定义在域外的变量(ps:Java 8对这个限制做了优化,可以不用显式使用final修饰,但是编译器隐式当成final来处理)。
- 上次说到可以用lambda表达式来代替匿名内部类,但这两者还是有明显区别的:一是this关键字,匿名类的this关键字指向匿名类,而lambda表达式的this关键字指向包围lambda表达式的类;二是编译方式不同,Java编译器将lambda表达式编译成类的私有方法。
初识lambda表达式,可能有的人还会觉得很陌生,但是在掌握它的用法之后,一定能感受到它的强大之处!