烧霞

一步一步 架构师之路

从头学Java17-Stream API(二)结合Record、Optional

Stream API

Stream API 是按照map/filter/reduce方法处理内存中数据的最佳工具。
本系列教程由Record讲起,然后结合Optional,讨论collector的设计。

使用Record对不可变数据进行建模

Java 语言为您提供了几种创建不可变类的方法。可能最直接的是创建一个包含final字段的final类。下面是此类的示例。

public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

编写这些元素后,需要为字段添加访问器。您还将添加一个 toString() 方法,可能还有一个 equals() 以及一个 hashCode() 方法。手写所有这些非常乏味且容易出错,幸运的是,您的 IDE 可以为您生成这些方法。

如果需要通过网络或文件系统将此类的实例从一个应用程序传送到另一个应用程序,则还可以考虑使此类可序列化。如果这样做,还要添加一些有关如何序列化的信息。JDK 为您提供了几种控制序列化的方法。

最后,您的Point类可能有一百多行,主要是IDE 生成的代码,只是为了对需要写入文件的两个整数不可变集进行建模。

Record已经添加到 JDK 以改变这一切。只需一行代码即可为您提供所有这些。您需要做的就是声明record的状态;其余部分由编译器为您生成。

呼叫Record支援

Record可帮助您使此代码更简单。从 Java SE 14 开始,您可以编写以下代码。

public record Point(int x, int y) {}

这一行代码为您创建以下元素。

  1. 它是一个不可变的类,有两个字段:xy
  2. 它有一个标准的构造函数,用于初始化这两个字段。
  3. toString()、equals() 和 hashCode() 方法是由编译器为您创建的,其默认行为与 IDE 将生成的内容相对应。如果需要,可以通过添加自己的实现来修改此行为。
  4. 它可以实现Serializable接口,以便您可以通过网络或通过文件系统发送到其他应用程序。序列化和反序列化record的方式遵循本教程末尾介绍的一些特殊规则。

record使创建不可变的数据集变得更加简单,无需任何 IDE 的帮助。降低了错误的风险,因为每次修改record的组件时,编译器都会自动更新 equals() 和 hashCode() 方法。

record的类

record也是类,是用关键字record而不是class声明的类。让我们声明以下record。

public record Point(int x, int y) {}

编译器在创建record时为您创建的类是final的。

此类继承了 java.lang.Record 类。因此,您的record不能继承其他任何类。

一条record可以实现任意数量的接口。

声明record的组成部分

紧跟record名称的块是(int x, int y) 。它声明了record组件。对于record的每个组件,编译器都会创建一个同名的私有final字段。您可以在record中声明任意数量的组件。

除了字段,编译器还为每个组件生成一个访问器。此访问器跟组件的名称相同,并返回其值。对于此record,生成的两个方法如下。

public int x() {
    return this.x;
}

public int y() {
    return this.y;
}

如果此实现适用于您的应用程序,则无需添加任何内容。不过,也可以定义自己的访问器。

编译器为您生成的最后一个元素是 Object 类中 toString()、equals() 和 hashCode() 方法的重写。如果需要,您可以定义自己对这些方法的覆盖。

无法添加到record的内容

有三件事不能添加到record中:

  1. 额外声明的实例字段。不能添加任何与组件不对应的实例字段。
  2. 实例字段的初始化。
  3. 实例的初始化块。

您可以使用静态字段,静态初始化块。

使用标准构造函数构造record

编译器还会为您创建一个构造函数,称为标准构造函数 canonical constructor。此构造函数以record的组件作为参数,并将其值复制到字段中。

在某些情况下,您需要覆盖此默认行为。让我们研究两种情况:

  1. 您需要验证组件的状态
  2. 您需要制作可变组件的副本。

使用紧凑构造函数

可以使用两种不同的语法来重新定义record的标准构造函数。可以使用紧凑构造函数或标准构造函数本身。

假设您有以下record。

public record Range(int start, int end) {}

对于该名称的record,应该预期 end大于start .您可以通过在record中编写紧凑构造函数来添加验证规则。

public record Range(int start, int end) {

    public Range {//不需要参数块
        if (end <= start) {
            throw new IllegalArgumentException("End cannot be lesser than start");
        }
    }
}

紧凑构造函数不需要声明其参数块。

请注意,如果选择此语法,则无法直接分配record的字段,例如this.start = start - 这是通过编译器添加代码为您完成的。 但是,您可以为参数分配新值,这会导致相同的结果,因为编译器生成的代码随后会将这些新值分配给字段。

public Range {
    // set negative start and end to 0
    // by reassigning the compact constructor's
    // implicit parameters
    if (start < 0)
        start = 0;//无法给this.start赋值
    if (end < 0)
        end = 0;
}

使用标准构造函数

如果您更喜欢非紧凑形式(例如,因为您不想重新分配参数),则可以自己定义标准构造函数,如以下示例所示。

public record Range(int start, int end) {//跟紧凑构造不能共存

    public Range(int start, int end) {
        if (end <= start) {
            throw new IllegalArgumentException("End cannot be lesser than start");
        }
        if (start < 0) {
            this.start = 0;
        } else {
            this.start = start;
        }
        if (end > 100) {
            this.end = 10;
        } else {
            this.end = end;
        }
    }
}

这种情况下,您编写的构造函数需要为record的字段手动赋值。

如果record的组件是可变的,则应考虑在标准构造函数和访问器中制作它们的副本。

自定义构造函数

还可以向record添加自定义构造函数,只要此构造函数内调用record的标准构造函数即可。语法与经典语法相同。对于任何类,调用this()必须是构造函数的第一个语句。

让我们检查以下Staterecord。它由三个组件定义:

  1. 此州的名称
  2. 该州首府的名称
  3. 城市名称列表,可能为空。

我们需要存储城市列表的副本,确保它不会从此record的外部修改。 这可以通过使用紧凑形式,将参数重新分配给副本。

拥有一个不用城市作参数的构造函数在您的应用程序中很有用。这可以是另一个构造函数,它只接收州名和首都名。第二个构造函数必须调用标准构造函数。

然后,您可以将城市作为 vararg 传递。为此,您可以创建第三个构造函数。

public record State(String name, String capitalCity, List<String> cities) {

    public State {
        // List.copyOf returns an unmodifiable copy,
        // so the list assigned to `cities` can't change anymore
        cities = List.copyOf(cities);
    }

    public State(String name, String capitalCity) {
        this(name, capitalCity, List.of());
    }

    public State(String name, String capitalCity, String... cities) {
        this(name, capitalCity, List.of(cities));//也是不可变的
    }

}

请注意,List.copyOf() 方法的参数不接受空值。

获取record的状态

您不需要向record添加任何访问器,因为编译器会为您执行此操作。一条record的每个组件都有一个访问器方法,该方法具有此组件的名称。

但是,某些情况下,您需要定义自己的访问器。 例如,假设上一节中的Staterecord在构造期间没有创建列表的不可修改的副本 - 那么它应该在访问器中执行此操作,以确保调用方无法改变其内部状态。 您可以在record中添加以下代码以返回此副本。

public List<String> cities() {
    return List.copyOf(cities);
}

序列化record

如果您的record类实现了可序列化,则可以序列化和反序列化record。不过也有限制。

  1. 可用于替换默认序列化过程的任何系统都不适用于record。创建 writeObject() 和 readObject() 方法不起作用,也不能实现 Externalizable
  2. record可用作代理对象来序列化其他对象。readResolve() 方法可以返回record。也可以在record中添加 writeReplace()。
  3. 反序列化record始终调用标准构造函数。因此,在此构造函数中添加的所有验证规则都将在反序列化record时强制执行。

这使得record在应用程序中作为数据传输对象非常合适。

在实际场景中使用record

record是一个多功能的概念,您可以在许多上下文中使用。

第一种方法是在应用程序的对象模型中携带数据。用record充当不可变的数据载体,也是它们的设计目的。

由于可以声明本地record,因此还可以使用它们来提高代码的可读性。

