Effective Java 第三版——44. 优先使用标准的函数式接口

时间:2021-07-18 09:44:20

Tips

《Effective Java, Third Edition》一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将近8年的时间,但随着Java 6,7,8,甚至9的发布,Java语言发生了深刻的变化。

在这里第一时间翻译成中文版。供大家学习分享之用。

Effective Java 第三版——44. 优先使用标准的函数式接口

44. 优先使用标准的函数式接口

现在Java已经有lambda表达式,编写API的最佳实践已经发生了很大的变化。 例如,模板方法模式[Gamma95],其中一个子类重写原始方法以专门化其父类的行为,变得没有那么吸引人。 现代替代的选择是提供一个静态工厂或构造方法来接受函数对象以达到相同的效果。 通常地说,可以编写更多以函数对象为参数的构造方法和方法。 选择正确的函数式参数类型需要注意。

考虑LinkedHashMap。 可以通过重写其受保护的removeEldestEntry方法将此类用作缓存,每次将新的key值加入到map时都会调用该方法。 当此方法返回true时,map将删除传递给该方法的最久条目。 以下代码重写允许map增长到一百个条目,然后在每次添加新key值时删除最老的条目,并保留最近的一百个条目:

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > 100;
}

这种技术很有效,但是你可以用lambdas做得更好。如果LinkedHashMap是现在编写的,那么它将有一个静态的工厂或构造方法来获取函数对象。查看removeEldestEntry方法的声明,你可能会认为函数对象应该接受一个Map.Entry <K,V>并返回一个布尔值,但是这并不完全是这样:removeEldestEntry方法调用size()方法来获取条目的数量,因为removeEldestEntry是map上的一个实例方法。传递给构造方法的函数对象不是map上的实例方法,无法捕获,因为在调用其工厂或构造方法时map还不存在。因此,map必须将自己传递给函数对象,函数对象把map以及最就的条目作为输入参数。如果要声明这样一个功能接口,应该是这样的:

// 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个接口。不能指望全部记住它们,但是如果记住了六个基本接口,就可以在需要它们时派生出其余的接口。基本接口操作于对象引用类型。Operator接口表示方法的结果和参数类型相同。Predicate接口表示其方法接受一个参数并返回一个布尔值。Function接口表示方法其参数和返回类型不同。Supplier接口表示一个不接受参数和返回值(或“供应”)的方法。最后,Consumer表示该方法接受一个参数而不返回任何东西,本质上就是使用它的参数。六种基本函数式接口概述如下:

接口 方法 示例
UnaryOperator T apply(T t) String::toLowerCase
BinaryOperator T apply(T t1, T t2) BigInteger::add
Predicate boolean test(T t) Collection::isEmpty
Function<T,R> R apply(T t) Arrays::asList
Supplier T get() Instant::now
Consumer void accept(T t) System.out::println

在处理基本类型int,long和double的操作上,六个基本接口中还有三个变体。 它们的名字是通过在基本接口前加一个基本类型而得到的。 因此,例如,一个接受int的Predicate是一个IntPredicate,而一个接受两个long值并返回一个long的二元运算符是一个LongBinaryOperator。 除Function接口变体通过返回类型进行了参数化,其他变体类型都没有参数化。 例如,LongFunction<int[]>使用long类型作为参数并返回了int []类型。

Function接口还有九个额外的变体,当结果类型为基本类型时使用。 源和结果类型总是不同,因为从类型到它自身的函数是UnaryOperator。 如果源类型和结果类型都是基本类型,则使用带有SrcToResult的前缀Function,例如LongToIntFunction(六个变体)。如果源是一个基本类型,返回结果是一个对象引用,那么带有<Src>ToObj的前缀Function,例如DoubleToObjFunction (三种变体)。

