流(Stream API)

本文参考书籍《Java 8实战》,陆明刚、劳佳  译,如有侵权,请联系删除!


在本章中,我们会使用这样一个例子:一个menu,它是一张菜肴列表:

    List menu = Arrays.asList (
        new Dish("pork", false, 800, Dish.Type.MEAT),
        new Dish("beef", false, 700, Dish.Type.MEAT),
        new Dish("chicken", false, 400, Dish.Type.MEAT),
        new Dish("french fries", true, 530, Dish.Type.OTHER),
        new Dish("rice", true, 350, Dish.Type.OTHER),
        new Dish("season fruit", true, 120, Dish.Type.OTHER),
        new Dish("pizza", true, 550, Dish.Type.OTHER),
        new Dish("prawns", false, 300, Dish.Type.FISH),
        new Dish("salmon", false, 450, Dish.Type.FISH));

Dish类的定义是:

public class Dish {

    private final String name;
    private final boolean vegetarian;
    private final int calories;
    private final Type type;

    public Dish(String name, boolean vegetarian, int calories, Type type) {
        this.name = name;
        this.vegetarian = vegetarian;
        this.calories = calories;
        this.type = type;
    }

    public String getName() {
        return name;
    }

    public boolean isVegetarian() {
        return vegetarian;
    }

    public int getCalories() {
        return calories;
    }

    public Type getType() {
        return type;
    }

    public enum Type {MEAT, FISH, OTHER}

    @Override
    public String toString() {
        return name;
    }

    public static final List menu =
            Arrays.asList(new Dish("pork", false, 800, Dish.Type.MEAT),
                    new Dish("beef", false, 700, Dish.Type.MEAT),
                    new Dish("chicken", false, 400, Dish.Type.MEAT),
                    new Dish("french fries", true, 530, Dish.Type.OTHER),
                    new Dish("rice", true, 350, Dish.Type.OTHER),
                    new Dish("season fruit", true, 120, Dish.Type.OTHER),
                    new Dish("pizza", true, 550, Dish.Type.OTHER),
                    new Dish("prawns", false, 400, Dish.Type.FISH),
                    new Dish("salmon", false, 450, Dish.Type.FISH));
}

流是什么

流是Java API的新成员,它允许你以声明性方式处理数据集合(通过查询语句来表达,而不是临时编写一个实现)。就现在来说,你可以把它们看成遍历数据集的高级迭代器。此外,流还可以透明地并行处理,你无需写任何多线程代码了!下面的代码是用来返回低热量的菜肴名称的,并按照卡路里排序,一个是用Java 7写的,另一个是用Java 8的流写的:

    // Java 7
    public static List getLowCaloricDishesNamesInJava7(List dishes) {
        List lowCaloricDishes = new ArrayList<>();
        for (Dish d : dishes) {
            if (d.getCalories() < 400) {
                lowCaloricDishes.add(d);
            }
        }
        Collections.sort(lowCaloricDishes, new Comparator() {
            public int compare(Dish d1, Dish d2) {
                return Integer.compare(d1.getCalories(), d2.getCalories());
            }
        });
        List lowCaloricDishesName = new ArrayList<>();
        for (Dish d : lowCaloricDishes) {
            lowCaloricDishesName.add(d.getName());
        }
        return lowCaloricDishesName;
    }

    // Java 8
    public static List getLowCaloricDishesNamesInJava8(List dishes) {
        return dishes.stream()
                .filter(d -> d.getCalories() < 400)
                .sorted(comparing(Dish::getCalories))
                .map(Dish::getName)
                .collect(toList());
    }

为了利用多核架构并行执行这段代码,只需要把stream()换成parallelStream():

    public static List getLowCaloricDishesNamesInJava8(List dishes) {
        return dishes
                // .stream()
                .parallelStream() // 并行执行
                .filter(d -> d.getCalories() < 400)
                .sorted(comparing(Dish::getCalories))
                .map(Dish::getName)
                .collect(toList());
    }

可以看出,使用流有几个显而易见的好处。首先,代码是以声明性方式写的:说明想要完成什么(筛选热量低的菜肴)而不是说明如何实现一个操作(利用循环和if条件等控制流语句)。其次,可以把几个基础操作链接起来,来表达复杂的数据处理流水线(在filter后面接上sorted、 map和collect操作),同时保持代码清晰可读。filter的结果被传给了sorted方法,再传给map方法,最后传给collect方法。因为filter、 sorted、 map和collect等操作是与具体线程模型无关的高层次构件,所以它们的内部实现可以是单线程的,也可能透明地充分利用你的多核架构!在实践中,这意味着你用不着为了让某些数据处理任务并行而去操心线程和锁了, Stream API都替你做好了!

总结一下, Java 8中的Stream API的优点如下:

声明性——更简洁,更易读;

可复合——更灵活;

可并行——性能更好。

流简介

要讨论流,我们先来谈谈集合,这是最容易上手的方式了。Java 8中的集合支持一个新的stream()方法,它会返回一个流(接口定义在java.util.stream.Stream里)。后面我们会看到,还有很多其他的方法可以得到流,比如利用数值范围或从I/O资源生成流元素。那么, 流到底是什么呢?简单的定义是:从支持数据处理操作的源生成的元素序列。让我们一步步剖析这个定义。

