Java函数式编程:三、流与函数式编程

时间:2022-11-06 07:13:46

本文是Java函数式编程的最后一篇,承接上文:
Java函数式编程:一、函数式接口,lambda表达式和方法引用
Java函数式编程:二、高阶函数,闭包,函数组合以及柯里化

前面都是概念和铺垫,主要讲述了函数式编程中,如何获取我们需要的函数作为参数或输出来进行编程,同时补充了一些要注意的知识。比如柯里化,闭包等等。

而这一篇要讲的是Java函数式编程的主菜,也就是如何把我们苦苦获取的函数,运用在真正的对于数据的处理之中。

在以前,我们通常会通过一个集合把这些数据放在一起,然后详细编写其处理过程使之能被逐一处理,最后再通过一个集合把它们获取出来,这没有任何问题。但是对于某些情况下而言,我们已完全洞悉并且厌烦了这些处理过程,我们渴望获得一种更轻便,更简易的手段,能使得整个集合中的数据处理就像水流通过管道一样,我们可以随意在这条管道上拼接各式各样的制式的处理器来处理这些数据,并最后给出一个结果。
——这个制式的处理器就是我们的函数,而这个管道就是流


流是一个与任何特定的存储机制都没有关系的元素序列,我们一般会这样说流:没有存储

不同于对于任何一个集合的操作,当我们使用流时,我们是从一个管道中抽取元素进行处理,这非常重要,因为大多数时候我们不会无缘无故的将元素放进一个集合,我们一定是希望对其进行一些处理,也就是说,我们不是为了存储才将它们放入集合的。

如果是这样,那么就意味着我们的编程很多时候需要转向流而不是集合。

流最关键的优点是,能够使得我们的程序更小也更好理解。事实上,lambda函数和方法引用正是在这里才发挥出了其真正的威力,它们一同将Java带入了声明式编程:我们说明想要完成什么,而不是指明需要怎么去做。

  • 类似流+函数式编程这样实现的声明式编程机制,就被称之为内部迭代,我们看不见其内部的具体操作
  • 而通过循环,将内部的数据一个一个处理成型的机制就被称为外部迭代,我们可以显式的看清和修改内部的操作

流带来的声明式编程是Java 8最重要的新特性之一,为此,Java还引入了新的关键词default以便它们大刀阔斧的修改一些老的集合类,以便使得它们支持流。

下面,我们将分三个阶段来了解,我们可以怎样去使用流,并运用流和函数式编程获得极佳的编程体验

  • 流的创建
  • 流的中间操作
  • 流的终结操作

1、流的创建

最基本的流的创建方法就是

  • Stream.of(一组条目)
  • Collection.stream()

我们可以把任意相同类型的一组条目写在Stream.of()的参数中使之变成一个流,比如:

Stream.of("a", "b", "c", "d");
Stream.of(new Node(1), new Node(2), new Node(3));
Stream.of(1, 2, 3, 4, 5);

Collection接口的stream()方法则更是我们的好伙伴,所有实现了该接口的集合,都可以直接转变为一个流由我们处理。

此外,我们还有以下生成流的手段

  • 随机数流
  • int基本类型的区间范围方法
  • generate()方法
  • iterate()方法
  • 流生成器
  • Arrays.stream()将数组转换为流
  • 正则表达式

下面来逐一了解

随机数流

Random类已经得到了增强,现在有一组可以生成流的方法。

  • ints()
  • longs()
  • doubles()
  • boxed()

可以清楚的看到,我们只能通过Random类获取三种基本类型的流,或者在其后加上boxed()来获取它们的包装类的流。实际上,Random类生成的这些数值,还有别的价值,比如通过随机数来获取某个列表中的随机下表对应值,以此来获取随机的对象。

int区间范围方法

IntStraem类提供了新的range()方法,可以生成一个流,它代表一个由int值组成的序列,对于IntStream.range(a, b)来说,这个流中的数据是[a, b)区间的所有整数。

利用这个方法,我们可以通过流很好的代替某些循环了,比如:

public class Repeat{
    public static repeat(int n, Runnable action){
        IntStream.range(0, n).forEach(i -> action.run());
    }
}

这样一个方法就是把我们的action方法执行n次,可以很好的替代普通的循环。

generate() 方法

Stream.generate()方法可以接受一个方法作为参数,该方法必须要返回一个实例或基本类型。总之,无论你给出的方法返回了什么,generate()方法会无限的根据该方法产生元素并塞入流中,如果你不希望它无限产生,那么你应该使用limit()来限制次数

AtomicInteger i = new AtomicInteger();
Stream.generate(() -> i.getAndIncrement())
    .limit(20)
    .forEach(System.out::println);
// 输出为从0到19

iterate()方法

顾名思义,这个方法通过迭代不断产生元素,它可以将第一个参数作为输入赋给第二个参数 (也就是那个方法),然后该方法会产生一个输出,随后该输出又会作为输入再度交给方法来产生下一个输出,由此不断迭代。一个典型的例子是由此产生一个斐波那契数列的方法,如下所示。

