Code前端首页关于Code前端联系我们

Java Stream 终止操作:缩减、分组和拆分

terry 2年前 (2023-09-25) 阅读数 51 #后端开发

JavaStream终结操作:归约、分组与分区

思维导图城市建设。

在上一篇文章中,我给大家讲了Stream的前半部分知识——包括Stream的整体概况、Stream的创建以及Stream转换流程的运作,并对Stream内部的一些优化点进行了简要说明。

虽然已经晚了,但我还是会继续向大家通报Stream的第二部分知识——终止行动。由于这部分API丰富且复杂,我会单独写一篇文章来详细讲解。

在正式开始之前,我们先来说一下聚合方法本身的特点(我会以聚合方法作为最终操作方法的参考):

  1. 聚合方法代表的是最终的结果整个流程计算,所以没有一个返回值是Stream。
  2. 聚合方法的返回值可以为空,例如过滤器不匹配,JDK8中使用Optional来避免NPE。
  3. 所有聚合方法都会调用evaluate方法。这是一个内部方法。您可以通过查看源代码来使用它来确定某个方法是否是聚合方法。

ok,既然知道了聚合方法的特点,为了方便理解,我把聚合方法分为几类:

JavaStream终结操作:归约、分组与分区

其中,我简单解释一下简单的聚合方法,重点介绍其他的,尤其是收藏家。他能做的事情有很多。 。 。

Stream聚合方法是我们使用Stream时必要的操作。如果你仔细学习这篇文章,你是否能够立即掌握Stream,至少能够流利地掌握Stream?

JavaStream终结操作:归约、分组与分区

1.简单的聚合方法

首先,让我们从简单的事情开始。

Stream的聚合方法比上一篇提到的更加无状态和有状态,不过有些喵喵一看就学会了。我们先来说第一部分这些方法:

  • count():返回Stream中元素的大小。
  • forEach():通过Stream中所有元素的内循环消耗每个元素。该方法没有返回值。
  • forEachOrder():效果和上面方法一样,但是可以保持消费的顺序,即使在多线程环境下也是如此。
  • 任何匹配(谓词):这是一个快捷操作。通过传递断言参数来确定元素是否可以与断言匹配。
  • allMatch(predicate):这是一个快捷操作,通过指定断言参数返回是否所有元素都可以匹配断言。
  • noneMatch(谓词):这是一个快捷操作。通过指定断言参数来指定是否所有元素都无法与断言匹配。如果是则返回 true,否则返回 false。
  • findFirst():这是一个快捷操作,返回 Stream 中的第一个元素。 Stream 可以为空,因此返回值由可选值处理。
  • findAny():这是一个返回字符串中任意元素的快捷操作。它通常是字符串中的第一个元素。字符串可以为空,因此返回值使用可选值进行处理。

虽然上面很简单,但是有五个涉及快捷操作的方法我要提一下:

第一个是

findFirst()和这两个方法,因为它们只需要终止一个元件,短路效应很容易理解。

然后是方法anyMatch,只需要匹配一个元素即可完成,所以它的短路效果也很容易理解。

最后,还有方法 allMatchnoneMatch。乍一看,这两个方法必须遍历整个流中的所有元素。事实上,事实并非如此。例如,allMatch只需要一个元素。如果与断言不匹配,它可以返回 false,而如果有一个元素与断言匹配,noneMatch 也可以返回 false,因此这两种方法都具有短路效果。

2。缩减

2.1 缩减:重复评估

第二部分我们会讲缩减。因为这个词太抽象了,所以我得找一个通俗易懂的解释来翻译这句话。换句话说,下面是归约的定义:

反复组合Stream中的所有元素得到一个结果,这样的操作称为归约。

注意:在函数式编程中,这称为折叠。

举一个很简单的例子,我有三个元素1、2、3,我把它们相加,最后得到数字6。这个过程就是归约。

再比如,我有三个元素1、2、3,我比较它们,最后选择最大的数3或者最小的数1。这个过程也是一个归约。