元素序列——就像集合一样,流也提供了一个接口,可以访问特定元素类型的一组有序值。因为集合是数据结构,所以它的主要目的是以特定的时间/空间复杂度存储和访问元素(如ArrayList 与 LinkedList)。但流的目的在于表达计算,比如我们前面见到的filter、 sorted和map。集合讲的是数据,流讲的是计算。我们会在后面详细解释这个思想。

源——流会使用一个提供数据的源,如集合、数组或输入/输出资源。 请注意,从有序集合生成流时会保留原有的顺序。由列表生成的流,其元素顺序与列表一致。

数据处理操作——流的数据处理功能支持类似于数据库的操作,以及函数式编程语言中的常用操作,如filter、 map、 reduce、 find、 match、 sort等。流操作可以顺序执行,也可并行执行。

此外,流操作有两个重要的特点。

流水线——很多流操作本身会返回一个流,这样多个操作就可以链接起来,形成一个大的流水线。这让一些优化成为可能,如延迟和短路。流水线的操作可以看作对数据源进行数据库式查询。

内部迭代——与使用迭代器显式迭代的集合不同,流的迭代操作是在背后进行的。

让我们来看一段能够体现所有这些概念的代码:

    List threeHighCaloricDishNames =
        menu.stream()  // 从menu获得流( 菜肴列表)
        .filter(d -> d.getCalories() > 300) // 首先选出高热量的菜肴
        .map(Dish::getName) // 获取菜名
        .limit(3) // 只选择头三个
        .collect(toList()); // 将结果保存在另一个List中

在本例中,我们先是对menu调用stream方法,由菜单得到一个流。数据源是菜肴列表(菜单),它给流提供一个元素序列。接下来,对流应用一系列数据处理操作: filter、 map、 limit和collect。除了collect之外,所有这些操作都会返回另一个流,这样它们就可以接成一条流水线,于是就可以看作对源的一个查询。最后,collect操作开始处理流水线,并返回结果(它和别的操作不一样,因为它返回的不是流,在这里是一个List)。在调用collect之前,没有任何结果产生,实际上根本就没有从menu里选择元素。你可以这么理解:链中的方法调用都在排队等待,直到调用collect。下图显示了流操作的顺序: filter、 map、 limit、 collect,每个操作简介如下:

流(Stream API)_第1张图片

filter——接受Lambda,从流中排除某些元素。在本例中,通过传递lambda d ->d.getCalories() > 300,选择出热量超过300卡路里的菜肴。

map——接受一个Lambda,将元素转换成其他形式或提取信息。在本例中,通过传递方法引用Dish::getName,相当于Lambda d -> d.getName(),提取了每道菜的菜名。

limit——截断流,使其元素不超过给定数量。

collect——将流转换为其他形式。在本例中,流被转换为一个列表。可以把collect看作能够接受各种方案作为参数,并将流中的元素累积成为一个汇总结果的操作。这里的toList()就是将流转换为列表的方案。

我们刚刚解释的这段代码,与逐项处理菜单列表的代码有很大不同。首先,我们使用了声明性的方式来处理菜单数据,即你说的对这些数据需要做什么:查找热量最高的三道菜的菜名。你并没有去实现筛选(filter)、提取(map)或截断(limit)功能, Streams库已经自带了。因此, Stream API在决定如何优化这条流水线时更为灵活。

流与集合

粗略地说,集合与流之间的差异就在于什么时候进行计算。集合是一个内存中的数据结构,它包含数据结构中目前所有的值——集合中的每个元素都得先算出来才能添加到集合中(你可以往集合里加东西或者删东西,但是不管什么时候,集合中的每个元素都是放在内存里的,元素都得先算出来才能成为集合的一部分)。相比之下,流则是在概念上固定的数据结构(你不能添加或删除元素),其元素则是按需计算的。从另一个角度来说,流就像是一个延迟创建的集合:只有在消费者要求的时候才会计算值(用管理学的话说这就是需求驱动,甚至是实时制造)。与此相反,集合则是急切创建的。

只能遍历一次

和迭代器类似,流只能遍历一次。遍历完之后,我们就说这个流已经被消费掉了。你可以从原始数据源那里再获得一个新的流来重新遍历一遍,就像迭代器一样(假设它是集合之类的可重复的源,如果是I/O通道就没戏了)。例如,以下代码会抛出一个异常,说流已被消费掉了:

    // 流只能遍历一次
    public static void main(String...args){
        List names = Arrays.asList("Java8", "Lambdas", "In", "Action");
        Stream s = names.stream();
        s.forEach(System.out::println);
        // java.lang.IllegalStateException: stream has already been operated upon or closed
        s.forEach(System.out::println);
    }

外部迭代与内部迭代

使用Collection接口需要用户去做迭代(比如用for-each),这称为外部迭代。 相反,Streams库使用内部迭代——它帮你把迭代做了,还把得到的流值存在了某个地方,你只要给出一个函数说要干什么就可以了。下面的代码列表说明了这种区别。

    // 集合:用for-each循环外部迭代
    List names = new ArrayList<>();
    for(Dish d: menu) {
        names.add(d.getName());
    }

    // 集合:用迭代器做外部迭代
    List names = new ArrayList<>();
    Iterator iterator = menu.iterator();
    while(iterator.hasNext()) {
        Dish d = iterator.next();
        names.add(d.getName());
    }

    // 流:内部迭代
    List names = menu.stream()
        .map(Dish::getName)
        .collect(toList()); // 开始执行操作流水线;没有迭代!