有三个包含两个参数版本的基本功能接口,使它们有意义:BiPredicate <T,U>BiFunction <T,U,R>BiConsumer <T,U>。 也有返回三种相关基本类型的BiFunction变体:ToIntBiFunction <T,U>ToLongBiFunction <T,U>ToDoubleBiFunction <T,U> Consumer有两个变量,它们带有一个对象引用和一个基本类型:ObjDoubleConsumer <T>ObjIntConsumer <T>ObjLongConsumer <T>。 总共有九个两个参数版本的基本接口。

最后,还有一个BooleanSupplier接口,它是Supplier的一个变体,它返回布尔值。 这是任何标准函数式接口名称中唯一明确提及的布尔类型,但布尔返回值通过Predicate及其四种变体形式支持。 前面段落中介绍的BooleanSupplier接口和42个接口占所有四十三个标准功能接口。 无可否认,这是非常难以接受的,并且不是非常正交的。 另一方面,你所需要的大部分功能接口都是为你写的,而且它们的名字是经常性的,所以在你需要的时候不应该有太多的麻烦。

大多数标准函数式接口仅用于提供对基本类型的支持。 不要试图使用基本的函数式接口来装箱基本类型的包装类而不是基本类型的函数式接口。 虽然它起作用,但它违反了第61条中的建议:“优先使用基本类型而不是基本类型的包装类”。使用装箱基本类型的包装类进行批量操作的性能后果可能是致命的。

现在你知道你应该通常使用标准的函数式接口来优先编写自己的接口。 但是,你应该什么时候写自己的接口? 当然,如果没有一个标准模块能够满足您的需求,例如,如果需要一个带有三个参数的Predicate,或者一个抛出检查异常的Predicate,那么需要编写自己的代码。 但有时候你应该编写自己的函数式接口,即使与其中一个标准的函数式接口的结构相同。

考虑我们的老朋友Comparator <T>,它的结构与ToIntBiFunction <T, T>接口相同。 即使将前者添加到类库时后者的接口已经存在,使用它也是错误的。 Comparator值得拥有自己的接口有以下几个原因。 首先,它的名称每次在API中使用时都会提供优秀的文档,并且使用了很多。 其次,Comparator接口对构成有效实例的构成有强大的要求,这些要求构成了它的普遍契约。 通过实现接口,就要承诺遵守契约。 第三,接口配备很多了有用的默认方法来转换和组合多个比较器。

如果需要一个函数式接口与Comparator共享以下一个或多个特性,应该认真考虑编写一个专用函数式接口,而不是使用标准函数式接口:

  • 它将被广泛使用,并且可以从描述性名称中受益。
  • 它拥有强大的契约。
  • 它会受益于自定义的默认方法。

如果选择编写你自己的函数式接口,请记住它是一个接口,因此应非常小心地设计(条目 21)。

请注意,EldestEntryRemovalFunction接口(第199页)标有@FunctionalInterface注解。 这种注解在类型类似于@Override。 这是一个程序员意图的陈述,它有三个目的:它告诉读者该类和它的文档,该接口是为了实现lambda表达式而设计的;它使你保持可靠,因为除非只有一个抽象方法,否则接口不会编译; 它可以防止维护人员在接口发生变化时不小心地将抽象方法添加到接口中。 始终使用@FunctionalInterface注解标注你的函数式接口

最后一点应该是关于在api中使用函数接口的问题。不要提供具有多个重载的方法,这些重载在相同的参数位置上使用不同的函数式接口,如果这样做可能会在客户端中产生歧义。这不仅仅是一个理论问题。ExecutorServicesubmit方法可以采用Callable<T> Runnable接口,并且可以编写需要强制类型转换以指示正确的重载的客户端程序(条目 52)。避免此问题的最简单方法是不要编写在相同的参数位置中使用不同函数式接口的重载。这是条目52中建议的一个特例,“明智地使用重载”。

总之,现在Java已经有了lambda表达式,因此必须考虑lambda表达式来设计你的API。 在输入上接受函数式接口类型并在输出中返回它们。 一般来说,最好使用java.util.function.Function中提供的标准接口,但请注意,在相对罕见的情况下,最好编写自己的函数式接口。