Java 进阶 -- 集合(二)

3、聚合操作

注意:为了更好地理解本节中的概念,请阅读Lambda表达式方法引用

你用集合做什么?您不能简单地将对象存储在集合中,然后将它们留在那里。在大多数情况下,使用集合来检索存储在集合中的项。

再次考虑Lambda表达式一节中描述的场景。假设您正在创建一个社交网络应用程序。您希望创建一个功能,使管理员能够对满足某些标准的社交网络应用程序的成员执行任何类型的操作,例如发送消息。

和前面一样,假设这个社交网络应用程序的成员由以下Person类表示:

public class Person {
    
    

    public enum Sex {
    
    
        MALE, FEMALE
    }

    String name;
    LocalDate birthday;
    Sex gender;
    String emailAddress;
    
    // ...

    public int getAge() {
    
    
        // ...
    }

    public String getName() {
    
    
        // ...
    }
}

下面的示例使用for-each循环打印集合roster中包含的所有成员的名称:

for (Person p : roster) {
    
    
    System.out.println(p.getName());
}

下面的示例打印集合roster 中包含的所有成员,但使用了聚合操作forEach:

roster
    .stream()
    .forEach(e -> System.out.println(e.getName());

尽管在本例中,使用聚合操作的版本比使用for-each循环的版本长,但您将看到,对于更复杂的任务,使用大容量数据操作的版本将更加简洁。

包括以下主题:

BulkDataOperationsExamples示例中查找本节描述的代码摘录。

管道及流

管道(pipeline)是一系列聚合操作。下面的示例使用由聚合操作filterforEach组成的管道打印集合roster 中包含的男性成员:

roster
    .stream()
    .filter(e -> e.getGender() == Person.Sex.MALE)
    .forEach(e -> System.out.println(e.getName()));

将此示例与下面的示例进行比较,下面的示例使用for-each循环打印集合roster 中包含的男性成员:

for (Person p : roster) {
    
    
    if (p.getGender() == Person.Sex.MALE) {
    
    
        System.out.println(p.getName());
    }
}

管道包含以下组件:

  • 源(source):它可以是一个集合、一个数组、一个生成器函数或一个I/O通道。在本例中,源是集合roster
  • 零个或多个中间操作(intermediate operations)。中间操作(如filter)产生新的流。
    流是元素的序列。与集合不同,它不是存储元素的数据结构。相反,流通过管道携带来自源的值。这个示例通过调用方法stream从集合roster 册中创建一个流。
    过滤器(filter)操作返回一个新流,其中包含与其谓词(此操作的参数)匹配的元素。在本例中,谓词是lambda表达式e -> e.getGender() == Person.Sex.MALE。如果对象egender字段的值为Person.Sex.MALE,则返回布尔值true。因此,本例中的筛选操作返回一个包含集合roster册中所有男性成员的流。
  • 最终操作(terminal operation)最终操作(如forEach)产生非流结果,如原始值(如双精度值)、集合,或者在forEach的情况下根本没有值。在本例中,forEach操作的参数是lambda表达式e -> System.out.println(e.getName()),它调用对象e上的getName方法(Java运行时和编译器推断对象e的类型为Person)。

下面的例子用一个由聚合操作filtermapToIntaverage组成的管道计算集合花牌中所有男性成员的平均年龄:

double average = roster
    .stream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .mapToInt(Person::getAge)
    .average()
    .getAsDouble();

mapToInt操作返回一个IntStream类型的新流(它是一个只包含整数值的流)。该操作将其形参中指定的函数应用于特定流中的每个元素。在本例中,函数是Person::getAge,它是返回成员年龄的方法引用。(或者,您可以使用lambda表达式 e -> e.getAge())因此,本例中的mapToInt操作返回一个流,其中包含集合roster册中所有男性成员的年龄。

average操作计算IntStream类型流中包含的元素的平均值。它返回一个OptionalDouble类型的对象。如果流不包含任何元素,则average操作返回一个空的OptionalDouble实例,并且调用getAsDouble方法会抛出NoSuchElementException。JDK包含许多最终操作,例如average,通过组合流的内容返回一个值。这些操作称为归纳操作(reduction operations);有关更多信息,请参阅Reduction 部分。

聚合操作和迭代器的区别

forEach这样的聚合操作看起来就像迭代器。然而,它们有几个根本的区别:

  • 它们使用内部迭代: 聚合操作不包含像next这样的方法来指示它们处理集合的下一个元素。使用内部委托(internal delegation),应用程序决定迭代什么集合,但是JDK决定如何迭代集合使用外部迭代,应用程序决定迭代什么集合以及如何迭代集合。但是,外部迭代只能按顺序遍历集合的元素内部迭代没有这个限制。它可以更容易地利用并行计算的优势,并行计算包括将一个问题划分为子问题,同时解决这些问题,然后将子问题的解的结果结合起来。有关更多信息,请参阅并行性一节。
  • 它们处理来自流的元素: 聚合操作处理来自流的元素,而不是直接来自集合。因此,它们也被称为流操作(stream operations)。
  • 它们支持行为作为参数: 您可以为大多数聚合操作指定lambda表达式作为参数。这使您能够自定义特定聚合操作的行为。

3.1 归纳

汇总操作部分描述了下面的操作管道,它计算集合roster册中所有男性成员的平均年龄:

double average = roster
    .stream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .mapToInt(Person::getAge)
    .average()
    .getAsDouble();

JDK包含许多最终操作(例如averagesumminmaxcount),它们通过组合流的内容返回一个值。这些操作称为归纳操作(reduction operations)。JDK还包含返回一个集合而不是单个值的归纳操作许多归纳操作执行特定的任务,例如查找值的平均值或将元素分组到类别中。但是,JDK为您提供了通用的缩减操作reducecollect,本节将详细介绍这些操作。

本节涵盖以下主题:

您可以在示例ReductionExamples中找到本节中描述的代码摘录。

Stream.reduce 方法

这条Stream.reduce方法是一种通用的简化操作。考虑下面的管道,它计算集合roster册中男性成员年龄的总和。它使用Stream.sum 归纳运算:

Integer totalAge = roster
    .stream()
    .mapToInt(Person::getAge)
    .sum();

将其与下面使用Stream的管道进行比较。减少运算来计算相同的值:

Integer totalAgeReduce = roster
   .stream()
   .map(Person::getAge)
   .reduce(
       0,
       (a, b) -> a + b);

本例中的reduce操作接受两个参数:

  • identity: identity元素既是归纳的初始值,也是流中没有元素时的默认结果。在本例中,单位元素为0;这是年龄总和的初始值,如果集合roster中不存在成员,则为默认值。
  • accumulator: accumulator函数接受两个参数:归纳的部分结果(在本例中是迄今为止处理过的所有整数的和)和流的下一个元素(在本例中是整数)。它返回一个新的部分结果。在本例中,accumulator函数是一个lambda表达式,它将两个Integer值相加并返回一个Integer值: (a, b) -> a + b

reduce操作总是返回一个新值。然而,每次处理流中的元素时,accumulator函数也会返回一个新值。假设您希望将流的元素归纳为更复杂的对象,例如集合。这可能会影响应用程序的性能。如果您的reduce操作涉及到向集合中添加元素,那么每次您的accumulator函数处理一个元素时,它都会创建一个包含该元素的新集合,这是低效的。对您来说,更新现有集合会更有效。你可以用Stream.collect方法来做这个,下一节将对其进行描述。

Stream.collect 方法

reduce方法总是在处理元素时创建一个新值,而collect方法则不同,它修改或改变一个现有值。

考虑一下如何找到流中值的平均值。您需要两部分数据:值的总数和这些值的总和。但是,与reduce方法和所有其他简化方法一样,collect方法只返回一个值。您可以创建一个新的数据类型,其中包含跟踪值的总数和这些值的总和的成员变量,例如下面的类Averager:

class Averager implements IntConsumer
{
    
    
    private int total = 0;
    private int count = 0;
        
    public double average() {
    
    
        return count > 0 ? ((double) total)/count : 0;
    }

    public void accept(int i) {
    
     total += i; count++; }
    public void combine(Averager other) {
    
    
        total += other.total;
        count += other.count;
    }
}

下面的管道使用Averager类和collect方法来计算所有男性成员的平均年龄:

Averager averageCollect = roster.stream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .map(Person::getAge)
    .collect(Averager::new, Averager::accept, Averager::combine);
                   
System.out.println("Average age of male members: " +
    averageCollect.average());

本例中的collect操作接受三个参数:

  • supplier: supplier是工厂功能;它构造新的实例。对于collect 操作,它创建结果容器的实例。在本例中,它是Averager类的一个新实例。
  • accumulatoraccumulator函数将流元素合并到结果容器中。在本例中,它通过将count变量加1并将流元素的值添加到total成员变量中来修改Averager结果容器,该值是一个表示男性成员年龄的整数。
  • combiner: combiner函数接受两个结果容器并合并它们的内容。在本例中,它通过将count变量增加到另一个Averager实例的count成员变量,并将另一个Averager实例的total成员变量的值添加到total成员变量,从而修改Averager结果容器。

注意以下几点:

  • supplie 是一个lambda表达式(或一个方法引用),而不是像reduce操作中的identity元素这样的值。
  • 累加器(accumulator)和组合器(combiner)函数不返回值。
  • 你可以在并行流中使用collect操作;有关更多信息,请参阅并行性一节。(如果使用并行流运行collect方法,那么每当combiner函数创建新对象时,JDK就会创建一个新线程,例如本例中的Averager对象。因此,您不必担心同步问题。)

collect 操作最适合于集合。下面的示例使用collect操作将男性成员的名字放入集合中:

List<String> namesOfMaleMembersCollect = roster
    .stream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .map(p -> p.getName())
    .collect(Collectors.toList());

这个版本的collect操作接受一个Collector类型的形参。该类封装了在需要三个参数(supplier, accumulator, and combiner functions)的collect操作中用作参数的函数。

collections类包含许多有用的归纳操作,例如将元素累积到集合中,并根据各种标准对元素进行汇总。这些归纳操作返回类Collector的实例,因此您可以将它们用作collect操作的参数。

本例使用 Collectors.toList 操作,该操作将流元素累加到List的新实例中。与Collectors 类中的大多数操作一样,toList操作符返回Collector的实例,而不是集合。

以下是按性别对收集roster 成员进行分组的例子:

Map<Person.Sex, List<Person>> byGender =
    roster
        .stream()
        .collect(
            Collectors.groupingBy(Person::getGender));

groupingBy操作返回一个映射,其键是应用指定为其参数的lambda表达式(称为分类函数, classification function)所得到的值。在本例中,返回的映射包含两个键Person.Sex.MALEPerson.Sex.FEMALE。键的对应值是List的实例,其中包含由分类函数处理时对应于键值的流元素。例如,键Person.Sex.MALE对应的值是包含所有男性成员的List实例。

以下示例检索集合roster 册中每个成员的姓名,并按性别对其进行分组:

Map<Person.Sex, List<String>> namesByGender =
    roster
        .stream()
        .collect(
            Collectors.groupingBy(
                Person::getGender,                      
                Collectors.mapping(
                    Person::getName,
                    Collectors.toList())));

本例中的groupingBy操作接受两个参数,一个分类函数和一个Collector实例。Collector参数称为下游收集器(downstream collector)。这是一个收集器,Java运行时将其应用于另一个收集器的结果。因此,这个groupingBy操作使您能够对由groupingBy操作符创建的List值应用collect方法。本例应用收集器mapping,它将映射函数Person::getName应用于流的每个元素。因此,结果流仅由成员的名称组成。包含一个或多个下游收集器的管道(如本例)称为多级归纳(multilevel reduction)。

下面的示例检索每个性别成员的总年龄:

Map<Person.Sex, Integer> totalAgeByGender =
    roster
        .stream()
        .collect(
            Collectors.groupingBy(
                Person::getGender,                      
                Collectors.reducing(
                    0,
                    Person::getAge,
                    Integer::sum)));

reducing操作接受三个参数:

  • identity:像Stream.reduce操作一样,identity 元素既是归纳的初始值,也是流中没有元素时的默认结果。在本例中,单位元素为0;这是年龄总和的初始值,如果没有成员存在,则为默认值。
  • mapper: reducing 操作将此映射器函数应用于所有流元素。在本例中,映射器检索每个成员的年龄。
  • operation: operation函数用于归纳映射值。在本例中,操作函数添加Integer 值。

下面的示例检索每个性别成员的平均年龄:

Map<Person.Sex, Double> averageAgeByGender = roster
    .stream()
    .collect(
        Collectors.groupingBy(
            Person::getGender,                      
            Collectors.averagingInt(Person::getAge)));

3.2 并行化

并行计算包括将一个问题划分为子问题,同时解决这些问题(并行地,每个子问题在单独的线程中运行),然后将子问题的解决结果组合在一起。Java SE提供了fork/join框架,它使您能够更轻松地在应用程序中实现并行计算。然而,使用这个框架,您必须指定如何细分问题(分区)。通过聚合操作,Java运行时为您执行这种解决方案的分区和组合。

在使用集合的应用程序中实现并行性的一个困难是,集合不是线程安全的,这意味着多个线程无法在不引入线程干扰内存一致性错误的情况下操作集合。集合框架提供了同步包装器它将添加自动同步到任意集合,使其线程安全。但是,同步会引入线程竞争。您希望避免线程竞争,因为它会阻止线程并行运行。聚合操作和并行流使您能够实现非线程安全集合的并行性,前提是在操作集合时不修改集合。

请注意,并行并不一定比串行执行操作更快,尽管如果您有足够的数据和处理器内核,可能会更快。虽然聚合操作使您能够更容易地实现并行性,但确定应用程序是否适合并行性仍然是您的责任。

本节涵盖以下主题:

  • 并行地执行流
  • 并发归纳
  • 排序
  • 副作用:
    • Laziness
    • 干扰
    • 有状态Lambda表达式

您可以在ParallelismExamples示例中找到本节中描述的代码摘录。

3.2.1 并行地执行流

您可以串行或并行地执行流。当流并行执行时,Java运行时将流划分为多个子流。聚合操作并行遍历和处理这些子流,然后组合结果。

当您创建一个流时,除非另有说明,它总是一个串行流。要创建并行流,请调用Collection.parallelStream操作。或者,调用BaseStream.parallel操作。例如,下面的语句并行计算所有男性成员的平均年龄:

double average = roster
    .parallelStream()
    .filter(p -> p.getGender() == Person.Sex.MALE)
    .mapToInt(Person::getAge)
    .average()
    .getAsDouble();

3.2.2 并发归纳

再次考虑以下按性别分组成员的示例(在Reduction一节中描述)。下面的例子调用了collect操作,该操作将收集rosterMap:

Map<Person.Sex, List<Person>> byGender =
    roster
        .stream()
        .collect(
            Collectors.groupingBy(Person::getGender));

以下是并发等效:

ConcurrentMap<Person.Sex, List<Person>> byGender =
    roster
        .parallelStream()
        .collect(
            Collectors.groupingByConcurrent(Person::getGender));

这被称为并发归纳(concurrent reduction)。对于包含collect操作的特定管道,如果以下所有条件都为真,则Java运行时执行并发归纳:

注意:这个例子返回一个ConcurrentMap的实例而不是Map,并且调用groupingByConcurrent操作而不是groupingBy。(有关ConcurrentMap的更多信息,请参见并发集合一节。)与操作groupingByConcurrent不同,操作groupingBy对并行流的性能很差。(这是因为它通过按键合并两个映射来操作,这在计算上很昂贵。)类似地,操作Collectors.toConcurrentMap在处理并行流时比操作Collectors.toMap执行得更好。

3.2.3 排序

管道处理流元素的顺序取决于流是串行执行还是并行执行、流的源和中间操作。例如,考虑下面的例子,使用forEach操作多次打印ArrayList实例的元素:

Integer[] intArray = {
    
    1, 2, 3, 4, 5, 6, 7, 8 };
List<Integer> listOfIntegers =
    new ArrayList<>(Arrays.asList(intArray));

System.out.println("listOfIntegers:");
listOfIntegers
    .stream()
    .forEach(e -> System.out.print(e + " "));
System.out.println("");

System.out.println("listOfIntegers sorted in reverse order:");
Comparator<Integer> normal = Integer::compare;
Comparator<Integer> reversed = normal.reversed(); 
Collections.sort(listOfIntegers, reversed);  
listOfIntegers
    .stream()
    .forEach(e -> System.out.print(e + " "));
System.out.println("");
     
System.out.println("Parallel stream");
listOfIntegers
    .parallelStream()
    .forEach(e -> System.out.print(e + " "));
System.out.println("");
    
System.out.println("Another parallel stream:");
listOfIntegers
    .parallelStream()
    .forEach(e -> System.out.print(e + " "));
System.out.println("");
     
System.out.println("With forEachOrdered:");
listOfIntegers
    .parallelStream()
    .forEachOrdered(e -> System.out.print(e + " "));
System.out.println("");

这个例子由五个管道组成。它输出如下所示:

listOfIntegers:
1 2 3 4 5 6 7 8
listOfIntegers sorted in reverse order:
8 7 6 5 4 3 2 1
Parallel stream:
3 4 1 6 2 5 7 8
Another parallel stream:
6 3 1 5 7 8 4 2
With forEachOrdered:
8 7 6 5 4 3 2 1

这个例子是这样做的:

  • 第一个管道按照添加到列表中的顺序打印列表listOfIntegers的元素。
  • 第二个管道在通过Collections.sort方法对listOfIntegers进行排序后打印其元素。
  • 第三和第四个管道以显然是随机的顺序打印列表中的元素。记住,流操作在处理流的元素时使用内部迭代。因此,当并行执行流时,Java编译器和运行时决定处理流元素的顺序,以最大限度地利用并行计算的好处,除非流操作另有规定。
  • 第五个管道使用forEachOrdered方法,该方法按照其源指定的顺序处理流的元素,而不管您是以串行还是并行方式执行流。注意,如果对并行流使用forEachOrdered这样的操作,可能会失去并行性的好处。

3.2.4 副作用

如果一个方法或表达式除了返回或产生一个值之外,还修改了计算的状态,那么它就有副作用。例子包括可变归纳(使用collect操作的操作;有关更多信息,请参阅Reduction一节),以及调用System.out.println方法进行调试。JDK可以很好地处理管道中的某些副作用。特别是,collect方法被设计为以并行安全的方式执行最常见的具有副作用的流操作。像forEachpeek这样的操作是为副作用而设计的;返回void的lambda表达式,例如调用System.out.println的表达式。除了有副作用什么都不能做。

猜你喜欢

转载自blog.csdn.net/chinusyan/article/details/130948879