外部迭代一个集合,需要显式地取出每个项目再加以处理。内部迭代时,项目可以透明地并行处理,或者用更优化的顺序进行处理。要是用Java过去的那种外部迭代方法,这些优化都是很困难的。

流操作

java.util.stream.Stream中的Stream接口定义了许多操作。我们再来看一下前面的例子:

    List threeHighCaloricDishNames =
        menu.stream()  // 从menu获得流( 菜肴列表)
        .filter(d -> d.getCalories() > 300) // 中间操作
        .map(Dish::getName) // 中间操作
        .limit(3) // 中间操作
        .collect(toList()); // 将Stream转化为List

这些操作分为两类:filter、 map和limit可以连成一条流水线;collect触发流水线执行并关闭它。可以连接起来的流操作称为中间操作,关闭流的操作称为终端操作

中间操作

诸如filter或sorted等中间操作会返回另一个流。这让多个操作可以连接起来形成一个查询。重要的是,除非流水线上触发一个终端操作,否则中间操作不会执行任何处理——它们很懒。这是因为中间操作一般都可以合并起来,在终端操作时一次性全部处理。

终端操作

终端操作会从流的流水线生成结果,其结果是任何不是流的值,比如List、 Integer,甚至void。例如,在下面的流水线中,forEach是一个返回void的终端操作,它会对源中的每道菜应用一个Lambda。把System.out.println传递给forEach,并要求它打印出由menu生成的流中的每一个Dish:

    menu.stream().forEach(System.out::println);

使用流

总而言之,流的使用一般包括三件事:一个数据源(如集合)来执行一个查询;一个中间操作链,形成一条流的流水线;一个终端操作,执行流水线,并能生成结果。

筛选和切片

用谓词筛选

Stream接口支持filter方法,该操作接受一个谓词(一个返回boolean的函数)作为参数,并返回一个包括所有符合谓词的元素的流。

    List vegetarianMenu = menu.stream()
        .filter(Dish::isVegetarian)
        .collect(toList());

筛选各异的元素

流还支持一个叫作distinct的方法,它会返回一个元素各异(根据流所生成元素的hashCode和equals方法实现)的流。例如,以下代码会筛选出列表中所有的偶数,并确保没有重复。

    List numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4);
    numbers.stream()
        .filter(i -> i % 2 == 0)
        .distinct()
        .forEach(System.out::println);

截短流

流支持limit(n)方法,该方法会返回一个不超过给定长度的流。所需的长度作为参数传递给limit。如果流是有序的,则最多会返回前n个元素。比如,你可以建立一个List,选出热量超过300卡路里的头三道菜:

    List dishes = menu.stream()
        .filter(d -> d.getCalories() > 300)
        .limit(3)
        .collect(toList());

limit也可以用在无序流上,比如源是一个Set。这种情况下, limit的结果不会以任何顺序排列。

跳过元素

流还支持skip(n)方法,返回一个扔掉了前n个元素的流。如果流中元素不足n个,则返回一个空流。例如,下面的代码将跳过超过300卡路里的头两道菜,并返回剩下的。

    List dishes = menu.stream()
        .filter(d -> d.getCalories() > 300)
        .skip(2)
        .collect(toList());

映射

流支持map方法,它会接受一个函数作为参数。这个函数会被应用到每个元素上,并将其映射成一个新的元素(使用映射一词,是因为它和转换类似,但其中的细微差别在于它是“创建一个新版本”而不是去“修改”)。例如,下面的代码把方法引用Dish::getName传给了map方法,来提取流中菜肴的名称:

    List dishNames = menu.stream()
        .map(Dish::getName)
        .collect(toList())

如果要找出每道菜的名称有多长,可以像下面这样,再链接上一个map:

    List dishNameLengths = menu.stream()
        .map(Dish::getName)
        .map(String::length)
        .collect(toList());

流的扁平化

看如下例子:对于一张单词表 , 如 何 返 回 一 张 列 表 , 列 出 里 面 各 不 相 同 的 字 符 呢 ? 例 如 , 给 定 单 词 列 表["Hello","World"],返回列表["H","e","l", "o","W","r","d"]。可以把每个单词映射成一张字符表,然后调用distinct来过滤重复的字符。第一个版本可能是这样的:

    words.stream()
        .map(word -> word.split(""))
        .distinct()
        .collect(toList());

这个方法的问题在于,传递给map方法的Lambda为每个单词返回了一个String[](String列 表 )。 因 此 , map 返 回 的流 实 际 上 是 Stream 类 型 的 。 而 我 们 真 正 想 要 的 是 用Stream来表示一个字符流。下图说明了这个问题。

流(Stream API)_第2张图片

幸好可以用flatMap来解决这个问题!让我们一步步看看怎么解决它。

1. 尝试使用map和Arrays.stream()

首先,我们需要一个字符流,而不是数组流。有一个叫作Arrays.stream()的方法可以接受一个数组并产生一个流,例如:

    String[] arrayOfWords = {"Goodbye", "World"};
    Stream streamOfwords = Arrays.stream(arrayOfWords);

把它用在前面的那个流水线里,看看会发生什么:

    words.stream()
        .map(word -> word.split("")) // 将每个单词转换为由其字母构成的数组
        .map(Arrays::stream) // 让每个数组变成一个单独的流
        .distinct()
        .collect(toList());

