Java8-Lambda编程[1] Stream接口

引言

  Stream意为流,是Lambda编程中的一个重要角色。Stream类主要用于对收集类、数组、文件的迭代,以替代传统的foreach结构,将其革新为函数式的编程风格。使用Stream不仅会使代码看起来会更清爽,提高编程乐趣,还可以帮我们整合复杂操作,提高代码运行的效率。 例如我们要对一个List类型的收集变量进行遍历操作并输出每一个以“a”开头的元素,那么一般会有如下写法。

例1.0.0:
    List<String> list = Arrays.asList("ab", "acb", "ad", "fe", "gb", "cd", "azz", "zaz");
    for (String s : list) {
        if (s.startsWith("a"))
            System.out.println(s);
    }

  这样写实质上是Iterator在帮助我们迭代,foreach结构是一种语法糖(syntactic sugar),对它进行脱糖(desugar)后可得如下代码。

例1.0.1:
    Iterator<String> iterator=list.iterator();
    while(iterator.hasNext()) {
        String s=iterator.next();
        if(s.startsWith("a"))
            System.out.println(s);
    }

小试牛刀 forEach方法

  传统的写法看上去好像也没多麻烦,但是思想上来说,我们的关注点放在了具体的每一次迭代之上,没有放眼全局。而下面的写法,利用了Stream类,让代码的结构一下高明起来。

例1.1:
    Stream<String> stream=list.stream();
    stream.forEach(s->{if(s.startsWith("a"))System.out.println(s)});

  我们仍然从新的代码可以看到熟悉的“forEach”一词,但在这里它变成了一个函数,一个属于Stream对象的函数。此外还有一个名为forEachOredered的类似方法,看名字就知道此方法传入的任务会在迭代期间顺序执行,而forEach就不一定了,这是流式迭代和传统迭代的一个重要区别,具体原理我们再次先不细究。foreEach和它的兄弟forEachOrdered两个方法的参数均只有Consumer对象,Consumer意为消费者,此接口同之前见过的BinaryOperator接口与Predicate接口一样同属于_java.util.function_包,用于传递单一参数无返回值的函数。和前面几个有返回值的函数接口不同,Consumer鼓励写入带有副作用的代码,即函数内进行的操作要对函数外代码造成影响,否则一个无返回值的函数,我们难道用它自娱自乐不成?上面的代码中我们为表达式传入了一个字符串s,并在判断后将其输出。由于我想让代码看起来比较唬人,将迭代的内容写在了一行,所以看起来比较奇怪,如果选择将其展开成大括号的形式,可能看起来会比较自然些。

例1.2:
    Stream<String> stream=list.stream();
    stream.forEach(s-> {
        if(s.startsWith("a"))
        System.out.print(s);
    });

  这样看起来就清晰多了,但是感觉和普通的foreach结构也差不多,甚至有些化简为繁的嫌疑,但实际上这只是Stream最宽泛的用法,只有当它其他的神奇函数都实现不了你要的功能时,我们才会想到用普适的forEach函数。

流的生成

 从收集流出

  Java8为了使Lambda能够被广泛接纳,达到新天下耳目的效果,可谓是操碎了心。我们常用的收集类可以说没有不支持Stream的,上面的例子中我们就直接通过List对象的stream方法,连个get或者to前缀都不用写就得到了一个Stream对象。之所以可以这么方便,是因为stream方法被直接注射到了始祖接口Collection的血脉之中,就连不是Collection亲生的Map一族,也可以通过entrySet方法先获取一个entry(键值对)集合,再调用entry集合的stream方法。更甚至,连跟Collection毛关系都冇的数组与文件,也都能能通过其他手段获取相应的Stream对象,Stream的革新可谓革得很全面。

 从数组流出

  我们都知道数组不能像对象一样调用函数,那么J8妈到哪去给这厮找对象呢,答案就是请来_java.util_包中的Arrays类来充当媒人,通过调用Arrays类中的stream静态方法,将数组作为参数送入洞房,就可以生成数组的Stream对象。是不是很神奇又很刺激呢?_java.util_包中还有很多相当好使的工具类,为我们提供了大量静态方法,弥补了从前接口不能带有默认方法与静态方法的缺陷。每当我以为Java有缺陷,做不到“万物皆对象”的理念的时候,此包内的工具类总是能粗暴的解决我的问题。

 从文件流出

  从文件产生流要借助Files工具类,工厂方法的名字有些特殊,不叫stream而叫作lines,这个方法需要传入一个Path类型的参数,来获取由相应文件的每一行组成的流,类型为Stream。之所以产生文件流方法的名字不叫stream,是因为它还有三个也能产生stream的方法。一个叫做list,用来得到指定目录下所有文件与目录组成的流。另一个叫作find,看名字也能才出来用处,相比前者多了一个Predicate类型的参数来进行检索。最后一个方法叫作walk,在前者的基础上可以继续遍历指定深度下的所有文件与目录。这三个方法返回值都为Stream。