让我们考虑以下场景。您有两个建模为record的实体:CityState

public record City(String name, State state) {}
public record State(String name) {}

假设您有一个城市列表,您需要计算拥有最多城市数量的州。可以使用 Stream API 首先使用每个州拥有的城市数构建各州的柱状图。此柱状图由Map建模。

List<City> cities = List.of();

Map<State, Long> numberOfCitiesPerState =
    cities.stream()
          .collect(Collectors.groupingBy(
                   City::state, Collectors.counting()
          ));

获取此柱状图的最大值是以下代码。

Map.Entry<State, Long> stateWithTheMostCities =
    numberOfCitiesPerState.entrySet().stream()
                          .max(Map.Entry.comparingByValue())//最多城市
                          .orElseThrow();

最后一段代码是技术性的;它不具有任何业务意义;因为使用Map.Entry实例对柱状图的每个元素进行建模。

使用本地record可以大大改善这种情况。下面的代码创建一个新的record类,该类包含一个州和该州的城市数。它有一个构造函数,该构造函数将 Map.Entry 的实例作为参数,将键值对流映射到record流。

由于需要按城市数比较这些集,因此可以添加工厂方法来提供此比较器。代码将变为以下内容。

record NumberOfCitiesPerState(State state, long numberOfCities) {

    public NumberOfCitiesPerState(Map.Entry<State, Long> entry) {
        this(entry.getKey(), entry.getValue());//mapping过程
    }

    public static Comparator<NumberOfCitiesPerState> comparingByNumberOfCities() {
        return Comparator.comparing(NumberOfCitiesPerState::numberOfCities);
    }
}

NumberOfCitiesPerState stateWithTheMostCities =
    numberOfCitiesPerState.entrySet().stream()
                          .map(NumberOfCitiesPerState::new)
                          .max(NumberOfCitiesPerState.comparingByNumberOfCities())//record替换Entry
                          .orElseThrow();

您的代码现在以有意义的方式提取最大值。您的代码更具可读性,更易于理解,不易出错,从长远来看更易于维护。

使用collector作为末端操作

让我们回到Stream API。

使用collector收集流元素

您已经使用了一个非常有用的模式collect(Collectors.toList())来收集由 List 中的流处理的元素。此 collect() 方法是在 Stream 接口中定义的末端方法,它将 Collector 类型的对象作为参数。此Collector接口定义了自己的 API,可用于创建任何类型的内存中结构来存储流处理的数据。可以在CollectionMap的任何实例中进行收集,它可用来创建字符串,并且您可以创建自己的Collector实例以将自己的结构添加到列表中。

将使用的大多数collector都可以使用 Collectors 工厂类的工厂方法之一创建。这是您在编写 Collectors.toList() 或 Collectors.toSet() 时所做的。使用这些方法创建的一些collector可以组合使用,从而产生更多的collector。本教程涵盖了所有这些要点。

如果在此工厂类中找不到所需的内容,则可以决定通过实现 Collector 接口来创建自己的collector。本教程还介绍了如何实现此接口。

Collector API 在 Stream 接口和专用数字流IntStreamLongStreamDoubleStream中的处理方式不同:。Stream 接口有两个 collect() 方法重载,而数字流只有一个。缺少的正是将collector对象作为参数的那个。因此,不能将collector对象与专用的数字流一起使用。

在集合中收集

Collectors工厂类提供了三种方法,用于在Collection接口的实例中收集流的元素。

  1. toList() 将它们收集在 List 对象中。
  2. toSet() 将它们收集在 Set 对象中。
  3. 如果需要任何其他Collection实现,可以使用 toCollection(supplier),其中 supplier 参数将用于创建所需的 Collection 对象。如果您需要在 LinkedList 实例中收集您的数据,您应该使用此方法。

代码不应依赖于这些方法当前返回的 ListSet 的确切实现,因为它不是标准的一部分。

您还可以使用 unmodifiableList()toUnmodifiableSet() 两种方法获取 ListSet 的不可变实现。

以下示例显示了此模式的实际应用。首先,让我们在一个普通List实例中收集。

List<Integer> numbers =
IntStream.range(0, 10)
         .boxed()//需要装箱
         .collect(Collectors.toList());
System.out.println("numbers = " + numbers);

此代码使用 boxed() 中继方法从 IntStream.range() 创建的 IntStream 创建一个 Stream,方法是对该流的所有元素进行装箱。运行此代码将打印以下内容。

numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

第二个示例创建一个只有偶数且没有重复项的 HashSet

Set<Integer> evenNumbers =
IntStream.range(0, 10)
         .map(number -> number / 2)
         .boxed()
        .collect(Collectors.toSet());
System.out.println("evenNumbers = " + evenNumbers);

运行此代码将产生以下结果。

evenNumbers = [0, 1, 2, 3, 4]

最后一个示例使用 Supplier 对象来创建用于收集流元素的 LinkedList 实例。

LinkedList<Integer> linkedList =
IntStream.range(0, 10)
         .boxed()
         .collect(Collectors.toCollection(LinkedList::new));
System.out.println("linked listS = " + linkedList);

运行此代码将产生以下结果。

linked list = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

使用collector计数

Collectors 工厂类为您提供了几种方法来创建collector,这些collector执行的操作与普通末端方法为您提供的操作相同。Collectors.counting() 工厂方法就是这种情况,它与在流上调用 count() 相同。

这是值得注意的,您可能想知道为什么使用两种不同的模式实现了两次这样的功能。将在下一节有关在map中收集时回答此问题,您将在其中组合collector以创建更多collector。

目前,编写以下两行代码会导致相同的结果。

Collection<String> strings = List.of("one", "two", "three");

long count = strings.stream().count();
long countWithACollector = strings.stream().collect(Collectors.counting());

System.out.println("count = " + count);
System.out.println("countWithACollector = " + countWithACollector);

运行此代码将产生以下结果。

count = 3
countWithACollector = 3

收集在字符串中

Collectors 工厂类提供的另一个非常有用的collector是 joining() 。此collector仅适用于字符串流,并将该流的元素连接为单个字符串。它有几个重载。

  • 第一个将分隔符作为参数。
  • 第二个将分隔符、前缀和后缀作为参数。

让我们看看这个collector的实际效果。

String joined = 
    IntStream.range(0, 10)
             .boxed()
             .map(Object::toString)
             .collect(Collectors.joining());

System.out.println("joined = " + joined);

运行此代码将生成以下结果。

joined = 0123456789

可以使用以下代码向此字符串添加分隔符。

String joined = 
    IntStream.range(0, 10)
             .boxed()
             .map(Object::toString)
             .collect(Collectors.joining(", "));

System.out.println("joined = " + joined);

结果如下。

joined = 0, 1, 2, 3, 4, 5, 6, 7, 8, 9

让我们看看最后一个重载,它接收分隔符、前缀和后缀。

String joined = 
    IntStream.range(0, 10)
             .boxed()
             .map(Object::toString)
             .collect(Collectors.joining(", ", "{"), "}");

System.out.println("joined = " + joined);

结果如下。

joined = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

请注意,此collector可以正确处理流为空或仅处理单个元素的极端情况。

当您需要生成此类字符串时,此collector非常方便。即使您前面的数据不在集合中或只有几个元素,您也可能想使用它。如果是这种情况,使用 String.join() 工厂类或 StringJoiner 对象都将正常工作,无需支付创建流的开销。

使用Predicate对元素进行分区

Collector API 提供了三种模式,用于从流的元素创建map。我们介绍的第一个使用布尔键创建map。它是使用 partitionningBy() 工厂方法创建的。

流的所有元素都将绑定到布尔值truefalse。map将绑定到每个值的所有元素存储在列表中。因此,如果将此collector应用于Stream,它将生成具有以下类型的map:Map<Boolean,List<T>>

测试的Predicate应作为参数提供给collector。

下面的示例演示此collector的操作。

Collection<String> strings =
    List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
            "ten", "eleven", "twelve");

