8.2 在Java集合框架中使用lambda表达式

Java 8 引入 lambda 表达式的一个主要原因是大幅修改集合 API,让 Java 开发者使用更现代化的编程风格。在 Java 8 发布之前,使用 Java 处理数据结构的方式有点过时。现在,很多语言都支持把集合看成一个整体,而不用打散后再迭代。

事实上,很多 Java 开发者已经使用了替代的数据结构库,获取他们认为集合 API 缺乏的表现力和生产力。升级集合 API 的关键是引入参数能使用 lambda 表达式的新方法,定义需要做什么,而不用管具体怎么做。

8.2 在Java集合框架中使用lambda表达式 - 图1 有默认方法这个新语言特性的支持,才能在现有的接口中添加新方法(详情参见 4.1.6 节)。没有这个新机制的话,集合接口的原有实现在 Java 8 中不能编译,而且在 Java 8 的运行时中加载时无法链接。

本节简要介绍如何在 Java 集合框架中使用 lambda 表达式。完整的说明参阅 Richard Warburton 写的《Java 8 函数式编程》一书(O'Reilly 出版,http://shop.oreilly.com/product/0636920030713.do)。

8.2.1 函数式方式

Java 8 想实现的方式源于函数式编程语言和风格。我们在 4.5.2 节已经介绍过一些关键的模式,这里会再次介绍,并举些例子。

1. 过滤器

这个模式把集合中的每个元素代入一段代码(返回 truefalse),然后使用“通过测试”(即代入元素的那段代码返回 true)的元素构建一个新集合。

例如,下面这段代码处理一个由猫科动物名组成的集合,选出是老虎的元素:

  1. String[] input = {"tiger", "cat", "TIGER", "Tiger", "leopard"};
  2. List<String> cats = Arrays.asList(input);
  3. String search = "tiger";
  4. String tigers = cats.stream()
  5. .filter(s -> s.equalsIgnoreCase(search))
  6. .collect(Collectors.joining(", "));
  7. System.out.println(tigers);

上述代码的关键是对 filter() 方法的调用。filter() 方法的参数是一个 lambda 表达式,这个 lambda 表达式接受一个字符串参数,返回布尔值。整个 cats 集合中的元素都会代入这个表达式,然后创建一个新集合,只包含老虎(不过有些使用大写)。

filter() 方法的参数是一个 Predicate 接口的实例。Predicate 接口在新包 java.util.function 中定义。这是个函数式接口,只有一个非默认方法,因此特别适合 lambda 表达式。

注意,最后还调用了 collect() 方法。这个方法是流 API 的重要部分,作用是在 lambda 表达式执行完毕后“收集”结果。下一节会深入介绍这个方法。

Predicate 接口有一些十分有用的默认方法,例如用于合并判断条件的逻辑操作方法。假如想把豹子纳入老虎种群,可以使用 or() 方法:

  1. Predicate<String> p = s -> s.equalsIgnoreCase(search);
  2. Predicate<String> combined = p.or(s -> s.equals("leopard"));
  3. String pride = cats.stream()
  4. .filter(combined)
  5. .collect(Collectors.joining(", "));
  6. System.out.println(pride);

注意,必须显式创建 Predicate 类型的对象 p,这样才能在 p 上调用默认方法 or(),并把另一个 lambda 表达式(也会自动转换成 Predicate 类型的对象)传给 or() 方法。

2. 映射

Java 8 中的映射模式使用 java.util.function 包中的新接口 Function。这个接口和 Predicate 接口一样,是函数式接口,因此只有一个非默认方法——apply()。映射模式把一种类型元素组成的集合转换成另一种类型元素组成的集合。这一点在 API 中就体现出来了,因为 Function 接口有两个不同的类型参数,其中,类型参数 R 的名称表示这个方法的返回类型。

下面看一个使用 map() 方法的示例代码:

  1. List<Integer> namesLength = cats.stream()
  2. .map(String::length)
  3. .collect(Collectors.toList());
  4. System.out.println(namesLength);

3. 遍历

映射和过滤器模式的作用是以一个集合为基础,创建另一个集合。在完全支持函数式编程的语言中,除了这种方式之外,还需要 lambda 表达式的主体处理各个元素时不影响原来的集合。用计算机科学的术语来说,这意味着 lambda 表达式的主体“不能有副作用”。

当然,在 Java 中经常需要处理可变的数据,所以新的集合 API 提供了一个方法,在遍历集合时修改元素——forEach() 方法。这个方法的参数是一个 Consumer 类型的对象。Consumer 是函数式接口,要求使用副作用执行操作(然而,到底会不会真得修改数据不是那么重要)。因此,能转换成 Consumer 类型的 lambda 表达式,其签名为 (T t) -> void。下面是一个使用 forEach() 方法的简单示例:

  1. List<String> pets =
  2. Arrays.asList("dog", "cat", "fish", "iguana", "ferret");
  3. pets.stream().forEach(System.out::println);

在这个示例中,我们只是把集合中的每个元素打印出来。不过,我们把 lambda 表达式写成了一种特殊的方法引用。这种方法引用叫受限的方法引用(bound method reference),因为需要指定对象(这里指定的对象是 System.outSystem 类的公开静态字段)。这个方法引用和下面的 lambda 表达式等效:

  1. s -> System.out.println(s);

当然,根据方法的签名,这样写能明确表明 lambda 表达式要转换成一个实现 Consumer 接口类型的实例。

8.2 在Java集合框架中使用lambda表达式 - 图2 不是说 map()filter() 方法一定不能修改元素。不要使用这两个方法修改元素,这只是一种约定,每个 Java 程序员都要遵守。

在结束本节之前,还有最后一个函数式技术要介绍。这种技术把集合中的元素聚合成一个值,详情参见下一小节。

4. 化简

下面介绍 reduce() 方法。这个方法实现的是化简模式,包含一系列相关的类似运算,有时也称为合拢或聚合运算。

在 Java 8 中,reduce() 方法有两个参数:一个是初始值,一般叫作单位值(或零值);另一个参数是一个函数,逐步执行。这个函数属于 BinaryOperator 类型。BinaryOperator 也是函数式接口,有两个类型相同的参数,返回值也是同一类型。reduce() 方法的第二个参数是一个 lambda 表达式,接受两个参数。在 Java 的文档中,reduce() 方法的签名是:

  1. T reduce(T identity, BinaryOperator<T> aggregator);

reduce() 方法的第二个参数可以简单地理解成,在处理流的过程中“累积计数”:首先合并单位值和流中的第一个元素,得到第一个结果,然后再合并这个结果和流中的第二个元素,以此类推。

reduce() 方法的实现设想成下面这样有助于理解其作用:

  1. public T reduce(T identity, BinaryOperator<T> aggregator) {
  2. T runningTotal = identity;
  3. for (T element : myStream) {
  4. runningTotal = aggregator.apply(runningTotal, element);
  5. }
  6. return result;
  7. }

8.2 在Java集合框架中使用lambda表达式 - 图3 实际上,reduce() 方法的实现比这复杂得多,如果数据结构和运算有需要,甚至还可以并行执行。

下面看一个使用 reduce() 方法的简单示例,这个示例计算几个质数之和:

  1. double sumPrimes = ((double)Stream.of(2, 3, 5, 7, 11, 13, 17, 19, 23)
  2. .reduce(0, (x, y) -> {return x + y;}));
  3. System.out.println("Sum of some primes: " + sumPrimes);

你可能注意到了,本节举的所有示例中,都在 List 实例上调用了 stream() 方法。这是集合 API 演进的一部分——一开始选择这种方式是因为部分 API 有这方面的需求,但后来证实,这是极好的抽象。下面详细讨论流 API。

8.2.2 流API

库的设计者之所以引入流 API,是因为集合核心接口的大量实现已经广泛使用。这些实现在 Java 8 和 lambda 表达式之前就已存在,因此没有执行任何函数式运算的方法。更糟的是,map()filter() 等方法从未出现在集合 API 的接口中,实现这些接口的类可能已经使用这些名称定义了方法。

为了解决这个问题,设计者引入了一层新的抽象——StreamStream对象可以通过 stream() 方法从集合对象上生成。设计者引入这个全新的 Stream 对象是为了避免方法名冲突,这的确在一定程度上减少了冲突的几率,因为只有包含 stream() 方法的实现才会受到影响。

在处理集合的新方式中,Stream 对象的作用和 Iterator 对象类似。总体思想是让开发者把一系列操作(也叫“管道”,例如映射、过滤器或化简)当成一个整体运用在集合上。具体执行的各个操作一般使用 lambda 表达式表示。

在管道的末尾需要收集结果,或者再次“具化”为真正的集合。这一步使用 Collector 对象完成,或者以“终结方法”(例如 reduce())结束管道,返回一个具体的值,而不是另一个流。总的来说,处理集合的新方式类似下面这样:

  1. stream() filter() map() collect()
  2. Collection -> Stream -> Stream -> Stream -> Collection

Stream 类相当于一系列元素,一次访问一个元素(不过有些类型的流也支持并行访问,可以使用多线程方式处理大型集合)。Stream 对象和 Iterator 对象的工作方式一样,依次读取每个元素。

和 Java 中的大多数泛型类一样,Stream 类也使用引用类型参数化。不过,多数情况下,其实需要使用基本类型,尤其是 intdouble 类型构成的流,但是又没有 Stream 类型,所以 java.util.stream 包提供了专用的(非泛型)类,例如 IntStreamDoubleStream。这些类是 Stream 类的基本类型特化,其 API 和一般的 Stream 类十分类似,不过在适当的情况下会使用基本类型的值。

例如,在前面 reduce() 方法的示例中,多数时候,在管道中使用的其实就是 Stream 类的基本类型特化。

1. 惰性求值

其实,流比迭代器(甚至是集合)通用,因为流不用管理数据的存储空间。在早期的 Java 版本中,总是假定集合中的所有元素都存在(一般存储在内存中),不过有些处理方式也能避开这个问题,例如坚持在所有地方都使用迭代器,或者让迭代器即时构建元素。可是,这些方式既不十分便利,也不那么常用。

然而,流是管理数据的一种抽象,不关心存储细节。因此,除了有限的集合之外,流还能处理更复杂的数据结构。例如,使用 Stream 接口可以轻易实现无限流,处理一切平方数。实现方式如下所示:

  1. public class SquareGenerator implements IntSupplier {
  2. private int current = 1;
  3. @Override
  4. public synchronized int getAsInt() {
  5. int thisResult = current * current;
  6. current++;
  7. return thisResult;
  8. }
  9. }
  10. IntStream squares = IntStream.generate(new SquareGenerator());
  11. PrimitiveIterator.OfInt stepThrough = squares.iterator();
  12. for (int i = 0; i < 10; i++) {
  13. System.out.println(stepThrough.nextInt());
  14. }
  15. System.out.println("First iterator done...");
  16. // 只要想就可以一直这样进行下去……
  17. for (int i = 0; i < 10; i++) {
  18. System.out.println(stepThrough.nextInt());
  19. }

通过构建上述无限流,我们能得出一个重要结论:不能使用 collect() 这样的方法。这是因为无法把整个流具化为一个集合(在创建所需的无限个对象之前就会耗尽内存)。因此,我们采取的方式必须在需要时才从流中取出元素。其实,我们需要的是按需读取下一个元素的代码。为了实现这种操作,需要使用一个关键技术——惰性求值(lazy evaluation)。这个技术的本质是,需要时才计算值。

8.2 在Java集合框架中使用lambda表达式 - 图4 惰性求值对 Java 来说是个重大的变化,在 JDK 8 之前,表达式赋值给变量(或传入方法)后会立即计算它的值。这种立即计算值的方式我们已经熟知,术语叫“及早求值”(eager evaluation)。在多数主流编程语言中,“及早求值”都是计算表达式的默认方式。

幸好,实现惰性求值的重担几乎都落在了库的编写者身上,开发者则轻松得多,而且使用流 API 时,大多数情况下 Java 开发者都无需仔细考虑惰性求值。下面以一个示例结束对流的讨论。这个示例使用 reduce() 方法计算几个莎士比亚语录的平均单词长度:

  1. String[] billyQuotes = {"For Brutus is an honourable man",
  2. "Give me your hands if we be friends and Robin shall restore amends",
  3. "Misery acquaints a man with strange bedfellows"};
  4. List<String> quotes = Arrays.asList(billyQuotes);
  5. // 创建一个临时集合,保存单词
  6. List<String> words = quotes.stream()
  7. .flatMap(line -> Stream.of(line.split(" +")))
  8. .collect(Collectors.toList());
  9. long wordCount = words.size();
  10. // 校正为double类型只是为了避免Java按照整数方式计算除法
  11. double aveLength = ((double) words.stream()
  12. .map(String::length)
  13. .reduce(0, (x, y) -> {return x + y;})) / wordCount;
  14. System.out.println("Average word length: " + aveLength);

这个示例用到了 flatMap() 方法。在这个示例中,向 flatMap() 方法传入一个字符串 line,得到的是一个由字符串组成的流,流中的数据是拆分一句话得到的所有单词。然后再“整平”这些单词,把处理每句话得到的流都合并到一个流中。

这样做的目的是把每句话都拆分成单个单词,然后再组成一个总流。为了计算单词数量,我们创建了一个对象 words。其实,在管道处理流的过程中会“中断”,再次具化,把单词存入集合,在流操作恢复之前获取单词的数量。

这一步完成之后,下一步是化简运算,先计算所有语录中的单词总长度,然后再除以已经获取的单词数量。记住,流是惰性抽象,如果要执行及早操作(例如,计算流下面的集合大小),得重新具化为集合。

2. 处理流的实用默认方法

借着引入流 API 的机会,Java 8 向集合库引入了一些新方法。现在 Java 已经支持默认方法,因此可以向集合接口中添加新方法,而不会破坏向后兼容性。

新添加的方法中有一些是“基架方法”,用于创建抽象的流,例如 Collection::streamCollection::parallelStreamCollection::spliterator(这个方法可以细分为 List::spliteratorSet::spliterator)。

另一些则是“缺失方法”,例如 Map::removeMap::replaceList::sort 也属于“缺失方法”,在 List 接口中的定义如下所示:

  1. // 其实是把具体操作交给Collections类的辅助方法完成
  2. public default void sort(Comparator<? super E> c) {
  3. Collections.<E>sort(this, c);
  4. }

Map::putIfAbsent 也是缺失方法,根据 java.util.concurrent 包中 ConcurrentMap 接口的同名方法改写。

另一个值得关注的缺失方法是 Map::getOrDefault,程序员使用这个方法能省去很多检查 null 值的繁琐操作,因为如果找不到要查询的键,这个方法会返回指定的值。

其余的方法则使用 java.util.function 接口提供额外的函数式技术。

  • Collection::removeIf

这个方法的参数是一个 Predicate 对象,它会迭代整个集合,把满足判断条件的元素移除。

  • Map::forEach

这个方法只有一个参数,是一个 lambda 表达式;而这个 lambda 表达式有两个参数(一个是键的类型,一个是值的类型),返回 void。这个 lambda 表达式会转换成 BiConsumer 对象,应用在映射中的每个键值对上。

  • Map::computeIfAbsent

这个方法有两个参数:键和 lambda 表达式。lambda 表达式的作用是把键映射到值上。如果映射中没有指定的键(第一个参数),那就使用 lambda 表达式计算一个默认值,然后存入映射。

(其他值得学习的方法:Map::computeIfPresentMap::computeMap::merge。)