Java8新特性:Stream 方法剖析示例

一、示例初始化

Book.java

import java.time.Year;
import java.util.List;

public class Book {

    private String title;            // 标题
    private List<String> authors;    // 作者列表
    private int[] pageCounts;        // 多卷标题构成的卷的页数
    private Topic topic;             // 类目
    private Year pubDate;            // 出版日期
    private double height;           // 书本高度

    public Book() {
    }

    public Book(String title, List<String> authors, int[] pageCounts, Year pubDate, double height, Topic topic) {
        this.title = title;
        this.authors = authors;
        this.pageCounts = pageCounts;
        this.topic = topic;
        this.pubDate = pubDate;
        this.height = height;
    }

    // 省略getter和setter
}

Topic.java,书的类目

public enum Topic {
    HISTORY,        // 历史
    PROGRAMMING,    // 编程
    MEDICINE,       // 医学
    COMPUTING,      // 电脑
    FICTION         // 小说
}

Main.java 测试类,初始化书籍列表

List<Book> library = new ArrayList<>();

Book book1 = new Book("China 's image base",
        Arrays.asList("Li", "Fu", "Li"),
        new int[]{256},
        Year.of(2016),
        25.2,
        Topic.MEDICINE);

Book book2 = new Book("Computer use 3000 asked",
        Arrays.asList("Aho", "Lam", "听风", "Ullman"),
        new int[]{1009},
        Year.of(2013),
        23.6,
        Topic.COMPUTING);

Book book3 = new Book("Voss",
        Arrays.asList("Patrick White"),
        new int[]{478},
        Year.of(1980),
        19.8,
        Topic.FICTION);

Book book4 = new Book("New World",
        Arrays.asList("Tolkien"),
        new int[]{531, 416, 624},
        Year.of(1999),
        23.0,
        Topic.FICTION);

Book book5 = new Book("Starbucks",
        Arrays.asList("Li"),
        new int[]{26},
        Year.of(2013),
        15.2,
        Topic.MEDICINE);

Book book6 = new Book("Tesla",
        Arrays.asList("听风", "man"),
        new int[]{900},
        Year.of(2017),
        20.2,
        Topic.COMPUTING);

Book book7 = new Book("Yunnanbaiyao",
        Arrays.asList("White"),
        new int[]{478},
        Year.of(1980),
        19.8,
        Topic.HISTORY);

Book book8 = new Book("Programming guide",
        Arrays.asList("Jekl"),
        new int[]{416},
        Year.of(1981),
        23.0,
        Topic.COMPUTING);

library.add(book1);
library.add(book2);
library.add(book3);
library.add(book4);
library.add(book5);
library.add(book6);
library.add(book7);
library.add(book8);

二、方法剖析

转换管道

1、filter 过滤

有选择性的处理流元素

  • filter(Predicate<? super T> predicate)       Stream<T>

其输出是流中满足提供的Predicate的那些元素

// 只包含计算机的图书的流
Stream<Book> computingBooks = library.stream().filter(b -> b.getTopic() == Topic.COMPUTING);

// 打印结果
computingBooks.forEach(book -> System.out.println(book.getTitle()));

2、map 映射

方法map会通过提供的Function<T,R>转换每个流元素

  • map(Function<? super T, ? extends R> mapper)       <R> Stream<R>

其输出是一个流,包含了对输入流中每个元素应用了Function后的结果

// 图书标题的流
Stream<String> stringStream = library.stream().map(Book::getTitle);

// 打印结果
stringStream.forEach(System.out::println);

方法mapToInt、mapToLong与mapToDouble对应map。

它们会通过ToIntFunction<T>、ToLongFunction<T>与ToDoubleFunction<T>的实例将引用类型转换为原生流,每个转换都会接受一个T并返回一个原生值。

  • mapToInt(ToIntFunction<? super T> mapper);      IntStream

  • mapToLong(ToLongFunction<? super T> mapper);    LongStream

  • mapToDouble(ToDoubleFunction<? super T> mapper);    DoubleStream

// 所有书籍的作者总数
int totalAuthorships = library.stream()
        .mapToInt(b -> b.getAuthors().size())
        .sum();
System.out.println("作者总数:" + totalAuthorships);

3、flatMap 一对多映射

实现上一个示例的另外一种方式(不过性能要低一些)就是讲Book的流转换为Author的流,每个都表示一个作者关系。接下来只需要使用终止操作count来找出流中元素的数量。