Map<Boolean, List<String>> map =
    strings.stream()
           .collect(Collectors.partitioningBy(s -> s.length() > 4));

map.forEach((key, value) -> System.out.println(key + " :: " + value));

运行此代码将生成以下结果。

false :: [one, two, four, five, six, nine, ten]
true :: [three, seven, eight, eleven, twelve]

此工厂方法具有重载,它将另一个collector作为参数。此collector称为下游collector。我们将在本教程的下一段中介绍,届时我们将介绍 groupingBy()

在map中收集并进行分组

我们提供的第二个collector非常重要,因为它允许您创建柱状图。

对map中的流元素进行分组

可用于创建柱状图的collector是使用 Collectors.groupingBy() 方法创建的。此方法具有多个重载。

collector将创建map。通过对其应用 Function 实例,为流的每个元素计算一个键。此函数作为 groupingBy() 方法的参数提供。它在Collector API 中称为分类器 classifier

除了不应该返回 null 之外,此函数没有任何限制。

此函数可能会为流的多个元素返回相同的键。groupingBy() 支持这一点,并将所有这些元素收集在一个列表中。

因此,如果您正在处理 Stream 并使用 Function<T, K> 作为分类器,则 groupingBy() 会创建一个 Map<K,List<T>>

让我们检查以下示例。

Collection<String> strings =
    List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
            "ten", "eleven", "twelve");

Map<Integer, List<String>> map =
    strings.stream()
           .collect(Collectors.groupingBy(String::length));//返回<Integer, List<String>>

map.forEach((key, value) -> System.out.println(key + " :: " + value));

此示例中使用的分类器是一个函数,用于从该流返回每个字符串的长度。因此,map按字符串长度将字符串分组到列表中。它具有Map<Interger,List<String>>的类型。

运行此代码将打印以下内容。

3 :: [one, two, six, ten]
4 :: [four, five, nine]
5 :: [three, seven, eight]
6 :: [eleven, twelve]

对分组后的值进行处理

计算数量

groupingBy() 方法还接受另一个参数,即另一个collector。此collector在Collector API 中称为下游collector,但它没有什么特别的。使它成为下游collector的原因只是,它作为参数传递给前一个collector的创建。

此下游collector用于收集由 groupingBy() 创建的map的值。

在前面的示例中,groupingBy() 创建了一个map,其值是字符串列表。如果为 groupingBy() 方法提供下游collector,API 将逐个流式传输这些列表,并使用下游collector收集这些流。

假设您将 Collectors.counting() 作为下游collector传递。将计算的内容如下。

[one, two, six, ten]  .stream().collect(Collectors.counting()) -> 4L
[four, five, nine]    .stream().collect(Collectors.counting()) -> 3L
[three, seven, eight] .stream().collect(Collectors.counting()) -> 3L
[eleven, twelve]      .stream().collect(Collectors.counting()) -> 2L

此代码不是 Java 代码,因此您无法执行它。它只是在那里解释如何使用这个下游collector。

下面将创建的map取决于您提供的下游collector。键不会修改,但值可能会。在 Collectors.counting() 的情况下,值将转换为 Long。然后,map的类型将变为 Map<Integer,Long>

前面的示例变为以下内容。

Collection<String> strings =
        List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
                "ten", "eleven", "twelve");

Map<Integer, Long> map =
    strings.stream()
           .collect(
               Collectors.groupingBy(
                   String::length, 
                   Collectors.counting()));//List<String>转为Stream向下传递,变成Long

map.forEach((key, value) -> System.out.println(key + " :: " + value));

运行此代码将打印以下结果。它给出了每个长度的字符串数,这是字符串长度的柱状图。

3 :: 4
4 :: 3
5 :: 3
6 :: 2

连接列表的值

您还可以将 Collectors.joining() collector作为下游collector传递,因为此map的值是字符串列表。请记住,此collector只能用于字符串流。这将创建 Map<Integer,String> 的实例。您可以将上一个示例更改为以下内容。

Collection<String> strings =
        List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
                "ten", "eleven", "twelve");

Map<Integer, String> map =
        strings.stream()
                .collect(
                        Collectors.groupingBy(
                                String::length,
                                Collectors.joining(", ")));//变成String
map.forEach((key, value) -> System.out.println(key + " :: " + value));

运行此代码将生成以下结果。

3 :: one, two, six, ten
4 :: four, five, nine
5 :: three, seven, eight
6 :: eleven, twelve

控制map的实例

groupingBy() 方法的最后一个重载将supplier的实例作为参数,以便您控制需要此collector创建的 Map 实例。

您的代码不应依赖于 groupingBy() 返回的确切map类型,因为它不是标准的一部分。

使用ToMap在map中收集

Collector API 为您提供了创建map的第二种模式:Collectors.toMap() 模式。此模式适用于两个函数,这两个函数都应用于流的元素。

  1. 第一个称为密钥mapper,用于创建密钥。
  2. 第二个称为值mapper,用于创建值。

此collector的使用场景与 Collectors.groupingBy() 不同。特别是,它不处理流的多个元素生成相同密钥的情况。这种情况下,默认情况下会引发IllegalStateException

这个collector能非常方便的创建缓存。假设User类有一个类型为 LongprimaryKey属性。您可以使用以下代码创建User对象的缓存。

List<User> users = ...;

Map<Long, User> userCache = 
    users.stream()
         .collect(User::getPrimaryKey, 
                 Function.idendity());//key必须不同

使用 Function.identity() 工厂方法只是告诉collector不要转换流的元素。

如果您希望流的多个元素生成相同的键,则可以将进一步的参数传递给 toMap() 方法。此参数的类型为 BinaryOperator。当检测到冲突元素时,实现将它应用于冲突元素。然后,您的binary operator将生成一个结果,该结果将代替先前的值放入map中。

下面演示如何使用具有冲突值的此collector。此处的值用分隔符连接在一起。

Collection<String> strings =
    List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
            "ten", "eleven", "twelve");

Map<Integer, String> map =
    strings.stream()
            .collect(
                    Collectors.toMap(
                            element -> element.length(),
                            element -> element, 
                            (element1, element2) -> element1 + ", " + element2));//相同key,解决冲突,返回新值

map.forEach((key, value) -> System.out.println(key + " :: " + value));

在此示例中,传递给 toMap() 方法的三个参数如下:

  1. element -> element.length()键mapper
  2. element -> element值mapper
  3. (element1, element2) -> element1 + ", " + element2)合并函数,相同键的两个元素会调用。

运行此代码将生成以下结果。

3 :: one, two, six, ten
4 :: four, five, nine
5 :: three, seven, eight
6 :: eleven, twelve

另外也可以将supplier作为参数传递给 toMap() 方法,以控制此collector将使用的 Map 接口实例。

toMap() collector有一个孪生方法 toConcurrentMap(),它将在并发map中收集数据。实现不保证map的确切类型。

从柱状图中提取最大值

groupingBy() 是分析计算柱状图的最佳模式。让我们研究一个完整的示例,其中您构建柱状图,然后尝试根据要求找到其中的最大值。

提取唯一的最大值

您要分析的柱状图如下。它看起来像我们在前面的示例中使用的那个。

Collection<String> strings =
    List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
            "ten", "eleven", "twelve");

Map<Integer, Long> histogram =
    strings.stream()
            .collect(
                    Collectors.groupingBy(
                            String::length,
                            Collectors.counting()));

histogram.forEach((key, value) -> System.out.println(key + " :: " + value));

打印此柱状图将得到以下结果。

3 :: 4 //期望是4 =>3
4 :: 3
5 :: 3
6 :: 2

从此柱状图中提取最大值应得到结果:3 :: 4。Stream API 具有提取最大值所需的所有工具。不幸的是,Map接口上没有stream()方法。要在map上创建流,您首先需要获取可以从map获取的集合之一。

  1. entrySet() 方法的映射集。
  2. keySet() 方法的键集。
  3. 或者使用 values() 方法收集值。

这里你需要键和最大值,所以正确的选择是流式传输 entrySet() 返回的集合。

