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

痴心易碎 提交于 2019-12-01 20:18:04

6 , 词法域(Lexical Scoping)


    确定内部类中变量名字(包括this)的意义要比在顶级类中困难的多,并且很容易出错。继承成员--包括类对象中的方法--可能不小心就覆盖了外部类的声明, 未加限定的this引用总是指向外部类自身。

    Lambda表达式更加简单:他们不会从超类中继承任何名字,也不会引入任何新的级别的作用域。相反,他们具有词法作用域,意味着主体中的名字是解释执行的,就像是在封闭的环境中(通过对Lambda表达式形式参数添加名字)。作为一个自然的延伸,this关键字以及对成员的引用与在Lambda表达式的外部类中直接饮用有着相同的意义。

    为了说明这点,下面程序将会在控制台上打印两次“Hello, World!”:

public class Hello {
  Runnable r1 = () -> { System.out.println(this); }
  Runnable r2 = () -> { System.out.println(toString()); }

  public String toString() { return "Hello, world!"; }

  public static void main(String... args) {
    new Hello().r1.run();
    new Hello().r2.run();
  }
}


    如果同样的使用匿名内部类,也许打印Hello$1@5b89a773 andHello$2@537a7706会让程序员感到惊讶。

    本地参数化的结构模式如for循环和Catch语句同样符合词法作用域, Lambda表达式参数不可以覆盖任何闭合环境中的本地变量。


7, 变量捕获(Variable captrue)


    在Java SE 7 中,编译器对内部类中封闭环境下的本地变量(捕获变量)引用的检查是非常严格的:如果捕获变量没有声明为final,我们将会得到一个变异错误。现在我们放松了这中限制---对于Lambda表达式和内部类----通过允许捕获有效地final本地变量(effectively final local variable)

    通俗的讲,如果一个本地变量的初始值没有改变我们便称之为有效的final变量。换句话说,声明为final不会导致编译失败。

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

    对this的引用,本质上讲是对final局部变量的引用---包括对未限定的字段引用或者方法调用这样的隐式引用。包含这种引用的Lambda主体会捕获this的恰当实例。其他情况下,Lambda不会保留对this的引用。(注:大致意思应该是,如果不访问外围类的变量,就不会保留对外围类的引用)

    这对内存管理带来有益的影响:内部类总是会包含一个对外部类实例的强引用,而Lambda在没有访问外部类成员的情况下是不会保留对外部类的引用的。内部类的这一特征通常会是引起内存泄露的罪魁祸首。

    尽管我们放宽了对变量捕获的语法限制,但是我们依旧不允许访问可变的局部变量,这是因为像这样的语法风格:

int sum = 0;
list.forEach(e -> { sum += e.size(); }); // ERROR?

    本质上是串行的;很难写出没有竞态条件的Lambda表达式。除非我们想强制(最好在编译时期)这样一个方程不能逃离它的捕获线程(注:不可多线程访问),但是这一特性或许会适得其反,带来更多的麻烦。Lambda表达式封装的是值,而不是变量。

    另一个不可以捕获可变变量的原因是除了不可变外没有更好的办法解决积累问题,我们把这个问题看做Reduction。java.util.stream 包通用的及专门的(像sum,min,max)集合或其他数据结构上的Reduction.举个例子,我们可以下像这样以串行和并行都安全的方式进行Reduction,而不是使用forEach和可变变量:

int sum = list.stream()
              .maptoint(e -> e.size())
              .sum();

    sum()方法提供了方便,但是与下面Reduction中更一般的形式是等价的:

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

    Reduction需要一个基数(以防输入是空),以及一个操作符(这里是加法),然后计算下面的表达式

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

    Reduction同样可以完成其他的操作如minimum,maximum,product等,并且如果操作符是相关联的,很容易安全的并行化。因此,没有支持本质上是串行的并且容易产生数据争用的惯用语法,我们选择提供支持表达并行计算以及不太容易出错的库。


8,方法引用(Method reference)


    Lambda表达式允许我们定义匿名方法并且可以作为函数式接口的实例。通常我们期望使用存在的方法做同一件事情。

    方法引用是一种跟Lambda表达式有同样待遇的表达式(需要目标类型,作为函数式接口的实例),但是不同的是他们不提供方法体,而是引用一个已存在的方法的名称。

    举个例子,考虑一个Person类,我们可以按名字跟年龄进行排序:

class Person { 
    private final String name;
    private final int age;

    public int getAge() { return age; }
    public String getName() { return name; }
   ...
}

Person[] people = ...
Comparator<Person> byName = Comparator.comparing(p -> p.getName());
Arrays.sort(people, byName);


    我们可以使用方法引用来重写这个功能:

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::main;          // void main(String... args)


9,多种方法引用


