关于后端:讲透JAVA-Stream的collect用法与原理远比你想象的更强大

2次阅读

共计 12253 个字符,预计需要花费 31 分钟才能阅读完成。

大家好,又见面了。

在我后面的文章《吃透 JAVA 的 Stream 流操作,多年实际总结》中呢,对 Stream 的整体状况进行了粗疏全面的解说,也大略介绍了下后果收集器 Collectors 的常见用法 —— 但远不是全副。

本篇文章就来专门分析 collect 操作,一起解锁更多高级玩法,让 Stream 操作真正的成为咱们编码中的 神兵利器

初识 Collector

先看一个简略的场景:

现有团体内所有人员列表,须要从中筛选出上海子公司的全副人员

假设人员信息数据如下:

姓名 子公司 部门 年龄 工资
大壮 上海公司 研发一部 28 3000
二牛 上海公司 研发一部 24 2000
铁柱 上海公司 研发二部 34 5000
翠花 南京公司 测试一部 27 3000
玲玲 南京公司 测试二部 31 4000

如果你已经用过 Stream 流,或者你看过我后面对于 Stream 用法介绍的文章,那么借助 Stream 能够很轻松的实现上述诉求:

public void filterEmployeesByCompany() {List<Employee> employees = getAllEmployees().stream()
            .filter(employee -> "上海公司".equals(employee.getSubCompany()))
            .collect(Collectors.toList());
    System.out.println(employees);
}

上述代码中,先创立流,而后通过一系列两头流操作(filter办法)进行业务层面的解决,而后经由终止操作(collect办法)将解决后的后果输入为 List 对象。

但咱们理论面对的需要场景中,往往会有一些更简单的诉求,比如说:

现有团体内所有人员列表,须要从中筛选出上海子公司的全副人员,并依照部门进行分组

其实也就是加了个新的分组诉求,那就是先依照后面的代码实现逻辑根底上,再对后果进行分组解决就好咯:

public void filterEmployeesThenGroup() {
    // 先 筛选
    List<Employee> employees = getAllEmployees().stream()
            .filter(employee -> "上海公司".equals(employee.getSubCompany()))
            .collect(Collectors.toList());
    // 再 分组
    Map<String, List<Employee>> resultMap = new HashMap<>();
    for (Employee employee : employees) {
        List<Employee> groupList = resultMap
                .computeIfAbsent(employee.getDepartment(), k -> new ArrayList<>());
        groupList.add(employee);
    }
    System.out.println(resultMap);
}

仿佛也没啥故障,置信很多同学理论编码中也是这么解决的。但其实咱们也能够应用 Stream 操作间接实现:

public void filterEmployeesThenGroupByStream() {Map<String, List<Employee>> resultMap = getAllEmployees().stream()
            .filter(employee -> "上海公司".equals(employee.getSubCompany()))
            .collect(Collectors.groupingBy(Employee::getDepartment));
    System.out.println(resultMap);
}

两种写法都能够失去雷同的后果:

{研发二部 =[Employee(subCompany= 上海公司, department= 研发二部, name= 铁柱, age=34, salary=5000)], 
    研发一部 =[Employee(subCompany= 上海公司, department= 研发一部, name= 大壮, age=28, salary=3000), 
             Employee(subCompany= 上海公司, department= 研发一部, name= 二牛, age=24, salary=2000)]
}

上述 2 种写法相比而言,第二种是不是代码上要简洁很多?而且是不是有种 自正文 的滋味了?

通过 collect 办法的正当失当利用,能够让 Stream 适应更多理论的应用场景,大大的晋升咱们的开发编码效率。上面就一起来全面意识下 collect、解锁更多高级操作吧。

collect\Collector\Collectors 区别与关联

刚接触 Stream 收集器的时候,很多同学都会被 collect,Collector,Collectors 这几个概念搞的昏头昏脑,甚至还有很多人即便曾经应用 Stream 好多年,也只是晓得 collect 外面须要传入相似 Collectors.toList() 这种简略的用法,对其背地的细节也不甚了解。