您需要的代码如下。

Map.Entry<Integer, Long> maxValue =
    histogram.entrySet().stream()
             .max(Map.Entry.comparingByValue())
             .orElseThrow();

System.out.println("maxValue = " + maxValue);

您可以注意到,此代码使用 Stream 接口中的 max() 方法,该方法将comparator作为参数。实际上,Map.Entry 接口的确有几个工厂方法来创建这样的comparator。我们在此示例中使用的这个,创建了一个可以比较 Map.Entry 实例的comparator,使用这些键值对的值。仅当值实现Comparable接口时,此比较才有效。

这种代码模式非常普通,只要具有可比较的值,就可以在任何map上使用。我们可以使其特别一点,更具可读性,这要归功于Java SE 16中记录Record的引入。

让我们创建一个record来模拟此map的键值对。创建record只需要一行。由于该语言允许local records,因此您可以到任何方法中。

record NumberOfLength(int length, long number) {
    
    static NumberOfLength fromEntry(Map.Entry<Integer, Long> entry) {
        return new NumberOfLength(entry.getKey(), entry.getValue());//mapping过程
    }

    static Comparator<NumberOfLength> comparingByLength() {
        return Comparator.comparing(NumberOfLength::length);
    }
}

使用此record,以前的模式将变为以下内容。

NumberOfLength maxNumberOfLength =
    histogram.entrySet().stream()
             .map(NumberOfLength::fromEntry)
             .max(NumberOfLength.comparingByLength())//Record替换Entry,后面要引用字段
             .orElseThrow();

System.out.println("maxNumberOfLength = " + maxNumberOfLength);

运行此示例将打印出以下内容。

maxNumberOfLength = NumberOfLength[length=3, number=4]

您可以看到此record看起来像 Map.Entry 接口。它有一个mapping键值对的工厂方法和一个用于创建comparator的工厂方法。柱状图的分析变得更加可读和易于理解。

提取多个最大值

前面的示例是一个很好的示例,因为列表中只有一个最大值。不幸的是,现实生活中的情况通常不是那么好,您可能有几个与最大值匹配的键值对。

让我们从上一个示例的集合中删除一个元素。

Collection<String> strings =
    List.of("two", "three", "four", "five", "six", "seven", "eight", "nine",
            "ten", "eleven", "twelve");

Map<Integer, Long> histogram =
    strings.stream()
            .collect(
                    Collectors.groupingBy(
                            String::length,
                            Collectors.counting()));

histogram.forEach((key, value) -> System.out.println(key + " :: " + value));

打印此柱状图将得到以下结果。

3 :: 3
4 :: 3
5 :: 3//期望是3 =>[3,4,5]
6 :: 2

现在我们有三个键值对的最大值。如果使用前面的代码模式提取它,则将选择并返回这三个中的一个,隐藏其他两个。

解决此问题的解决方案是创建另一个map,其中键是字符串数量,值是与之匹配的长度。换句话说:您需要反转此map。对于 groupingBy() 来说,这是一个很好的场景。此示例将在本部分的后面介绍,因为我们还需要一个元素来编写此代码。

使用中继collector

到目前为止,我们介绍的collector只是计数、连接和收集到列表或map中。它们都属于末端操作。Collector API 也提供了执行中继操作的其他collector:mapping、filtering和flatmapping。您可能想知道这样的意义是什么。事实上,这些特殊的collector并不能单独创建。它们的工厂方法都需要下游collector作为第二个参数。

也就是说,您这样创建的整体collector是中继操作和末端操作的组合。

使用collector来mapping

我们可以检查的第一个中继操作是mapping操作。mapping collector是使用 Collectors.mapping() 工厂方法创建的。它将常规mapping函数作为第一个参数,将必需的下游collector作为第二个参数。

在下面的示例中,我们将mapping与列表中mapping后的元素的集合相结合。

Collection<String> strings =
    List.of("one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
            "ten", "eleven", "twelve");

List<String> result = 
    strings.stream()
        .collect(
            Collectors.mapping(String::toUpperCase, Collectors.toList()));//集成了mapping

System.out.println("result = " + result);

Collectors.mappping() 工厂方法创建一个常规collector。您可以将此collector作为下游collector传递给任何接受collector的方法,例如,包括 groupingBy()toMap()。您可能还记得在“提取多个最大值”一节中,我们留下了一个关于反转map的悬而未决的问题。让我们使用这个mapping collector来解决问题。

在此示例中,您创建了一个柱状图。现在,您需要使用 groupingBy() 反转此柱状图以查找所有最大值。

以下代码创建此类map。

Map<Integer, Long> histogram = ...; 

var map = 
    histogram.entrySet().stream()
        .map(NumberOfLength::fromEntry)
        .collect(
            Collectors.groupingBy(NumberOfLength::number));//<Long, List<NumberOfLength>>

让我们检查此代码并确定所构建map的确切类型。

此map的键是每个长度在原始流中存在的次数。它是NumberOfLengthrecord的number部分,Long。类型。

这些值是此流的元素,收集到列表中。因此,是NumberOfLength的对象列表。这张map的确切类型是Map<Long,NumberOfLength>

当然,这不是您所要的。您需要的只是字符串的长度,而不是record。从record中提取组件是一个mapping过程。您需要将这些NumberOfLength实例mapping为其length组件。现在我们介绍了mapping collector,可以解决这一点。您需要做的就是将正确的下游collector添加到 groupingBy() 调用中。

代码将变为以下内容。

Map<Integer, Long> histogram = ...; 

var map = 
    histogram.entrySet().stream()
        .map(NumberOfLength::fromEntry)
        .collect(
            Collectors.groupingBy(
                NumberOfLength::number, 
                Collectors.mapping(NumberOfLength::length, Collectors.toList())));//<Long, List<Integer>>

构建的map的值现在是使用NumberOfLength::lengthNumberOfLength做mapping后生成的对象列表。此map的类型为Map<Long,List<Integer>>,这正是您所需要的。

要获取所有最大值,您可以像之前那样,使用 key 获取最大值而不是值。

柱状图中的完整代码,包括最大值提取,如下所示。

Map<Long, List<Integer>> map =
    histogram.entrySet().stream()
             .map(NumberOfLength::fromEntry)
             .collect(
                Collectors.groupingBy(
                    NumberOfLength::number,//变成了number=>length列表
                    Collectors.mapping(NumberOfLength::length, Collectors.toList())));

Map.Entry<Long, List<Integer>> result =
    map.entrySet().stream()
       .max(Map.Entry.comparingByKey())//再求key的max
       .orElseThrow();

System.out.println("result = " + result);

运行此代码将生成以下内容。

result = 3=[3, 4, 5]//最多的length列表

这意味着有三种长度的字符串在此流中出现三次:3、4 和 5。

此示例显示嵌套在另外两个collector中的collector,在使用此 API 时,这种情况经常发生。乍一看可能看起来很吓人,但它只是使用下游collector组合成了collector。

您可以看到为什么拥有这些中继collector很有趣。通过使用collector提供的中继操作,您可以为几乎任何类型的处理创建下游collector,从而对map的值进行后续处理。

使用collector进行filtering和flatmapping

filtering collector遵循与mapping collector相同的模式。它是使用 Collectors.filtering() 工厂方法创建的,该方法接收常规Predicate来filter数据,同时要有必需的下游collector。

Collectors.flatMapping() 工厂方法创建的flatmapping collector也是如此,它接收flatmapping函数(返回流的函数)和必需的下游collector。

使用末端collector

Collector API 还提供了几个末端操作,对应于Stream API 上可用的末端操作。

创建自己的collector

了解collector的工作原理

如前所述,Collectors工厂类仅处理对象流,因为将collector对象作为参数的 collect() 方法仅存在于 Stream 中。如果您需要收集数字流,那么您需要了解collector的组成元素是什么。

简单说,collector建立在四个基本组件之上。前两个用于收集流的元素。第三个仅用于并行流。某些类型的collector需要第四个,这些collector需要对构建的容器作后续处理。