有多重不同的方法引用,每种都有些轻微的语法不同:

  • 静态方法(ClassName::methName)

  • 某对象的实例方法(instanceRef::methName)

  • 某实例的超类方法(super::methName)

  • 某一类型的任意对象的实例方法(ClassName::methName)

  • 类的构造器引用(ClassName::new)

  • 数组的构造器引用(TypeName[]::new)


对于静态方法引用,方法所属的类在::分割符之前,就像Integer::sum。

对于某一对象的实例方法引用,一个代表对象引用的表达式在分隔符之前:

Set<String> knownNames = ...
Predicate<String> isKnown = knownNames::contains;


    这里隐式的Lambda表达式会捕获被knownNames引用的String对象,并且Lambda表达式主体会使用该对象作为接受者调用Set.contains方法。

    这种可以引用某一特定对象方法的能力提供了在不同函数式接口类型之间进行转换的方便途径

Callable<Path> c = ...
PrivilegedAction<Path> a = c::call;


    对于任意对象的实例方法引用,方法所属对象的类型在::分隔符之前,并且方法调用的接受者是函数式接口的第一个参数:

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


    这里,隐式Lambda表达式有一个参数,要被转化为大写的字符串,现在变成了toUpperCase()方法调用的接受者。

    如果实例方法的类是泛型的,可以在::分隔符前提供它的类型参数,或者在大多数情况下,编译器可以根据目标类型进行推断。

    注意:静态方法引用的语法可能会被理解为某个类的实例方法的引用。编译器通过尝试对两种情况进行判断来确定哪一个是我们想要的(记住:实例方法少一个参数)。

    对于所有形式的方法引用,如有必要方法的参数类型会被进行推断,或者在::分隔符后显示的指定。

    构造器只需使用new, 便可以像静态方法那样被引用。

SocketImplFactory factory = MySocketImpl::new;

    如果一个类有多个构造器,那么目标类型的方法签名会被用来选择一个最匹配的构造器, 构造器调用时会使用同样的策略。

    对于内部类来说,没有语法支持显示地提供一个外围类实例构造器的引用。

    如果被实例化的类是泛型的,那么参数类型可以在类的名称后指定,或者像‘钻石’构造器调用那样进行推断。

    还有一个数组的构造器引用的特殊语法,即把数组当做有一个接受int参数的构造器,举个例子:

IntFunction<int[]> arrayMaker = int[]::new;
int[] array = arrayMaker.apply(10);  // creates an int[10]


10,默认和静态接口方法


    Lambda表达式跟方法引用为Java语言增添了许多表现力,但是真正完成我们以符合习惯的将代码数据化(make code-as-data)的目标的关键是对标准核心库的调整。

    Java SE 7 中对现有库添加新的功能还是有些困难。尤其是接口一旦发布便是不可改变的了;除非我们同时更新了一个接口所有的实现,否则对接口添加一个新方法会使得现有实现全部挂掉。默认方法(之前被称为virtual extension 方法,或者defender方法)的目的是使得接口能以兼容首发版本的方式进行演化。

    标准集合API显然应该体统新的有好的Lambda操作。举个例子,removeAll方法广义的讲应该可以删除含有任意可以表示为函数式接口Predicate的属性的任何元素。但是这个新方法应该定义到哪呢?我们不能在Collection接口中添加一个抽象的方法---许多已经存在的实现并不会知道这一改变。我们或许可以在Collections工具类中添加静态方法,但这将会使得新的Lambda操作降到二级地位。

    默认方法提供了一种更加面向对象的方式向接口中添加具体的行为。这是一种新的方法:接口方法既可以是abstract的也可以是default的。默认方法有一个被类继承但没有覆盖的实现(详情看下一部分)。函数式接口中的默认方法不影响只有一个抽象方法的限制。举个例子,我们可以有(但是没有真正添加)一个skip方法在Iterator中,像下面这样:

interface Iterator<E> {
    boolean hasNext();
    E next();
    void remove();

    default void skip(int i) {
        for (; i > 0 && hasNext(); i--) next();
    }
}

    鉴于以上Iterator的定义,所有实现了Iterator的类都将继承skip方法。从客户端角度来看,skip只是另一个接口提供的虚方法。在一个没有覆盖skip方法Iterator子类上调用skip方法结果是调用默认实现:多次调用hasNext和next方法。如果一个想要以一个更好的实现覆盖skip方法--举个例子,通过推进一个私有的游标,或者实现线程安全---这一切都是可以的。

    当一个接口继承另一个接口时,它可以为继承自父接口的抽象方法添加defult,还可以提供新的默认方法来覆盖继承自父接口的默认方法,或者重新将一个默认方法声明为抽象的。

    除了允许在接口的默认方法中添加实现代码,Java SE 8 同样引用了在接口中定义静态方法的能力。这就允许某一特定接口的辅助方法可以与接口共存,而不是在另一个类中(通常以接口名称的复数形式命名)。举个例子,Comparator后来有了一个用来生成比较器的静态辅助方法,该方法接受一个提取可比较排序键的函数(Function实例)并且返回一个Comparator实例:

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