不过map并适用于这个一对多的目的,因为它对输入流的元素执行一对一的转换,而问题是需要讲单个Book转换为输出流中的几个Author元素。

我们需要讲每一个Book映射为一个Author流,即book.getAuthors().stream(),然而将生成的一系列流压平针对所有图书的单个Author的流,就是这种操作flatMap

  • flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);     <R> Stream<R>

  • flatMapToInt(Function<? super T, ? extends IntStream> mapper);      IntStream

  • flatMapToLong(Function<? super T, ? extends LongStream> mapper);        LongStream

  • flatMapToDouble(Function<? super T, ? extends DoubleStream> mapper);        DoubleStream

// 所有书籍作者列表
Stream<String> authorStream = library.stream().flatMap(b->b.getAuthors().stream());
authorStream.forEach(System.out::println);

运行结果
Li
Fu
Li
Aho
Lam
听风
Ullman
Patrick White
Tolkien
Li
听风
man
White
Jekl

类似于map对应的用语转换为原生流的方法,也存在着原生转换方法:flatMapToInt、flatMapToLong与flatMapToDouble

例如,我们可以获得所有图书所有卷的总页数,方式是通过IntStream.of为每一个Book创建一个单独的IntStream,然后通过flatMapToInt将它们连接起来。

// 所有书籍的总页数
int totalPageCount = library.stream()
        .flatMapToInt(book -> IntStream.of(book.getPageCounts()))
        .sum();
System.out.println("所有书籍的总页数:" + totalPageCount);

4、peek 调试

调用管道的终止操作会导致其中间操作得到执行。这样,通常的单步调试技术就不适用于流了。

Stream API提供的另外一个操作peek与其他中间操作不同,其输出流会包含相同的元素,并且与输入流的顺序相同。peek旨在对于管道中间位置的流元素之行处理;

  • peek(Consumer<? super T> action);        Stream<T>

peek 生成一个包含原Stream的所有元素的新Stream,同时会提供一个消费函数(Consumer实例),新Stream每个元素被消费的时候都会执行给定的消费函数;

例如,我们要打印出每一本书的标题,并且在其发送给下游做进一步的处理之前传递一个过滤器

List<Book> multipleAuthoredHistories = library.stream()
        .filter(book -> book.getTopic() == Topic.COMPUTING)
        .peek(book -> System.out.println(book.getTitle() + ", 作者数量:" + book.getAuthors().size()))
        .filter(book -> book.getAuthors().size() > 1)
        .collect(Collectors.toList());

System.out.println("----------");

multipleAuthoredHistories.forEach(book -> System.out.println(book.getTitle() + ", 作者数量:" + book.getAuthors().size()));

运行结果
Computer use 3000 asked, 作者数量:4
Tesla, 作者数量:2
Programming guide, 作者数量:1
----------
Computer use 3000 asked, 作者数量:4
Tesla, 作者数量:2

如果熟悉Unix管道,那么你会发现这个非常类似于tee,不过peek的通用性更强一些,它可以接受任何类型适当的Consumer作为参数。

该方法用于对调试提供支持,由于其有副作用,因此不应该用于其他任何目的。

5、sorted 排序 与 distinct 去重复

操作sorted的行为与我们期望的一致:输出流包含输入流中的元素,并且是有序的。

  • distinct();     Stream<T> 

  • sorted();       Stream<T> 

  • sorted(Comparator<? super T> comparator);       Stream<T>

排序算法对于流来说是稳定的,这意味着如果被排序的流是有序的,那么在输入与输出之间,相同键的元素之间的相对顺序会保持不变。

Stream<String> sortedTitles = library.stream().map(Book::getTitle).sorted();
sortedTitles.forEach(System.out::println);

运行结果
China 's image base
Computer use 3000 asked
New World
Programming guide
Starbucks
Tesla
Voss
Yunnanbaiyao

第2个重载的sorted会接受一个Comparator;例如,静态方法Comparator.comparing会将根据一个键抽取器创建一个Comparator

// Book的流,根据标题排序
Stream<Book> booksSortedByTitle = library.stream()
        .sorted(Comparator.comparing(Book::getTitle));

booksSortedByTitle.forEach(book -> System.out.println(book.getTitle() + " 作者数量:" + book.getAuthors().size()));