当前的解决方案仍然搞不定!这是因为,我们现在得到的是一个流的列表(更准确地说是Stream)!的确,我们先是把每个单词转换成一个字母数组,然后把每个数组变成了一个独立的流。

2. 使用flatMap

可以使用flatMap来解决这个问题:

    List uniqueCharacters =
        words.stream()
        .map(w -> w.split(""))
        .flatMap(Arrays::stream)
        .distinct()
        .collect(Collectors.toList());

使用flatMap方法的效果是,各个数组并不是分别映射成一个流,而是映射成流的内容。所有使用map(Arrays::stream)时生成的单个流都被合并起来,即扁平化为一个流。下图说明了使用flatMap方法的效果。

流(Stream API)_第3张图片

一言以蔽之, flatmap方法让你把一个流中的每个值都换成另一个流,然后把所有的流连接起来成为一个流。

查找和匹配

另一个常见的数据处理套路是看看数据集中的某些元素是否匹配一个给定的属性。Stream API通过allMatch、anyMatch、 noneMatch、findFirst和findAny方法提供了这样的工具。

anyMatch

anyMatch是指流中是否有一个元素能匹配给定的谓词。比如,你可以用它来看看菜单里面是否有素食可选择:

    if (menu.stream().anyMatch(Dish::isVegetarian)) {
        System.out.println("The menu is (somewhat) vegetarian friendly!!");
    }

anyMatch方法返回一个boolean,因此是一个终端操作。

allMatch

allMatch方法的工作原理和anyMatch类似,但它会看看流中的元素是否都能匹配给定的谓词。比如,你可以用它来看看菜品是否有利健康(即所有菜的热量都低于1000卡路里):

    boolean isHealthy = menu.stream()
        .allMatch(d -> d.getCalories() < 1000);

noneMatch

和allMatch相对的是noneMatch,它可以确保流中没有任何元素与给定的谓词匹配。比如,你可以用noneMatch重写前面的例子:

    boolean isHealthy = menu.stream()
        .noneMatch(d -> d.getCalories() >= 1000)

anyMatch、 allMatch和noneMatch这三个操作都用到了短路特性,这就是Java中&&和||运算符短路在流中的版本。

查找元素

findAny方法将返回当前流中的任意元素。它可以与其他流操作结合使用。比如,你可能想找到一道素食菜肴,可以结合使用filter和findAny方法来实现这个查询:

    Optional dish =
        menu.stream()
        .filter(Dish::isVegetarian)
        .findAny();

代码中的Optional类(java.util.Optional)是一个容器类,代表一个值存在或不存在,后面会详细介绍。

查找第一个元素

findFirst方法用于返回流中的第一个元素。例如:给定一个数字列表,下面的代码能找出第一个平方能被3整除的数:

    List someNumbers = Arrays.asList(1, 2, 3, 4, 5);
    Optional firstSquareDivisibleByThree =
        someNumbers.stream()
        .map(x -> x * x)
        .filter(x -> x % 3 == 0)
        .findFirst(); // 9

归约

到目前为止,我们见到过的终端操作都是返回一个boolean(allMatch之类的)、 void(forEach)或Optional对象(findAny等)。我们也见过了使用collect来将流中的所有元素组合成一个List。在本节中,我们将看到如何把一个流中的元素组合起来,使用reduce操作来表达更复杂的查询,比如“计算菜单中的总卡路里”或“菜单中卡路里最高的菜是哪一个”。此类查询需要将流中所有元素反复结合起来,得到一个值,比如一个Integer。这样的查询可以被归类为归约操作(将流归约成一个值)。用函数式编程语言的术语来说,这称为折叠(fold)。

元素求和

在我们研究如何使用reduce方法之前,先来看看如何使用for-each循环来对数字列表中的元素求和:

    int sum = 0;
    for (int x : numbers) {
        sum += x;
    }

numbers中的每个元素都用加法运算符反复迭代来得到结果。通过反复使用加法,你把一个数字列表归约成了一个数字。这段代码中有两个参数:总和变量的初始值,在这里是0;将列表中所有元素结合在一起的操作,在这里是+。要是还能把所有的数字相乘,而不必去复制粘贴这段代码,岂不是很好?这正是reduce操作的用武之地,它对这种重复应用的模式做了抽象。你可以像下面这样对流中所有的元素求和:

    int sum = numbers.stream().reduce(0, (a, b) -> a + b);

reduce接受两个参数:一个初始值,这里是0;一个BinaryOperator来将两个元素结合起来产生一个新值,这里我们用的是lambda (a, b) -> a + b。

你也很容易把所有的元素相乘,只需要将另一个Lambda: (a, b) -> a * b传递给reduce操作就可以了:

    int product = numbers.stream().reduce(1, (a, b) -> a * b);

注意,此时的初始值为1。

让我们深入研究一下reduce操作是如何对一个数字流求和的。首先, 0作为Lambda的第一个参数(a),从流中获得4作为第二个参数(b)。 0 + 4得到4,它成了新的累积值。然后再用累积值和流中下一个元素5调用Lambda,产生新的累积值9。接下来,再用累积值和下一个元素3调用Lambda,得到12。最后,用12和流中最后一个元素9调用Lambda,得到最终结果21。

