第三章:JDK的函数式接口
许多函数式编程语言仅使用一个单一且动态的“函数”概念来描述它们的lambda,而不考虑它们的参数、返回类型或实际用例。然而,Java是一种严格类型的语言,要求在所有情况下都需要具体的类型,包括lambda。这就是为什么JDK在其java.util.function包中提供了超过40个现成的函数接口,以帮助您启动函数式工具集的原因。 本章将向您展示最重要的函数式接口,解释为什么有这么多变体,并展示如何扩展自己的代码以更具函数式特性。
四个主要的函数式接口类别
java.util.function中的40多个函数式接口可以分为四个主要类别,每个类别代表一个重要的函数式用例:
- Functions(函数):它们接受参数并返回一个结果。在函数式编程中,函数用于将一个值转换为另一个值。常见的函数式接口包括
java.util.function.Function。 - Consumers(消费者):它们只接受参数但不返回结果。消费者用于执行一些操作,例如打印输出或修改对象的状态。常见的函数式接口是
java.util.function.Consumer。 - Suppliers(供应商):它们不接受参数,只返回结果。供应商用于生成值,例如从数据库或其他外部源获取数据。常见的函数式接口是
java.util.function.Supplier。 - Predicates(谓词):它们接受参数并对其进行表达式测试,并将布尔原始类型作为结果返回。断言用于进行条件判断,例如筛选、过滤或验证。常见的函数式接口是
java.util.function.Predicate。
这四个类别涵盖了许多使用情况,它们的名称与函数式接口类型及其变体相关联。 现在让我们来看一下这四个主要的函数式接口类别。
Functions(函数)
Functions(函数),以及它们对应的java.util.function.Function<T, R>接口,是最核心的函数式接口之一。它们代表了一个具有单个输入和输出的“经典”函数,如图3-1所示。
Function<T, R>的单个抽象方法被称为apply,接受类型为T的参数并产生类型为R的结果:
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
}
以下代码接受一个字符串并将其打印出来:
Function<String, Integer> stringLength = str -> str != null ? str.length() : 0;
Integer result = stringLength.apply("Hello, Function!");
输入类型T和输出类型R可以相同。然而,在《函数元数》中,我将讨论具有相同类型的专用函数式接口变体。
Consumers(消费者)
顾名思义,消费者(Consumer)仅消费输入参数,但不返回任何结果,如图3-2所示。核心的消费者函数式接口是java.util.function.Consumer<T>。
Consumer<T>的单个抽象方法被称为accept,需要一个类型为T的参数:
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
}
以下代码接受一个字符串并将其打印出来:
Consumer<String> println = str -> System.out.println(str);
println.accept("Hello, Consumer!");
尽管在表达式中仅消费一个值可能不符合“纯”函数式概念,但它是在Java中采用更函数式编码风格的重要组成部分,弥合了非函数式代码和高阶函数之间的许多差距。 Consumer<T>接口类似于java.util.concurrent包中的Java 5+ Callable<V>,只是后者会抛出一个受检异常。关于受检异常和非受检异常的概念以及它们对Java中函数式代码的影响将在第10章中详细探讨。
Suppliers(供应商)
供应商(Suppliers)是消费者(Consumers)的反义词。基于中心函数式接口java.util.function.Supplier<T>,不同的供应商变体不接受任何输入参数,而是返回类型为T的单个值,如图3-3所示。
Supplier<T>的单个抽象方法被称为get:
@FunctionalInterface
public interface Supplier<T> {
T get();
}
以下的Supplier<Double>在调用get()时提供一个新的随机值:
Supplier<Double> random = () -> Math.random();
Double result = random.get();
供应商通常用于延迟执行,例如当一个昂贵的任务被包装在供应商中,在需要时才调用get(),这将在第11章中讨论。
Predicates(谓词)
谓词(Predicates)是接受单个参数进行逻辑测试并返回true或false的函数。主要的函数式接口java.util.function.Predicate<T>的语法如图3-4所示。
单个抽象方法被称为test,接受类型为T的参数,并返回布尔原始类型:
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
}
它是用于决策的首选函数式接口,例如在第6章中将更详细地学习的函数式模式map/filter/reduce的过滤方法。 以下代码测试一个整数是否大于9000:
Predicate<Integer> over9000 = i -> i > 9_000;
Integer result = over9000.test(1_234);
为什么会有这么多函数式接口的变体呢?
尽管这四个主要类别及其主要函数式接口已经涵盖了许多使用情况,但也存在各种变体和更专门的变体可供使用。所有这些不同的类型都是为了在不牺牲向后兼容性的情况下将lambda引入Java中而必要的。由于这一点,尽管在Java中使用lambda比其他语言稍微复杂一些,但在我看来,整合这样的功能而不破坏广泛的生态系统是值得的。
有办法在不同的函数式接口之间进行桥接,每个变体都有自己的最佳问题背景可供使用。一开始处理这么多不同类型可能看起来令人生畏,但在使用更函数式的方法一段时间后,知道在什么情况下使用哪种类型将变得几乎是第二天性。
函数元数(Function Arity)
函数元数(arity)描述了函数接受的操作数数量。例如,元数为1表示一个lambda接受一个参数:
Function<String, String> greeterFn = name -> "Hello " + name;
由于Java方法(如SAM)中的参数数量是固定的,因此必须有一个显式的函数式接口来表示每个所需的元数。为了支持大于1的元数,JDK包含了接受参数的主要函数式接口类别的专用变体,如表3-1所示。
默认情况下,只支持最多两个参数的函数接口。通过查看Java中的函数式API和用例,可以发现元数为一或二涵盖了最常见的任务。这很可能是Java语言设计者决定停留在这里,而没有添加更高元数的原因。 不过,添加更高元数是很简单的,就像以下的代码一样:
@FunctionalInterface
public interface TriFunction<T, U, V, R> {
R accept(T t, U u, V, v);
}
然而,我不建议这样做,除非绝对必要。正如您将在本章和本书中看到的,所包含的函数式接口通过静态方法和默认方法为您提供了许多附加功能。这就是为什么依赖它们可以确保最佳的兼容性和易于理解的使用模式。
函数操作符的概念通过为您提供具有相同泛型类型的函数式接口来简化了最常用的两个元数。例如,如果您需要一个函数接受两个String参数来创建另一个String值,使用BiFunction<String, String, String>的类型定义可能会显得很重复。相反,您可以使用定义如下的BinaryOperator<String>:
@FunctionalInteface
interface BinaryOperator<T> extends BiFunction<T, T, T> {
// ...
}
实现一个共同的超级接口允许您编写更简洁、更具有意义的类型的代码。 可用的操作符函数式接口列在表3-2中。
请注意,操作符类型和它们的超级接口不能互换使用。这一点在设计API时尤为重要。 想象一下,一个方法签名要求一个UnaryOperator<String>作为参数;它将与Function<String, String>不兼容。然而,反过来是可以的,如示例3-1所示。
UnaryOperator<String> unaryOp = String::toUpperCase;
Function<String, String> func = String::toUpperCase;
void acceptsUnary(UnaryOperator<String> unaryOp) { ... };
void acceptsFunction(Function<String, String> func) { ... };
acceptsUnary(unaryOp); // OK
acceptsUnary(func); // COMPILE-ERROR
acceptsFunction(func); // OK
acceptsFunction(unaryOp); // OK
这个例子强调了你应该为方法参数选择最常见的通用类型,就本例而言,就是Function<String, String>,因为它提供了最高的兼容性。尽管它增加了方法签名的冗长度,但在我看来,这是一个可以接受的折衷,因为它最大程度地提高了可用性,并不限制参数为专门的函数式接口。另一方面,当创建一个lambda表达式时,专门的类型可以实现更简洁的代码,而不失去任何表达能力。
原始类型(Primitive Types)
到目前为止,你遇到的大多数函数式接口都有一个泛型类型定义,但并非总是如此。原始类型无法用作泛型类型(尚不支持)。这就是为什么有专门的原始类型的函数式接口。
你可以使用任何泛型函数式接口来处理对象包装类型,并让自动装箱(autoboxing)来处理其余部分。然而,自动装箱并不是免费的,所以可能会影响性能。
这就是为什么JDK提供的许多函数式接口处理原始类型,以避免自动装箱。这些原始类型的函数式接口,如特定元数的接口,虽然不是适用于所有原始类型,但主要集中在数值原始类型int、long和double上。
表3-3列出了仅适用于int的可用函数式接口,同样也有适用于long和double的等效接口。
布尔原始类型只有一个特殊的变体:BooleanSupplier。 针对原始类型的函数式接口并不是Java的新函数式部分中唯一需要特别考虑的。正如你将在本书后面学到的那样,流(Streams)和可选类型(Optionals)也提供了专门的类型,以减少自动装箱带来的不必要开销。
桥接函数式接口
函数式接口是接口,而lambda表达式是这些接口的具体实现。类型推断使人很容易忘记不能在它们之间互换使用或者简单地在不相关的接口之间进行类型转换。即使它们的方法签名是相同的,也会抛出异常,就像之前在“创建Lambda表达式”中看到的那样:
interface LikePredicate<T> {
boolean test(T value);
}
LikePredicate<String> isNull = str -> str == null;
Predicate<String> wontCompile = isNull;
// Error:
// incompatible types: LikePredicate<java.lang.String> cannot be
// converted to java.util.function.Predicate<java.lang.String>
Predicate<String> wontCompileEither = (Predicate<String>) isNull;
// Exception java.lang.ClassCastException: class LikePredicate
// cannot be cast to class java.util.function.Predicate
从基于lambda的角度来看,这两个SAM是相同的。它们都接受一个String参数并返回一个boolean结果。然而,对于Java的类型系统来说,它们没有任何联系,因此无法在它们之间进行类型转换。然而,可以通过在“方法引用”中讨论的特性来弥合“兼容但不兼容类型”的函数式接口之间的差距。
通过使用方法引用而不是尝试在“相同但不兼容”的函数式接口之间进行类型转换,您可以引用SAM来使您的代码编译通过:
Predicate<String> thisIsFine = isNull::test;
使用方法引用创建一个新的动态调用点,由字节码操作码invokedynamic调用该调用点,而不是尝试隐式或显式地转换函数式接口本身。 就像您在“重新finalize引用”中学到的重新finalize变量一样,通过方法引用来弥合函数式接口是另一种处理无法通过其他方式重构或重新设计的代码的临时解决方法。尽管如此,这是一个易于使用且有时是必要的工具,在您的函数式工具包中是必备的,特别是如果您正在从传统的代码库转向更加函数式的方法,或者您正在使用提供其自己的函数式接口的第三方代码。
函数组合
函数组合是函数式编程中将小的函数单元组合成更大、更复杂任务的重要部分,Java为此提供了相应支持。然而,为了确保向后兼容性,Java采用了典型的Java方式。它在函数接口本身上直接实现了默认方法作为“粘合剂”。借助这些方法,您可以轻松地组合这四个大类的函数接口。这些粘合方法通过返回具有组合功能的新函数接口来构建两个函数接口之间的桥梁。
对于Function<T, R>,有两个默认方法可用:
这两个方法的区别在于组合的方向,如参数名称和返回的Function及其泛型类型所示。第一个方法compose创建一个组合函数,将before参数应用于其输入,并将结果应用于this函数。第二个方法andThen是compose的对应方法,它首先评估this函数,然后将after应用于先前的结果。
选择函数组合的方向,是使用compose还是andThen,取决于上下文和个人偏好。调用fn1.compose(fn2)等效于fn1(fn2(input))。要使用andThen方法实现相同的流程,组合的顺序必须颠倒为fn2.andThen(fn1(input))的调用,如图3-5所示。
就个人而言,我更喜欢使用andThen方法,因为生成的流畅的方法调用链类似于自然语言,更符合函数逻辑流程,对于那些不熟悉函数式编程命名约定的读者来说更容易理解。
考虑通过删除所有小写字母"a"的出现并将结果转为大写来操作一个字符串。整个任务由两个Function<String, String>组成,每个函数都完成一个具体的任务。使用适当的粘合方法,无论您选择哪种组合方式,最终的结果都不会有区别,就像在示例3-2中所示。
Function<String, String> removeLowerCaseA = str -> str.replace("a", "");
Function<String, String> upperCase = String::toUpperCase;
var input = "abcd";
removeLowerCaseA.andThen(upperCase)
.apply(input);
// => "BCD"
upperCase.compose(removeLowerCaseA)
.apply(input);
// => "BCD"
请注意,并非每个函数式接口都提供这种“粘合方法”来轻松支持组合,即使这样做是合理的。以下列表总结了大四类主要接口在支持组合方面的现状:
Function<T, R>及其特定的arity(如UnaryOperator)支持双向组合。而-Bi变体只支持andThen。
Predicate支持多种方法来组合新的Predicate和与它们相关的常见操作:and、or、negate。
只支持andThen方法,它将两个Consumer组合在一起以依次接受一个值。
特定的原始函数式接口 对于原始类型的专门函数式接口而言,它们在支持函数组合方面与其泛型对应接口相比还有所不足。甚至在它们自身之间,支持的程度在原始类型之间也有所不同。
但是不要担心!编写自己的函数组合助手很容易,我将在下一节中讨论这个问题。
扩展功能支持
大多数功能接口不仅仅提供了定义Lambda签名的单个抽象方法,通常还提供其他默认方法来支持功能组合等概念,或者提供静态助手方法来简化该类型的常见用法。 尽管无法更改JDK中的类型,但仍可以使自己的类型更加功能化。您可以选择以下三种方法,这也是JDK使用的方法之一:
- 向接口添加默认方法以使现有类型更具功能性。
- 显式实现功能接口。
- 创建静态助手方法以提供常见的功能操作。
添加默认方法
向接口添加默认方法是一种增强现有类型功能的强大方式。它允许您在不破坏向后兼容性的情况下扩展接口的能力。
要向接口添加默认方法,只需在接口中定义方法并提供默认实现即可。所有实现该接口的类都会自动继承该默认实现。现有的接口实现无需任何修改即可正常工作。
添加默认方法可以为接口添加新的功能,但需要在所有实现中实现新方法。在处理小型项目时,更新任何实现可能是可行的,但在更大和共享的项目中,这通常并不容易。在库代码中更糟糕;您可能会破坏使用您库的任何人的代码。这就是默认方法发挥作用的地方。
与其仅仅更改类型接口的约定并让所有实现它的类型处理后果(在任何实现接口的类型上添加新方法),您可以使用默认方法提供一个“常识”实现。这为所有其他类型提供了所需逻辑的通用变体,因此您无需抛出 UnsupportedOperationException。通过这种方式,您的代码是向后兼容的,因为只有接口本身发生了变化,但任何实现接口的类型仍然可以根据需要创建自己更合适的实现。这正是 JDK 如何为实现 java.util.Collection 接口的任何类型添加 Stream 支持的方式。
下面的代码显示了实际的默认方法,它们为任何基于 Collection 的类型提供了开箱即用的 Stream 功能,而不需要额外的(实现)成本:
public interface Collection<E> extends Iterable<E> {
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
default Stream<E> parallelStream() {
return StreamSupport.stream(spliterator(), true);
}
// ...
}
这两个默认方法通过调用静态辅助方法 StreamSupport.stream 和默认方法 spliterator 来创建新的 Stream 实例。spliterator 方法最初在 java.util.Iterable 中定义,但根据需要进行了重写,如示例 3-3 所示。
public interface Iterable<T> {
default Spliterator<T> spliterator() {
return Spliterators.spliteratorUnknownSize(iterator(), 0);
}
// ...
}
public interface Collection<E> extends Iterable<E> {
@Override
default Spliterator<E> spliterator() {
return Spliterators.spliterator(this, 0);
}
// ...
}
public class ArrayList<E> extends AbstractList<E> implements List<E>, ... {
@Override
public Spliterator<E> spliterator() {
return new ArrayListSpliterator(0, -1, 0);
}
// ...
}
通过默认方法的层次结构,您可以在不破坏任何实现的情况下向接口添加新功能,并仍然提供新方法的常规变体。即使一个类型从未为自身实现更具体的变体,它仍可以使用默认方法提供的逻辑作为回退选项。
显式实现函数接口
函数接口可以通过lambda表达式或方法引用隐式地实现,但显式实现函数接口对于您的某些类型来说也很有用,这样它们就可以在高阶函数中使用。
您的某些类型可能已经实现了一些具有功能性的接口,例如java.util.Comparator或java.lang.Runnable。 直接实现函数接口可以在之前的“非功能性”类型和它们在功能性代码中的简便使用之间建立桥梁。一个很好的例子是面向对象的命令设计模式2。
通常,命令已经有了专门的接口。想象一个文本编辑器,它有常见的命令,比如打开文件或保存文件。这些命令之间可以共享一个简单的命令接口,例如:
public interface TextEditorCommand {
String execute();
}
具体的命令类将接受所需的参数,但执行的命令只会返回更新后的编辑器内容。如果你仔细看,会发现该接口与 Supplier 匹配。
正如我在“桥接功能接口”中讨论的那样,仅仅在功能接口之间逻辑上的等价性是不足以创建兼容性的。然而,通过将 TextEditorCommand 扩展为 Supplier,你可以通过默认方法填补这个差距,如下所示:
public interface TextEditorCommand extends Supplier<T> {
String execute();
default String get() {
return execute();
}
}
接口允许多重继承,因此添加一个功能接口不应该成为问题。功能接口的SAM是一个简单的默认方法,调用实际执行工作的方法。这样,不需要修改任何一个命令,但它们都能与接受 Supplier 的任何高阶函数兼容,而不需要将方法引用作为桥梁。
实现一个或多个功能接口是赋予您的类型功能性起点的好方法,包括在功能接口上可用的所有附加默认方法。
创建静态辅助方法
Functional interfaces通常通过具有默认方法和常用任务的静态辅助方法来增加其灵活性。然而,如果你无法控制类型,例如由JDK自身提供的功能接口,你可以创建一个辅助类型来积累静态方法。
在“Functional Composition”一节中,我讨论了在四个主要接口上使用可用的默认方法进行函数组合的方法。虽然最常见的用例已经涵盖了,但某些不同的函数接口并没有涵盖。
然而,你可以自己创建它们。 让我们来看一下Function<T, R>如何在示例3-4中实现其compose方法,以便我们可以开发一个组合辅助类型来接受其他类型。
@FunctionalInterface
public interface Function<T, R> {
default <V> Function<V, R> compose(Function<V, T> before) {
Objects.requireNonNull(before);
return (V v) -> {
T result = before.apply(v);
return apply(result);
};
}
// ...
}
要创建自己的组合方法,首先你需要考虑你想要实现的具体目标。涉及的函数接口及其组合顺序决定了方法签名所必须反映的整体类型链,如表3-4所示。
让我们为Function<T, R>和Supplier/Consumer开发一个组合器。 由于Supplier不接受参数,所以只有两种组合的可能性,它无法评估Function<T, R>的结果。而对于Supplier来说正好相反的情况也是如此。因为你不能直接扩展Function<T, R>接口,所以需要以静态帮助程序的形式进行间接组合。这导致以下方法签名,其中参数顺序反映了组合顺序:
示例3-5展示了一个简单的组合器实现,与JDK中等效方法的实现几乎没有区别。
public final class Compositor {
public static <T, R> Supplier<R> compose(Supplier<T> before,
Function<T, R> fn) {
Objects.requireNonNull(before);
Objects.requireNonNull(fn);
return () -> {
T result = before.get();
return fn.apply(result);
};
}
public static <T, R> Consumer<T> compose(Function<T, R> fn,
Consumer<R> after) {
Objects.requireNonNull(fn);
Objects.requireNonNull(after);
return (T t) -> {
R result = fn.apply(t);
after.accept(result);
};
}
private Compositor() {
// suppress default constructor
}
}
现在,通过在Example 3-2的基础上添加一个额外的Consumer来打印结果,组合之前的String操作变得非常简单,如Example 3-6所示。
// SINGULAR STRING FUNCTIONS
Function<String, String> removeLowerCaseA = str -> str.replace("a", "");
Function<String, String> upperCase = String::toUpperCase;
// COMPOSED STRING FUNCTIONS
Function<String, String> stringOperations =
removeLowerCaseA.andThen(upperCase);
// COMPOSED STRING FUNCTIONS AND CONSUMER
Consumer<String> task = Compositor.compose(stringOperations,
System.out::println);
// RUNNING TASK
task.accept("abcd");
// => BCD
将值在功能接口之间传递的简单组合器是功能组合的一个明显应用场景。然而,它也适用于其他用例,比如引入一定程度的逻辑和决策。例如,您可以使用Predicate来保护一个Consumer,如Example 3-7所示。
public final class Compositor {
public static Consumer<T> acceptIf(Predicate<T> predicate,
Consumer<T> consumer) {
Objects.requireNonNull(predicate);
Objects.requireNonNull(consumer);
return (T t) -> {
if (!predicate.test(t)) {
return;
}
consumer.accept(t);
}
}
// ...
}
根据需要,您可以通过为您的类型添加新的静态助手来填补JDK留下的空白。从个人经验来看,我建议根据需要添加助手,而不是主动填补空缺。只实现当前需要的功能,因为很难预见将来会需要什么。如果当前不使用的任何额外代码,它在一段时间后仍需要维护,并且如果要使用它并且实际需求变得清晰,它可能需要进行更改或重构。
总结
-
JDK提供了40多个功能接口,因为Java的类型系统要求针对不同的用例提供明确的接口。可用的功能接口分为四个类别:函数(Functions)、消费者(Consumers)、提供者(Suppliers)和断言(Predicates)。
-
对于最多两个参数的情况,存在更专门的功能接口变体。然而,为了最大限度地提高兼容性,方法签名应使用它们的等效超接口。
-
基本类型通过自动装箱或相应的int、long、double和boolean的功能接口变体来支持。 功能接口的行为与任何其他接口相同,并且需要一个公共祖先才能互换使用。然而,通过使用SAM的方法引用,可以弥合“相同但不兼容”的功能接口之间的差距。
-
为自己的类型添加功能支持很容易。在接口上使用默认方法来涵盖功能用例,而无需更改任何实现。 常见的或缺失的功能任务可以在一个带有静态方法的辅助类型中累积。