运行结果
China 's image base 作者数量:3
Computer use 3000 asked 作者数量:4
New World 作者数量:1
Programming guide 作者数量:1
Starbucks 作者数量:1
Tesla 作者数量:2
Voss 作者数量:1
Yunnanbaiyao 作者数量:1

通过一个键创建一个Comparator,除了对流元素的自然排序之外还有其他排序方式,比如下面的倒序排列,修改排序代码如下

.sorted(Comparator.comparing(Book::getTitle, Comparator.reverseOrder()));

另一个重载的Comparator.comparing接受一个针对抽取键的Comparator,这样就可以对其使用不同的排序规则流。

例如,为了根据作者数量对图书进行排序,可以写成下面这样。

// 根据作者数量对图书进行排序
Stream<Book> bookSortedByAuthorCount = library.stream()
        .sorted(Comparator.comparing(Book::getAuthors, Comparator.comparing(List::size)));

bookSortedByAuthorCount.forEach(book -> System.out.println(book.getTitle() + " ,作者数量:" + book.getAuthors().size()));

运行结束

Voss ,作者数量:1
New World ,作者数量:1
Starbucks ,作者数量:1
Yunnanbaiyao ,作者数量:1
Programming guide ,作者数量:1
Tesla ,作者数量:2
China 's image base ,作者数量:3
Computer use 3000 asked ,作者数量:4

同样的,也可以进行倒序排列

.sorted(Comparator.comparing(Book::getAuthors, Comparator.comparing(List::size)).reversed());

组中的第2个操作distinct会从流中删除重复的元素。其输出流中只包含输入元素中单此出现的元素——根据equals方法,所有重复元素都被丢弃掉。

// 使用这个排序流创建一个作者流,根据图书标题排序,并且去除重复作者
Stream<String> authorInBookTitleOrder = library.stream()
        .sorted(Comparator.comparing(Book::getTitle))
        .flatMap(b -> b.getAuthors().stream())
        .distinct();

authorInBookTitleOrder.forEach(System.out::println);

运行结果
Li
Fu
Aho
Lam
听风
Ullman
Tolkien
Jekl
man
Patrick White
White

由于equals方法可能不会考虑到流元素的所有字段,因此“重复”可能不同于其他方法的结果。

因此,与sorted相关的稳定性概念也适用于distinct:如果输入流是有序的,那么元素之间的相对顺序也会被保留下来,对于大量相同元素来说,他会选择第一个,对于输入流为无序的,那么可能就会选择任意一个元素(在并行管道中这是一个代价很低的操作)

6、limit,skip 截断

截断有两个操作,会对来自流的输出采取一些措施,skip会丢弃掉前n个流元素,返回剩余的元素;limit会保留前n个元素,返回流中只包含这些元素。

  • limit(long maxSize);    Stream<T> 

  • skip(long n);              Stream<T> 

// 以标题的字母顺序生成前100个图书的流
Stream<Book> readingList = library.stream()
        .sorted(Comparator.comparing(Book::getTitle))
        .limit(100);

// 除去前100个图书的流
Stream<Book> remainderList = library.stream()
        .sorted(Comparator.comparing(Book::getTitle))
        .skip(100);

终止管道

上面的一些示例有一个共同点:由于这些示例所演示的流操作都是延迟计算的,因此其效果就是将各个流组合到管道中而不会对起任何元素进行处理。

本节所介绍的操作都是立即执行的:对流调用其中任何一个方法都会开始流元素的计算,将元素从流的源中拉取出来。

由于这些操作的结果都不是流,从某种意义上来说,它们都是将流的内容汇聚为单个值。

不过最好将其划分为3个类别


  • 搜索操作:用于检测满足某种约束条件的流元素,因此有时在没有处理完整个流时就会结束。

  • 汇聚:会返回单个值,作为对流元素的一个总结。 现在我们只关注两个方面:汇聚(Reduction)方法,如count和max,以及简单的收集器,它会通过将元素累加到集合中来终止流。

  • 副作用操作:这个类别只包含两个方法:forEach和forEachOrdered。Stream API中只有这两个终止操作带有副作用

1、搜索操作

可以划分为“搜索”操作的Stream方法可以分为两组:

第一组:包含“Match”匹配

  • anyMatch(Predicate<? super T> predicate);   boolean

  • allMatch(Predicate<? super T> predicate);   boolean

  • noneMatch(Predicate<? super T> predicate);  boolean

anyMatch在找到与predicate匹配的元素时会返回true;