11,默认方法的继承


    默认方法像其他方法一样可以被继承,大多数情况下,继承会像我们所期望的那样。然而,当类或接口的多个超类型提供了多个相同方法签名的方法时,继承规则会遵循以下两个基本原则来尝试解决这一冲突:

  • 类方法声明优先于接口默认方法。无论类方法声明是具体的还是抽象的,都优先于接口默认方法。(所以defult关键字:默认方法是在类层次结构中什么也没做时的 一个备用方案。

  • 被其他类或接口覆盖的方法会被忽略。这种情况会出现在多个超类型继承自一个共同的祖先时。

    举个例子说明第二个规则如果发挥作用,Collection和List接口提供了不同的removeAll方法的默认实现,并且Queue 继承了Collection的默认实现;在下面的 implements语句中,List声明的默认方法优先于已被Queue继承的Collection的默认方法:

class LinkedList<E> implements List<E>, Queue<E> { ... }

    如果两个独立定义的默认方法冲突,或者一个默认方法与一个抽象方法冲突,将导致编译错误。在这种情况下,程序员必须显示的覆盖超类中的方法。通常,这意味着选择了一个首选的默认方法,并且定义一个方法体调用首选的默认方法。一个对super的增强语法支持对某个特定父接口默认实现的调用:

interface Robot implements Artist, Gun {
    default void draw() { Artist.super.draw(); }
}

    super 前的名称必须引用一个定义了或继承了被调用默认方法的直接父接口。这种形式的方法调用不仅限于简单的消除歧义---还可以用于任何的方法调用,包括类和接口中。

    implements或extends语句中接口声明的顺序,或着某个接口被“第一个”实现了等等,这些在任何情况下都不会影响继承。


12, 融会贯通


    Lambda表达式的语言特性与标准库是被设计成一体的。为了说明这点,我们考虑这样一个任务,对一个People的列表,以LastName排序。

    目前我们可以这样写:

List<Person> people = ...
Collections.sort(people, new Comparator<Person>() {
    public int compare(Person x, Person y) {
        return x.getLastName().compareTo(y.getLastName());
    }
});

    这是一个非常冗长的方式。

    使用lambda表达式,我们可以使它更加简洁:

Collections.sort(people, 
                 (Person x, Person y) -> x.getLastName().compareTo(y.getLastName()));


然而, 更加简洁却意味着不在抽象;这就使得程序员依旧自己实现真正的比较(当排序键是基本类型时会更加糟糕)。对标准库的一点小的改动,如为Comparator接口添加了静态comparing方法,对此会有很大帮助:

Collections.sort(people, Comparator.comparing((Person p) -> p.getLastName()));

通过编译器对Lambda参数的类型推断,以及静态导入comparing方法还可以进一步缩短代码:

Collections.sort(people, comparing(p -> p.getLastName()));

上面的Lambda表达式是一个对getLastName()方法的简单转发,我们可以使用方法引用代替Lambda去重用已经存在的方法:

Collections.sort(people, comparing(Person::getLastName));

    像Collections.sort这样的辅助方法由于许多原因而不受欢迎:不够简洁;不能应用到每一种数据结构的List上;它暗中破坏了List接口的价值,因为用户很难通过查看List的文档找到静态的sort方法。

    默认方法提供了更加面向对象的解决方案,我们想List接口中添加了sort()方法:

people.sort(comparing(Person::getLastName));

    这读起来也更像是起初对问题的陈述:按LastName排序people列表。

    如果我们像Comparator接口中添加reversed()默认方法,用来生成一个使用同样排序建但是反序的Comparator,我们就可以很简单的表达降序排列了:

people.sort(comparing(Person::getLastName).reversed());


13,总结


Java SE 8 添加了相对少量的心语言特性---Lambda表达式,方法引用,接口中的默认和静态方法,以及更加普遍的类型推断。但是总起来说,新特性可以使得程序员们使用更少的代码更加简洁清晰的表达自己的意图了,并且使得开发更加 强大,以及更加友好的并行库。



译注:到此对Java SE 8 中的新语言特性及原理也就翻译完成了,至少我是满怀期待。

来个预告的,后续会对“Lambda标准库的概览”进行翻译并发布,希望给众多Javaer们带来帮助。个人水平有限,可能翻译的不是很好,但绝对是认真的揣摩了作者的用意,以及结合Java语言的知识仔细翻译的。

新年快到了,祝大家步步高升,技术精进,新年新气象。


献给即将到来的Java SE 8.

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