这里以一个 collect 收集器最简略的应用场景来分析阐明下其中的关系:

📢概括来说

1️⃣ collect是 Stream 流的一个 终止办法 ,会应用传入的收集器(入参)对后果执行相干的操作,这个收集器必须是Collector 接口 的某个具体实现类
2️⃣ Collector 是一个 接口 ,collect 办法的收集器是 Collector 接口的 具体实现类
3️⃣ Collectors 是一个 工具类 ,提供了很多的动态工厂办法, 提供了很多 Collector 接口的具体实现类,是为了不便程序员应用而预置的一些较为通用的收集器(如果不应用 Collectors 类,而是本人去实现 Collector 接口,也能够)。

Collector 应用与分析

到这里咱们能够看出,Stream 后果收集操作的实质,其实 就是将 Stream 中的元素通过收集器定义的函数解决逻辑进行加工,而后输入加工后的后果

依据其执行的操作类型来划分,又可将收集器分为几种不同的 大类

上面别离论述下。

恒等解决 Collector

所谓 恒等解决 ,指的就是 Stream 的元素在通过 Collector 函数解决前后齐全不变,例如toList() 操作,只是最终将后果从 Stream 中取出放入到 List 对象中,并没有对元素自身做任何的更改解决:

恒等解决类型的 Collector 是理论编码中 最常被应用 的一种,比方:

list.stream().collect(Collectors.toList());
list.stream().collect(Collectors.toSet());
list.stream().collect(Collectors.toCollection());

归约汇总 Collector

对于 归约汇总 类的操作,Stream 流中的元素一一遍历,进入到 Collector 处理函数中,而后会与上一个元素的处理结果进行合并解决,并失去一个新的后果,以此类推,直到遍历实现后,输入最终的后果。比方 Collectors.summingInt() 办法的解决逻辑如下:

比方本文结尾举的例子,如果须要计算上海子公司每个月须要领取的员工总工资,应用 Collectors.summingInt() 能够这么实现:

public void calculateSum() {Integer salarySum = getAllEmployees().stream()
            .filter(employee -> "上海公司".equals(employee.getSubCompany()))
            .collect(Collectors.summingInt(Employee::getSalary));
    System.out.println(salarySum);
}

须要留神的是,这里的 汇总计算 不单单只数学层面的累加汇总,而是一个狭义上的汇总概念,行将多个元素进行解决操作,最终生成 1 个后果的操作 ,比方计算Stream 中最大值的操作,最终也是多个元素中,最终失去一个后果:

还是用之前举的例子,当初须要晓得上海子公司外面工资最高的员工信息,咱们能够这么实现:

public void findHighestSalaryEmployee() {Optional<Employee> highestSalaryEmployee = getAllEmployees().stream()
            .filter(employee -> "上海公司".equals(employee.getSubCompany()))
            .collect(Collectors.maxBy(Comparator.comparingInt(Employee::getSalary)));
    System.out.println(highestSalaryEmployee.get());
}

因为这里咱们要演示 collect 的用法,所以用了上述的写法。理论的时候 JDK 为了方便使用,也提供了上述逻辑的简化封装,咱们能够间接应用 max() 办法来简化,即上述代码与上面的写法等价:

public void findHighestSalaryEmployee2() {Optional<Employee> highestSalaryEmployee = getAllEmployees().stream()
            .filter(employee -> "上海公司".equals(employee.getSubCompany()))
            .max(Comparator.comparingInt(Employee::getSalary));
    System.out.println(highestSalaryEmployee.get());
}

分组分区 Collector

Collectors 工具类 中提供了 groupingBy 办法用来失去一个分组操作 Collector,其外部解决逻辑能够参见下图的阐明:

groupingBy()操作须要指定两个要害输出,即 分组函数 值收集器

  • 分组函数 :一个处理函数,用于基于指定的元素进行解决,返回一个用于分组的值(即 分组后果 HashMap 的 Key 值),对于通过此函数解决后返回值雷同的元素,将被调配到同一个组里。
  • 值收集器 :对于分组后的数据元素的进一步解决转换逻辑,此处还是一个惯例的 Collector 收集器,和 collect() 办法中传入的收集器齐全等同(能够想想 俄罗斯套娃,一个概念)。