int x = 0;
public Stream<Integer> numbers(){
    return Stream.iterate(1, o ->{
        int result = o + x;
        x = o;
        return result;
    });
}

public static void main(String[] args) {
    test2 t = new test2();
    t.numbers()
        .limit(20)
        .forEach(System.out::println);
}

流生成器

流生成器方法Stream.builder()可以返回Stream.Builder<T>类,你可以自定义这个返回的类的泛型以便适配需求,随后,你可以将它当作一个类似StringBuilder一样的存在使用,通过add()等方法向里面塞入元素,并最终通过build()方法来返回一个流。

Stream.Builder<String> builder = Stream.builder();
builder.add("a").add("b").add("c").build()
    .map(x -> x.toUpperCase())
    .forEach(System.out::print);
// 输出ABC

Arrays流方法

Arrays.stream()静态方法可以将一个数组转化为流,非常简单易理解

int[] chars = {1,2,3,4,5};
        Arrays.stream(chars)
                .forEach(System.out::print);
// 输出12345

正则表达式

Java 8在java.util.regex.Pattern类中加入了一个新方法splitAsStream(),该方法接受一个字符序列并可以根据我们传入的公式将其分拆为一个流。

要注意的是,这个地方的输入不能直接是一个流,必须得是一个CharSequence

String s = "abcdefg";
Pattern.compile("[be]").splitAsStream(s)
    .map(x -> x+"?")
    .forEach(System.out::print);
// 输出a?cd?fg?

2、中间操作

我们获取了流,那么我们要做什么呢?显然,我们希望逐个对流中的数据进行操作,我们有以下方式可选:

  • 查看元素
    • peek()
  • 对元素排序
    • sorted()
    • sorted(Comparator compa)
  • 移除元素
    • distinct()
    • filter(Predicate)
  • 将函数应用于每个元素
    • map(Function func)
    • mapToInt(ToIntFunction func)
    • mapToLong(ToLongFunction func)
    • mapToDouble(ToDoubleFunction func)
  • 应用函数期间组合流
    • flatMap(Function func)
    • flatMapToInt(ToIntFunction func)
    • flatMapToLong(ToLongFunction func)
    • flatMapToDouble(ToDoubleFunction func)

查看元素

主要就是peek(),它允许我们在不做任何操作的情况下查看流中的所有元素,其意义在于我们可以通过它来跟踪和调试我们的流代码,当你不知道你的代码中,这些流元素究竟被变成了什么样子的话,可以使用这个方法而不是forEach()来终止流。

对元素排序

sorted()方法,同样很好理解,如果你不给Comparator作为参数,那么就是一个很普通的排序方法,类似Arrays.sort()这样,你可以查看源码来看看默认顺序究竟如何。

不过更可靠的方法是我们自己来实现一个Comparator来操控整个流的比较结果。

移除元素

主要有两种方法,分别是distinct()filter二者都很好用,distinct()可以消除那些重复的元素,这比通过Set来获取元素要便捷得多。

filter(Predicate)更是全能,该方法需要以一个返回值为布尔的方法为变量,它会负责抛弃那些返回值为false的方法,留下那些返回值为true的方法,可以大大降低我们的代码量。

将函数应用于各个元素

主要就是map(Function func),其他三个方法只是返回值变为对应的基本类型流而已,主要是为了提高效率。我们需要提供一个能够处理流中元素并返回新值的方法,随后该方法就会将我们提供的参数方法应用于每个元素上,十分方便

在应用map()期间组合流

flatMap(),其实和map()的区别就是,有时候我们提供的参数方法会返回一个流而不是一个元素。这样的话,我们就需要另一个方法能够以流为参数进行处理,也就是需要一个方法把我们返回的流平展开成为元素,类似于把所有返回的流拼接在一起,成为一个更大的流然后再进行处理。

一个典型的例子:

public static void main(String[] args){
    Stream.of(1, 2, 3)
        .flatMap(i -> Stream.of('a', 'b', 'c'))
        .forEach(System.out::println);
    
    // 上面的flatMap()处如果使用map()那么会返回三个元素为{a, b, c}的流
    // 而如果是faltMap()则返回的是元素为{a, b, c, a, b, c, a, b, c}的流
}

3、Optional类型

到此我们已经了解了流的创建和中间操作,但是在学习终结操作之前,我们还有一个更重要的问题:健壮性研究。

在前面的处理环节我们需要考虑,如果流中存在一个null会发生什么呢?要知道流可不是什么快乐通道,作为程序员,我们必须要考虑周全,环环相扣。

所以为了防止在某些不该出现null的地方出现了null导致处理失败,我们需要一个类似占位符的存在,它既可以作为流元素占位也可以在我们要找的元素不存在时告知我们(即不会抛出异常)