下面我将给出一个总结示例来演示减少。归约使用归约的方法:

        Optional<Integer> reduce = List.of(1, 2, 3).stream()
                .reduce((i1, i2) -> i1 + i2);

首先你可能注意到了,在上面的小例子中我用了“亮亮”这个词,意思就是归约。大约处理两个元素,然后得到最终值,因此reduce方法的参数是一个二进制表达式,对两个参数进行任意处理,最终得到一个结果,其参数和结果必须是同一类型。

例如,在代码中,i1和i2是二进制表达式的两个参数。它们代表一个元素中的第一个元素和第二个元素。第一次加法完成后,结果会赋值给i1,i2继续代表下一个元素,直到元素用完,得到最终结果。

如果觉得这段文字写得不够优雅,也可以使用 Integer 中默认的方法:

        Optional<Integer> reduce = List.of(1, 2, 3).stream()
                .reduce(Integer::sum);

这也是使用 方法引用 来表达 lambda 的例子表达。

您可能还会注意到,它们的返回值是可选的,以避免 Stream 没有元素的情况。

你还可以想办法摆脱这种情况,即元素中必须至少有一个值。这里reducer给了我们一个重载方法:

        Integer reduce = List.of(1, 2, 3).stream()
                .reduce(0, (i1, i2) -> i1 + i2);

如上面的例子,在二元表达式之前添加了一个额外的参数,这个参数称为初始值,所以即使你的Stream没有元素,它最终也会返回0 ,所以不需要可选的。

在该方法的实际执行中,第一次执行时初始值会占据位置 i1,i2 代表 Stream 中的第一个元素,然后所得的和再次占据位置 i1,i2 代表下一个元素。

但是,使用初始值并不是没有成本的。它应该满足原则: accumulator.apply(identity, i1) == i1,这意味着当它第一次执行时,它的返回结果应该是你的 Stream 的第一个元素。

比如我上面的例子是一个加法运算。第一个加法是0 + 1 = 1,与上述原理一致。这个原则是为了保证在并行流的情况下得到正确的结果。

如果你的初始值为1,那么当前情况下每个线程都初始化为1,那么你最终的总数会比你预期的要大。

2.2 max:使用归约求最大值。

max方法也是直接调用reduce方法的reduce方法。

先看一个例子:

        Optional<Integer> max = List.of(1, 2, 3).stream()
                .max((a, b) -> {
                    if (a > b) {
                        return 1;
                    } else {
                        return -1; 
                    }
                });

是的,max方法就是这样使用的,这让我感觉我没有使用函数式接口。当然,你也可以用Integer的方法来化简:

        Optional<Integer> max = List.of(1, 2, 3).stream()
                .max(Integer::compare);

即便如此,这个方法在我看来还是很麻烦。虽然我知道在 max 方法中传递参数允许我们自定义排序规则,但我不明白为什么没有默认的自然排序方法。让我输入参数。

后来我才想到基本的Stream型。当然可以,不传参数直接取最大值即可:

        OptionalLong max = LongStream.of(1, 2, 3).max();

当然,类库的设计者已经想到了我能想到的一切~

:OptionalLong 是对基本类型OptionalLong 的封装。 ?很简单,无需赘述。

3。收集器

第三部分,我们来看看收集器。它的功能是收集Stream的元素,形成一个新的整体。

虽然我在本文开头已经给出了思维导图,但由于收集器的 API 比较多,所以我又画了一个与开头补充的:

JavaStream终结操作:归约、分组与分区

Collector 方法名称为collect,它的方法定义如下:

    <R, A> R collect(Collector<? super T, A, R> collector);

顾名思义,收集器是用来收集Stream的元素的。我们可以自定义最终的集合,但一般情况下我们不需要自己编写,因为JDK有一个内置的实现类Collector——Collectors。 ?使用toCollection是因为它需要传递参数,而没有人喜欢传递参数。