allMatch在找到不满足predicate的任意一个元素时会返回false,否则返回true;

noneMatch如果找到任意一个满足predicate的元素时会返回false,否则返回true;

不难理解,无非也就是六种结果

.anyMatch(b -> false);  // false
.allMatch(b -> false);  // false
.anyMatch(b -> false);  // true

.anyMatch(b -> true);  // true
.allMatch(b -> true);  // true
.anyMatch(b -> true);  // false

调试观察

library.stream().
        filter(b -> b.getTopic() == Topic.COMPUTING).
        forEach(b -> System.out.println(b.getTopic().name() + ",标题:" + b.getTitle() + ",高度:" + b.getHeight()));

System.out.println("------------");

// 电脑书籍,找出大于21高度的书籍是否可以放在书架上
// anyMatch返回true,allMatch返回false,noneMatch返回false
boolean withinShelfHeight = library.stream()
        .filter(b -> b.getTopic() == Topic.COMPUTING)
        .peek(b -> System.out.println(b.getTopic().name() + ",标题:" + b.getTitle() + ",高度:" + b.getHeight()))
        .allMatch(b -> b.getHeight() > 21);

System.out.println(withinShelfHeight);

运行结果
COMPUTING,标题:Computer use 3000 asked,高度:23.6
COMPUTING,标题:Tesla,高度:20.2
COMPUTING,标题:Programming guide,高度:23.0
------------
COMPUTING,标题:Computer use 3000 asked,高度:23.6
COMPUTING,标题:Tesla,高度:20.2
false

对比发现,以上的匹配中,一旦找出了不满足条件的就会直接返回,后续的流操作将不再继续进行。下面的“find”方法也是同理。

第二组:包含两个“find”方法构成

  • findFirst();        Optional<T>

  • findAny();          Optional<T>

如果找到,那么上面两个方法就会返回一个流元素,区别在于返回的元素是不同的。

返回类型为java.util.Optional<T>:该类时一个包装器,可能包含也可能不包含非null的T类型值。“find”方法考虑了空流的可能性,因此它返回是一个Optional。

// 计算机流的第一本书
Book book = library.stream().filter(b -> b.getTopic() == Topic.COMPUTING).findFirst().get();
System.out.println(book.getTitle());

findFirst会返回遇到第一个元素。findAny返回任意一个元素。

如果是有序流,比较建议使用findAny方式;因为findFirst在维持顺序上来说要做一些不必要的工作,这是我们不需要的。

如果是无序流,这两个方法之间也就没什么本质区别了。

2、汇聚

Stream API设计借鉴了 Larry Wall的著名口号,即让简单事情保持简单,同时让困到的事情成为可能。

接下来会通过 收集器 与 分割迭代器 来完成复杂的工作。不过大多数的工作都是简单的,对于简单的工作,有专门为简单工作设计的Stream.reduce变种。数字原生流(如 IntStream)提供了更多的特性。

  • sum();      int

  • min();      OptionalInt

  • max();      OptionalInt

  • count();    long

  • average();      OptionalDouble

  • summaryStatistics();        IntSummaryStatistics

除了最后一个方法 summaryStatistics 外,其他的方法都是见名知意的。

最后一个方法会创建出一个 IntSummaryStatistics 的实例,它是一个拥有5个属性的值对象:sum、min、max、count 和 average。如果想要对数据一次遍历后得到多个结果,那么它就是非常有用的了。

// 例如,下面代码获取图书馆中图书的页数统计信息(多个卷的页数求和)
IntSummaryStatistics pageCountStatistics = library.stream()
        .mapToInt(b -> IntStream.of(b.getPageCounts()).sum())
        .summaryStatistics();
        
System.out.println(pageCountStatistics);

运行结果
IntSummaryStatistics{count=8, sum=5134, min=26, average=641.750000, max=1571}

同样的模式也出现在LongStream和DoubleStream中,引用流也有一些汇聚的方法:

// 图书馆中最早出版的图书
Optional<Book> oldest = library.stream()
        .min(Comparator.comparing(Book::getPubDate));

注意:为了找出拥有自然顺序的Stream元素中最小值与最大值,你需要显示提供一个Comparator。如下,根据字母顺序排列获取第一本书。

// 自然排序,正序
Optional<String> oldest = library.stream().map(Book::getTitle).min(Comparator.naturalOrder());
System.out.println(oldest.get());