对于 groupingBy 分组操作而言,分组函数 值收集器 二者必不可少。为了方便使用,在 Collectors 工具类中,提供了两个 groupingBy 重载实现,其中有一个办法只须要传入一个分组函数即可,这是因为其默认应用了 toList()作为值收集器:

例如:仅仅是做一个惯例的数据分组操作时,能够仅传入一个分组函数即可:

public void groupBySubCompany() {
    // 依照子公司维度将员工分组
    Map<String, List<Employee>> resultMap =
            getAllEmployees().stream()
                    .collect(Collectors.groupingBy(Employee::getSubCompany));
    System.out.println(resultMap);
}

这样 collect 返回的后果,就是一个HashMap,其每一个 HashValue 的值为一个List 类型

而如果不仅须要分组,还须要对分组后的数据进行解决的时候,则须要同时给定分组函数以及值收集器:

public void groupAndCaculate() {
    // 依照子公司分组,并统计每个子公司的员工数
    Map<String, Long> resultMap = getAllEmployees().stream()
            .collect(Collectors.groupingBy(Employee::getSubCompany,
                    Collectors.counting()));
    System.out.println(resultMap);
}

这样就同时实现了分组与组内数据的解决操作:

{南京公司 =2, 上海公司 =3}

下面的代码中 Collectors.groupingBy() 是一个分组 Collector,而其内又传入了一个归约汇总 Collector Collectors.counting(),也就是一个收集器中嵌套了另一个收集器。

除了上述演示的场景外,还有一种非凡的分组操作,其分组的 key 类型仅为布尔值,这种状况,咱们也能够通过 Collectors.partitioningBy() 提供的 分区收集器 来实现。

例如:

统计上海公司和非上海公司的员工总数, true 示意是上海公司,false 示意非上海公司

应用分区收集器的形式,能够这么实现:

public void partitionByCompanyAndDepartment() {Map<Boolean, Long> resultMap = getAllEmployees().stream()
            .collect(Collectors.partitioningBy(e -> "上海公司".equals(e.getSubCompany()),
                    Collectors.counting()));
    System.out.println(resultMap);
}

后果如下:

{false=2, true=3}

Collectors.partitioningBy()分区收集器的应用形式与 Collectors.groupingBy() 分组收集器的应用形式雷同。单纯从应用维度来看,分组收集器的 分组函数 返回值为 布尔值,则成果等同于一个分区收集器

Collector 的叠加嵌套

有的时候,咱们须要依据先依据某个维度进行分组后,再依据第二维度进一步的分组,而后再对分组后的后果进一步的解决操作,这种场景外面,咱们就能够通过 Collector 收集器的 叠加嵌套 应用来实现。

例如上面的需要:

现有整个团体整体员工的列表,须要统计各子公司内各部门下的员工人数。

应用 Stream 的嵌套 Collector,咱们能够这么实现:

public void groupByCompanyAndDepartment() {
    // 依照子公司 + 部门双层维度,统计各个部门内的人员数
    Map<String, Map<String, Long>> resultMap = getAllEmployees().stream()
            .collect(Collectors.groupingBy(Employee::getSubCompany,
                    Collectors.groupingBy(Employee::getDepartment,
                            Collectors.counting())));
    System.out.println(resultMap);
}

能够看下输入后果,达到了需要预期的诉求:

{
    南京公司 ={
        测试二部 =1, 
        测试一部 =1}, 
    上海公司 ={
        研发二部 =1, 
        研发一部 =2}
}

下面的代码中, 就是一个典型的 Collector 嵌套解决的例子,同时也是一个典型的 多级分组 的实现逻辑。对代码的整体处理过程进行分析,大抵逻辑如下:

借助多个 Collector 嵌套应用,能够让咱们解锁很多简单场景解决能力。你能够将这个操作设想为一个 套娃操作,如果违心,你能够有限嵌套上来(理论中不太可能会有如此荒诞的场景)。

Collectors 提供的收集器