可以使用方法引用让这段代码更简洁。在Java 8中, Integer类现在有了一个静态的sum方法来对两个数求和,这恰好是我们想要的,用不着反复用Lambda写同一段代码了:

    int sum = numbers.stream().reduce(0, Integer::sum);

reduce还有一个重载的变体,它不接受初始值,但是会返回一个Optional对象:

    Optional sum = numbers.stream().reduce((a, b) -> (a + b));

为什么它返回一个Optional呢?考虑流中没有任何元素的情况。reduce操作无法返回其和,因为它没有初始值。这就是为什么结果被包裹在一个Optional对象里,以表明和可能不存在。

最大值和最小值

可以像下面这样使用reduce来计算流中的最大值:

    Optional max = numbers.stream().reduce(Integer::max);

要计算最小值,只需要把Integer.min传给reduce来替换Integer.max:

    Optional min = numbers.stream().reduce(Integer::min);

当然也可以写成Lambda (x, y) -> x < y ? x : y而不是Integer::min,不过后者比较易读。

数值流

我们在前面看到了可以使用reduce方法计算流中元素的总和。例如,你可以像下面这样计算菜单的热量:

    int calories = menu.stream()
        .map(Dish::getCalories)
        .reduce(0, Integer::sum);

这段代码的问题是,它有一个暗含的装箱成本。每个Integer都必须拆箱成一个原始类型,再进行求和。要是可以直接像下面这样调用sum方法,岂不是更好?

    int calories = menu.stream()
        .map(Dish::getCalories)
        .sum();

但这是不可能的。问题在于map方法会生成一个Stream。虽然流中的元素是Integer类型,但Stream接口没有定义sum方法。为什么没有呢?比方说,你只有一个像menu那样的Stream,把各种菜加起来是没有任何意义的。但是,Stream API提供了原始类型流特化,专门支持处理数值流的方法。

原始类型流特化

Java 8引入了三个原始类型特化流接口来解决这个问题: IntStream、 DoubleStream和LongStream,分别将流中的元素特化为int、 long和double,从而避免了暗含的装箱成本。每个接口都带来了进行常用数值归约的新方法,比如对数值流求和的sum,找到最大元素的max。此外还有在必要时再把它们转换回对象流的方法。要记住的是,这些特化的原因并不在于流的复杂性,而是装箱造成的复杂性——即类似int和Integer之间的效率差异。

1. 映射到数值流

将流转换为特化版本的常用方法是mapToInt、 mapToDouble和mapToLong。这些方法和前面说的map方法的工作方式一样,只是它们返回的是一个特化流,而不是Stream。例如,你可以像下面这样用mapToInt对menu中的卡路里求和:

    int calories = menu.stream() // 返回一个Stream
        .mapToInt(Dish::getCalories) // 返回一个IntStream
        .sum();

这里, mapToInt会从每道菜中提取热量(用一个Integer表示),并返回一个IntStream(而不是一个Stream)。然后你就可以调用IntStream接口中定义的sum方法,对卡路里求和了!请注意,如果流是空的, sum默认返回0。 IntStream还支持其他的方便方法,如max、 min、 average等。

2. 转换回对象流

同样,一旦有了数值流,你可能会想把它转换回非特化流。例如, IntStream上的操作只能产 生 原 始 整 数 : IntStream 的 map 操 作 接 受 的 Lambda 必 须 接 受 int 并 返 回 int ( 一 个IntUnaryOperator)。但是你可能想要生成另一类值,比如Dish。为此,你需要访问Stream接口中定义的那些更广义的操作。要把原始流转换成一般流(每个int都会装箱成一个Integer),可以使用boxed方法,如下所示:

    IntStream intStream = menu.stream().mapToInt(Dish::getCalories); // 将Stream转换为数值流
    Stream stream = intStream.boxed(); // 将数值流转换为Stream

3. 默认值OptionalInt

求和的那个例子很容易,因为它有一个默认值: 0。但是,如果你要计算IntStream中的最大元素,就得换个法子了,因为0是错误的结果。如何区分没有元素的流和最大值真的是0的流呢?前面我们介绍了Optional类,这是一个可以表示值存在或不存在的容器。 Optional可以用Integer、 String等参考类型来参数化。对于三种原始流特化,也分别有一个Optional原始类型特化版本: OptionalInt、 OptionalDouble和OptionalLong。例如,要找到IntStream中的最大元素,可以调用max方法,它会返回一个OptionalInt:

    OptionalInt maxCalories = menu.stream()
        .mapToInt(Dish::getCalories)
        .max();

现在,如果没有最大值的话,你就可以显式处理OptionalInt去定义一个默认值了:

    int max = maxCalories.orElse(1);

数值范围

Java 8引入了两个可以用于IntStream和LongStream的静态方法,帮助生成这种数值范围:range和rangeClosed。这两个方法都是第一个参数接受起始值,第二个参数接受结束值。但range是不包含结束值的,而rangeClosed则包含结束值。让我们来看一个例子:

    IntStream evenNumbers = IntStream.rangeClosed(1, 100)
        .filter(n -> n % 2 == 0); // 一个从1到100的偶数流
    System.out.println(evenNumbers.count()); // 输出50,从1到100有50个偶数