您还可以使用 toUnmodifyingList。它与toList的区别在于,它返回的集合不能更改元素,例如删除或添加新元素。

再举个例子,如果你想在去除重复后收集元素,那么你可以使用toSet或toUnmodifyingSet。

这是一个相当简单的例子:

        // toList
        List.of(1, 2, 3).stream().collect(Collectors.toList());

        // toUnmodifiableList
        List.of(1, 2, 3).stream().collect(Collectors.toUnmodifiableList());

        // toSet
        List.of(1, 2, 3).stream().collect(Collectors.toSet());

        // toUnmodifiableSet
        List.of(1, 2, 3).stream().collect(Collectors.toUnmodifiableSet());

上述方法没有参数,可以立即使用。底层toList也是经典的ArrayList,底层toSet是经典的HashSet。


有时候你可能希望将其收集到一个Map中,例如将订单数据转换为订单对应的订单号,那么你可以使用toMap():

        List<Order> orders = List.of(new Order(), new Order());

        Map<String, Order> map = orders.stream()
                .collect(Collectors.toMap(Order::getOrderNo, order -> order));

toMap() 有两个参数:

  1. 第一个参数代表 key ,表示要设置地图键。这里我指定了元素中的订单号。
  2. 第二个参数代表一个值,表示你要设置地图的值。我直接使用元素本身作为值,因此结果是 Map。

也可以使用元素属性作为值:

        List<Order> orders = List.of(new Order(), new Order());

        Map<String, List<Item>> map = orders.stream()
                .collect(Collectors.toMap(Order::getOrderNo, Order::getItemList));

返回订单号图+商品列表。

toMap() 有两个配套方法:

  • toUnmodifyingMap():返回不可修改的映射。
  • toConcurrentMap():返回一个线程安全的映射。

这两个方法的参数与toMap()完全相同。唯一的区别是底层生成的地图的特征不同。我们一般使用一个简单的toMap(),它的底层是我们最常用的层。 HashMap() 的实现。

toMap() 虽然功能强大且常用,但它有一个致命的缺陷。

我们知道HahsMap遇到相同的key时会被覆盖,但是如果toMap()方法生成map时你指定的key重复,则直接抛出异常。

例如上面的订单示例,我们假设两个订单的序号相同,但是如果你指定订单号作为key,那么这个方法会直接抛出IllegalStateException,因为它不允许指定元素相同。

3.2 分组方法

如果你想对数据进行分类,但你指定的key可能会重复,那么你应该使用groupingBy而不是toMap。

举个简单的例子,我想按照订单类型对订单集合进行分组,那么可以这样做:

        List<Order> orders = List.of(new Order(), new Order());

        Map<Integer, List<Order>> collect = orders.stream()
                .collect(Collectors.groupingBy(Order::getOrderType));

直接指定用于分组的元素的属性,它会自动按该属性进行分组,并且分组结果将收集为列表。

        List<Order> orders = List.of(new Order(), new Order());

        Map<Integer, Set<Order>> collect = orders.stream()
                .collect(Collectors.groupingBy(Order::getOrderType, toSet()));

groupingBy 还提供了一个重载,允许您自定义收集器类型,因此它的第二个参数是 Collector 对象。

对于Collector类型,我们一般使用Collectors类。由于我们之前使用过收集器,因此无需声明直接传递到这里的 toSet() 方法,这意味着我们将把分组的元素收集到一个集合中。

groupingBy 有一个类似的方法,称为 groupingByConcurrent()。这种方法可以提高并行分组的效率,但是不保证顺序,这里不再赘述。

3.3 划分方法

接下来我将介绍另一种分组情况——划分。这个名字有点令人困惑,但含义很简单:

按 TRUE 或 FALSE 对数据进行分组称为分区。

例如,我们根据是否付款对订单列表进行分组。这是拆分:

        List<Order> orders = List.of(new Order(), new Order());
        
        Map<Boolean, List<Order>> collect = orders.stream()
                .collect(Collectors.partitioningBy(Order::getIsPaid));        

