Java8新特性Stream API与Lambda表达式详解(1)

1 为什么需要Stream与Lambda表达式?

1.1  为什么需要Stream

Stream作为 Java 8 的一大亮点,它与 java.io 包里的 InputStream 和 OutputStream 是完全不同的概念。它也不同于 StAX 对 XML 解析的 Stream,也不是 Amazon Kinesis 对大数据实时处理的 Stream。Java 8 中的 Stream 是对集合(Collection)对象功能的增强,它专注于对集合对象进行各种非常便利、高效的聚合操作(aggregate operation),或者大批量数据操作 (bulk data operation)。Stream API 借助于同样新出现的 Lambda 表达式,极大的提高编程效率和程序可读性。同时它提供串行和并行两种模式进行汇聚操作,并发模式能够充分利用多核处理器的优势,使用 fork/join 并行方式来拆分任务和加速处理过程。通常编写并行代码很难而且容易出错, 但使用 Stream API 无需编写一行多线程的代码,就可以很方便地写出高性能的并发程序。

 

什么是聚合操作?

在传统的 J2EE 应用中,Java 代码经常不得不依赖于关系型数据库的聚合操作来完成诸如:

>客户每月平均消费金额

>最昂贵的在售商品

>本周完成的有效订单(排除了无效的)

>取十个数据样本作为首页推荐

这类的操作。

但在当今这个数据大爆炸的时代,在数据来源多样化、数据海量化的今天,很多时候不得不脱离 RDBMS,或者以底层返回的数据为基础进行更上层的数据统计。而 Java 的集合 API 中,仅仅有极少量的辅助型方法,更多的时候是程序员需要用 Iterator 来遍历集合,完成相关的聚合应用逻辑。这是一种远不够高效、笨拙的方法。在 Java 7 中,如果要发现 type 为 grocery 的所有交易,然后返回以交易值降序排序好的交易 ID 集合,我们需要这样写:

//代码1 Java 7 的排序、取值实现
List<Transaction>groceryTransactions = new Arraylist<>();
for(Transactiont: transactions){
 if(t.getType() == Transaction.GROCERY){
 groceryTransactions.add(t);
 }
}
Collections.sort(groceryTransactions,new Comparator(){
 public int compare(Transaction t1, Transactiont2){
 return t2.getValue().compareTo(t1.getValue());
 }
});
List<Integer>transactionIds = new ArrayList<>();
for(Transaction t:groceryTransactions){
 transactionsIds.add(t.getId());
}

而在 Java 8 使用 Stream,代码更加简洁易读;而且使用并发模式,程序执行速度更快。

//代码2. Java 8 的排序、取值实现
List<Integer>transactionsIds = transactions.parallelStream().
 filter(t -> t.getType() ==Transaction.GROCERY).
 sorted(comparing(Transaction::getValue).reversed()).
 map(Transaction::getId).
 collect(toList());

1.2  为什么需要Lambda表达式

我们为什么需要Lambda表达式

   主要有三个原因:

  > 更加紧凑的代码

     比如Java中现有的匿名内部类以及监听器(listeners)和事件处理器(handlers)都显得很冗长

  > 修改方法的能力(我个人理解为代码注入,或者有点类似JavaScript中传一个回调函数给另外一个函数)

     比如Collection接口的contains方法,当且仅当传入的元素真正包含在集合中,才返回true。而假如我们想对一个字符串集合,传入一个字符串,只要这个字符串出现在集合中(忽略大小写)就返回true。

     简单地说,我们想要的是传入“一些我们自己的代码”到已有的方法中,已有的方法将会执行我们传入的代码。Lambda表达式能很好地支持这点

  > 更好地支持多核处理

     例如,通过Java 8新增的Lambda表达式,我们可以很方便地并行操作大集合,充分发挥多核CPU的潜能。

     并行处理函数如filter、map和reduce。

2 Stream与Lambda表达式总览

2.1 Stream总览

2.1.1 什么是流

Stream不是集合元素,它不是数据结构并不保存数据,它是有关算法和计算的,它更像一个高级版本的 Iterator。原始版本的 Iterator,用户只能显式地一个一个遍历元素并对其执行某些操作;高级版本的 Stream,用户只要给出需要对其包含的元素执行什么操作,比如 “过滤掉长度大于 10 的字符串”、“获取每个字符串的首字母”等,Stream 会隐式地在内部进行遍历,做出相应的数据转换。

Stream就如同一个迭代器(Iterator),单向,不可往复,数据只能遍历一次,遍历过一次后即用尽了,就好比流水从面前流过,一去不复返。

而和迭代器又不同的是,Stream 可以并行化操作,迭代器只能命令式地、串行化操作。顾名思义,当使用串行方式去遍历时,每个 item 读完后再读下一个 item。而使用并行去遍历时,数据会被分成多个段,其中每一个都在不同的线程中处理,然后将结果一起输出。Stream 的并行操作依赖于 Java7 中引入的 Fork/Join 框架(JSR166y)来拆分任务和加速处理过程。Java 的并行 API 演变历程基本如下:

1.0-1.4中的 java.lang.Thread

5.0中的 java.util.concurrent

6.0中的 Phasers 等

7.0中的 Fork/Join 框架

8.0中的 Lambda

Stream的另外一大特点是,数据源本身可以是无限的。

2.1.2 流的构成

当我们使用一个流的时候,通常包括三个基本步骤:

获取一个数据源(source)→ 数据转换→执行操作获取想要的结果,每次转换原有 Stream 对象不改变,返回一个新的 Stream 对象(可以有多次转换),这就允许对其操作可以像链条一样排列,变成一个管道。