第一个组件用于创建收集流元素的容器。此容器易于识别。例如,在上一部分介绍的情况下,我们使用了 ArrayList 类、HashSet 类和 HashMap 类。可以使用supplier实例对创建此类容器进行建模。第一个组件称为supplier

第二个组件旨在将流中的单个元素添加到容器。Stream API 的实现将重复调用此操作,将流的所有元素逐个添加到容器中。

在Collector API中,此组件由BiConsumer的实例建模。这个biconsumer有两个参数。

  1. 第一个是容器本身,流的先前元素填充了部分。
  2. 第二个是应添加的流元素。

此biconsumer在Collector API 的上下文中称为accumulator

这两个组件应该足以让collector工作,但 Stream API 带来了一个约束,使collector正常工作需要另外两个组件。

你可能还记得,Stream API 支持并行化。本教程稍后将更详细地介绍这一点。您需要知道的是,并行化将流的元素拆分为子流,每个元素都由 CPU 的内核处理。Collector API 可以在这样的上下文中工作:每个子流将只收集在自己的容器实例中。

处理完这些子流后,您将拥有多个容器,每个容器都包含它所处理的子流中的元素。这些容器是相同的,因为它们是与同一supplier一起创建的。现在,您需要一种方法将它们合并为一个。为了能够做到这一点,Collector API 需要第三个组件,即combiner,它将这些容器合并在一起。combiner由 BinaryOperator 的实例建模,该实例接收两个部分填充的容器并返回一个。

Stream API 的 collect() 也有个重载,这个 BinaryOperator 变成了 BiConsumer ,我们主要使用这个。

第四个组件称为finisher,本部分稍后将介绍。

在集合中收集原始类型

使用前三个组件,您可以尝试专用数字流中的 collect() 方法。IntStream.collect() 方法有三个参数:

让我们编写代码以在List<Integer>中收集IntStream

Supplier<List<Integer>> supplier                  = ArrayList::new;//容器
ObjIntConsumer<List<Integer>> accumulator         = Collection::add;//元素如何进入容器
BiConsumer<List<Integer>, List<Integer>> combiner = Collection::addAll;//多个片段如何合并

List<Integer> collect =
    IntStream.range(0, 10)
             .collect(supplier, accumulator, combiner );

System.out.println("collect = " + collect);

运行此代码将生成以下结果。

collect = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

将这些数据收集为Set只需要更改supplier的实现并相应地调整类型。

在 StringBuffer 中收集原始类型

让我们研究一下如何自己实现 Collectors.joining() ,以将原始类型流的元素连接在单个字符串中。String 类是不可变的,因此无法在其中累积元素。您可以使用可变的 StringBuffer 类。

StringBuffer 中收集元素遵循与前一个相同的模式。

Supplier<StringBuffer> supplier                 = StringBuffer::new;//
ObjIntConsumer<StringBuffer> accumulator        = StringBuffer::append;
BiConsumer<StringBuffer, StringBuffer> combiner = StringBuffer::append;

StringBuffer collect = 
    IntStream.range(0, 10)
             .collect(supplier, accumulator, combiner);

System.out.println("collect = " + collect);

运行此代码将生成以下结果。

collect = 0123456789

使用finisher对collector进行后续处理

你在上一段中编写的代码几乎完成了你需要的:它在 StringBuffer 实例中连接字符串,你可以通过调用它的 toString() 方法来创建一个常规的 String 对象。但是 Collectors.joining() collector直接生成一个字符串,而无需你调用 toString()。那么它是怎么做到的呢?

Collector API 精确地定义了第四个组件来处理这种情况,称为finisher。finisher是一个Function,它获取累积元素的容器并将其转换为其他内容。在 Collectors.joining() 的情况下,这个函数只是下面的。

Function<StringBuffer, String> finisher = stringBuffer -> stringBuffer.toString();

对很多collector来说,finisher只是恒等函数。比如:toList()toSet()、groupingBy()toMap()。

其他情况下,collector内部使用的可变容器成为中继容器,在返回到应用程序之前,该容器将mapping为其他对象(可能是另一个容器)。这就是Collector API 处理不可变列表、set或map创建的方式。finisher用于将中继容器密封到不可变容器中,返回到应用程序。

finisher还有其他用途,可以提高代码的可读性。Collectors 工厂类有一个工厂方法,我们还没有介绍:collectingAndThen() 方法。此方法将collector作为第一个参数,将finisher作为第二个参数。它会将第一个collector收集的结果,使用您提供的finisher对其进行mapping。

您可能还记得以下示例,我们已经在前面的部分中多次检查过该示例。它是关于提取柱状图的最大值。

Collection<String> strings =
    List.of("two", "three", "four", "five", "six", "seven", "eight", "nine",
            "ten", "eleven", "twelve");

Map<Integer, Long> histogram =
    strings.stream()
           .collect(
                   Collectors.groupingBy(
                           String::length,
                           Collectors.counting()));

Map.Entry<Integer, Long> maxValue =
    histogram.entrySet().stream()
             .max(Map.Entry.comparingByValue())
             .orElseThrow();

System.out.println("maxValue = " + maxValue);

第一步,您构建了 Map<Integer,Long> 类型的柱状图,在第二步中,您提取了此柱状图的最大值,按值比较键值对。

第二步实际上是将map转换为特殊的键/值对。您可以使用以下函数对其进行建模。

Function<Map<Integer, Long>, Map.Entry<Integer, Long>> finisher = 
    map -> map.entrySet().stream()
              .max(Map.Entry.comparingByValue())
              .orElseThrow();

此函数的类型起初可能看起来很复杂。事实上,它只是从map中提取一个键值对,类型为 Map.Entry

现在您已经有了这个函数,您可以使用 collectingAndThen() 将此最大值提取步骤集成到collector本身中。然后,模式将变为以下内容。

Collection<String> strings =
        List.of("two", "three", "four", "five", "six", "seven", "eight", "nine",
                "ten", "eleven", "twelve");

Function<Map<Integer, Long>, Map.Entry<Integer, Long>> finisher =
    map -> map.entrySet().stream()
              .max(Map.Entry.comparingByValue())
              .orElseThrow();//提取此finisher需要特别注意类型,消费Map,产出Entry

Map.Entry<Integer, Long> maxValue =
    strings.stream()
           .collect(
               Collectors.collectingAndThen(
                   Collectors.groupingBy(
                           String::length,
                           Collectors.counting()),
                   finisher
               ));

System.out.println("maxValue = " + maxValue);

您可能想知道为什么需要编写此看起来非常复杂的代码?

现在,您已经拥有了由单个collector建模的最大值提取器,您可以将其用作另一个collector的下游collector。做到这一点,可以组合更多的collector对您的数据进行更复杂的计算。

将两个collector的结果与三通collector相结合

在 Java SE 12 的 Collectors 类中添加了一个名为 teeing() 的方法。此方法需要两个下游collector和一个合并函数。

让我们通过一个场景,看看您可以使用collector做什么。想象一下,您有以下CarTruck两种record。

enum Color {
    RED, BLUE, WHITE, YELLOW
}

enum Engine {
    ELECTRIC, HYBRID, GAS
}

enum Drive {
    WD2, WD4
}

interface Vehicle {}

record Car(Color color, Engine engine, Drive drive, int passengers) {}

record Truck(Engine engine, Drive drive, int weight) {}

Car对象有几个组成部分:颜色、引擎、驱动器以及它可以运输的一定数量的乘客。Truck有引擎,有驱动器,可以运输一定量的货物。两者都实现相同的接口:Vehicle

假设您有一系列Vehicle,您需要找到所有配备电动引擎的Car。根据您的应用程序,您可能会使用流filter您的Car集合。或者,如果您知道下一个需求,将是找到配备混合动力引擎的Car,您可能更愿意准备一个map,以引擎为键,并以配备该引擎的Car列表作为值。在这两种情况API 都会为你提供正确的模式来获取所需的内容。

假设您需要将所有电动Truck添加到此集合中。也有可能想一次处理所有Vehicle,但是用于filter数据的Predicate变得越来越复杂。它可能如下所示。

Predicate<Vehicle> predicate =
    vehicle -> vehicle instanceof Car car && car.engine() == Engine.ELECTRIC ||
               vehicle instanceof Truck truck && truck.engine() == Engine.ELECTRIC;
//这个是instanceof新用法,后面直接赋值变量,同时跟短路操作

您真正需要的是以下内容:

  1. filterVehicle以获得所有电动Car
  2. filterVehicle以获得所有电动Truck
  3. 合并两个结果。

这正是teeing collector可以为您做的事情。teeing collector由 Collectors.teeing() 工厂方法创建,该方法接收三个参数。

  1. 第一个下游collector,用于收集流的数据。
  2. 第二个下游collector,也用于收集数据。
  3. 一个bifunction,用于合并由两个下游collector创建的两个容器。

您的数据将一次性处理,以保证最佳性能。

我们已经介绍了使用collector来filter流元素的模式。合并函数只是对 Collection.addAll() 方法的调用。以下是代码:

List<Vehicle> electricVehicles = vehicles.stream()
    .collect(
        Collectors.teeing(
            Collectors.filtering(
                vehicle -> vehicle instanceof Car car && car.engine() == Engine.ELECTRIC,
                Collectors.toList()),
            Collectors.filtering(
                vehicle -> vehicle instanceof Truck truck && truck.engine() == Engine.ELECTRIC,
                Collectors.toList()),
            (cars, trucks) -> {
                cars.addAll(trucks);
                return cars;
            }));

实现collector接口

为什么要实现collector接口?

有三种方法可以创建自己的collector。

包括将现有collector与Collectors工厂类结合,将collector作为下游collector传递给另一个collector,或者作为finisher一起使用 collectingAndThen() 。我们上面教程中已经介绍过。

您还可以调用 collect() 方法,该方法接收构建collector的三个元素。这些方法在原始类型流和对象流上都可用。他们接收了我们在前面部分中提出的三个参数。

  1. 用于创建可变容器的supplier,其中累积了流的元素。
  2. accumulator,由biconsumer建模。
  3. combiner也由biconsumer建模,用于组合两个部分填充的容器,用于并行流的情况。

第三种方法是自己实现 Collector 接口,并将您的实现传递给我们已经介绍过的 collect() 方法。实现自己的collector可以为您提供最大的灵活性,但也更具技术性。

了解collector的参数类型

让我们检查一下这个接口的参数。

interface Collector<T, A, R> {
    
    // content of the interface
}

让我们首先检查以下类型:T,R

第一种类型是 ,它对应于此collector正在处理的流元素的类型。T

最后一个类型是 ,它是此collector生成的类型。R

比如在 Stream 实例上调用的 toList() collector,类型RList。它 toSet() collector将是 Set

groupingBy() 方法接收一个函数作参数,来计算返回map的键。如果用它收集 Stream,则需要传递一个对T实例作mapping 的函数。因此,生成的map的类型将为 Map<K,List<T>>。也就是R的类型。

A类型处理起来比较复杂。您可能已尝试使用 IDE 来存储您在前面的示例中创建的collector之一。如果这样做,您可能意识到 IDE 没有为此类型提供显式值。以下示例就是这种情况。

Collection<String> strings =
        List.of("two", "three", "four", "five", "six", "seven", "eight", "nine",
                "ten", "eleven", "twelve");

Collector<String, ?, List<String>> listCollector = Collectors.toList();
List<String> list = strings.stream().collect(listCollector);

Collector<String, ?, Set<String>> setCollector = Collectors.toSet();
Set<String> set = strings.stream().collect(setCollector);

Collector<String, ?, Map<Integer, Long>> groupingBy = 
        Collectors.groupingBy(String::length, Collectors.counting());
Map<Integer, Long> map = strings.stream().collect(groupingBy);

对于所有这些collector,第二个参数类型仅为 ?

如果需要实现Collector接口,则必须为A提供个显式值。A是此collector使用的中继可变容器的实际类型。对于 toList() collector,它将是 ArrayList,对于 toSet() collector,它将是 HashSet。事实上,此类型被 toList() 的返回类型隐藏了,这就是为什么在前面的示例中无法将 ?类型替换为 ArrayList 的原因。

即使内部可变容器是由实现直接返回的,也可能发生类型AR不同的情况。例如, toList() ,您可以通过修改 ArrayList 和 List 来实现 Collector><T,A,R> 接口。

了解collector的特征

collector定义了内部特征,流实现用它来优化collector使用。

有三个。

  1. IDENTITY_FINISH指示此collector的finisher是恒等函数。该实现不会为具有此特征的collector调用finisher。
  2. UNORDERED指示此collector不保留它处理流元素的顺序。toSet() collector就是这种情况。而toList() 就没有。
  3. CONCURRENT 特性表示accumulator用来存储已处理元素的容器支持并发访问。这一点对于并行流很重要。

这些特征在collectorCollector.Characteristics枚举中定义,并由Collector接口的 characteristics() 方法以set返回。

实现 toList() 和 toSet() collector

使用这些元素,您现在可以重新创建类似于 toList() collector的实现。

class ToList<T> implements Collector<T, List<T>, List<T>> {


    public Supplier<List<T>> supplier() {
        return ArrayList::new;
    }

    public BiConsumer<List<T>, T> accumulator() {
        return Collection::add;
    }

    public BinaryOperator<List<T>> combiner() {
        return (list1, list2) -> {list1.addAll(list2); return list1; };
    }

    public Function<List<T>, List<T>> finisher() {
        return Function.identity();
    }

    public Set<Characteristics> characteristics() {
        return Set.of(Characteristics.IDENTITY_FINISH);//不调用finisher
    }
}

可以使用以下模式使用此collector。

Collection<String> strings =
        List.of("one", "two", "three", "four", "five") ;

List<String> result = strings.stream().collect(new ToList<>());
System.out.println("result = " + result);

此代码打印出以下结果。

result = [one, two, three, four, five]

实现一个类似于 toSet() 的collector只需要两处修改。

实现 joining() collector

重新创建此collector的实现很有趣,因为它只对字符串进行操作,并且它的finisher不是恒等函数。

此collector在 StringBuffer 实例中累积它处理的字符串,然后调用 toString() 方法以生成final结果。

此collector的特征集为空。它确实保留了处理元素的顺序(因此没有UNORDERED特征),它的finisher不是恒等函数,并且不能并发使用。

让我们看看如何实现这个collector。

class Joining implements Collector<String, StringBuffer, String> {

    public Supplier<StringBuffer> supplier() {
        return StringBuffer::new;
    }

    public BiConsumer<StringBuffer, String> accumulator() {
        return StringBuffer::append;
    }

    public BinaryOperator<StringBuffer> combiner() {
        return StringBuffer::append;
    }

    public Function<StringBuffer, String> finisher() {//会调用
        return Object::toString;
    }

    public Set<Characteristics> characteristics() {
        return Set.of();
    }
}

您可以在以下示例中看到如何使用此collector。

Collection<String> strings =
        List.of("one", "two", "three", "four", "five") ;

String result = strings.stream().collect(new Joining());
System.out.println("result = " + result);

运行此代码将生成以下结果。

result = onetwothreefourfive

要支持分隔符、前缀和后缀可以使用 StringJoiner

使用Optional

创建Optional对象

Optional 类是具有私有构造函数的final类。因此,创建它实例的唯一方法是调用其工厂方法之一。其中有三个。

  1. 您可以通过调用 Optional.empty() 创建一个空的Optional。
  2. 您可以通过调用 Optional.of() 将某元素作为参数。不允许将 null传递给此方法。这种情况下,您将获得一个 NullPointerException
  3. 您可以通过调用 Optional.ofNullable() 将某元素作为参数。可以将null传递给此方法。这种情况下,您将获得一个空的Optional。

这些是创建此类实例的唯一方法。如您所见,不能将null直接赋给Optional对象。打开非空Optional将始终返回非null。

Optional<T> 有三个等效的类,用于专用数字流:OptionalIntOptionalLongOptionalDouble。这些类是原始类型(即值)的包装器。ofNullable() 方法对这些类没有意义,因为原始值不能为null。

打开Optional对象

有几种方法可以使用Optional元素并访问它包装的元素(如果有)。你可以直接查询你拥有的实例,如果里面有东西,就打开它,或者你可以在上面使用类似流的方法: map(), flatMap(), filter(),甚至是 forEach() 的等价物。

打开Optional以获取其内容时应谨慎,因为如果Optional为空,它将引发 NoSuchElementException。除非您确定Optional元素中存在元素,否则应首先通过测试来保护此操作。

有两种方法可供您测试Optional对象:isPresent()isEmpty(),它们在 Java SE 11 中添加。

然后,要打开您的Optional,您可以使用以下方法。

您还可以提供一个对象,如果Optional对象为空,将返回该对象。

最后,如果此Optional为空,则可以创建另一个Optional。

  • or(supplier<Optional>supplier):如果它不为空,则返回此未修改的Optional,如果空,则调用提供的supplier。此supplier创建另一个Optional供方法返回。

处理Optional对象

Optional 类还提供模式,以便您可以将Optional对象与流处理集成。它具有直接对应Stream API 的方法,您可以使用这些方法以相同的方式处理数据,并且将与流无缝集成。这些方法是 map(), filter(),和flatMap(),前两个接收的参数与Stream API中的方法相同,后者的函数参数需要返回Optional<T>而不是Stream

这些方法按以下规则返回Optional对象。

  1. 如果调用的对象为空,则返回Optional。
  2. 如果不为空,则它们的参数、函数或Predicate将应用于此Optional的内容。将结果包装在另一个Optional中返回。

使用这些方法可以在某些流模式中生成更具可读性的代码。

假设您有一个具有id属性的Customer实例列表。您需要查找具有给定 ID 的客户的名称。

您可以使用以下模式执行此操作。

String findCustomerNameById(int id){
    List<Customer> customers = ...;

    return customers.stream()
                    .filter(customer->customer.getId() == id);
                    .findFirst()//返回Optional
                    .map(Customer::getName)
                    .orElse("UNKNOWN");
}

您可以看到 map() 方法来自 Optional 类,它与流处理很好地集成在一起。你不需要检查 findFirst() 方法返回的Optional对象是否为空;调用map()实际上可以为您执行此操作。

找出发表文章最多的两位联合作者

让我们看另一个更复杂的示例。通过此示例,向您展示Stream API、Collector API 和Optional对象的几种主要模式。

假设您有一组需要处理的文章。一篇文章有标题、发表年份和作者列表。作者有一个名字。

您的列表中有很多文章,您需要知道哪些作者一起联合发表了最多的文章。

您的第一个想法可能是为文章构建一对作者的流。这实际上是文章和作者集的笛卡尔乘积。您并不需要此流中的所有对。您对两位作者实际上是同一对的情况不感兴趣;一对作者(A1,A2)与(A2A1)实际相同。若要实现此约束,可以添加约束条件,声明一对作者时,声明作者按字母顺序排序。

让我们为这个模型写两条record。

record Article (String title, int inceptionYear, List<Author> authors) {}

record Author(String name) implements Comparable<Author> {

    public int compareTo(Author other) {
        return this.name.compareTo(other.name);
    }
}

record PairOfAuthors(Author first, Author second) {
    
    public static Optional<PairOfAuthors> of(Author first, Author second) {//用Optional实现了排序后的创建
        if (first.compareTo(second) > 0) {
            return Optional.of(new PairOfAuthors(first, second));
        } else {
            return Optional.empty();
        }
    }
}

PairOfAuthorsrecord中的创建工厂方法,可以控制哪些实例是允许的,并防止不需要的创建。若要表明此工厂方法可能无法生成结果,可以将其包装在Optional方法中。这完全尊重了以下原则:如果无法生成结果,则返回一个空的 optional。

让我们编写一个函数,为给定的文章创建一个 Stream<PairOfAuthors> 。您可以用两个嵌套流生成笛卡尔乘积。

作为第一步,您可以编写一个bifunction,从文章和作者创建此流。

BiFunction<Article, Author, Stream<PairOfAuthors>> buildPairOfAuthors =
    (article, firstAuthor) ->
        article.authors().stream().flatMap(//对每个author都遍历authors创建作者对,生成Stream<PairOfAuthors>
            secondAuthor -> PairOfAuthors.of(firstAuthor, secondAuthor).stream());//Optional的Stream

此bifunction从 firstAuthorsecondAuthor 创建一个Optional对象,取自基于文章作者构建的流。您可以看到 stream() 方法是在 of() 方法返回的Optional对象上调用的。如果Optional流为空,则返回的流为空,否则仅包含一对作者。此流由 flatMap() 方法处理。此方法打开流,空的流将消失,并且只有有效的对将出现在生成的流中。

您现在可以构建一个函数,该函数使用此bifunction从文章中创建作者对流。

Function<Article, Stream<PairOfAuthors>> toPairOfAuthors =
    article ->
    article.authors().stream()
                     .flatMap(firstAuthor -> buildPairOfAuthors.apply(article, firstAuthor));

找到联合发表最多的两位作者可以通过柱状图来完成,柱状图中的键是作者对,值是他们一起写的文章数。

您可以使用 groupingBy() 构建柱状图。让我们首先创建一对作者的流。

Stream<PairOfAuthors> pairsOfAuthors =
    articles.stream()
            .flatMap(toPairOfAuthors);

此流的构建方式是,如果一对作者一起写了两篇文章,则这对作者在流中出现两次。因此,您需要做的是计算每个对在此流中出现的次数。这可以通过 groupingBy() 来完成,其中分类器是恒等函数:对本身。此时,这些值是您需要计数的对列表。所以下游collector只是 counting() collector。

Map<PairOfAuthors, Long> numberOfAuthorsTogether =
    articles.stream()
            .flatMap(toPairOfAuthors)//所有文章的Stream<PairOfAuthors>
            .collect(Collectors.groupingBy(
                    Function.identity(),
                    Collectors.counting()//<PairOfAuthors, Long>
            ));

找到一起发表文章最多的作者包括提取此map的最大值。您可以为此处理创建以下函数。

Function<Map<PairOfAuthors, Long>, Map.Entry<PairOfAuthors, Long>> maxExtractor =
    map -> map.entrySet().stream()
                         .max(Map.Entry.comparingByValue())
                         .orElseThrow();

此函数在 Stream.max() 方法返回的Optional对象上调用 orElseThrow() 方法。

这个Optional对象可以为空吗?要使其为空,map本身必须为空,这意味着原始流中没有成对的作者。只要您至少有一篇文章至少有两位作者,那么这个Optional就不为空。

找出每年发表文章最多的两位联合作者

让我们更进一步,想知道您是否可以根据年份进行相同的处理。事实上,如果能使用单个collector实现,接下来就可以将其作为下游collector传递给 groupingBy(Article::inceptionYear)

对map后续提取最大值可以使用collectingAndThen()。此模式已在上一节“使用finisher对collector进行后续处理”中介绍过。此collector如下。

让我们提取 groupingBy() collector和finisher。如果使用 IDE 键入此代码,可以获取collector的正确类型。

Collector<PairOfAuthors, ?, Map<PairOfAuthors, Long>> groupingBy =
        Collectors.groupingBy(
                Function.identity(),
                Collectors.counting()
        );

Function<Map<PairOfAuthors, Long>, Map.Entry<PairOfAuthors, Long>> finisher =
        map -> map.entrySet().stream()
                  .max(Map.Entry.comparingByValue())
                  .orElseThrow();

现在,您可以将它们合并到单个 collectingAndThen() 中。将 groupingBy() 作为为第一个参数,将finisher作为第二个。

Collector<PairOfAuthors, ?, Map.Entry<PairOfAuthors, Long>> pairOfAuthorsEntryCollector =
    Collectors.collectingAndThen(
            Collectors.groupingBy(
                Function.identity(),
                Collectors.counting()
            ),
            map -> map.entrySet().stream()
                      .max(Map.Entry.comparingByValue())
                      .orElseThrow()
    );

现在,您可以使用初始flatmap操作和此collector编写完整模式。

Map.Entry<PairOfAuthors, Long> numberOfAuthorsTogether =
    articles.stream()
            .flatMap(toPairOfAuthors)
            .collect(pairOfAuthorsEntryCollector);

多亏了 flatMapping(),您可以通过合并中继 flatMap() 和末端collector来使用单个collector编写此代码。以下代码等效于上一个代码。

Map.Entry<PairOfAuthors, Long> numberOfAuthorsTogether =
    articles.stream()
            .collect(
                Collectors.flatMapping(
                    toPairOfAuthors,
                    pairOfAuthorsEntryCollector));

找到每年发表最多的两位联合作者,只需将这个 flatMapping() 作为下游collector传递给正确的 groupingBy() 即可。

Collector<Article, ?, Map.Entry<PairOfAuthors, Long>> flatMapping = 
    Collectors.flatMapping(
            toPairOfAuthors,
            pairOfAuthorsEntryCollector));

Map<Integer, Map.Entry<PairOfAuthors, Long>> result =
    articles.stream()
            .collect(
                Collectors.groupingBy(
                    Article::inceptionYear,
                    flatMapping
                )
            );

你可能还记得,在这个flatMapping()的深处,有一个对Optional.orElseThrow()的调用。在这个的模式中,很容易检查此调用是否会失败,因为此时有一个空的Optional很容易猜到。

现在我们已将此collector用作下游collector,情况就不同了。你怎么能确定,每年至少有一篇文章由至少两位作者撰写?保护此代码免受任何 NoSuchElementException 的影响会更安全。

避免打开Optional

在第一个上下文中可以接受的模式现在更加危险。处理它包括首先不要调用orElseThrow()。

这种情况下,collector将变为以下项。它不是创建一对作者和一长串数字的键值对,而是将结果包装在一个Optional对象中。

Collector<PairOfAuthors, ?, Optional<Map.Entry<PairOfAuthors, Long>>> 
        pairOfAuthorsEntryCollector =
            Collectors.collectingAndThen(
                Collectors.groupingBy(
                    Function.identity(),
                    Collectors.counting()
                ),
                map -> map.entrySet().stream()
                          .max(Map.Entry.comparingByValue())
            );

请注意,orElseThrow() 不再被调用,从而导致collector的签名中有一个Optional。

这个Optional也出现在 flatMapping() collector的签名中。

Collector<Article, ?, Optional<Map.Entry<PairOfAuthors, Long>>> flatMapping =
        Collectors.flatMapping(
                toPairOfAuthors,
                pairOfAuthorsEntryCollector
        );

使用此collector会创建一个类型为 Map<Integer, Optional<Map.Entry<PairOfAuthors, Long>>> 类型的map,我们不需要这种类型:拥有一个值为Optional的map是无用的,而且可能很昂贵。这是一种反模式。不幸的是,在计算此最大值之前,您无法猜测此Optional是否为空。

构建此中继map后,您需要删除空的Optional来构建表示所需柱状图的map。我们将使用与之前相同的技术:在flatMap() 中调用Optional的stream())方法,以便 flatMap() 操作静默删除空的Optional。

模式如下。

Map<Integer, Map.Entry<PairOfAuthors, Long>> histogram =
    articles.stream()
            .collect(
                Collectors.groupingBy(
                        Article::inceptionYear,
                        flatMapping
                )
            )  // Map<Integer, Optional<Map.Entry<PairOfAuthors, Long>>>
            .entrySet().stream()
            .flatMap(
                entry -> entry.getValue()//如果Optional为空,会成为空流,从而安全跳过
                              .map(value -> Map.entry(entry.getKey(), value))
                              .stream())
            .collect(Collectors.toMap(
                    Map.Entry::getKey, Map.Entry::getValue
            )); // Map<Integer, Map.Entry<PairOfAuthors, Long>>

请注意此模式中的flatmap函数。它接受一个entry作参数 ,类型为 Optional<Map.Entry<PairOfAuthors, Long>> ,并在此Optional上调用 map()。

如果Optional为空,则此调用返回空的Optional。然后忽略map函数。接下来调用 stream() 返回一个空流,该流将从主流中删除,因为我们处于 flatMap() 调用中。

如果Optional中有一个值,则使用此值调用map函数。此map函数创建一个具有相同键和此现有值的新键值对。此键值对的类型为 Map.Entry,并且通过此 map() 方法将其包装在Optional对象中。对 stream() 的调用会创建一个包含此Optional内容的流,然后由 flatMap() 调用打开该流。

此模式用空的Optional将 Stream<Map.Entry<Integer, Optional<Map.Entry<PairOfAuthors, Long>>>>mapping为 Stream<Map.Entry<Integer, Map.Entry<PairOfAuthors, Long>>>,删除所有具有空Optional的键/值对。

使用 toMap() collector可以安全地重新创建map,因为您知道在此流中不能使用两次相同的键。

此模式使用了Optional和Stream API 的三个要点。

  1. Optional.map() 方法,如果在空Optional上调用,则返回空的Optional。
  2. Optional.stream() 方法,该方法在 Optional的内容上打开流。如果Optional为空,则返回的流也为空。它允许您从Optional空间无缝的移动到流空间。
  3. Stream.flatMap() 方法,用于打开从Optional构建的流,以静默方式删除空流。

消费Optional的内容

Optional 类还具有两个将Consumer作为参数的方法。

烧哥总结

(验证中,代码库持续更新)

lambda将匿名类换成了匿名方法,能代表某个操作,让代码更直观(语法糖),但良好的命名很重要。

改写为lambda首先得是函数接口,Operator是Function的简化版。

可以序列化,从而可以作为字段、方法参数和返回类型,实现了方法引用、链式调用、函数式编程。

lambda已经深入JDK内部,所以性能方面很关注,为避免装箱拆箱,提供了很多原生类型专用版,但有时候要手动装箱。

为了性能,避免在内存中处理大量数据,同时也提高可读性,出现了Stream API。

流处理的整个过程最好都是流,所以有flatmap、mapMulti各种中继操作,

甚至末端collector也可以有下游collector,甚至collector可以串联、三通,比如神奇的Collectors.flatMapping()

流不应该作为变量或参数。

流中不应该改变外围变量,会捕获外界变量,降低处理性能,也会把并行流变成多线程并发。

每次中继操作都产生一个新流。

同样为了性能,reduce可以并行,但要具有可结合性、要有幺元。如果幺元未知,会返回Optional。

三参数的reduce组合了mapping过程。

专用数字流的sum、min、max、count、average、summaryStatistics为末端操作。

转换为流的源如果用Set,会是乱序的。

map、flatmap会删除SORTED、DISTINCTED、NONNULL。

本教程未详细说明的:spliterator、不可变流、并发流。

Stream.collect(Collectors.toList()) 只能用于对象流,数字流要么装箱,要么用三参数那个,或者自定义collector,五个参数。

flatmap会跳过空流,包括Optional.stream()产生的流,所以看到Optional,不要orElseThrow(),可以用flatmap取出。

API是看起来越来越复杂,Collectors.mapping()

public static <T,U,A,R> Collector<T,?,R> mapping(Function<? super T,? extends U> mapper,
 Collector<? super U,A,R> downstream)

方法名前面四个,返回类型里三个,还有问号,参数里super了三个。

Map<City, Set<String>> lastNamesByCity
   = people.stream().collect(
     groupingBy(Person::getCity,
                mapping(Person::getLastName,
                        toSet())));

虽然很好用。

posted on 2023-07-05 21:02  烧霞  阅读(166)  评论(0编辑  收藏  举报

导航