(翻译)Java SE 8 Lambda 特性与基本原理(上)

限于喜欢 提交于 2019-11-30 16:24:34

Lambda 语言特性与原理

java se 8 edition

        Java语言规范-JSR335 中对Java语言一些新功能的非正式描述这些增强性功能已被OpenJDK 的Lambda项目实现。并且该文章细化了上次发布在2011年九月份的迭代一些JSR关于语言特性变更的正式描述戳JSR-335同时OpenJDK的开发者预览版已经发布Developer Preview), 一些以往的设计文档查看OpenJDK project page.),这里State of the Lambda, Libraries Edition还有一篇相关文档描述了API中关于JSR335的一些变化。


Lambda 项目力图使Java能够方便的以通用语法的方式将建模代码(modeling code)作为数据。主要的新语言特性包括:

  • Lambda 表达式(通俗的称谓,“闭包”或者“匿名方法”)

  • 方法和构造器引用

  • 扩充目标类型和类型推断

  • 接口中的默认方法和静态方法


下面便是详细的描述与解释。


1.背景


Java主要是一门面向对象的编程语言。在面向对象和函数式语言中,基本的数据值可以被动态地封装到程序的行为中:如面向对象语言中的包含方法的对象,以及函数式语言中的函数。这种相同点也许并不明显,这是因为对一系列单独声明的包含许多域和方法的类的实例化使得java对象往往相对重量级些。

但是一些对象本质上只是作为一个方程确实相当普遍的。 一个典型的用例中,一个java API定义了一个接口,有时被称作‘回调接口’, 希望用户在调用该API时提供该接口的一个实例,举个例子:

public interface ActionListener { 
    void actionPerformed(ActionEvent e);
}


用户通常会实例化一个匿名内部类,而不会为一个只调用一次的接口实例单独声明一个实现了ActionListener的类:

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


许多有用的库都依赖这种模式。对于并行(parallel)API尤为重要,以并行方式执行的代码必须独立于它所运行的线程。并行编程领域是特别有趣的,因为随着摩尔定律不断给我们带来更多的内核,而不是更快的内核,串行(serial)API越来越受限于正在减少的可用的处理能力。

考虑到回调与其他函数式编程风格越来越多的关联性,尽可能的将Java中建模代码作为数据变得轻量些也就尤为重要了。从这方面看,匿名内部类存在以下几个重要缺陷:


  1. 笨重的语法

  2. 变量名称与this引用引起混乱

  3. 不可变的类加载和实例创建语义

  4. 无法访问非final的局部变量

  5. 无法抽象控制流


这个项目解决了许多问题。第1)2)两个问题通过引入新的更加简洁的局部变量规则和表达形式得以解决。第3)个问题通过定义新的更加灵活的语义表达式来避开。第4)个问题通过允许编译器推断最终结果(可以访问最终有效的局部变量)得以改善。

然而,这个项目的目标不是解决所有这些内部类的问题。 因为4)5)都不在这个项目的范围内(尽管可能在将来的语言新特性中得到解决)。


2. 函数式接口(Functional Interface)


尽管匿名内部类存在局限性, 但是它却有着非常好的适合java 类型系统的属性:一个含有函数值的接口类型。这一特点有这么几点便利之处:接口本身就是类型系统的一部分;天生具有运行时表达方式;并且它具有Javadoc注释表示的非正式契约,如可交互的操作,断言(原文:they carry with them informal contracts expressed by Javadoc comments, such as an assertion that an operation is commutative)。


如上面使用的ActionListener接口只有一个方法。许多常用的回调接口都有这样的属性,如Runnable,Comparator。我们给所有这些只有一个名字的接口取了一个名字:函数式接口(functional interfaces)。(之前被叫做SAM类型,意思是“   单一抽象方法”(Single Abstract Method)类型。)


不需要做特殊的工作便可声明一个函数式接口;编译器会通过它的机构进行识别。(识别过程不仅仅是计算声明方法的个数;一个接口或许有多个方法继承自Object,如toString(), 还有可能存在static或者default方法,这些都多余了一个方法的限制)然而,API作者可以使用@FunctionalInterface注解来捕获一个自己想要的函数式接口(而不是碰巧只有一个方法)。在这种情况下编译器会验证该接口是否符合函数式接口的结构要求。


早期存在一个可选的(补充的)创建函数类型的方式,被称作'箭头类型'(arrow type), 需要引入新结构的函数类型。如一个将字符串和对象转换为int的函数类型可以写成(String, Object) -> int.因为以下几个缺点,我们放弃了这种想法:


  • 使类型系统复杂化,更加混合结构与名义上的类型(Java几乎完全是名义上的类型)

  • 使库的风格发生分歧--一些库继续使用回调接口,而另一些使用函数式类型

  • 这种语法不够灵活,尤其是受控异常被包裹在内时。

  • 不可能为每一个不同的函数式类型提供一个运行时表示,也就是说开发人员将更多的接触和受限于类型擦出。举个例子,不可能(或许出人意料)重载m(T->U)和m(X->Y).


因此,我们遵循‘用你所知’的思想路径 --因为当前库中广泛使用了函数式接口,我们将利用这种模式,使得当前库同样可以使用Lambda表达式。

举例说明,下面是Java SE 7中已经存在的非常符合新语言特性的函数式接口, 我们下面的诸多例子中便使用了其中的几个:



另外, Java SE 8 添加了一个新的 java.util.function包,其中包含了经常被用到的函数式接口,如:


  • Predicate<T> ---- 表示一个布尔值函数

  • Consumer<T> ----  表示一个没有返回值的函数

  • Function<T, R> ---- 表示一个将T转换为R的函数

  • Supplier<T> ---- 表示一个生成新实例的函数

  • UnaryOperator<T> ---- 表示一个操作并返回同一个类型值得函数

  • BinaryOperator<T> ----- 表示一个接受两个同样类型值得参数,并返回一个相同类型值得函数


除了这些基本的“形状”,还有一些像IntSupplier或者LongBinaryOperator这样的基本类型的特殊化。(我门只提供了int,long,和double的特殊化的函数式接口而不是全部基本类型的实现,因为其他基本类型可以通过转化得到),同样的,也有一些多参数量的特殊化的函数式接口,像BiFunction<T,U,R>, 代表一个将(T,U)转化为R的函数。


3. Lambda 表达式


匿名内部类最大的痛楚就是笨重。我们可以称之为“垂直问题”:第一部分中的ActionListener实例用了五行代码才实现一个一个简单的动作。

Lambda表达式是匿名方法,旨在使用轻量级机制代替匿名内部类的机械性来解决“垂直问题”。

下面是几个lambda表达式的例子:

(int x, int y) -> x + y

() -> 42 

(String s) -> { System.out.println(s); }


第一个表达式包含两个参数x,y,并且返回他们的和。第二没有参数,返回Integer类型的值42。第三个有一个string类型的参数没有返回值,只是将参数值打印到控制台上。


Lambda表达式通常的语法包括一个参数列表,箭头令牌 ->, 以及主体。主体可以是单行表达式,也可以是代码块。对于表达式形式的主体,只是简单的求值并返回。而对于代码块形式的主体会像方法体一样执行,然后return语句将控制权返回给匿名方法的调用者;break和continue不可用于顶层代码中(译注:意思应该是不可以作为主体的return),但在循环体中可以使用;如果主体产生一个结果,每个控制路径必须返回或者抛出异常。


正如上文所述,在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();


4, 目标类型(Target typing)


注意,函数式接口的名字不是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表达式所使用的类型与目标类型的方法签名是否一致。就是说,如果以下所有条件得到满足,那么这个lambda表达式便可以赋值给目标类型T:


  • T是一个函数式接口

  • lambda表达式与T方法的参数列表的参数个数与类型都一致

  • lambda表达式的返回值类型与T方法的相兼容

  • lambda表达式抛出的异常与T方法的相兼容


既然函数式接口目标类型已经“知道”了lambda表达式正式的参数都是什么类型,那么也就没有必要重复他们。使用目标类型便可以推断lambda表达式的参数类型:

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


这里,编译器推断s1,与s2是String。另外,当只有一个类型推断的参数时(一个很常见的情况),参数两侧的圆括号便是可选的了:

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

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


这一增强功能更贴近了一个理想的设计目标:“不要把垂直问题转变成水平问题。”我们希望读者不需要费太大力就能品尝到lambda表达式这块肥肉。


Lambda表达式不是第一个依赖上下文确定类型的表达式:如泛型方法的调用,以及“钻石”构造器的调用同样依赖于所赋值的目标类型进行类型检查:

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

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


5,目标类型上下文


前面我们已经提到Lambda表达式只能出现在具有目标类型的上下文环境中。下面例举的几种情况的上下文含有目标类型:


  • 变量定义

  • 赋值语句

  • 返回语句

  • 数组初始化

  • 方法或构造器参数

  • Lambda表达式主体

  • 条件表达式(?:)

  • Cast表达式


在前三种情况中,目标类型只是简单的分配或者返回。

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

public Runnable toDoLater() {
  return () -> {
    System.out.println("later");
  };
}


数组初始化上下文有点像赋值语句,除了“变量”是数组的组件以及类型来自于数组的类型。

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


对于方法参数这种情况,事情变得就有点复杂了:目标类型推断就需要与另外两个语言特性,重载决策和类型参数推断相互作用了。


重载决策涉及到对于特定的方法调用找出最好的方法声明。因为不同的方法声明有着不同的签名,这就 影响到作为参数的Lambda表达式的目标类型。编译器会用它所知道的关于Lambda表达式的一切做出选择。如果Lambda表达式是显示的类型(指定参数类型的),编译器将不仅知道参数类型,也会知道方法体内所有的返回表达式的类型。如果Lambda表达式是隐式类型(推断参数类型)重载决策将会忽略Lambda主体,而只考虑Lambda表达式参数个数。


如果对于最好方法的选择是模糊的,转型或者明确的Lambda表达式会对编译器消除歧义提供额外的类型信息。如果一个Lambda表达式的返回类型的目标取决于类型参数推断,那么Lambda表达式主体或许会为编译器进行推断提供类型信息。

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


在这里,ps是一个List<Person>, 那么ps.stream() 是一个Stream<Person>. map()方法中的R是泛型的,其中map()方法的参数是Function<T, R> , 其中T是Stream的元素类型。(这时T是已知的Person),一旦重载被选定并且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);


最后,如果无法从上下文中推断,转型表达式可以提供一种机制来显示的指定Lambda表达式的目标类型:

// Illegal: 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();



预告:Java SE 8 Lambda 特性与基本原理(下)主要包括词法域, 变量捕获,接口中的default static 方法以及继承问题等,敬请期待。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!