// 翻转排序,倒序
Optional<String> firstTitle = library.stream().map(Book::getTitle).min(Comparator.reverseOrder());
System.out.println(firstTitle.get());

 收集流元素

对于流来说,第二种汇聚使用了Stream.collect将流之积聚到可变容器中,例如Java集合框架中的类,这称为可变汇聚

  • collect(Supplier<R> supplier, BiConsumer<R, ? super T> accumulator, BiConsumer<R, R> combiner);        <R> R

  • collect(Collector<? super T, A, R> collector);        <R, A> R

collect参数是 java.util.stream.Collectors 接口的一个实例,T代表被收集的类型,R代表返回结果类型

大多数集合用例都可以通过类Collectors的工厂方法所返回预定义Collector实现所涵盖。本章将会介绍最简单的3个方法。

这里所介绍的3个工厂方法会将流元素收集到3个主要的Java框架接口:Set、List与Map其中一个实现(具体由框架选择)

  • toList()       Collector<T, ?, List<T>>

  • toSet()        Collector<T, ?, Set<T>>

// 图书馆中图书的标题集合
Set<String> titles = library.stream()
        .map(Book::getTitle)
        .collect(Collectors.toSet());

常见做法是导入静态 Collectors 工厂方法,最后一行变为这样:.collect(Collectors.toSet()); 

在内部,操作是通过 Set.add 实现的,同时语义保持不变,元素是无序的,重复数据会被丢弃。

方法 toList 非常类似于 toSet,通过List.add实现了元素积聚;这样流是有序的,那么创建的 list 就有相同顺序。

不过,创建一个会积聚为 Map 的收集器要稍微复杂一些。toMap 的两个重载方法就是完成这个目标的,每个方法都接受一个从 T 到 K 键的抽取函数以及从 T 到 U 的值抽取函数,这两个函数会应用到每个流元素上,从而生成一个键值对。

  • toMap(Function<T,K>, Function<T,U>)            Collection<T, ?, Map<K, U>>

  • toMap(Function<T,K>, Function<T,U>, BinaryOperator<U>)            Collection<T, ?, Map<K, U>>

  • toMap(Function<T,K>, Function<T,U>, BinaryOperator<U>,Supplier<M>)            Collection<T, ?, Map<K, U>>

例如,通过toMap的第一个重载收集器将集合中的每本图书标题映射到出版日期上

Map<String, Year> titleToPubDate = library.stream()
                .collect(Collectors.toMap(Book::getTitle, Book::getPubDate));

toMap 的第2个重载方法考虑到了重复键的情况。书籍可能有用同样的标题不同的版本,出版日期是不同的。而Map不能包含重复的键,因此对于上述代码来讲,就会出现IllegalStateException异常。第二个 toMap 就是为了解决这个重复键的问题的。

通过类型为 BinaryOperator<U> 的merge函数形式指定的,它会从两个已有值(一个是位于map中,一个是要添加到map中)生成一个新值。

// 在 Map 中包含每本书的最新版本
Map<String, Year> titleToPubDate = library.stream()
        .collect(Collectors.toMap(
                Book::getTitle,
                Book::getPubDate,
                (x, y) -> x.isAfter(y) ? x : y));

titleToPubDate.forEach((t, y) -> System.out.println("标题:" + t + ", 出版日期:" + y.getValue()));

由于toMap返回的收集器(就像 toSet 和 toList 返回的一样)会积聚到非线程安全到容器中,因此管理多线程环境下的结果的积聚会对并行流的性能造成影响。

3、副作用操作

它们会终止一个流,将其按照顺序对每个元素应用相同的Consumer,Stream API 不支持副作用的操作,但它们是个例外。

  • void forEach(Consumer<? super T> action);

  • void forEachOrdered(Consumer<? super T> action);

forEach 用于在并行流上高效执行,因此它不保留顺序,所以他并不保证操作的同步性,操作可能在不同的线程中执行。

forEachOrdered 会强制在串行模式下执行。保证输出按照元素顺序执行。

总结

以上部分所介绍到的Stream,IntStream等等等等方法,只列出了极少的一部分,很多内容需要自行来摸索学习,推荐在学习测试的过程中,多多留心观察源代码中的设计。


未完待补充。。


本篇文章参见书籍《lambda表达式权威指南 第二章节》

作者:程序喵


未经允许请勿转载:程序喵 » Java8新特性:Stream 方法剖析示例

点  赞 (0) 打  赏
分享到: