系列中的上一篇
当前教程
在应用程序中使用 Lambda 表达式

系列中的上一篇: 编写第一个 Lambda 表达式

系列中的下一篇: 将 Lambda 表达式编写为方法引用

在应用程序中使用 Lambda 表达式

Java SE 8 中引入 Lambda 表达式时,对 JDK API 进行了重大重写。在引入 Lambda 表达式后,JDK 8 中更新的类比在引入泛型后 JDK 5 中更新的类更多。

由于函数式接口的定义非常简单,许多现有的接口在无需修改的情况下就变成了函数式接口。对于您现有的代码也是如此:如果您在应用程序中拥有在 Java SE 8 之前编写的接口,它们可能会在无需修改的情况下就变成函数式接口,从而可以使用 Lambda 表达式来实现它们。

 

发现 java.util.function

JDK 8 还引入了一个新包:java.util.function,其中包含函数式接口,供您在应用程序中使用。这些函数式接口在 JDK API 中也得到了广泛使用,尤其是在集合框架和流 API 中。此包位于 java.base 模块中。

这个包包含 40 多个接口,乍一看可能有点吓人。事实证明,它围绕四个主要接口进行组织。了解它们就能掌握理解所有其他接口的关键。

 

使用 Supplier<T> 创建或提供对象

实现 Supplier<T> 接口

第一个接口是 Supplier<T> 接口。简而言之,供应商不接受任何参数并返回一个对象。

我们应该真正地说:实现供应商接口的 Lambda 不接受任何参数并返回一个对象。只要不造成混淆,使用快捷方式可以使事情更容易记住。

这个接口非常简单:它没有默认方法或静态方法,只有一个简单的 get() 方法。以下是此接口

@FunctionalInterface
public interface Supplier<T> {

    T get();
}

以下 Lambda 是此接口的实现

Supplier<String> supplier = () -> "Hello Duke!";`

此 Lambda 表达式只是返回 Hello Duke! 字符串。您还可以编写一个供应商,它在每次调用时都返回一个新对象

Random random = new Random(314L);
Supplier<Integer> newRandom = () -> random.nextInt(10);

for (int index = 0; index < 5; index++) {
    System.out.println(newRandom.get() + " ");
}

调用此供应商的 get() 方法将调用 random.nextInt(),并将生成随机整数。由于此随机生成器的种子固定为值 314L,您应该看到生成以下随机整数

1
5
3
0
2

请注意,此 Lambda 正在捕获来自封闭范围的变量:random,这使得此变量实际上是最终的

使用 Supplier<T>

请注意,您是如何在前面的示例中使用 newRandom 供应商生成随机数的

for (int index = 0; index < 5; index++) {
    System.out.println(newRandom.get() + " ");
}

调用 Supplier 接口的 get() 方法将调用您的 Lambda。

使用专门的供应商

Lambda 表达式用于处理应用程序中的数据。因此,Lambda 表达式的执行速度在 JDK 中至关重要。可以节省的任何 CPU 周期都必须节省,因为它可能代表真实应用程序中的重大优化。

遵循此原则,JDK API 还提供了 Supplier<T> 接口的专门优化版本。

您可能已经注意到,我们的第二个示例提供了 Integer 类型,其中 Random.nextInt() 方法返回一个 int。因此,在您编写的代码中,幕后发生了两件事

  • Random.nextInt() 返回的 int 首先通过自动装箱机制装箱为 Integer
  • 然后,此 Integer 通过自动拆箱机制在分配给 nextRandom 变量时被拆箱。

自动装箱是将 int 值直接分配给 Integer 对象的机制

int i = 12;
Integer integer = i;

在幕后,会为您创建一个对象,包装该值。

自动拆箱则相反。您可以通过解开 Integer 中的值,将 Integer 分配给 int

Integer integer = Integer.valueOf(12);
int i = integer;

这种装箱/拆箱并非免费的。大多数情况下,与应用程序执行的其他操作(例如从数据库或远程服务获取数据)相比,此成本很小。但在某些情况下,此成本可能无法接受,您需要避免支付此成本。

好消息是:JDK 使用 IntSupplier 接口为您提供了解决方案。以下是此接口

@FunctionalInterface
public interface IntSupplier {

    int getAsInt();
}

请注意,您可以使用完全相同的代码来实现此接口

Random random = new Random(314L);
IntSupplier newRandom = () -> random.nextInt();

您对应用程序代码的唯一修改是,您需要调用 getAsInt() 而不是 get()

for (int i = 0; i < 5; i++) {
    int nextRandom = newRandom.getAsInt();
    System.out.println("next random = " + nextRandom);
}

运行此代码的结果相同,但这次没有发生装箱/拆箱:此代码比之前的代码性能更高。

JDK 为您提供了四个这样的专门供应商,以避免在应用程序中进行不必要的装箱/拆箱:IntSupplierBooleanSupplierLongSupplierDoubleSupplier.

您将看到更多这些处理基本类型的专门版本的函数式接口。它们的抽象方法有一个简单的命名约定:取主抽象方法的名称(在供应商的情况下为 get()),并在其后添加返回类型。因此,对于供应商接口,我们有:getAsBoolean()getAsInt()getAsLong()getAsDouble().

 

使用 Consumer<T> 使用对象

实现和使用消费者

第二个接口是 Consumer<T> 接口。消费者与供应商相反:它接受一个参数,但不返回任何内容。

此接口稍微复杂一些:其中包含默认方法,将在本教程的后面部分介绍。让我们关注它的抽象方法

@FunctionalInterface
public interface Consumer<T> {

    void accept(T t);

    // default methods removed
}

您已经实现了消费者

Consumer<String> printer = s -> System.out.println(s);

您可以使用此消费者更新之前的示例

for (int i = 0; i < 5; i++) {
    int nextRandom = newRandom.getAsInt();
    printer.accept("next random = " + nextRandom);
}

使用专用消费者

假设您需要打印整数。然后您可以编写以下消费者

Consumer<Integer> printer = i -> System.out.println(i);`

然后您可能会遇到与供应商示例相同的自动装箱问题。从性能角度来看,这种装箱/拆箱在您的应用程序中是否可以接受?

如果情况并非如此,请不要担心,JDK 提供了三种可用的专用消费者:IntConsumerLongConsumerDoubleConsumer。这三个消费者的抽象方法遵循与供应商相同的约定,因为返回类型始终为 void,它们都被命名为 accept

使用 BiConsumer 消费两个元素

然后 JDK 添加了 Consumer<T> 接口的另一个变体,它接受两个参数而不是一个,自然地称为 BiConsumer<T, U> 接口。以下是此接口

@FunctionalInterface
public interface BiConsumer<T, U> {

    void accept(T t, U u);

    // default methods removed
}

以下是一个双消费者的示例

BiConsumer<Random, Integer> randomNumberPrinter =
        (random, number) -> {
            for (int i = 0; i < number; i++) {
                System.out.println("next random = " + random.nextInt());
            }
        };

您可以使用此双消费者以不同的方式编写之前的示例

randomNumberPrinter.accept(new Random(314L), 5));

有三个 BiConsumer<T, U> 接口的专用版本来处理基本类型:ObjIntConsumer<T>ObjLongConsumer<T>ObjDoubleConsumer<T>

将消费者传递给 Iterable

已在集合框架的接口中添加了一些重要方法,这些方法将在本教程的另一部分中介绍。其中一个方法接受 Consumer<T> 作为参数,并且非常有用:Iterable.forEach() 方法。以下是一个简单的示例,您将在各处看到它

List<String> strings = ...; // really any list of any kind of objects
Consumer<String> printer = s -> System.out.println(s);
strings.forEach(printer);

这最后一行代码只会将消费者应用于列表中的所有对象。在这里,它将简单地将它们逐个打印到控制台上。您将在后面的部分中看到另一种编写此消费者的方式。

forEach() 方法公开了一种方法,可以访问对任何 Iterable 中所有元素的内部迭代,并传递您需要对这些元素中的每一个执行的操作。这是一种非常强大的方法,它还可以使您的代码更具可读性。

 

使用 Predicate<T> 测试对象

实现和使用谓词

第三个接口是 Predicate<T> 接口。谓词用于测试对象。它用于过滤流 API 中的流,这是一个您将在后面看到的主题。

它的抽象方法接受一个对象并返回一个布尔值。此接口再次比 Consumer<T> 稍微复杂一些:它定义了默认方法和静态方法,您将在后面看到。让我们关注它的抽象方法

@FunctionalInterface
public interface Predicate<T> {

    boolean test(T t);

    // default and static methods removed
}

您已经在前面的部分中看到了 Predicate<String> 的示例

Predicate<String> length3 = s -> s.length() == 3;

要测试给定的字符串,您需要做的就是调用 test() 方法 Predicate 接口

String word = ...; // any word
boolean isOfLength3 = length3.test(word);
System.out.prinln("Is of length 3? " + isOfLength3);

使用专用谓词

假设您需要测试整数值。您可以编写以下谓词

Predicate<Integer> isGreaterThan10 = i -> i > 10;

消费者、供应商和此谓词也是如此。此谓词作为参数接受的是对 Integer 类的实例的引用,因此在将此值与 10 进行比较之前,此对象将被自动拆箱。这非常方便,但会带来开销。

JDK 提供的解决方案与供应商和消费者相同:专用谓词。除了 Predicate<String> 之外,还有三个专用接口:IntPredicateLongPredicateDoublePredicate。它们的抽象方法都遵循命名约定。由于它们都返回一个 boolean,因此它们只被命名为 test() 并且接受与接口相对应的参数。

因此您可以将之前的示例编写如下

IntPredicate isGreaterThan10 = i -> i > 10;

您可以看到 lambda 本身的语法是相同的,唯一的区别是 i 现在是 int 类型而不是 Integer

使用 BiPredicate 测试两个元素

遵循您在 Consumer<T> 中看到的约定,JDK 还添加了 BiPredicate<T, U> 接口,它测试两个元素而不是一个。以下是此接口

@FunctionalInterface
public interface BiPredicate<T, U> {

    boolean test(T t, U u);

    // default methods removed
}

以下是一个此类双谓词的示例

Predicate<String, Integer> isOfLength = (word, length) -> word.length() == length;

您可以使用此双谓词和以下模式

String word = ...; // really any word will do!
int length = 3;
boolean isWordOfLength3 = isOfLength.test(word, length);

没有 BiPredicate<T, U> 的专用版本来处理基本类型。

将谓词传递给集合

添加到集合框架中的方法之一接受谓词:removeIf() 方法。此方法使用此谓词来测试集合中的每个元素。如果测试结果为 true,则此元素将从集合中删除。

您可以在以下示例中看到此模式的实际应用

List<String> immutableStrings =
        List.of("one", "two", "three", "four", "five");
List<String> strings = new ArrayList<>(immutableStrings);
Predicate<String> isEvenLength = s -> s.length() % 2 == 0;
strings.removeIf(isEvenLength);
System.out.println("strings = " + strings);

运行此代码将产生以下结果

strings = [one, two, three]

此示例中有一些值得注意的地方

  • 如您所见,调用 removeIf() 会改变此集合。
  • 因此,您不应该在不可变集合上调用 removeIf(),例如由 List.of() 工厂方法生成的集合。如果您这样做,您将收到一个异常,因为您无法从不可变集合中删除元素。
  • Arrays.asList() 生成一个行为类似于数组的集合。您可以改变其现有元素,但您不允许从此工厂方法返回的列表中添加或删除元素。因此,在该列表上调用 removeIf() 也不起作用。

 

使用 Function<T, R> 将对象映射到其他对象

实现和使用函数

第四个接口是 Function<T, R> 接口。函数的抽象方法接受类型为 T 的对象,并返回该对象转换为任何其他类型 U 的转换。此接口还具有默认方法和静态方法。

@FunctionalInterface
public interface Function<T, R> {

    R apply(T t);

    // default and static methods removed
}

函数在流 API 中用于将对象映射到其他对象,这是一个将在后面介绍的主题。谓词可以看作是函数的一种专用类型,它返回一个 boolean

使用专用函数

这是一个函数的示例,它接受一个字符串并返回该字符串的长度。

Function<String, Integer> toLength = s -> s.length();
String word = ...; // any kind of word will do
int length = toLength.apply(word);

在这里,您再次可以发现装箱和拆箱操作的实际应用。首先,length() 方法返回一个 int。由于函数返回一个 Integer,因此此 int 将被装箱。但是,结果被分配给类型为 int 的变量 length,因此 Integer 随后被拆箱以存储在此变量中。

如果您的应用程序中性能不是问题,那么这种装箱和拆箱实际上并不算什么大问题。如果是,您可能希望避免它。

JDK 为您提供了解决方案,它提供了 Function<T, R> 接口的专用版本。这组接口比我们在 SupplierConsumer<T>Predicate 类别中看到的接口更复杂,因为专用函数是针对输入参数的类型和返回类型定义的。

输入参数和输出都可以有四种不同的类型

  • 参数化类型 T
  • 一个 int
  • 一个 long
  • 一个 double

事情并没有就此结束,因为 API 的设计中存在一个细微差别。有一个特殊的接口:UnaryOperator<T>,它扩展了 Function<T, T>。此一元运算符概念用于命名接受给定类型的参数并返回相同类型的结果的函数。一元运算符正是您所期望的。所有经典的数学运算符都可以用 UnaryOperator<T> 来建模:平方根、所有三角运算符、对数和指数。

以下是您可以在 java.util.function 包中找到的 16 种专用函数类型。

参数类型 T int long double
T UnaryOperator<T> IntFunction<T> LongFunction<T> DoubleFunction<T>
int ToIntFunction<T> IntUnaryOperator LongToIntFunction DoubleToIntFunction
long ToLongFunction<T> IntToLongFunction LongUnaryOperator DoubleToLongFunction
double ToDoubleFunction<T> IntToDoubleFunction LongToDoubleFunction DoubleUnaryOperator

所有这些接口的抽象方法都遵循相同的约定:它们以该函数的返回值类型命名。以下是它们的名字

将一元运算符传递给列表

您可以使用 UnaryOperator<T> 来转换列表的元素。有人可能会问,为什么使用 UnaryOperator<T> 而不是基本的 Function。答案实际上很简单:一旦声明,您就无法更改列表的类型。因此,您应用的函数可以更改列表的元素,但不能更改它们的类型。

接受此一元运算符的方法将其传递给 replaceAll() 方法。以下是一个示例

List<String> strings = Arrays.asList("one", "two", "three");
UnaryOperator<String> toUpperCase = word -> word.toUpperCase();
strings.replaceAll(toUpperCase);
System.out.println(strings);

运行此代码将显示以下内容

[ONE, TWO, THREE]

请注意,这次我们使用的是用 Arrays.asList() 模式创建的列表。实际上,您不需要向该列表添加或删除任何元素:此代码只是逐个修改每个元素,这对于此特定列表是可能的。

使用双函数映射两个元素

对于消费者和谓词,函数也有一个接受两个参数的版本:双函数。该接口是 BiFunction<T, U, R>,其中 TU 是参数,R 是返回值类型。以下是该接口

@FunctionalInterface
public interface BiFunction<T, U, R> {

    R apply(T t, U u);

    // default methods removed
}

您可以使用 lambda 表达式创建双函数

BiFunction<String, String, Integer> findWordInSentence =
    (word, sentence) -> sentence.indexOf(word);

UnaryOperator<T> 接口也有一个具有两个参数的兄弟接口:BinaryOperator<T>,它扩展了 BiFunction<T, U, R>。正如您所料,四种基本算术运算可以用 BinaryOperator 来建模。

所有可能的双函数专业化版本的子集已添加到 JDK 中

 

总结四类函数式接口

java.util.function 包现在是 Java 中的核心,因为您将在 Collections Framework 或 Stream API 中使用的所有 lambda 表达式都实现了该包中的一个接口。

正如您所见,该包包含许多接口,在其中找到自己的路可能很棘手。

首先,您需要记住的是,有 4 类接口

  • 供应商:不接受任何参数,返回一些东西
  • 消费者:接受一个参数,不返回任何东西
  • 谓词:接受一个参数,返回一个布尔值
  • 函数:接受一个参数,返回一些东西

其次:某些接口具有接受两个参数而不是一个参数的版本

  • 双消费者
  • 双谓词
  • 双函数

第三:某些接口具有专门的版本,添加以避免装箱和拆箱。它们太多,无法一一列出。它们以它们接受的类型命名。例如:IntPredicate,或它们返回的类型,如 ToLongFunction<T>。它们可以以两者命名:IntToDoubleFunction

最后:Function<T, R>BiFunction<T, U, R> 的扩展,用于所有类型都相同的情况:UnaryOperator<T>BinaryOperator<T>,以及针对基本类型的专门版本。

更多学习


上次更新: 2021 年 10 月 26 日


系列中的上一篇
当前教程
在应用程序中使用 Lambda 表达式

系列中的上一篇: 编写第一个 Lambda 表达式

系列中的下一篇: 将 Lambda 表达式编写为方法引用