这里我们用了rangeClosed方法来生成1到100之间的所有数字。它会产生一个流,然后链接filter方法,只选出偶数。到目前为止还没有进行任何计算。最后,对生成的流调用count。因为count是一个终端操作,所以它会处理流,并返回结果50,这正是1到100(包括两端)中所有偶数的个数。请注意,比较一下,如果改用IntStream.range(1, 100),则结果将会是49个偶数,因为range是不包含结束值的。

构建流

到目前为止,我们已经能够使用stream方法从集合生成流了。此外,我们还介绍了如何根据数值范围创建数值流。但创建流的方法还有许多!本节将介绍如何从值序列、数组、文件来创建流,甚至由生成函数来创建无限流。

由值创建流

可以使用静态方法Stream.of,通过显式值创建一个流。它可以接受任意数量的参数。例如,以下代码直接使用Stream.of创建了一个字符串流。然后,你可以将字符串转换为大写,再一个个打印出来:

    Stream stream = Stream.of("Java 8 ", "Lambdas ", "In ", "Action");
    stream.map(String::toUpperCase).forEach(System.out::println);

可以使用empty得到一个空流,如下所示:

    Stream emptyStream = Stream.empty();

由数组创建流

可以使用静态方法Arrays.stream从数组创建一个流。它接受一个数组作为参数。例如,你可以将一个原始类型int的数组转换成一个IntStream,如下所示:

    int[] numbers = {2, 3, 5, 7, 11, 13};
    int sum = Arrays.stream(numbers).sum();

由文件生成流

java.nio.file.Files中的很多静态方法都会返回一个流。例如,一个很有用的方法是Files.lines,它会返回一个由指定文件中的各行构成的字符串流。下面的代码用于查看一个文件中有多少各不相同的词:

        long uniqueWords = Files.lines(Paths.get("data.txt"), Charset.defaultCharset())
                .flatMap(line -> Arrays.stream(line.split(" ")))
                .distinct()
                .count();

可以使用Files.lines得到一个流,其中的每个元素都是给定文件中的一行。然后,对line调用split方法将行拆分成单词。应该注意的是,应该使用flatMap产生一个扁平的单词流,而不是给每一行生成一个单词流。最后,把distinct和count方法链接起来,得到流中有多少各不相同的单词。

由函数生成流:创建无限流

Stream API提供了两个静态方法来从函数生成流: Stream.iterate和Stream.generate。这两个操作可以创建所谓的无限流:不像从固定集合创建的流那样有固定大小的流。由iterate和generate产生的流会用给定的函数按需创建值,因此可以无穷无尽地计算下去!一般来说,应该使用limit(n)来对这种流加以限制,以避免打印无穷多个值。

1. 迭代

我们先来看一个iterate的简单例子,然后再解释:

    Stream.iterate(0, n -> n + 2)
        .limit(10)
        .forEach(System.out::println);

iterate方法接受一个初始值(在这里是0),还有一个依次应用在每个产生的新值上的Lambda(UnaryOperator类型)。这里,我们使用Lambda n -> n + 2,返回的是前一个元素加上2。因此, iterate方法生成了一个所有正偶数的流:流的第一个元素是初始值0。然后加上2来生成新的值2,再加上2来得到新的值4,以此类推。这种iterate操作基本上是顺序的,因为结果取决于前一次应用。请注意,此操作将生成一个无限流——这个流没有结尾,因为值是按需计算的,可以永远计算下去。我们说这个流是无界的。正如我们前面所讨论的,这是流和集合之间的一个关键区别。我们使用limit方法来显式限制流的大小。这里只选择了前10个偶数。然后可以调用forEach终端操作来消费流,并分别打印每个元素。

2. 生成

与iterate方法类似, generate方法也可让你按需生成一个无限流。但generate不是依次对每个新生成的值应用函数的。它接受一个Supplier类型的Lambda提供新的值。我们先来看一个简单的用法:

    Stream.generate(Math::random)
        .limit(5)
        .forEach(System.out::println);

这段代码将生成一个流,其中有五个0到1之间的随机双精度数。例如,运行一次得到了下面的结果:

Math.Random静态方法被用作新值生成器。同样,可以用limit方法显式限制流的大小,否则流将会无限长。

收集器简介

收集器使用Collector接口定义,它作为collect方法的参数,描述了如何对流中的元素进行处理。比如前面我们看到的toList()方法,它是Collectors类的一个静态方法,返回一个Collector,作用就是将流中的元素转换为一个List。

预定义收集器

预定义收集器就是那些可以从Collectors类提供的工厂方法(例如groupingBy、toList)创建的收集器,它们主要提供了三大功能:将流元素归约和汇总为一个值;元素分组;元素分区。

归约和汇总

在需要将流项目重组成集合时,一般会使用收集器(Stream方法collect的参数)。再宽泛一点来说,但凡要把流中所有的项目合并成一个结果时就可以用。这个结果可以是任何类型,可以复杂如代表一棵树的多级映射,或是简单如一个整数——也许代表了菜单的热量总和。

我们先来举一个简单的例子,利用counting工厂方法返回的收集器,数一数菜单里有多少种菜:

    long howManyDishes = menu.stream().collect(Collectors.counting());

还可以写得更为直接:

    long howManyDishes = menu.stream().count();

在本章后面的部分,我们假定你已导入了Collectors类的所有静态工厂方法:

    import static java.util.stream.Collectors.*;

查找流中的最大值和最小值