有多种方式生成 Stream Source:

从 Collection 和数组

  Collection.stream()

  Collection.parallelStream()

Arrays.stream(T array) or Stream.of()

从 BufferedReader

 java.io.BufferedReader.lines()

静态工厂

  java.util.stream.IntStream.range()

  java.nio.file.Files.walk()

自己构建

  java.util.Spliterator

其它

  Random.ints()

  BitSet.stream()

  Pattern.splitAsStream(java.lang.CharSequence)

  JarFile.stream()

流的操作类型分为两种:

Intermediate:一个流可以后面跟随零个或多个 intermediate 操作。其目的主要是打开流,做出某种程度的数据映射/过滤,然后返回一个新的流,交给下一个操作使用。这类操作都是惰性化的(lazy),就是说,仅仅调用到这类方法,并没有真正开始流的遍历。

Terminal:一个流只能有一个 terminal 操作,当这个操作执行后,流就被使用“光”了,无法再被操作。所以这必定是流的最后一个操作。Terminal 操作的执行,才会真正开始流的遍历,并且会生成一个结果,或者一个 side effect。

在对于一个 Stream 进行多次转换操作 (Intermediate 操作),每次都对 Stream 的每个元素进行转换,而且是执行多次,这样时间复杂度就是 N(转换次数)个 for 循环里把所有操作都做掉的总和吗?其实不是这样的,转换操作都是 lazy 的,多个转换操作只会在 Terminal 操作的时候融合起来,一次循环完成。我们可以这样简单的理解,Stream 里有个操作函数的集合,每次转换操作就是把转换函数放入这个集合中,在 Terminal 操作的时候循环 Stream 对应的集合,然后对每个元素执行所有的函数。

还有一种操作被称为 short-circuiting。用以指:

对于一个 intermediate 操作,如果它接受的是一个无限大(infinite/unbounded)的 Stream,但返回一个有限的新 Stream。

对于一个 terminal 操作,如果它接受的是一个无限大的 Stream,但能在有限的时间计算出结果。

当操作一个无限大的 Stream,而又希望在有限时间内完成操作,则在管道内拥有一个 short-circuiting 操作是必要非充分条件。

//代码3. 一个流操作的示例
int sum = widgets.stream()
.filter(w -> w.getColor() == RED)
 .mapToInt(w -> w.getWeight())
 .sum();

stream()获取当前小物件的 source,filter 和 mapToInt 为 intermediate 操作,进行数据筛选和转换,最后一个 sum() 为 terminal 操作,对符合条件的全部小物件作重量求和。

2.1.3 流和集合

集合和流尽管在表面上看起来很相似,但它们的设计目标是不同的:集合主要用来对其元素进行有效(effective)的管理和访问(access),而流并不支持对其元素进行直接操作或直接访问,而只支持通过声明式操作在其上进行运算然后得到结果。除此之外,流和集合还有一些其它不同:

无存储:流并不存储值;流的元素源自数据源(可能是某个数据结构、生成函数或I/O通道等等),通过一系列计算步骤得到;

天然的函数式风格(Functional in nature):对流的操作会产生一个结果,但流的数据源不会被修改;

惰性求值:多数流操作(包括过滤、映射、排序以及去重)都可以以惰性方式实现。这使得我们可以用一遍遍历完成整个流水线操作,并可以用短路操作提供更高效的实现;

无需上界(Bounds optional):不少问题都可以被表达为无限流(infinite stream):用户不停地读取流直到满意的结果出现为止(比如说,枚举完美数这个操作可以被表达为在所有整数上进行过滤)。集合是有限的,但流不是(操作无限流时我们必需使用短路操作,以确保操作可以在有限时间内完成);

从API的角度来看,流和集合完全互相独立,不过我们可以既把集合作为流的数据源(Collection拥有stream()和parallelStream()方法),也可以通过流产生一个集合(使用前例的collect()方法)。Collection以外的类型也可以作为stream的数据源,比如JDK中的BufferedReader、Random和BitSet已经被改造可以用做流的数据源,Arrays.stream()则产生给定数组的流视图。事实上,任何可以用Iterator描述的对象都可以成为流的数据源,如果有额外的信息(比如大小、是否有序等特性),库还可以进行进一步的优化。


集合上的流操作一般会生成一个新的值或集合。不过有时我们希望就地修改集合,所以我们为集合(例如Collection,List和Map)提供了一些新的方法,比如Iterable.forEach(Consumer),Collection.removeAll(Predicate),List.replaceAll(UnaryOperator),List.sort(Comparator)和Map.computeIfAbsent()。除此之外,ConcurrentMap中的一些非原子方法(例如replace和putIfAbsent)被提升到Map之中。

2.1.4 惰性(Laziness)

过滤和映射这样的操作既可以被急性求值(以filter为例,急性求值需要在方法返回前完成对所有元素的过滤),也可以被惰性求值(用Stream代表过滤结果,当且仅当需要时才进行过滤操作)在实际中进行惰性运算可以带来很多好处。比如说,如果我们进行惰性过滤,我们就可以把过滤和流水线里的其它操作混合在一起,从而不需要对数据进行多遍遍历。相类似的,如果我们在一个大型集合里搜索第一个满足某个条件的元素,我们可以在找到后直接停止,而不是继续处理整个集合。(这一点对无限数据源是很重要,惰性求值对于有限数据源起到的是优化作用,但对无限数据源起到的是决定作用,没有惰性求值,对无限数据源的操作将无法终止)

 

对于过滤和映射这样的操作,我们很自然的会把它当成是惰性求值操作,不过它们是否真的是惰性取决于它们的具体实现。另外,像sum()这样生成值的操作和forEach()这样产生副作用的操作都是“天然急性求值”,因为它们必须要产生具体的结果。

 

以下面的流水线为例:

int sum = shapes.stream()
                .filter(s -> s.getColor() ==BLUE)
                .mapToInt(s ->s.getWeight())
                .sum();

这里的过滤操作和映射操作是惰性的,这意味着在调用sum()之前,我们不会从数据源提取任何元素。在sum操作开始之后,我们把过滤、映射以及求和混合在对数据源的一遍遍历之中。这样可以大大减少维持中间结果所带来的开销。

 

大多数循环都可以用数据源(数组、集合、生成函数以及I/O管道)上的聚合操作来表示:进行一系列惰性操作(过滤和映射等操作),然后用一个急性求值操作(forEach,toArray和collect等操作)得到最终结果——例如过滤—映射—累积,过滤—映射—排序—遍历等组合操作。惰性操作一般被用来计算中间结果,这在Streams API设计中得到了很好的体现——与其让filter和map返回一个集合,我们选择让它们返回一个新的流。在Streams API中,返回流对象的操作都是惰性操作,而返回非流对象的操作(或者无返回值的操作,例如forEach())都是急性操作。绝大多数情况下,潜在的惰性操作会被用于聚合,这正是我们想要的——流水线中的每一轮操作都会接收输入流中的元素,进行转换,然后把转换结果传给下一轮操作。

 

在使用这种数据源—惰性操作—惰性操作—急性操作流水线时,流水线中的惰性几乎是不可见的,因为计算过程被夹在数据源和最终结果(或副作用操作)之间。这使得API的可用性和性能得到了改善。

 

对于anyMatch(Predicate)和findFirst()这些急性求值操作,我们可以使用短路(short-circuiting)来终止不必要的运算。以下面的流水线为例:

Optional<Shape> firstBlue =shapes.stream()
                                  .filter(s-> s.getColor() == BLUE)
                                  .findFirst();

由于过滤这一步是惰性的,findFirst在从其上游得到一个元素之后就会终止,这意味着我们只会处理这个元素及其之前的元素,而不是所有元素。findFirst()方法返回Optional对象,因为集合中有可能不存在满足条件的元素。Optional是一种用于描述可缺失值的类型。

 

在这种设计下,用户并不需要显式进行惰性求值,甚至他们都不需要了解惰性求值。类库自己会选择最优化的计算方式。

2.1.5 并行(Parallelism)

流水线既可以串行执行也可以并行执行,并行或串行是流的属性。除非你显式要求使用并行流,否则JDK总会返回串行流。(串行流可以通过parallel()方法被转化为并行流)

 

尽管并行是显式的,但它并不需要成为侵入式的。利用parallelStream(),我们可以轻松的把之前重量求和的代码并行化:

int sum = shapes.parallelStream()
                .filter(s -> s.getColor =BLUE)
                .mapToInt(s ->s.getWeight())
                .sum();

并行化之后和之前的代码区别并不大,然而我们可以很容易看出它是并行的(此外我们并不需要自己去实现并行代码)。

 

因为流的数据源可能是一个可变集合,如果在遍历流时数据源被修改,就会产生干扰(interference)。所以在进行流操作时,流的数据源应保持不变(held constant)。这个条件并不难维持,如果集合只属于当前线程,只要lambda表达式不修改流的数据源就可以。(这个条件和遍历集合时所需的条件相似,如果集合在遍历时被修改,绝大多数的集合实现都会抛出ConcurrentModificationException)我们把这个条件称为无干扰性(non-interference)。

 

我们应避免在传递给流方法的lambda产生副作用。一般来说,打印调试语句这种输出变量的操作是安全的,然而在lambda表达式里访问可变变量就有可能造成数据竞争或是其它意想不到的问题,因为lambda在执行时可能会同时运行在多个线程上,因而它们所看到的元素有可能和正常的顺序不一致。无干扰性有两层含义:

 

不要干扰数据源;

不要干扰其它lambda表达式,当一个lambda在修改某个可变状态而另一个lambda在读取该状态时就会产生这种干扰。

只要满足无干扰性,我们就可以安全的进行并行操作并得到可预测的结果,即便对线程不安全的集合(例如ArrayList)也是一样。

 

并行的实质(Parallelism under the hood)

 

JavaSE 7引入了Fork/Join模型,以便高效实现并行计算。不过,通过Fork/Join编写的并行代码和同功能的串行代码的差别非常巨大,这使改写串行代码变的非常困难。通过提供串行流和并行流,用户可以在串行操作和并行操作之间进行便捷的切换(无需重写代码),从而使得编写正确的并行代码变的更加容易。

 

为了实现并行计算,我们一般要把计算过程递归分解(recursive decompose)为若干步:

 

把问题分解为子问题;

串行解决子问题从而得到部分结果(partial result);

合并部分结果合为最终结果。

这也是Fork/Join的实现原理。

 

为了能够并行化任意流上的所有操作,我们把流抽象为Spliterator,Spliterator是对传统迭代器概念的一个泛化。分割迭代器(spliterator)既支持顺序依次访问数据,也支持分解数据:就像Iterator允许你跳过一个元素然后保留剩下的元素,Spliterator允许你把输入元素的一部分(一般来说是一半)转移(carve off)到另一个新的Spliterator中,而剩下的数据则会被保存在原来的Spliterator里。(这两个分割迭代器还可以被进一步分解)除此之外,分割迭代器还可以提供源的元数据(比如元素的数量,如果已知的话)和其它一系列布尔值特征(比如说“元素是否被排序”这样的特征),Streams框架可以利用这些数据来进行优化。

 