为了不便程序员应用呢,JDK 中的 Collectors 工具类封装提供了很多现成的 Collector 实现类,可供编码时间接应用,对罕用的收集器介绍如下:

办法 含意阐明
toList 将流中的元素收集到一个 List 中
toSet 将流中的元素收集到一个 Set 中
toCollection 将流中的元素收集到一个 Collection 中
toMap 将流中的元素映射收集到一个 Map 中
counting 统计流中的元素个数
summingInt 计算流中指定 int 字段的累加总和。针对不同类型的数字类型,有不同的办法,比方 summingDouble 等
averagingInt 计算流中指定 int 字段的平均值。针对不同类型的数字类型,有不同的办法,比方 averagingLong 等
joining 将流中所有元素(或者元素的指定字段)字符串值进行拼接,能够指定拼接连接符,或者首尾拼接字符
maxBy 依据给定的比拟器,抉择出值最大的元素
minBy 依据给定的比拟器,抉择出值最小的元素
groupingBy 依据给定的分组函数的值进行分组,输入一个 Map 对象
partitioningBy 依据给定的分区函数的值进行分区,输入一个 Map 对象,且 key 始终为布尔值类型
collectingAndThen 包裹另一个收集器,对其后果进行二次加工转换
reducing 从给定的初始值开始,将元素进行一一的解决,最终将所有元素计算为最终的 1 个值输入

上述的大部分办法,后面都有应用示例,这里对 collectAndThen 补充介绍下。

collectAndThen对应的收集器,必须传入一个真正用于后果收集解决的 理论收集器 downstream以及一个finisher 办法,当 downstream 收集器计算出后果后,应用 finisher 办法对后果进行二次解决,并将处理结果作为最终后果返回。

还是拿之前的例子来举例:

给定团体所有员工列表,找出上海公司中工资最高的员工。

咱们能够写出如下代码:

public void findHighestSalaryEmployee() {Optional<Employee> highestSalaryEmployee = getAllEmployees().stream()
            .filter(employee -> "上海公司".equals(employee.getSubCompany()))
            .collect(Collectors.maxBy(Comparator.comparingInt(Employee::getSalary)));
    System.out.println(highestSalaryEmployee.get());
}

然而这个后果最终输入的是个 Optional<Employee> 类型,应用的时候比拟麻烦,那能不能间接返回咱们须要的 Employee 类型呢?这里就能够借助 collectAndThen 来实现:

public void testCollectAndThen() {Employee employeeResult = getAllEmployees().stream()
            .filter(employee -> "上海公司".equals(employee.getSubCompany()))
            .collect(
                    Collectors.collectingAndThen(Collectors.maxBy(Comparator.comparingInt(Employee::getSalary)),
                            Optional::get)
            );
    System.out.println(employeeResult);
}

这样就能够啦,是不是超简略的?

开发个自定义收集器

后面咱们演示了很多 Collectors 工具类中提供的收集器的用法,上一节中列出来的 Collectors 提供的罕用收集器,也能够笼罩大部分场景的开发诉求了。

但兴许在我的项目中,咱们会遇到一些定制化的场景,现有的收集器无奈满足咱们的诉求,这个时候,咱们也能够本人来实现 定制化的收集器

Collector 接口介绍

咱们晓得,所谓的收集器,其实就是一个 Collector 接口的具体实现类。所以如果想要定制本人的收集器,首先要先理解 Collector 接口到底有哪些办法须要咱们去实现,以及各个办法的作用与用处。

当咱们新建一个 MyCollector 类并申明实现 Collector 接口的时候,会发现须要咱们实现 5 个 接口:

这 5 个接口的含意阐明归纳如下:

接口名称 性能含意阐明
supplier 创立新的后果容器,能够是一个容器,也能够是一个累加器实例,总之是用来存储后果数据的
accumlator 元素进入收集器中的具体解决操作
finisher 当所有元素都解决实现后,在返回后果前的对后果的最终解决操作,当然也能够抉择不做任何解决,间接返回
combiner 各个子流的处理结果最终如何合并到一起去,比方并行流解决场景,元素会被切分为好多个分片进行并行处理,最终各个分片的数据须要合并为一个整体后果,即通过此办法来指定子后果的合并逻辑
characteristics 对此收集器解决行为的补充形容,比如此收集器是否容许并行流中解决,是否 finisher 办法必须要有等等,此处返回一个 Set 汇合,外面的候选值是固定的几个可选项。

