42.Lambda 优先于匿名类
??在之前的做法中(Historically),使用单个抽象方法的接口(或很少的抽象类【只有一个抽象方法的抽象类数量比较少】)被用作函数类型。它们的实例称为函数对象,代表一个函数或一种行为。自 JDK 1.1 于 1997 年发布以来,创建函数对象的主要方法是匿名类(第 24 项)。下面的这个代码片段,用于按长度顺序对字符串列表进行排序,使用匿名类创建排序的比较函数(强制排序顺序):
// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
??匿名类适用于需要经典功能的面向对象的设计模式,特别是策略模式[Gamma95]。Comparator 接口表示用于排序的抽象策略;
上面的匿名类是排序字符串的具体策略。然而,匿名类的冗长使得 Java 中的函数式编程成为一个没有吸引力的前景。
??在 Java 8 中,该语言正式成为这样一种概念,即使用单一抽象方法的接口是特殊的,值得特别对待。这些接口现在称为功能接口,该语言允许你使用 lambda 表达式或简称 lambdas 创建这些接口的实例。Lambdas 在功能上与匿名类相似,但更加简洁。以下是上面的代码片段如何将匿名类替换为 lambda。样板消失了,行为很明显:
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
??请注意,lambda(
Comparator
)的类型,其参数(s1 和 s2,两个 String)及其返回值(int)的类型不在代码中。编译器使用称为类型推断的过程从上下文中推导出这些类型。在某些情况下,编译器将无法确定类型,你必须指定它们。
类型推断的规则很复杂:它们占据了 JLS 的整个章节 [JLS,18]。很少有程序员详细了解这些规则,但这没关系。
省略所有 lambda 参数的类型,除非它们的存在使您的程序更清晰。
如果编译器生成错误,告诉你无法推断 lambda 参数的类型,请指定它。有时你可能必须转换返回值或整个 lambda 表达式,但这种情况很少见。
??关于类型推断,应该添加一个警告。第 26 项告诉你不要使用原始类型,第 29 项告诉你支持泛型类型,第 30 项告诉你支持泛型方法。当你使用 lambdas 时,这个建议是非常重要的,因为编译器获得了从泛型的执行类型推断出的大多数类型信息。如果你不提供此信息,编译器将无法进行类型推断,你必须在 lambdas 中手动指定类型,这将大大增加它们的详细程度【也就是代码量】。举例来说,如果变量词被声明为原始类型 List 而不是参数化类型 List ,那么上面的代码片段将无法编译。
??顺便提一下,如果使用比较器构造方法代替 lambda,则片段中的比较器可以更简洁(第 14. 43 项):
Collections.sort(words, comparingInt(String::length));
??实际上,通过利用 Java 8 中添加到 List 接口的 sort 方法,可以使代码段更短:
words.sort(comparingInt(String::length));
??将 lambda 添加到语言中使得使用函数对象变得切实可行。例如,请考虑第 34 项中的 Operation 枚举类型。因为每个枚举对其 apply 方法需要不同的行为,所以我们使用特定于常量的类主体并覆盖每个枚举常量中的 apply 方法。为了让你有清晰的记忆,这里是代码:
// Enum type with constant-specific class bodies & data (Item 34)
public enum Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y;
}
},
MINUS("-") {
public double apply(double x, double y) { return x - y;
}
},
TIMES("*") {
public double apply(double x, double y) { return x * y;
}
},
DIVIDE("/") {
public double apply(double x, double y) { return x / y;
}
};
private final String symbol;
Operation(String symbol) { this.symbol = symbol;
}
@Override
public String toString() { return symbol;
}
public abstract double apply(double x, double y);
}
??第 34 项说 enum 实例字段比特定于常量的类体更可取。使用前者而不是后者,Lambdas 可以轻松实现特定于常量的行为。只需将实现每个枚举常量行为的 lambda 传递给它的构造函数。构造函数将 lambda 存储在实例字段中,apply 方法将调用转发给 lambda。生成的代码比原始版本更简单,更清晰:
// Enum with function object fields & constant-specific behavior
public enum Operation {
PLUS ("+", (x, y) -> x + y),
MINUS ("-", (x, y) -> x - y),
TIMES ("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private final String symbol;
private final DoubleBinaryOperator op;
Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}@Override
public String toString() { return symbol;
}public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}
??请注意,我们使用 DoubleBinaryOperator 接口来表示枚举常量行为的 lambdas。这是 java.util.function(第 44 项)中许多预定义的功能接口之一。它表示一个函数,它接受两个 double 参数并返回一个 double 结果。
??查看基于 lambda 的 Operation 枚举,您可能会认为特定于常量的方法体已经过时了,但事实并非如此。跟类和方法不一样,lambdas 缺乏名称和文档;
如果一个运算过程不能自我解释【代码就是最好的文档】,或超过几行,请不要将它放在 lambda 中。一行【代码】对于 lambda 是理想的,三行【代码】是合理的最大值。如果违反此规则,可能会严重损害程序的可读性。如果 lambda 很长或难以阅读,要么找到简化它的方法,要么重构你的程序来取代 lambda。此外,传递给枚举构造函数的参数在静态上下文中进行运算。因此,枚举构造函数中的 lambdas 无法访问枚举的实例成员。如果枚举类型具有难以理解的特定于常量的行为,无法在几行【代码】中实现,或者需要访问实例字段或方法,则仍然可以使用特定于常量的类主体。
??同样,你可能会认为匿名类在 lambdas 时代已经过时了。这很接近事实,但是你可以用匿名类做一些你无法用 lambdas 做的事情。Lambdas 仅限于函数接口。如果要创建抽象类的实例,可以使用匿名类,但不能使用 lambda。同样,你可以使用匿名类来创建具有多个抽象方法的接口实例。最后,lambda 无法获得对自身的引用。在 lambda 中,this 关键字引用封闭的实例,这通常是你想要的。在匿名类中,this 关键字引用匿名类实例。如果需要从其体内【类内部】访问函数对象,则必须使用匿名类。【在 lambda 表达式中使用 this 关键字,获得的引用是 lambda 所在的实例的引用,在匿名类中使用 this 关键字,获得的是当前匿名类的实例的引用】
??Lambdas 与匿名类都具有无法在实现中可靠地序列化和反序列化它们的属性【lambda 和匿名类都无法被序列化和反序列化】。因此,你应该很少(如果有的话)序列化 lambda(或匿名类实例)。如果您有一个要进行序列化的函数对象,例如 Comparator,请使用私有静态嵌套类的实例(第 24 项)。
??总之,从 Java 8 开始,lambda 是迄今为止表示小函数对象的最佳方式。除非必须创建非功能接口类型的实例,否则不要对函数对象使用匿名类。另外,请记住,lambda 使得通过使用对象来代表小函数变得如此容易,以至于它打开了以前在 Java 中不实用的函数式编程技术的大门。
43.方法引用优先于 Lambda
??lambda 优于匿名类的主要优点是它们更简洁。Java 提供了一种生成函数对象的方法,它比 lambda 更简洁:方法引用。这是一个程序的代码片段,它维护从任意 key 到 Integer 值的映射。如果该值被解释为 key 实例数的计数,则该程序是多集实现。代码段的功能是将数字 1 与 key 相关联(如果它不在映射中),并在 key 已存在时增加相关值:
map.merge(key, 1, (count, incr) -> count + incr);
??请注意,此代码使用 merge 方法,该方法已添加到 Java 8 中的 Map 接口。如果给定键 key 没有映射,则该方法只是插入给定的值;
如果已存在映射,则 merge 将给定的函数应用于当前值和给定值,并使用结果覆盖当前值。这段代码表示 merge 方法的典型用例。
??代码读起来很 nice,但仍然有一些样板【代码】。参数 count 和 incr 不会增加太多值,并且占用相当大的空间。实际上,所有 lambda 告诉你的是该函数返回其两个参数的总和。从 Java 8 开始,Integer(以及所有其他包装的数字基本类型)提供了一个完全相同的静态方法 sum。我们可以简单地传递对此方法的引用,获得相同的结果,并且【代码】看起来不会那么乱:
map.merge(key, 1, Integer::sum);
??方法具有的参数越多,使用方法引用可以消除的样板【代码】就越多。但是,在某些 lambda 中,你选择的参数名称提供了有用的文档,使得 lambda 比方法引用更易读和可维护,即使 lambda 更长。
??对于一个你不能用 lambda 做的方法引用,你无能为力(有一个模糊的例外 - 如果你很好奇,请参阅 JLS,9.9-2)。也就是说,方法引用通常会导致更短,更清晰的代码。如果 lambda 变得太长或太复杂,它们也会给你一个方向(out):你可以将 lambda 中的代码提取到一个新方法中,并用对该方法的引用替换 lambda。你可以为该方法提供一个好名称,并将其记录在核心的内容中。
??如果你使用 IDE 进行编程,如果可以的话,它就会提供方法引用替换 lambda。你要经常(并不总是)接受 IDE 提供的建议。有时候,lambda 将比方法引用更简洁。当方法与 lambda 属于同一类时,这种情况最常发生。例如,考虑这个片段,假定它出现在名为 GoshThisClassNameIsHumongous 的类中:
service.execute(GoshThisClassNameIsHumongous::action);
??使用 lambda 看起来像这样:
service.execute(() -> action());
??使用方法引用的代码段既不比使用 lambda 的代码段更短也更清晰,所以更喜欢后者。类似地,Function 接口提供了一个通用的静态工厂方法来返回 Identity 函数 Function.identity()。它通常更短更清洁,不使用此方法,而是编写等效的 lambda 内联:x -> x。
??许多方法引用会引用静态方法,但有四种方法引用不会引用静态方法。其中两个是绑定和未绑定的实例方法引用。在绑定引用中,接收对象在方法引用中指定。绑定引用在本质上类似于静态引用:函数对象采用与引用方法相同的参数。在未绑定的引用中,在应用函数对象时,通过方法声明的参数之前的附加参数指定接收对象。未绑定引用通常用作流管道(stream pipelines)(第 45 项)中的映射和过滤功能。最后,对于类和数组,有两种构造函数引用。构造函数引用充当工厂对象。所有五种方法参考总结在下表中:
Method Ref Type |
Example |
Lambda Equivalent |
Static |
Integer::parseInt |
str -> Integer.parseInt(str) |
Bound |
Integer::parseIntr |
Instant then = Instant.now();
t -> then.isAfter(t) |
Unbound |
String::toLowerCase |
str -> str.toLowerCase() |
Class Constructor |
TreeMap::new |
() -> new TreeMap |
Array Constructor |
int[]::new |
len -> new int[len] |
??总之,方法引用通常提供一种更简洁的 lambda 替代方案。在使用方法引用可以更简短更清晰的地方,就使用方法引用,如果无法使代码更简短更清晰的地方就坚持使用 lambda。(Where method references are shorter and clearer, use them;
where they aren’t, stick with lambdas.)
44.坚持使用标准的函数接口
??既然 Java 有 lambda,那么编写 API 的最佳实践已经发生了很大变化。例如,模板方法模式[Gamma95],其中子类重写基本方法进而具体化其超类的行为,远没那么有吸引力。现在的替代方案是提供一个静态工厂或构造函数,它接受一个函数对象来实现相同的效果。更一般地说,你将编写更多以函数对象作为参数的构造函数和方法。需要谨慎地选择正确的功能参数类型。
??考虑 LinkedHashMap。你可以通过重写其受保护的 removeEldestEntry 方法将此类用作缓存,该方法每次将新 key 添加到 map 时都会调用。当此方法返回 true 时,map 将删除其最旧的 entry,该 entry 将传递给该方法。 以下覆盖允许 map 增长到一百个 entry,然后在每次添加新 key 时删除最旧的 entry,保留最近的一百个 entry:
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > 100;
}
??这种技术【实现方式】很好,但你可以用 lambda 做得更好。如果现在编写 LinkedHashMap,它将有一个带有函数对象的静态工厂或构造函数。查看 removeEldestEntry 的声明,你可能会认为函数对象应该采用 Map.Entry
并返回一个布尔值,但是不会这样做:removeEldestEntry 方法调用 size()来获取 map 中 entry 的数目,因为 removeEldestEntry 是 map 的实例方法。传递给构造函数的函数对象不是 map 上的实例方法,并且无法捕获它,因为在调用其工厂或构造函数时 map 尚不存在。因此,map 必须将自身传递给函数对象,因此函数对象必须在输入的地方获得 map,就像获取最老的 entry【方式】一样【函数的形参需要传入 map 本身以及最老的 entry】。如果你要声明这样一个功能性接口,它看起来像这样:
// Unnecessary functional interface;
use a standard one instead.
@FunctionalInterface
interface EldestEntryRemovalFunction{
boolean remove(Map map, Map.Entry eldest);
}
??此接口可以正常工作,但您不应该使用它,因为你不需要为了这个目的声明新接口。java.util.function 包提供了大量标准功能性接口供您使用。如果其中一个标准功能接口完成了这项工作,您通常应该优先使用它,而不是专门构建的功能接口。这将使您的 API 学习起来更容易,通过减少其概念表面积(by reducing its conceptual surface area),并将提供重要的互操作性优势(and will provide significant interoperability benefits),因为许多标准功能性接口提供有用的默认方法。例如,Predicate 接口提供了结合断言(combine predicates)的方法。对于 LinkedHashMap 示例,应优先使用标准 BiPredicate