上面的分解方法也同样适用于其它数据结构,数据结构的作者只需要提供分解逻辑,然后就可以直接享用并行流操作带来的遍历。

 

大多数用户无需去实现Spliterator接口,因为集合上的stream()方法往往就足够了。但如果你需要实现一个集合或一个流,那么你可能需要手动实现Spliterator接口。Spliterator接口的API如下所示:

 

public interface Spliterator<T> {
  //Element access
 boolean tryAdvance(Consumer< ? super T> action);
 void forEachRemaining(Consumer< ? super T> action);
 
  //Decomposition
 Spliterator<T> trySplit();
 
 //Optional metadata
 long estimateSize();
  intcharacteristics();
 Comparator< ? super T> getComparator();
}

集合库中的基础接口Collection和Iterable都实现了正确但相对低效的spliterator()实现,但派生接口(例如Set)和具体实现类(例如ArrayList)均提供了高效的分割迭代器实现。分割迭代器的实现质量会影响到流操作的执行效率;如果在split()方法中进行良好(平衡)的划分,CPU的利用率会得到改善;此外,提供正确的特性(characteristics)和大小(size)这些元数据有利于进一步优化。

 

出现顺序(Encounter order)

 

多数数据结构(例如列表,数组和I/O通道)都拥有自然出现顺序(natural encounter order),这意味着它们的元素出现顺序是可预测的。其它的数据结构(例如HashSet)则没有一个明确定义的出现顺序(这也是HashSet的Iterator实现中不保证元素出现顺序的原因)。

 

是否具有明确定义的出现顺序是Spliterator检查的特性之一(这个特性也被流使用)。除了少数例外(比如Stream.forEach()和Stream.findAny()),并行操作一般都会受到出现顺序的限制。这意味着下面的流水线:

List<String> names =people.parallelStream()
                           .map(Person::getName)
                           .collect(toList());

代码中名字出现的顺序必须要和流中的Person出现的顺序一致。一般来说,这是我们所期待的结果,而且它对多大多数的流实现都不会造成明显的性能损耗。从另外的角度来说,如果源数据是HashSet,那么上面代码中名字就可以以任意顺序出现。

2.2 Lambda表达式总览

2.2.1 背景

Java是一门面向对象编程语言。面向对象编程语言和函数式编程语言中的基本元素(Basic Values)都可以动态封装程序行为:面向对象编程语言使用带有方法的对象封装行为,函数式编程语言使用函数封装行为。但这个相同点并不明显,因为Java的对象往往比较“重量级”:实例化一个类型往往会涉及不同的类,并需要初始化类里的字段和方法。

 

不过有些Java对象只是对单个函数的封装。例如下面这个典型用例:Java API中定义了一个接口(一般被称为回调接口),用户通过提供这个接口的实例来传入指定行为,例如:

public interface ActionListener {
  voidactionPerformed(ActionEvent e);
}

这里并不需要专门定义一个类来实现ActionListener接口,因为它只会在调用处被使用一次。用户一般会使用匿名类型把行为内联(inline):

button.addActionListener(newActionListener) {
 public void actionPerformed(ActionEvent e) {
   ui.dazzle(e.getModifiers());
  }
}

很多库都依赖于上面的模式。对于并行API更是如此,因为我们需要把待执行的代码提供给并行API,并行编程是一个非常值得研究的领域,因为在这里摩尔定律得到了重生:尽管我们没有更快的CPU核心(core),但是我们有更多的CPU核心。而串行API就只能使用有限的计算能力。

 

随着回调模式和函数式编程风格的日益流行,我们需要在Java中提供一种尽可能轻量级的将代码封装为数据(Model code as data)的方法。匿名内部类并不是一个好的选择,因为:

 

语法过于冗余

匿名类中的this和变量名容易使人产生误解

类型载入和实例创建语义不够灵活

无法捕获非final的局部变量

无法对控制流进行抽象

上面的多数问题均在Java SE 8中得以解决:

 

通过提供更简洁的语法和局部作用域规则,Java SE 8彻底解决了问题1和问题2

通过提供更加灵活而且便于优化的表达式语义,Java SE 8绕开了问题3

通过允许编译器推断变量的“常量性”(finality),Java SE 8减轻了问题4带来的困扰

不过,Java SE 8的目标并非解决所有上述问题。因此捕获可变变量(问题4)和非局部控制流(问题5)并不在Java SE 8的范畴之内。

2.2.2 函数式接口

尽管匿名内部类有着种种限制和问题,但是它有一个良好的特性,它和Java类型系统结合的十分紧密:每一个函数对象都对应一个接口类型。之所以说这个特性是良好的,是因为:

·        接口是Java类型系统的一部分

·        接口天然就拥有其运行时表示(Runtime representation)

·        接口可以通过Javadoc注释来表达一些非正式的协定(contract),例如,通过注释说明该操作应可交换(commutative)

上面提到的ActionListener接口只有一个方法,大多数回调接口都拥有这个特征:比如Runnable接口和Comparator接口。我们把这些只拥有一个方法的接口称为函数式接口。(之前它们被称为SAM类型,即单抽象方法类型(Single Abstract Method))

