ITEM 44: FAVOR THE USE OF STANDARD FUNCTIONAL INTERFACES
现在Java有了lambdas,编写api的最佳实践已经发生了很大的变化。例如,Template Method 模式[Gamma95],其中子类覆盖原语方法来专门化其超类的行为,这就不那么有吸引力了。现代的替代方法是提供一个接受函数对象的静态工厂或构造函数来实现相同的效果。更一般地,您将编写更多以函数对象为参数的构造函数和方法。选择正确的函数参数类型需要谨慎。
考虑 LinkedHashMap。您可以通过覆盖其受保护的 removeEldestEntry 方法来使用这个类作为缓存,该方法是在每次向映射中添加新键时由 put 调用的。当此方法返回 true 时,映射将删除传递给该方法的最年长的条目。下面的覆盖允许映射增长到100个条目,然后在每次添加一个新键时删除最老的条目,保持最近的100个条目:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > 100;
}
这种设计很好,但是使用 lambdas 可以做得更好。如果 LinkedHashMap 是在今天编写的,它将有一个静态工厂或构造函数对象。查看 removeEldestEntry 的声明,您可能认为函数对象应该带一个 入参 Map.Entry<K,V> ,并返回一个布尔值,但这并不能完全做到: removeEldestEntry方法调用size()来获得映射中的条目数量,这是因为 removeEldestEntry 是映射上的一个实例方法。传递给构造函数的函数对象不是映射上的实例方法,因此无法捕获它,因为在调用其工厂或构造函数时映射还不存在。因此,映射必须将自己传递给函数对象,函数对象因此必须将映射及其最老的条目作为输入。如果你要声明这样一个功能接口,它应该是这样的:
// Unnecessary functional interface; use a standard one instead.
@FunctionalInterface interface EldestEntryRemovalFunction<K,V> {
boolean remove(Map<K,V> map, Map.Entry<K,V> eldest);
}
这个接口可以很好地工作,但是您不应该使用它,因为您不需要为此声明一个新的接口。
工具包 java.util.function 提供了大量的标准功能接口供您使用。如果某个标准功能接口能够完成此工作,则通常应该优先使用它,而不是专门构建的功能接口。这将使您的API更容易学习(通过减少其概念范围),并将提供重要的互操作性好处,因为许多标准功能接口都提供了有用的默认方法。例如,Predicate 接口提供了组合谓词的方法。在我们的 LinkedHashMap 示例中, BiPredicate<Map<K,V> Map.Entry<K,V>> 接口应该优先使用自定义EldestEntryRemovalFunction 接口。
在 java.util.Function 中有43个接口。你不可能记住所有的接口,但是如果你记住了6个基本接口,你可以在需要的时候推导出其余的接口。基本接口操作对象引用类型。操作符接口表示结果和参数类型相同的函数。
Predicate 接口表示接受参数并返回布尔值的函数。
Function 接口表示参数和返回类型不同的函数。
Supplier接口表示没有入参并返回(或“供应”)值的函数。
最后,Consumer表示一个函数,该函数接受一个参数,但不返回任何结果,本质上是消费它的参数。六大基本功能接口概述如下:
6个基本接口中的每一个都有3个变体,用于操作基本类型 int、long 和 double。它们的名称由基本接口派生而来,在它们前面加上一个基本类型前缀。例如,接受 int 的 Predicate 接口是IntPredicate,而接受两个 long 值并返回 long 的二元操作符是 LongBinaryOperator。除了由返回类型参数化的函数变量外,这些变量类型都没有参数化。例如,LongFunction 接受一个long并返回一个 int[]。
当结果类型是基本类型时,函数接口还有9个额外的变体。源类型和结果类型总是不同的,因为从类型到自身的函数是 UnaryOperator。如果源类型和结果类型都是基本类型,则使用SrcToResult 作为前缀函数,例如 LongToIntFunction(六个变体)。如果源是一个原语,结果是一个对象引用,那么前缀函数 ToObj,例如 DoubleToObjFunction (三个变体)。
有两个参数版本的三个基本功能接口是有意义的: BiPredicate<T,U>, BiFunction<T,U,R>,和BiConsumer<T,U>。还有返回三个相关原始类型的双函数变体: ToIntBiFunction<T,U>, ToLongBiFunction<T,U> 和 ToDoubleBiFunction<T, U>。有两个参数的Consumer变量,它们采用一个对象引用和一个基本类型: ObjDoubleConsumer<T>, ObjIntConsumer<T>, ObjLongConsumer<T>。总的来说,基本接口有9个双参数版本。
最后是 BooleanSupplier 接口,它是 Supplier 的变体,返回布尔值。这是标准函数接口名中唯一明确提到布尔类型的地方,但是通过谓词及其四种变体形式支持布尔返回值。
BooleanSupplier 接口和前面各段中描述的42个接口构成了所有43个标准功能接口。诚然,这是一个难以接受的事实,而且也不是那么的正交。另一方面,您将需要的大部分功能接口都是为您编写的,它们的名称也很有规律,因此在需要时您应该不会遇到太多麻烦。
大多数标准功能接口的存在只是为了提供对基本类型的支持。不要试图将基本功能接口与装箱的原语一起使用,而不是使用原语功能接口。虽然它可以工作,但是它违反了 item 61 条的建议,“宁可使用原语类型,也不要使用盒装原语。”在批量操作中使用装箱原语的性能后果可能是致命的。
现在您知道了,您通常应该使用标准的函数接口,而不是编写自己的接口。但是什么时候你应该自己写呢?当然,如果标准的谓词都不能满足您的需要,那么您需要编写自己的谓词,例如,如果您需要接受三个参数的谓词,或者需要抛出一个已检查异常的谓词。但是有时您应该编写自己的函数接口,即使其中一个标准接口在结构上是相同的。
考虑我们的老朋友 Comparator<T>,它在结构上与 ToIntBiFunction<T,T> 接口相同。即使后者接口在将前者添加到库中时已经存在,使用它也是错误的。比较器有自己的接口是有几个原因的。首先,每次在 API 中使用它时,它的名称都提供了很好的文档,而且它使用得很多。通过实现接口,您承诺遵守其契约。第三,该接口大量配备了有用的默认方法来转换和组合比较器。
如果你需要一个与 Comparator 共享以下一个或多个特性的功能接口,你应该认真考虑编写一个专用的功能接口,而不是使用标准接口:
• 它将被广泛使用,并可能受益于一个描述性的名称。
• 它有一个强大的合同。
• 它将受益于自定义默认方法。
如果您选择编写自己的功能接口,请记住它是一个接口,因此在设计时应该非常小心(item 21)。
注意,EldestEntryRemovalFunction 接口(第199页) 被标记为 @FunctionalInterface 注释。这个注释类型在本质上类似于 @Override。这是一个程序员意图的声明,有三个目的:它告诉类及其文档的读者,这个接口是为启用 lambdas 而设计的;它让你保持诚实,因为接口不会编译,除非它有一个抽象方法;它还可以防止维护人员在接口发展过程中意外地向接口添加抽象方法。总是使用 @FunctionalInterface 注释您的功能接口。
关于 api 中函数接口的使用,应该注意最后一点。不要提供具有多个重载的方法,这些重载将不同的功能接口置于相同的参数位置,如果它可能在客户端造成歧义的话。这不仅仅是一个理论问题。ExecutorService 的提交方法可以采用 Callable<T> 或 Runnable,并且可以编写需要强制转换以指示正确的重载的客户端程序(item 52)。避免此问题的最简单方法是不编写将不同功能接口置于相同参数位置的重载。这是 item 52 建议的一个特例,“明智地使用重载”。
总之,既然 Java 已经有了 lambdas,那么您就必须在设计 api 时考虑 lambdas。在输入时接受函数接口类型,并在输出时返回它们。通常最好使用 java.util.function 中提供的标准接口。函数,但是要注意相对少见的情况,在这种情况下,您最好编写自己的函数接口。