对于 characteristics 返回 set 汇合中的可选值,阐明如下:

取值 含意阐明
UNORDERED 申明此收集器的汇总归约后果与 Stream 流元素遍历程序无关,不受元素解决程序影响
CONCURRENT 申明此收集器能够多个线程并行处理,容许并行流中进行解决
IDENTITY_FINISH 申明此收集器的 finisher 办法是一个恒等操作,能够跳过

当初,咱们晓得了这 5 个接口办法各自的含意与用处了,那么作为一个 Collector 收集器,这几个接口之间是如何配合解决并将 Stream 数据收集为须要的输入后果的呢?上面这张图能够清晰的论述这一过程:

当然,如果咱们的 Collector 是反对在 并行流 中应用的,则其处理过程会稍有不同:

为了对上述办法有个直观的了解,咱们能够看下 Collectors.toList() 这个收集器的实现源码:

static final Set<Collector.Characteristics> CH_ID
            = Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));

public static <T> Collector<T, ?, List<T>> toList() {return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
                               (left, right) -> {left.addAll(right); return left; },
                               CH_ID);
}

对上述代码拆解剖析如下:

  • supplier 办法 ArrayList::new,即 new 了个ArrayList 作为后果存储容器。
  • accumulator 办法 List::add,也就是对于 stream 中的每个元素,都调用list.add() 办法增加到后果容器追踪。
  • combiner 办法 (left, right) -> {left.addAll(right); return left; },也就是对于并行操作生成的各个 子 ArrayList后果,最终通过 list.addAll() 办法合并为最终后果。
  • finisher 办法:没提供,应用的默认的,因为无需做任何解决,属于恒等操作。
  • characteristics:返回的是 IDENTITY_FINISH,也即最终后果间接返回,无需 finisher 办法去二次加工。留神这里没有申明CONCURRENT,因为 ArrayList 是个非线程平安的容器,所以这个收集器是 不反对在并发过程中应用

通过下面的一一办法形容,再联想下 Collectors.toList() 的具体表现,想必对各个接口办法的含意应该有了比拟直观的了解了吧?

实现 Collector 接口

既然曾经搞清楚 Collector 接口中的次要办法作用,那就能够开始入手写本人的收集器啦。新建一个 class 类,而后申明实现 Collector 接口,而后去实现具体的接口办法就行咯。

后面介绍过,Collectors.summingInt收集器是用来计算每个元素中某个 int 类型字段的总和的,假如咱们须要一个新的累加性能:

计算流中每个元素的某个 int 字段值平方的总和

上面,咱们就一起来自定义一个收集器来实现此性能。

  • supplier 办法

supplier 办法的职责,是 创立一个后果存储累加的容器 。既然咱们要计算多个值的累加后果,那首先就是要先申明一个int sum = 0 用来存储累加后果。然而为了让咱们的收集器能够反对在并发模式下应用,咱们这里能够采纳 线程平安的 AtomicInteger来实现。

所以咱们便能够确定 supplier 办法的实现逻辑了:

@Override
public Supplier<AtomicInteger> supplier() {// 指定用于最终后果的收集,此处返回 new AtomicInteger(0),后续在此基础上累加
    return () -> new AtomicInteger(0);
}
  • accumulator 办法

accumulator办法是实现具体的计算逻辑的,也是整个 Collector 的 外围业务逻辑 所在的办法。收集器解决的时候,Stream 流中的元素会一一进入到 Collector 中,而后由 accumulator 办法来进行一一计算:

@Override
public BiConsumer<AtomicInteger, T> accumulator() {
    // 每个元素进入的时候的遍历策略,以后元素值的平方与 sum 后果进行累加
    return (sum, current) -> {int intValue = mapper.applyAsInt(current);
        sum.addAndGet(intValue * intValue);
    };
}