例1.3:
    //收集
    Collection collection;//适用于List或Set
    collection.stream();
    //映射
    Map map;
    map.entrySet().stream();
    //数组
    int[] array;
    Arrays.stream(array);
    //文件
    Path path;
    Files.lines(path);//迭代文件
    Files.list(path);//迭代目录

 从工厂流出

  流除了从以上三类数据结构中产生,还可以借助Stream接口的工厂方法,直接按照我们想要的规则来进行定制。比如通过枚举元素直接产生,只需要调用Stream.of静态方法,如果枚举为空还可以直接调用Stream.empty方法,直接生成一个空流。of的底层实现其实还是Arrays.stream方法,从数组来生成流。此外还有generate与iterate两个静态方法,看名字也很好理解,前者传入()->res形式的Supplier(提供者)对象,直接按照指定规则生成每个元素,后者传入一个起始值seed与e->next(e)形式的UnaryOperator(单元操作)对象,通过递推方式产生后面的元素。要注意的是这两种方法产生流都是无穷的,必须要在后面调用limit方法来进行节制,否则产生了无穷流泛滥开来,整个程序都容不下。最后还有一个concat静态方法,用于将两个相同类型的流合并程一个流。

例1.4:
    //枚举
    Stream.of(1,2,3);//数组产生的流
    Stream.empty();//空流
    //引发
    Stream.generate(()-> Math.random()).limit(1000);
    //递推
    Stream.iterate(0,i->i+1).limit(1000);
    //合流
    Stream.concat(stream,empty);

流的级联

  有了这么多的生成渠道,Stream这厮便可以在Java的第8纪元大行其道了。可光是生成出来还不行,具体要用起来还不知道怎么样。接下来就是一个典型的Stream用法,仍旧遍历我们最上面的那个字符串列表,不过这次我们要将长度为3的字符串削去首字符,再筛选出以“a”开头的字符串,并统计个数,任务虽然变得更加负责,代码却依然很清晰。

例1.4:
    List<String> list = Arrays.asList("ab", "acb", "ad", "fe", "gb", "cd", "azz", "azb");
    list.stream()
            .map(s->s.length()==3?s.substring(1):s)
            .filter(s->s.startsWith("a"))
            .count();

  上面的代码通过级联的方式对Stream对象进行了三项操作,从方法名便可以看出各个方法的功能。map方法执行的是映射操作,如果s的长度为3则将其替换为削去首字符的字符串,否则替换为自身,方法执行完毕后会返回一个Stream对象方便下一步的操作。filter方法执行的是过滤操作,对map方法返回的流进行筛选,保留其中以“a”开头的字符串,去除剩下的部分。最后一步调用count方法统计filter方法返回的流中元素的个数,由于该方法需要返回一个整数值,所以无法再继续级联。Stream的操作方法分为两种,一种是延时求值(lazy evaluation,直译为惰性求值)方法,一种是及时求值方法(eager evaluation,直译为急切求值)。前者的返回值为Stream对象,后者的返回值为其他类型,执行后无法再级联其他方法,除非你写一个Stream。

 引而不发 延时求值方法

  延时求值的方法调用后并不会立即执行,而是要等待后面级联的方法中出现及时求值方法才会执行,下面的示例代码将不会执行。

例1.5:
    List<String> list = Arrays.asList("ab", "acb", "ad", "fe", "gb", "cd", "azz", "azb");
    list.stream()
            .map(s -> s.substring(0, 1))
            .filter(s -> !s.startsWith("a"))
            .limit(3)
            .skip(0)
            .distinct()
            .sorted();

  虽然我级联着写了六个方法,但它们全都是延时求值方法,如果不在后面加上一个及时求值方法,它们就只能一起干等着。让我们一起来分析一下这六个方法的功能,前两个方法前面已经介绍过,limit方法顾名思义是用来限制流中元素的个数我们也已经见过,skip方法跳过前面一些元素,distinct方法去除重复元素。sorted方法对流中元素进行排序,但只适用于有序的收集类(List)或数组生的成流,如果流是从Set之类的无序集合类中生成的,那么调用sorted函数后果会不堪设想。

  延时求值的函数还有peek、flatMap、parallel、sequential以及mapTo族函数、flatMapTo族函数。parallel与sequential函数涉及并行与串行操作,留到第五章来讲。peek意为查看,它的参数是一个Consumer对象,将传进来的元素消费掉并产生副作用。flatMap意为平面映射,它要求映射的原象(preimage)必须为Stream类型,通过flatmap函数,我们可以分支的流汇合在一起。

