美文网首页Java8·Lambda
Java8-Lambda编程[1] Stream接口

Java8-Lambda编程[1] Stream接口

作者: 斯特的简书 | 来源:发表于2018-01-10 13:16 被阅读27次

    引言

    Stream意为流,是Lambda编程中的一个重要角色。Stream类主要用于对收集类、数组、文件的迭代,以替代传统的foreach结构,将其革新为函数式的编程风格。使用Stream不仅会使代码看起来会更清爽,提高编程乐趣,还可以帮我们整合复杂操作,提高代码运行的效率。 例如我们要对一个List<String>类型的收集变量进行遍历操作并输出每一个以“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<String>。之所以产生文件流方法的名字不叫stream,是因为它还有三个也能产生stream的方法。一个叫做list,用来得到指定目录下所有文件与目录组成的流。另一个叫作find,看名字也能才出来用处,相比前者多了一个Predicate类型的参数来进行检索。最后一个方法叫作walk,在前者的基础上可以继续遍历指定深度下的所有文件与目录。这三个方法返回值都为Stream<Path>。

    例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<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方法执行之后的流,但这并不意味着流被遍历了两次。流作为一个特殊的结构,只能进行一次遍历,遍历完成后流基本上就废了,无法再进行二次操作。如果对同一个流执行两次及时求值方法,第二次遍历将会抛出<em style="color:#FF0000;">java.lang.IllegalStateException: stream has already been operated upon or closed</em>。该异常告诉我们流已经被操过了,目前已经处于非法状态,不能再继续操作。

    除了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,根本不想求值。如果我们在迭代过程中改变了心意,不想继续进行下去,就可以调用此方法来废弃斯流。

    相关文章

      网友评论

        本文标题:Java8-Lambda编程[1] Stream接口

        本文链接:https://www.haomeiwen.com/subject/krkonxtx.html