这里也补充说下,收集器中的几个办法中,仅有 accumulator 是须要反复执行的,有几个元素就会执行几次,其余的办法都不会间接与 Stream 中的元素打交道。

  • combiner 办法

因为咱们后面 supplier 办法中应用了线程平安的 AtomicInteger 作为后果容器,所以其反对在并行流中应用。依据下面介绍,并行流是将 Stream 切分为多个分片,而后别离对分片进行计算解决失去分片各自的后果,最初这些 分片的后果须要合并为同一份总的后果,这个如何合并,就是此处咱们须要实现的:

@Override
public BinaryOperator<AtomicInteger> combiner() {
    // 多个分段后果解决的策略,间接相加
    return (sum1, sum2) -> {sum1.addAndGet(sum2.get());
        return sum1;
    };
}

因为咱们这里是要做一个数字平方的总和,所以这里对于分片后的后果,咱们间接累加到一起即可。

  • finisher 办法

咱们的收集器指标后果是输入一个累加的 Integer 后果值,然而为了保障并发流中的线程平安,咱们应用 AtomicInteger 作为了后果容器。也就是最终咱们须要将外部的 AtomicInteger 对象转换为 Integer 对象,所以 finisher 办法咱们的实现逻辑如下:

@Override
public Function<AtomicInteger, Integer> finisher() {
    // 后果解决实现之后对后果的二次解决
    // 为了反对多线程并发解决,此处外部应用了 AtomicInteger 作为了后果累加器
    // 然而收集器最终须要返回 Integer 类型值,此处进行对后果的转换
    return AtomicInteger::get;
}
  • characteristics 办法

这里呢,咱们申明下该 Collector 收集器的一些个性就行了:

  1. 因为咱们实现的收集器是容许并行流中应用的,所以咱们申明了 CONCURRENT 属性;
  2. 作为一个数字累加算总和的操作,对元素的先后计算程序并没有关系,所以咱们也同时申明 UNORDERED 属性;
  3. 因为咱们的 finisher 办法外面是做了个后果解决转换操作的,并非是一个恒等解决操作,所以这里就不能申明 IDENTITY_FINISH 属性。

基于此剖析,此办法的实现如下:

@Override
public Set<Characteristics> characteristics() {Set<Characteristics> characteristics = new HashSet<>();
    // 指定该收集器反对并发解决(后面也发现咱们采纳了线程平安的 AtomicInteger 形式)characteristics.add(Characteristics.CONCURRENT);
    // 申明元素数据处理的先后顺序不影响最终收集的后果
    characteristics.add(Characteristics.UNORDERED);
    // 留神: 这里没有增加上面这句,因为 finisher 办法对后果进行了解决,非恒等转换
    // characteristics.add(Characteristics.IDENTITY_FINISH);
    return characteristics;
}

这样呢,咱们的自定义收集器就实现好了,如果须要残缺代码,能够到文末的 github 仓库地址上获取。

咱们应用下本人定义的收集器看看:

public void testMyCollector() {Integer result = Stream.of(new Score(1), new Score(2), new Score(3), new Score(4))
            .collect(new MyCollector<>(Score::getScore));
    System.out.println(result);
}

输入后果:

30

完全符合咱们的预期,自定义收集器就实现好了。回头再看下,是不是挺简略的?

总结

好啦,对于 Java 中 Stream 的 collect 用法与 Collector 收集器的内容,这里就给大家分享到这里咯。看到这里,不晓得你是否把握了呢?是否还有什么疑难或者更好的见解呢?欢送多多留言切磋交换。

📢此外:

  • 对于本文中波及的 演示代码 的残缺示例,我曾经整顿并提交到 github 中,如果您有须要,能够自取:https://github.com/veezean/JavaBasicSkills

我是悟道,聊技术、又不仅仅聊技术~

如果感觉有用,请点个关注,也能够关注下我的公众号【架构悟道】,获取更及时的更新。

期待与你一起探讨,一起成长为更好的本人。

正文完
 0