因为订单是否支付只有两种状态:已支付和未支付,所以这种分组方式称为拆分。

和groupingBy一样,它也有一个重载的收集器类型匹配方法:

        List<Order> orders = List.of(new Order(), new Order());

        Map<Boolean, Set<Order>> collect = orders.stream()
                .collect(Collectors.partitioningBy(Order::getIsPaid, toSet()));

3.4经典复制方法

我们终于到了最后一部分。请原谅我没有命名该方法的这一部分。它有一个如此不起眼的名字,但这些方法正是我所说的:经典的复制品。

也就是说,收藏家们又执行了Stream原来的做法,包括:

  1. mapmappingfilter›‼filter❙ flatmap → 地图计数 → 计数
  2. 减少❙❙减少
  3. maxBy
  4. min → minBy

我不会使用这些方法.一一列举。之前的文章已经详细解释过。唯一的区别是某些方法有一个额外的参数。这个参数就是我们所说的分组和拆分时的参数。通过收集参数,您可以指定应该收集哪个容器。

我提它们主要是想讲一下为什么有这么多的饲养方法。我这里说的是我个人的观点,不代表官方观点。

我觉得主要是为了功能的组合。

这是什么意思?假设我有另一个要求:按订单类型对订单进行分组,并查看每组中有多少订单。

我们已经讨论了订单分组。要了解每个组中有多少个订单,我们只需要获取相应列表的大小即可。但我们不能这么烦人,还是一步到位吧。输出结果时,键值对是有序的。订购的类型和数量:

        Map<Integer, Long> collect = orders.stream()
                .collect(Collectors.groupingBy(Order::getOrderType, counting()));

就是这样,就这么简单,就是这样。这相当于说我们对分组后的数据又进行了一次计数操作。

上面的例子可能不太明显。当我们需要在最终收集后处理数据时,我们通常必须将其转换回 Stream,然后再使用它,但是使用这些收集器方法可以使您变得非常容易。数据处理在收集器中完成。

再比如,我们仍然按照订单类型对订单进行分组,但是如果我们想得到每种类型的订单量最大,那么我们可以这样做:

        List<Order> orders = List.of(new Order(), new Order());        
       
        Map<Integer, Optional<Order>> collect2 = orders.stream()
                .collect(groupingBy(Order::getOrderType, 
                        maxBy(Comparator.comparing(Order::getMoney))));

这样更加简洁方便,我们不需要分组后一一求最大值,我们可以一步完成。

进一步分组后,求出每组的序号:

        List<Order> orders = List.of(new Order(), new Order());        
       
        Map<Integer, Long> collect = orders.stream()
                .collect(groupingBy(Order::getOrderType, summingLong(Order::getMoney)));

不过,我们这里并没有讲 summingLong。它是一个内置的加法运算,支持 Integer、Long 和 Double。

有一个类似的方法,称为averagingLong。只要看一下名字,你就会发现找到平均值相当容易。闲着没事的时候,建议扫两遍。


该结束了,最后一个joining()方法对于连接字符串来说非常实用:

        List<Order> orders = List.of(new Order(), new Order());

        String collect = orders.stream()
                .map(Order::getOrderNo).collect(Collectors.joining(","));

这个方法的方法名看起来有点眼熟,是的,String类在JDK8之后新添加了一个 join() 方法 也用于连接字符串。连接收集器具有相同的功能,基本实现也相同,使用 StringJoiner 类。

4。总结

终于完成了。

在这篇有关Stream中最终操作的文章中,我提到了Stream中所有聚合方法。可以说,读完这篇文章你就会对Stream中的所有聚合操作有一个很好的了解。如果您不知道如何使用它们也没关系。只要知道这个东西存在就行了,不然在你的知识体系里Stream根本就做不了XX事,那就有点可笑了。

当然,我还是建议大家在项目中使用这些简洁的API,以提高代码的可读性和清晰度。考试后也很容易给别人留下深刻的印象~

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门