例1.6:
    Stream<List> stream=Stream.of(
            Arrays.asList(1,2,3),
            Arrays.asList("1","2","3"),
            Arrays.asList(1.0,2.0,3.0));
    stream.flatMap(list->list.stream())
            .peek(System.out::println)
            .toArray();

  我先通过Arays生成了三个List,又将它们合成在一个数组中,通过of方法来生成一个List类型的流。而在最后我希望可以将这种二维接口拆散,把每一个元素都取出来合并到一个数组中,这就需要借助flatmap方法将其进行平面投影。合并的过程中我们通过peek方法对每一个对象进行了输出,并在最后调用了一个简单的及时求值方法toArray来将流转化为数组,以此驱使流执行遍历操作。运行代码可得到如下结果:

 1  2  3  1  2  3  1.0  2.0  3.0

  如果我们将flatMap方法去掉,将会输出三个List:

 [1, 2, 3]  [1, 2, 3]  [1.0, 2.0, 3.0]

  三个List中的元素均为Object类型,向下转型后可得到原始类型。分别为Integer、Double、String。这里强调一点,虽然在上上面的代码中peek方法操作的是flatmap方法执行之后的流,但这并不意味着流被遍历了两次。流作为一个特殊的结构,只能进行一次遍历,遍历完成后流基本上就废了,无法再进行二次操作。如果对同一个流执行两次及时求值方法,第二次遍历将会抛出__java.lang.IllegalStateException: stream has already been operated upon or closed__。该异常告诉我们流已经被操过了,目前已经处于非法状态,不能再继续操作。

  除了map与flatMap外,还有很多名称形如mapToInt、flatMapToLong的映射方法,其映射的象(image)被限定为IntStream、LongStream、DoubleStream类型,是用来将普通的对象流转化为基本类型流的。由于基本类型存在装包拆包的问题,严重影响流的效率,所以干脆就为它们开发了专用的流。这些流用法大多和对象流相同,只是建立在了基本类型的基础之上,级联方法要返回相应的基本类型流。如IntStream的map方法就只能映射为int类型的值,如果要映射为对象就需要调用mapToObj方法,返回一个对象流,后面就可以级联对象的方法了。

 一触即发 及时求值方法

  说了这么多延时求值方法,若是没有及时求值方法,一个也不能被执行,我们还是快来看看及时求值方法有哪些吧。在看之前,先来提一个概念——reduce操作。reduce意为缩减、归纳,所谓reduce操作看起来很洋,说白了就是从一堆元素中产生一个元素。譬如我们前面例子中用到的count方法,就是一个典型的reduce操作。除了它之外,常用的还有max、min方法,看名字也知道是什么作用,但是要注意这两个方法返回的都是Optional类型的对象,此类型用于解决null带来的恼人问题,将在第四章来好好讲一讲,这里就不细说了。我们在调用max、min方法之后,还需要调用Optional对象的get方法来获取值,如果它确实有值的话就返回该值,否则就返回空。其他的reduce族方法还有findAny、findFirst,它们没有参数,用来寻找流中任一个和第一个元素,并返回一个Optional对象,因为存在找不到元素的风险。allMatch、anyMatch、noneMatch三个方法传入一个参数进行匹配,并返回一个boolean类型的对象,用处不用我说,只要懂英语看名字就能知道作用。

  总的来看,reduce操作方法大多和统计与查找有关,似乎难以满足我们更为复杂的需求,比如求个累加值之类的。别担心,这里还有一系列更为直接的重载方法,名字就叫reduce,完全可以满足我们的需求。例如下面的代码,通过reduce方法来实现累加器。

例1.7:
    Stream.of(2, 1, 4, 3, 5, 7, 6,8,10)
          .filter(i -> i % 2 == 0)
          .reduce(0, (acc, i) -> acc + i);

  ruduce方法的第一个参数是结果的初始值,第二个参数是一个BinaryOperator对象,第一个操作数就是reduce方法要返回的值,第二个操作数则是每一次被遍历到的元素。在上面的例子中,我们对流中的元素过滤后进行了累加操作。

  除了上述的将流归纳为一个元素的reduce族方法外,还有toArray方法与collect两个及时求值方法,分别将流转换为数组与收集类,前者我们已经接触过,后者涉及到Collector接口,我们下一章再仔细讲。

流的关闭

  除了上述方法,Stream接口还有close与onClose方法,分别用于关闭流与在流关闭时进行回调onClose方法是一个延时求值方法,参数是一个Runnable对象,可以直接传入一个Lambda表达式来执行操作。而close方法既不是延时求值方法也不是及时求值方法,因为很显然它的返回值是void,根本不想求值。