这个想法的实现就是Optional类型,这些类型只会通过某些标准流操作返回,因为这些操作不一定能保证所要的结果一定存在:

  • findFirst()返回包含第一个元素的Optional,若流为空,则返回Optional.empty
  • findAny()返回包含任何元素的Optional,若流为空,则返回Optional.empty
  • max()min()分别返回包含流中最大或最小值的Optional,若流为空,则返回Optional.empty
  • reduce()的其中一个实现,参数为一个接收两个参数并返回一个结果的方法引用,其作用就是返回各个元素根据该参数计算得到的值,其中每次迭代计算出的值会作为下一次计算的第一个参数
    比如1,2,3,4给出reduce((x1, x2) -> x1+x2)
    那么计算流程会是1+2=3, 3+3=6,6+4=10
  • average()可以对数值化的流计算均值并以对应的Optional类对象返回

现在,我们可以从流中获取Optional对象了,那么有什么用呢?这就要提到便捷函数了

便捷函数可以用于获取Optional中封装的数据,并且简化了步骤

  • ifPresent(Consumer):如果值存在,则通过该值调用Consumer函数,否则跳过
  • orElse(otherObject):如果值存在,则返回该对象,否则返回参数对象
  • orElseGet(Supplier):如果值存在,则返回该对象,否则返回Supplier方法创造的对象
  • orElseThrow(Supplier):如果值存在,则返回该对象,否则抛出一个使用Supplier方法创造的异常

如果我们需要自己创建Optional对象,那么我们可以使用这些Optional类的静态方法:

  • empty():返回一个空的Optional
  • of(value):如果已经知道这个value不是null,可以使用该方法把它封装在一个Optional对象中
  • ofNullable(value):如果不能确定封装值是不是null,则使用此方法封装

最后,还有三种方法支持对Optional进行事后处理,提供最后一次处理机会

  • filter(Predicate)
  • map(Function)
  • flatMap(Function)

它们的作用都和中间操作中的对应方法一致,只不过返回值会被封装在Optional对象中

最后,回到我们的主角Stream上来,有时候,我们不是给出的参数含有null而是处理的结果可能含有null那么我们可能会希望将这些返回值包含在Optional对象中,那么我们可以通过类似x -> Optional.of(result)这样的方法将其封装,但是,如果这么做了就一定要清楚我们该如何获取这样的流中的对象。请牢记,要先验证是否存在,才能获取

Stream
    .filter(Optional::isPresent)
    .map(Optional::get)  // 到这里,流中的数据就都是Optional对象中包含的值了
    // 继续处理

4、终结操作

这些操作接受一个流作为参数,并生成一个最终结果而非返回那个流,因此,只要调用这些方法,流处理就将终结

  • 将流转化为一个数组
    • toArray()
    • toArray(generator)
      该方法会将元素保存在generator中,而不是创建一个新的并返回
  • 在每个流元素上应用某个终结操作
    • forEach(Consumer)
      在每个元素上调用Consumer方法
    • forEachOrdered(Consumer)
      该版本确保对元素的操作顺序是原始的流顺序
  • 收集操作
    • collect(Collector)
      相当复杂的一个方法,可以将所有元素存入我们给出的Collector容器中。
      • 本方法主要复杂在,我们实际上可以使用java.util.stream.Collectors文档中相当多的对象,而且其中有一部分很复杂
        比如如果我们希望放入一个TreeSet中使它们总是有序,那么我们可以使用Collectors.toCollection(TreeSet::new)来创建该容器并应用
    • collect(Supplier, BiConsumer, BiConsumer)
      • 在极小情况下,我们无法从Collectors类中找到我们想要的处理容器,那么就需要第二个方法
  • 组合所有的流元素
    • reduce(BinaryOperator)
      组合所有元素,组合的方法就是参数方法
    • reduce(identity, BinaryOperator)
      以identity为初始值组合所有元素,方法为第二个参数
    • reduce(identity, BiFunction, BinaryOperator)
      复杂,未作介绍
  • 匹配,都是根据Predicate返回一个布尔值
    • allMatch(Predicate)
    • anyMatch(Predicate)
    • noneMatch(Predicate)
  • 选择一个元素
    • findFirst()
      返回一个包含流中第一个元素的Optional对象,若流中没有元素即返回Optional.empty
    • findAny()
      返回一个包含流中任意一个元素的Optional对象,若流中没有元素则为Optional.empty
      • 不过需要注意的是,该方法对于非并行的流似乎总是会选择流中的第一个元素,如果是并行的则随机
  • 获取流相关的信息
    • count()
      计算流中元素数量
    • max(Comparator)
      通过Comaprator获取流中最大的元素
    • min(Comparator)
      通过Comparator获取流中最小的元素
    • 如果是数值化的流,除了上面这些,还有以下方法
    • average()
      获得平均值
    • sum()
      获得累加值
    • summaryStatics()
      返回可能有用的摘要数据,基本没什么用