假设你想要找出菜单中热量最高的菜。你可以使用两个收集器, Collectors.maxBy和Collectors.minBy,来计算流中的最大或最小值。这两个收集器接收一个Comparator参数来比较流中的元素。你可以创建一个Comparator来根据所含热量对菜肴进行比较,并把它传递给Collectors.maxBy:

    Comparator dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
    Optional mostCalorieDish = menu.stream().collect(maxBy(dishCaloriesComparator));

汇总

Collectors类专门为汇总提供了一个工厂方法: Collectors.summingInt。它可接受一个把对象映射为求和所需int的函数,并返回一个收集器;该收集器在传递给普通的collect方法后即执行我们需要的汇总操作。例如,计算菜单热量的总和:

    int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));

汇总不仅仅是求和;还有Collectors.averagingInt,连同对应的averagingLong和averagingDouble可以计算数值的平均数:

    double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));

到目前为止,你已经看到了如何使用收集器来给流中的元素计数,找到这些元素数值属性的最大值和最小值,以及计算其总和和平均值。不过很多时候,你可能想要得到两个或更多这样的结果,而且你希望只需一次操作就可以完成。在这种情况下,你可以使用summarizingInt工厂方法返回的收集器。例如,通过一次summarizing操作你可以就数出菜单中元素的个数,并得到菜肴热量总和、平均值、最大值和最小值:

    IntSummaryStatistics menuStatistics = menu.stream().collect(summarizingInt(Dish::getCalories));

这个收集器会把所有这些信息收集到一个叫作IntSummaryStatistics的类里,它提供了方便的取值(getter)方法来访问结果。打印menuStatisticobject会得到以下输出:

    IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}

同样,相应的summarizingLong和summarizingDouble工厂方法有相关的LongSummaryStatistics 和DoubleSummaryStatistics 类 型 , 适用 于 收 集 的 属 性 是 原 始 类 型long 或double的情况。

连接字符串

joining工厂方法返回的收集器会把对流中每一个对象应用toString方法得到的所有字符串连接成一个字符串。这意味着你可以把菜单中所有菜肴的名称连接起来:

    String shortMenu = menu.stream().map(Dish::getName).collect(joining());

joining在内部使用了StringBuilder来把生成的字符串逐个追加起来。如果Dish类有一个toString方法来返回菜肴的名称,那你无需用提取每一道菜名称的函数来对原流做映射就能够得到相同的结果:

    String shortMenu = menu.stream().collect(joining());

二者均可产生以下字符串:

    porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon

该字符串的可读性并不好。joining工厂方法有一个重载版本可以接受元素之间的分界符,这样你就可以得到一个逗号分隔的菜肴名称列表:

    String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));

它会生成:

    pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon

广义的归约汇总

事实上,我们已经讨论的所有收集器,都是一个可以用reducing工厂方法定义的归约过程的特殊情况而已。 Collectors.reducing工厂方法是所有这些特殊情况的一般化。可以说,先前讨论的案例仅仅是为了方便程序员而已。例如,可以用reducing方法创建的收集器来计算你菜单的总热量,如下所示:

    int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j));

它需要三个参数。第一个参数是归约操作的起始值,也是流中没有元素时的返回值,所以很显然对于数值和而言0是一个合适的值;第二个参数是一个函数函数,这里是将菜肴转换成一个表示其所含热量的int的方法引用;第三个参数是一个BinaryOperator,将两个项目累积成一个同类型的值。这里它就是对两个int求和。

同样,你可以使用下面这样单参数形式的reducing来找到热量最高的菜,如下所示:

    Optional mostCalorieDish = menu.stream()
        .collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));

可以把单参数reducing工厂方法创建的收集器看作三参数方法的特殊情况,它把流中的第一个项目作为起点,把恒等函数(即一个函数仅仅是返回其输入参数)作为一个转换函数。这也意味着,要是把单参数reducing收集器传递给空流的collect方法,收集器就没有起点,它将因此而返回一个Optional对象。

分组

假设你要把菜单中的菜按照类型进行分类,有肉的放一组,有鱼的放一组,其他的都放另一组。在Java 7中要实现这一功能要写大量代码,而Java 8中,使用Collectors.groupingBy工厂方法返回的收集器就可以轻松地完成这项任务,如下所示:

    Map> dishesByType = menu.stream().collect(groupingBy(Dish::getType));

其结果是下面的Map:

    {FISH=[prawns, salmon], OTHER=[french fries, rice, season fruit, pizza], MEAT=[pork, beef, chicken]}

这里,你给groupingBy方法传递了一个Function(以方法引用的形式),它提取了流中每一道Dish的Dish.Type。我们把这个Function叫作分类函数,因为它用来把流中的元素分成不同的组。分组操作的结果是一个Map,把分组函数返回的值作为映射的键,把流中所有具有这个分类值的项目的列表作为对应的映射值。在菜单分类的例子中,键就是菜的类型,值就是包含所有对应类型的菜肴的列表。

但是,分类函数不一定像方法引用那样可用,因为你想用以分类的条件可能比简单的属性访问器要复杂。例如,你可能想把热量不到400卡路里的菜划分为“低热量”(diet),热量400到700卡路里的菜划为“普通”(normal),高于700卡路里的划为“高热量”(fat)。由于Dish类的作者没有把这个操作写成一个方法,你无法使用方法引用,但你可以把这个逻辑写成Lambda表达式:

    private static Map> groupDishesByCaloricLevel() {
        return menu.stream().collect(
                groupingBy(dish -> {
                    if (dish.getCalories() <= 400) return CaloricLevel.DIET;
                    else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
                    else return CaloricLevel.FAT;
                }));
    }