我们并不需要额外的工作来声明一个接口是函数式接口:编译器会根据接口的结构自行判断(判断过程并非简单的对接口方法计数:一个接口可能冗余的定义了一个Object已经提供的方法,比如toString(),或者定义了静态方法或默认方法,这些都不属于函数式接口方法的范畴)。不过API作者们可以通过@FunctionalInterface注解来显式指定一个接口是函数式接口(以避免无意声明了一个符合函数式标准的接口),加上这个注解之后,编译器就会验证该接口是否满足函数式接口的要求。

实现函数式类型的另一种方式是引入一个全新的结构化函数类型,我们也称其为“箭头”类型。例如,一个接收String和Object并返回int的函数类型可以被表示为(String, Object) -> int。我们仔细考虑了这个方式,但出于下面的原因,最终将其否定:

·        它会为Java类型系统引入额外的复杂度,并带来结构类型(StructuralType指名类型(NominalType的混用。(Java几乎全部使用指名类型)

·        它会导致类库风格的分歧——一些类库会继续使用回调接口,而另一些类库会使用结构化函数类型

·        它的语法会变得十分笨拙,尤其在包含受检异常(checked exception)之后

·        每个函数类型很难拥有其运行时表示,这意味着开发者会受到类型擦除(erasure的困扰和局限。比如说,我们无法对方法m(T->U)m(X->Y)进行重载(Overload)

所以我们选择了“使用已知类型”这条路——因为现有的类库大量使用了函数式接口,通过沿用这种模式,我们使得现有类库能够直接使用lambda表达式。例如下面是Java SE 7中已经存在的函数式接口:

·        java.lang.Runnable

·        java.util.concurrent.Callable

·        java.security.PrivilegedAction

·        java.util.Comparator

·        java.io.FileFilter

·        java.beans.PropertyChangeListener

除此之外,Java SE 8中增加了一个新的包:java.util.function,它里面包含了常用的函数式接口,例如:

·        Predicate<T>——接收T对象并返回boolean

·        Consumer<T>——接收T对象,不返回值

·        Function<T,R>——接收T对象,返回R对象

·        Supplier<T>——提供T对象(例如工厂),不接收值

·        UnaryOperator<T>——接收T对象,返回T对象

·        BinaryOperator<T>——接收两个T对象,返回T对象

除了上面的这些基本的函数式接口,我们还提供了一些针对原始类型(Primitive type)的特化(Specialization)函数式接口,例如IntSupplier和LongBinaryOperator。(我们只为int、long和double提供了特化函数式接口,如果需要使用其它原始类型则需要进行类型转换)同样的我们也提供了一些针对多个参数的函数式接口,例如BiFunction<T, U, R>,它接收T对象和U对象,返回R对象。

 

2.2.3  lambda表达式

匿名类型最大的问题就在于其冗余的语法。有人戏称匿名类型导致了“高度问题”(height problem):比如前面ActionListener的例子里的五行代码中仅有一行在做实际工作。

lambda表达式是匿名方法,它提供了轻量级的语法,从而解决了匿名内部类带来的“高度问题”。

下面是一些lambda表达式:

(int x, int y) -> x + y
() -> 42
(String s) -> { System.out.println(s); }

第一个lambda表达式接收x和y这两个整形参数并返回它们的和;第二个lambda表达式不接收参数,返回整数'42';第三个lambda表达式接收一个字符串并把它打印到控制台,不返回值。

lambda表达式的语法由参数列表、箭头符号->和函数体组成。函数体既可以是一个表达式,也可以是一个语句块:

·        表达式:表达式会被执行然后返回执行结果。

·        语句块:语句块中的语句会被依次执行,就像方法中的语句一样——

·        return语句会把控制权交给匿名方法的调用者

·        breakcontinue只能在循环中使用

·        如果函数体有返回值,那么函数体内部的每一条路径都必须返回值

表达式函数体适合小型lambda表达式,它消除了return关键字,使得语法更加简洁。

lambda表达式也会经常出现在嵌套环境中,比如说作为方法的参数。为了使lambda表达式在这些场景下尽可能简洁,我们去除了不必要的分隔符。不过在某些情况下我们也可以把它分为多行,然后用括号包起来,就像其它普通表达式一样。

下面是一些出现在语句中的lambda表达式:

FileFilter java = (File f) ->f.getName().endsWith("*.java");
 
String user = doPrivileged(() ->System.getProperty("user.name"));
 
new Thread(() -> {
 connectToService();
 sendNotification();
}).start();

 

2.2.4 目标类型

需要注意的是,函数式接口的名称并不是lambda表达式的一部分。那么问题来了,对于给定的lambda表达式,它的类型是什么?答案是:它的类型是由其上下文推导而来。例如,下面代码中的lambda表达式类型是ActionListener:

ActionListener l = (ActionEvent e) ->ui.dazzle(e.getModifiers());

这就意味着同样的lambda表达式在不同上下文里可以拥有不同的类型:

Callable<String> c = () -> "done";

PrivilegedAction<String> a = ()-> "done";

第一个lambda表达式() -> "done"是Callable的实例,而第二个lambda表达式则是PrivilegedAction的实例。

编译器负责推导lambda表达式的类型。它利用lambda表达式所在上下文所期待的类型进行推导,这个被期待的类型被称为目标类型。lambda表达式只能出现在目标类型为函数式接口的上下文中。

当然,lambda表达式对目标类型也是有要求的。编译器会检查lambda表达式的类型和目标类型的方法签名(method signature)是否一致。当且仅当下面所有条件均满足时,lambda表达式才可以被赋给目标类型T:

·        T是一个函数式接口

·        lambda表达式的参数和T的方法参数在数量和类型上一一对应

·        lambda表达式的返回值和T的方法返回值相兼容(Compatible)

·        lambda表达式内所抛出的异常和T的方法throws类型相兼容

由于目标类型(函数式接口)已经“知道”lambda表达式的形式参数(Formal parameter)类型,所以我们没有必要把已知类型再重复一遍。也就是说,lambda表达式的参数类型可以从目标类型中得出:

Comparator<String> c = (s1, s2)-> s1.compareToIgnoreCase(s2);

在上面的例子里,编译器可以推导出s1和s2的类型是String。此外,当lambda的参数只有一个而且它的类型可以被推导得知时,该参数列表外面的括号可以被省略:

FileFilter java = f -> f.getName().endsWith(".java");

button.addActionListener(e ->ui.dazzle(e.getModifiers()));

这些改进进一步展示了我们的设计目标:“不要把高度问题转化成宽度问题。”我们希望语法元素能够尽可能的少,以便代码的读者能够直达lambda表达式的核心部分。

lambda表达式并不是第一个拥有上下文相关类型的Java表达式:泛型方法调用和“菱形”构造器调用也通过目标类型来进行类型推导:

List<String> ls = Collections.emptyList();

List<Integer> li = Collections.emptyList();

Map<String, Integer> m1 = new HashMap<>();

Map<Integer, String> m2 = new HashMap<>();

 

2.2.5 目标类型的上下文

目标类型的上下文(Contexts for target typing)

之前我们提到lambda表达式智能出现在拥有目标类型的上下文中。下面给出了这些带有目标类型的上下文:

·        变量声明

·        赋值

·        返回语句

·        数组初始化器

·        方法和构造方法的参数

·        lambda表达式函数体

·        条件表达式(? :

·        转型(Cast)表达式

在前三个上下文(变量声明、赋值和返回语句)里,目标类型即是被赋值或被返回的类型:

Comparator<String> c;
c = (String s1, String s2) -> s1.compareToIgnoreCase(s2);
 
publicRunnable toDoLater() {
  return () -> {
    System.out.println("later");
  }
}

数组初始化器和赋值类似,只是这里的“变量”变成了数组元素,而类型是从数组类型中推导得知:

filterFiles(new FileFilter[] {
              f -> f.exists(), f -> f.canRead(), f -> f.getName().startsWith("q")
            });

方法参数的类型推导要相对复杂些:目标类型的确认会涉及到其它两个语言特性:重载解析(Overload resolution)和参数类型推导(Type argument inference)。

重载解析会为一个给定的方法调用(method invocation)寻找最合适的方法声明(method declaration)。由于不同的声明具有不同的签名,当lambda表达式作为方法参数时,重载解析就会影响到lambda表达式的目标类型。编译器会通过它所得之的信息来做出决定。如果lambda表达式具有显式类型(参数类型被显式指定),编译器就可以直接 使用lambda表达式的返回类型;如果lambda表达式具有隐式类型(参数类型被推导而知),重载解析则会忽略lambda表达式函数体而只依赖lambda表达式参数的数量。

如果在解析方法声明时存在二义性(ambiguous),我们就需要利用转型(cast)或显式lambda表达式来提供更多的类型信息。如果lambda表达式的返回类型依赖于其参数的类型,那么lambda表达式函数体有可能可以给编译器提供额外的信息,以便其推导参数类型。

List<Person> ps = ...
Stream<String> names = ps.stream().map(p -> p.getName());

在上面的代码中,ps的类型是List<Person>,所以ps.stream()的返回类型是Stream<Person>。map()方法接收一个类型为Function<T, R>的函数式接口,这里T的类型即是Stream元素的类型,也就是Person,而R的类型未知。由于在重载解析之后lambda表达式的目标类型仍然未知,我们就需要推导R的类型:通过对lambda表达式函数体进行类型检查,我们发现函数体返回String,因此R的类型是String,因而map()返回Stream<String>。绝大多数情况下编译器都能解析出正确的类型,但如果碰到无法解析的情况,我们则需要:

·        使用显式lambda表达式(为参数p提供显式类型)以提供额外的类型信息

·        把lambda表达式转型为Function<Person,String>

·        为泛型参数R提供一个实际类型。(.<String>map(p -> p.getName())

lambda表达式本身也可以为它自己的函数体提供目标类型,也就是说lambda表达式可以通过外部目标类型推导出其内部的返回类型,这意味着我们可以方便的编写一个返回函数的函数:

Supplier<Runnable> c = () -> () -> { System.out.println("hi"); };

类似的,条件表达式可以把目标类型“分发”给其子表达式:

Callable<Integer> c = flag ? (() ->23) : (() ->42);

最后,转型表达式(Cast expression)可以显式提供lambda表达式的类型,这个特性在无法确认目标类型时非常有用:

// Object o = () -> { System.out.println("hi"); }; 这段代码是非法的
Object o = (Runnable)() -> { System.out.println("hi"); };

除此之外,当重载的方法都拥有函数式接口时,转型可以帮助解决重载解析时出现的二义性。

目标类型这个概念不仅仅适用于lambda表达式,泛型方法调用和“菱形”构造方法调用也可以从目标类型中受益,下面的代码在Java SE 7是非法的,但在Java SE 8中是合法的:

List<String> ls = Collections.checkedList(new ArrayList<>(), String.class);
 
Set<Integer> si = flag ? Collections.singleton(23) : Collections.emptySet();

 

2.2.6 变量捕获

在Java SE 7中,编译器对内部类中引用的外部变量(即捕获的变量)要求非常严格:如果捕获的变量没有被声明为final就会产生一个编译错误。我们现在放宽了这个限制——对于lambda表达式和内部类,我们允许在其中捕获那些符合有效只读(Effectively final)的局部变量。

简单的说,如果一个局部变量在初始化后从未被修改过,那么它就符合有效只读的要求,换句话说���加上final后也不会导致编译错误的局部变量就是有效只读变量。

Callable<String> helloCallable(String name) {
  String hello = "Hello";
  return () -> (hello + ", " + name);
}

对this的引用,以及通过this对未限定字段的引用和未限定方法的调用在本质上都属于使用final局部变量。包含此类引用的lambda表达式相当于捕获了this实例。在其它情况下,lambda对象不会保留任何对this的引用。

这个特性对内存管理是一件好事:内部类实例会一直保留一个对其外部类实例的强引用,而那些没有捕获外部类成员的lambda表达式则不会保留对外部类实例的引用。要知道内部类的这个特性往往会造成内存泄露。

尽管我们放宽了对捕获变量的语法限制,但试图修改捕获变量的行为仍然会被禁止,比如下面这个例子就是非法的:

intsum = 0;
list.forEach(e -> { sum += e.size(); });

为什么要禁止这种行为呢?因为这样的lambda表达式很容易引起race condition。除非我们能够强制(最好是在编译时)这样的函数不能离开其当前线程,但如果这么做了可能会导致更多的问题。简而言之,lambda表达式对封闭,对变量开放。

个人补充:lambda表达式对封闭,对变量开放的原文是:lambda expressions closeover values, not variables,我在这里增加一个例子以说明这个特性:

int sum = 0;
list.forEach(e -> { sum += e.size(); }); // Illegal, close over values
 
List<Integer> aList = newList<>();
list.forEach(e -> { aList.add(e); }); // Legal, open over variables

lambda表达式不支持修改捕获变量的另一个原因是我们可以使用更好的方式来实现同样的效果:使用规约(reduction)。java.util.stream包提供了各种通用的和专用的规约操作(例如sum、min和max),就上面的例子而言,我们可以使用规约操作(在串行和并行下都是安全的)来代替forEach:

intsum = list.stream()
              .mapToInt(e -> e.size())
              .sum();

sum()等价于下面的规约操作:

intsum = list.stream()
              .mapToInt(e -> e.size())
              .reduce(0 , (x, y) -> x + y);

规约需要一个初始值(以防输入为空)和一个操作符(在这里是加号),然后用下面的表达式计算结果:

0 + list[0] + list[1] + list[2] + ...

规约也可以完成其它操作,比如求最小值、最大值和乘积等等。如果操作符具有可结合性(associative),那么规约操作就可以容易的被并行化。所以,与其支持一个本质上是并行而且容易导致race condition的操作,我们选择在库中提供一个更加并行友好且不容易出错的方式来进行累积(accumulation)。

 

2.2.7 方法引用

lambda表达式允许我们定义一个匿名方法,并允许我们以函数式接口的方式使用它。我们也希望能够在已有的方法上实现同样的特性。

方法引用和lambda表达式拥有相同的特性(例如,它们都需要一个目标类型,并需要被转化为函数式接口的实例),不过我们并不需要为方法引用提供方法体,我们可以直接通过方法名称引用已有方法。

以下面的代码为例,假设我们要按照name或age为Person数组进行排序:

classPerson{
  privatefinal String name;
  privatefinalint age;
 
  publicintgetAge(){ return age; }
  public String getName(){return name; }
  ...
}
 
Person[] people = ...
Comparator<Person> byName = Comparator.comparing(p -> p.getName());
Arrays.sort(people, byName);

在这里我们可以用方法引用代替lambda表达式:

Comparator<Person> byName = Comparator.comparing(Person::getName);

这里的Person::getName可以被看作为lambda表达式的简写形式。尽管方法引用不一定(比如在这个例子里)会把语法变的更紧凑,但它拥有更明确的语义——如果我们想要调用的方法拥有一个名字,我们就可以通过它的名字直接调用它。

因为函数式接口的方法参数对应于隐式方法调用时的参数,所以被引用方法签名可以通过放宽类型,装箱以及组织到参数数组中的方式对其参数进行操作,就像在调用实际方法一样:

Consumer<Integer> b1 = System::exit;    // void exit(int status)
Consumer<String[]> b2 = Arrays:sort;    // void sort(Object[] a)
Consumer<String> b3 = MyProgram::main;  // void main(String... args)
Runnable r = Myprogram::mapToInt        // void main(String... args)

 

方法引用有很多种,它们的语法如下:

·        静态方法引用:ClassName::methodName

·        实例上的实例方法引用:instanceReference::methodName

·        超类上的实例方法引用:super::methodName

·        类型上的实例方法引用:ClassName::methodName

·        构造方法引用:Class::new

·        数组构造方法引用:TypeName[]::new

对于静态方法引用,我们需要在类名和方法名之间加入::分隔符,例如Integer::sum。

对于具体对象上的实例方法引用,我们则需要在对象名和方法名之间加入分隔符:

Set<String> knownNames = ...

Predicate<String> isKnown = knownNames::contains;

这里的隐式lambda表达式(也就是实例方法引用)会从knownNames中捕获String对象,而它的方法体则会通过Set.contains使用该String对象。

有了实例方法引用,在不同函数式接口之间进行类型转换就变的很方便:

Callable<Path> c = ...

Privileged<Path> a = c::call;

引用任意对象的实例方法则需要在实例方法名称和其所属类型名称间加上分隔符:

Function<String, String> upperfier = String::toUpperCase;

这里的隐式lambda表达式(即String::toUpperCase实例方法引用)有一个String参数,这个参数会被toUpperCase方法使用。

如果类型的实例方法是泛型的,那么我们就需要在::分隔符前提供类型参数,或者(多数情况下)利用目标类型推导出其类型。

需要注意的是,静态方法引用和类型上的实例方法引用拥有一样的语法。编译器会根据实际情况做出决定。

一般我们不需要指定方法引用中的参数类型,因为编译器往往可以推导出结果,但如果需要我们也可以显式在::分隔符之前提供参数类型信息。

和静态方法引用类似,构造方法也可以通过new关键字被直接引用:

SocketImplFactory factory =MySocketImpl::new;

如果类型拥有多个构造方法,那么我们就会通过目标类型的方法参数来选择最佳匹配,这里的选择过程和调用构造方法时的选择过程是一样的。

如果待实例化的类型是泛型的,那么我们可以在类型名称之后提供类型参数,否则编译器则会依照"菱形"构造方法调用时的方式进行推导。

数组的构造方法引用的语法则比较特殊,为了便于理解,你可以假想存在一个接收int参数的数组构造方法。参考下面的代码:

IntFunction<int[]> arrayMaker = int[]::new;

int[] array = arrayMaker.apply(10) // 创建数组 int[10]

默认方法和静态接口方法(Default and static interface methods)

lambda表达式和方法引用大大提升了Java的表达能力(expressiveness),不过为了使把代码即数据(code-as-data)变的更加容易,我们需要把这些特性融入到已有的库之中,以便开发者使用。

JavaSE 7时代为一个已有的类库增加功能是非常困难的。具体的说,接口在发布之后就已经被定型,除非我们能够一次性更新所有该接口的实现,否则向接口添加方法就会破坏现有的接口实现。默认方法(之前被称为虚拟扩展方法守护方法)的目标即是解决这个问题,使得接口在发布之后仍能被逐步演化。

这里给出一个例子,我们需要在标准集合API中增加针对lambda的方法。例如removeAll方法应该被泛化为接收一个函数式接口Predicate,但这个新的方法应该被放在哪里呢?我们无法直接在Collection接口上新增方法——不然就会破坏现有的Collection实现。我们倒是可以在Collections工具类中增加对应的静态方法,但这样就会把这个方法置于“二等公民”的境地。

默认方法利用面向对象的方式向接口增加新的行为。它是一种新的方法:接口方法可以是抽象的或是默认的。默认方法拥有其默认实现,实现接口的类型通过继承得到该默认实现(如果类型没有覆盖该默认实现)。此外,默认方法不是抽象方法,所以我们可以放心的向函数式接口里增加默认方法,而不用担心函数式接口的单抽象方法限制。

下面的例子展示了如何向Iterator接口增加默认方法skip:

interfaceIterator<E> {
  booleanhasNext();
  E next();
  voidremove();
 
  defaultvoidskip(int i){
    for ( ; i > 0 && hasNext(); i -= 1) next();
  }
}

根据上面的Iterator定义,所有实现Iterator的类型都会自动继承skip方法。在使用者的眼里,skip不过是接口新增的一个虚拟方法。在没有覆盖skip方法的Iterator子类实例上调用skip会执行skip的默认实现:调用hasNext和next若干次。子类可以通过覆盖skip来提供更好的实现——比如直接移动游标(cursor),或是提供为操作提供原子性(Atomicity)等。

当接口继承其它接口时,我们既可以为它所继承而来的抽象方法提供一个默认实现,也可以为它继承而来的默认方法提供一个新的实现,还可以把它继承而来的默认方法重新抽象化。

除了默认方法,Java SE 8还在允许在接口中定义静态方法。这使得我们可以从接口直接调用和它相关的辅助方法(Helper method),而不是从其它的类中调用(之前这样的类往往以对应接口的复数命名,例如Collections)。比如,我们一般需要使用静态辅助方法生成实现Comparator的比较器,在Java SE 8中我们可以直接把该静态方法定义在Comparator接口中:

publicstatic <T, U extends Comparable<? super U>>
    Comparator<T> comparing(Function<T, U> keyExtractor) {
  return (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}

继承默认方法(Inheritanceof default methods)

和其它方法一样,默认方法也可以被继承,大多数情况下这种继承行为和我们所期待的一致。不过,当类型或者接口的超类拥有多个具有相同签名的方法时,我们就需要一套规则来解决这个冲突:

·        类的方法(class method)声明优先于接口默认方法。无论该方法是具体的还是抽象的。

·        被其它类型所覆盖的方法会被忽略。这条规则适用于超类型共享一个公共祖先的情况。

为了演示第二条规则,我们假设Collection和List接口均提供了removeAll的默认实现,然后Queue继承并覆盖了Collection中的默认方法。在下面的implement从句中,List中的方法声明会优先于Queue中的方法声明:

classLinkedList<E> implementsList<E>, Queue<E> { ... }

当两个独立的默认方法相冲突或是默认方法和抽象方法相冲突时会产生编译错误。这时程序员需要显式覆盖超类方法。一般来说我们会定义一个默认方法,然后在其中显式选择超类方法:

interfaceRobotimplementsArtist, Gun {

  defaultvoiddraw() { Artist.super.draw(); }

}

super前面的类型必须是有定义或继承默认方法的类型。这种方法调用并不只限于消除命名冲突——我们也可以在其它场景中使用它。

最后,接口在inherits和extends从句中的声明顺序和它们被实现的顺序无关。

阅读更多
换一批

没有更多推荐了,返回首页