现在,你已经看到了如何对菜单中的菜肴按照类型和热量进行分组,但要是想同时按照这两个标准分类怎么办呢?分组的强大之处就在于它可以有效地组合。让我们来看看怎么做。

多级分组

要实现多级分组,我们可以使用一个由双参数版本的Collectors.groupingBy工厂方法创建的收集器,它除了普通的分类函数之外,还可以接受collector类型的第二个参数。那么要进行二级分组的话,我们可以把一个内层groupingBy传递给外层groupingBy,并定义一个为流中项目分类的二级标准,如下所示:

    private static Map>> groupDishedByTypeAndCaloricLevel() {
        return menu.stream().collect(
                groupingBy(Dish::getType,
                        groupingBy((Dish dish) -> {
                            if (dish.getCalories() <= 400) return CaloricLevel.DIET;
                            else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
                            else return CaloricLevel.FAT;
                        })
                )
        );
    }

这个二级分组的结果就是像下面这样的两级Map:

{MEAT={DIET=[chicken], NORMAL=[beef], FAT=[pork]}, FISH={DIET=[prawns], NORMAL=[salmon]}, OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}}

这里的外层Map的键就是第一级分类函数生成的值:“fish, meat, other”,而这个Map的值又是一个Map,键是二级分类函数生成的值:“normal, diet, fat”。最后,第二级map的值是流中元素构成的List,是分别应用第一级和第二级分类函数所得到的对应第一级和第二级键的值:“salmon、pizza…” 这种多级分组操作可以扩展至任意层级, n级分组就会得到一个代表n级树形结构的n级Map。

按子组收集数据

我们看到可以把第二个groupingBy收集器传递给外层收集器来实现多级分组。但进一步说,传递给第一个groupingBy的第二个收集器可以是任何类型,而不一定是另一个groupingBy。例如,要数一数菜单中每类菜有多少个,可以传递counting收集器作为groupingBy收集器的第二个参数:

    Map typesCount = menu.stream().collect(groupingBy(Dish::getType, counting()));

其结果是下面的Map:

    {MEAT=3, FISH=2, OTHER=4}

其实,普通的单参数groupingBy(f)(其中f是分类函数)实际上是groupingBy(f, toList())的简便写法。

分区

分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键类型是Boolean,于是它最多可以分为两组——true是一组, false是一组。例如,把菜单按照素食和非素食分开:

    Map> partitionedMenu = menu.stream().collect(partitioningBy(Dish::isVegetarian));

这会返回下面的Map:

    {false=[pork, beef, chicken, prawns, salmon], true=[french fries, rice, season fruit, pizza]}

收集器接口

Collector接口包含了一系列方法,为实现具体的归约操作(即收集器)提供了范本。我们已经看过了Collector接口中实现的许多收集器,例如toList或groupingBy。这也意味着,你可以为Collector接口提供自己的实现,从而自由地创建自定义归约操作。

开发自定义收集器

并行流

可以通过对收集源调用parallelStream方法来把集合转换为并行流。 并行流就是一个把内容分成多个数据块,并用不同的线程分别处理每个数据块的流。这样一来,你就可以自动把给定操作的工作负荷分配给多核处理器的所有内核,让它们都忙起来。

例如,假设你需要写一个方法,接受数字n作为参数,并返回从1到给定参数的所有数字的和。一个直接(也许有点土)的方法是生成一个无穷大的数字流,把它限制到给定的数目,然后用对两个数字求和的BinaryOperator来归约这个流,如下所示:

    public static long sequentialSum(long n) {
        return Stream.iterate(1L, i -> i + 1)
                .limit(n)
                .reduce(Long::sum).get();
    }

这似乎是利用并行处理的好机会,特别是n很大的时候。那怎么入手呢?你要对结果变量进行同步吗?用多少个线程呢?谁负责生成数呢?谁来做加法呢?根本用不着担心啦。用并行流的话,这问题就简单多了!

将顺序流转换为并行流

你可以把流转换成并行流,从而让前面的函数归约过程(也就是求和)并行运行——对顺序流调用parallel方法:

    public static long parallelSum(long n) {
        return Stream.iterate(1L, i -> i + 1)
                .limit(n)
                .parallel()
                .reduce(Long::sum).get();
    }

在上面的代码中,Stream在内部分成了几块。因此可以对不同的块独立并行进行归纳操作。最后,同一个归纳操作会将各个子流的部分归纳结果合并起来,得到整个原始流的归纳结果。

请注意,在现实中,对顺序流调用parallel方法并不意味着流本身有任何实际的变化。它在内部实际上就是设了一个boolean标志,表示你想让调用parallel之后进行的所有操作都并行执行。类似地,你只需要对并行流调用sequential方法就可以把它变成顺序流。请注意,你可能以为把这两个方法结合起来,就可以更细化地控制在遍历流时哪些操作要并行执行,哪些要顺序执行。例如,你可以这样做:

    stream.parallel()
        .filter(...)
        .sequential()
        .map(...)
        .parallel()
        .reduce();

但最后一次parallel或sequential调用会影响整个流水线。在本例中,流水线会并行执行,因为最后调用的是它。

你可能感兴趣的:(Java,8)