从头学 Java17-Lambda 表达式
Lambda 表达式
这一系列教程,旨在介绍 lambda 的概念,同时逐步教授如何在实践中使用它们。
回顾表达式、语句
表达式
表达式由变量、运算符和方法调用组成,其计算结果为单个值。您已经看到了表达式的示例,如下面的代码所示:
表达式返回值的数据类型取决于其中使用的元素。cadence = 0
返回 int
,因为赋值运算符返回与其左侧数据类型相同的值。从其他表达式中可以看出,也可以返回其他类型。
Java 编程语言允许您从各种较小的表达式构造复合表达式,只要表达式的一部分所需的数据类型与另一部分的数据类型匹配即可。下面是复合表达式的示例:
在此特定示例中,表达式的计算顺序并不重要,因为乘法的结果与顺序无关。但并非所有表达式都是如此。例如,以下表达式给出不同的结果,具体取决于您是先执行加法运算还是除法:
您可以使用括号,准确指定表达式的计算方式 。例如,要使前面的表达式明确,您可以编写以下内容:
如果未显式指示要执行的操作的顺序,则顺序由分配给表达式中使用的运算符的优先级确定。例如,除法的优先级高于加法。因此,以下两个语句是等效的:
编写复合表达式时,要明确,并用括号指示应首先计算哪些运算符。这种做法使代码更易于阅读和维护。
语句
语句大致相当于自然语言中的句子,构成一个完整的执行单元。以下类型的表达式可以通过;
来组成语句。
赋值表达式
任何++``--`
方法调用
对象创建表达式
此类语句称为表达式语句。下面是一些示例。
除了表达式语句,还有另外两种类型的语句:声明语句和控制流语句。声明语句声明一个变量。您已经看到许多声明语句的示例:
最后,控制流语句调节语句的执行顺序。
何时使用嵌套类、本地类、匿名类和 Lambda
在嵌套类、本地类、匿名类和 Lambda 之间进行选择
嵌套类使您能够对仅在一个位置使用的类进行逻辑分组,增加封装的使用,并创建更可读和可维护的代码。本地类、匿名类和 Lambda 也赋予了这些优势;但是,它们旨在用于更具体的情况:
本地类:如果需要创建类的多个实例、访问其构造函数或引入新的命名类型(例如,因为稍后需要调用其他方法),请使用它。
匿名类:如果需要声明字段或其他方法,请使用它。
lambda:
如果要封装要传递给其他代码的单个行为单元,请使用它。例如,如果您希望对集合的每个元素、进程完成或进程遇到错误时执行特定操作,可以使用 Lambda。
如果您需要函数接口的简单实例,并且上述条件都不适用(例如,您不需要构造函数、命名类型、字段或其他方法),请使用它。
嵌套类:如果你的要求与本地类的要求类似,希望使类型更广泛地可用,并且不需要访问局部变量或方法参数,请使用它。
如果您需要访问外层实例的非 public 字段和方法,请使用非静态嵌套类(或内部类)。如果不需要,请使用静态嵌套类。
开始编写您的第一个 lambda
2014 年,Java SE 8 引入了 lambda 的概念。在之前,您可能还记得匿名类的概念。也许您听说过 Lambda 是编写匿名类实例的另一种更简单的方法,在某些实际场景下。
如果不记得,那么你可能听说过,见到过匿名类,并且可能害怕这种晦涩难懂的语法。
好消息是:您不需要通过匿名类来了解如何编写 Lambda。此外,在许多情况下,由于在 Java 语言中添加了 lambda,您不再需要匿名类。
编写 Lambda 可以分解为理解三个步骤:
确定要编写的 Lambda 的类型
找到正确的实现方法
实现它。
这真的是它的全部。让我们详细了解这三个步骤。
识别 Lambda 的类型
在 Java 语言中,一切都有一个类型,这种类型在编译时是已知的。因此,总是可以找到 Lambda 的类型。它可以是变量、字段、方法参数或方法的返回类型。
Lambda 的类型有一个限制:它必须是一个函数接口 functional interface。因此,不实现函数接口的匿名类不能编写为 Lambda。
函数接口的完整定义有点复杂。此时你需要知道的是,函数接口是一个只有一个抽象方法的接口。
您应该知道,从 Java SE 8 开始,接口中允许使用具体方法。可以是实例方法,称为默认方法,也可以是静态方法。这些方法都不算,因为它们不是抽象方法。
是否需要在接口上添加注解
@FunctionalInterface
才能使其正常运行?不是的。此注解可帮助您确保接口是函数的。将此注解放在非函数接口类型上,编译器将引发错误。
函数接口示例
让我们看看从 JDK API 中获取的一些示例。我们刚刚从源代码中删除了注解。
Runnable
接口确实是函数式的,因为只有一个抽象方法。@FunctionalInterface
注解只是辅助,但不是必需。
Consumer
接口也是函数式的:它有一个抽象方法和一个默认的、不计数的具体方法。同样,不需要@FunctionalInterface
注解。
Predicate
接口稍微复杂一些,但它仍然是一个函数接口:
它有一个抽象方法
它有三个不计算的默认方法
它有两个静态方法,两者都不算数。
找到正确的实现
此时,您已经确定了需要编写的 lambda 的类型,好消息是:您已经完成了最困难的部分。
Lambda 是函数接口中那个唯一抽象方法的实现。因此,只需要找到此方法。
您可以花一分钟时间在上一段的三个示例中查找它。
对于Runanble
,它是:
对于Predicate
接口,它是:
对于Consumer
接口,它是:
使用 Lambda 正确的实现
编写实现Predicate
现在是最后一部分:编写 lambda 本身。您需要了解的是,您正在编写的 Lambda 是您找到的抽象方法的实现。使用 Lambda 语法,您可以在代码中很好地内联此过程。
此语法由三个元素组成:
参数块;
一个箭头
->
方法体代码块。
让我们看看这方面的例子。假设您需要一个Predicate
实例,该实例返回true
,表示字符串正好包含 3 个字符。
Lambda 的类型是
Predicate
你需要实现的方法是
boolean test(String s)
然后你写参数块,直接复制(String s)
然后添加一个微不足道的箭头:->
和方法体。您的结果应如下所示:
简化语法
然后可以简化此语法,这归功于编译器可以推断出许多内容,您无需编写它们。
首先,编译器知道您正在实现Predicate
接口的抽象方法,并且知道此方法将 String
作为参数。所以可以简化为。在这种情况下,如果只有一个参数,您甚至可以通过删除括号来更进一步。然后,(String s)
变为s
。如果您有多个参数或没有参数,则应保留括号。
其次,方法主体中只有一行代码。在这种情况下,您不需要大括号或return
所以最终实际上如下:
这是个好实践:保持 lambda 简短,这样它们就只是一行简单易读的代码。
实现Consumer
在某些时候,人们可能想走捷径。你可能听开发人员说“Consumer 拿一个对象,什么也不返回”。或者“当字符串正好有三个字符时,Predicate 为真”。大多数情况下,Lambda、它实现的抽象方法和保存此方法的函数接口之间存在一点混淆。
但是,由于函数接口的抽象方法和它的 Lambda 实现紧密地联系在一起,因此这种说法实际上完全有意义。没关系,不会导致任何歧义。
让我们编写一个 lambda,它使用String
并在System.out
打印 .语法可以是这样的:
这里我们直接编写了 Lambda 的简化版本。
实现 Runnable
实现 Runnable
原来是编写 void run()
的实现。此参数块为空,因此要用括号。请记住:有且只有一个参数时,才能省略括号。
因此,让我们编写一个 Runnable 对象,告诉我们它正在运行:
调用 Lambda
让我们回到前面的 Predicate 示例,并假设此Predicate
已在方法中定义。如何使用它来测试给定的字符串是否确实长度为 3?
好吧,尽管你用语法正确编写了 lambda,但你需要记住,这个 lambda 是接口Predicate
的一个实例。此接口定义了一个名为 test()
的方法,接受String并返回boolean
让我们这样写:
请注意您如何定义 Predicate,像前面一样。由于Predicate
接口定义了方法boolean test(String),
通过 Predicate 类型的变量调用Predicate
的方法完全合法。
稍后面,编写此代码有更好的方法,您将在本教程后面看到。
因此,每次编写 lambda 时,都可以调用在此 lamdba 实现的接口上定义的任何方法。调用抽象方法将调用 lambda 本身的代码,因为此 lambda 是该方法的实现。调用默认方法将调用接口中编写的代码。lambda 无法覆盖默认方法。
捕获局部变量
一旦习惯了,写 lambda 就会变得非常自然。它们很好地集成到集合框架、Stream API 和 JDK 的许多其他地方。从 Java SE 8 开始,lambda 无处不在,非常好。
使用 lambda 也存在约束,您可能遇到一些编译时错误。
让我们考虑以下代码:
即使这段代码看起来还好,尝试编译它会在totalPrice +=
那里报错:
Lambda 中使用的变量应该是 final 的,或实际 final 的
原因如下:lambda 无法修改在其主体外部定义的变量。可以读取,只要它们是final
,即不可变的。访问变量的过程称为捕获 capturing:lambda 无法捕获变量,它们只能捕获值。final 变量实际上是一个值。
您已经注意到报错消息告诉我们变量可以是 final 的,这是 Java 语言中的经典概念。它还告诉我们,变量可以实际是 final 的。这个概念在 Java SE 8 中被引入:即使你没有显式声明一个变量final
,编译器也可以为你做。如果它看到这个变量是从 lambda 中读取的,并且你没有修改它,会友好的为你添加声明。当然,这是在编译的代码中完成的,编译器不会修改您的源代码。这些变量不称为 final 变量;而是实际 final 变量。这是一个非常有用的功能。
序列化 Lambda
Lambda 实际是可以被序列化的。
为什么要序列化 Lambda?好吧,Lambda 可以存储在字段中,并且可以通过构造函数或 setter 方法访问该字段。然后,您可能在运行时在对象状态下有一个 Lambda,而没有意识到它。
因此为了保持兼容性,可以序列化 Lambda。
在应用程序中使用 Lambda
Java SE 8 中 Lambda 的引入也带来了对 JDK API 的重大重写。在引入 lamdba 之后,JDK 8 中更新的类比引入泛型的 JDK 5 中更新的更多。
由于函数接口的定义非常简单,许多现有接口无需修改即可实现函数化。现有代码也是如此:如果您的应用程序中有 Java SE 8 之前编写的接口,那么无需接触,即可用 lambda 实现它们。
探索java.util.function
包
JDK 8 还引入了一个新的包:java.util.function
,带有函数接口,供您在应用程序中使用。这些函数接口在 JDK API 中也被大量使用,特别是在集合框架和 Stream API 中。此包位于 java.base
模块。
这个包有 40 多个接口,一开始可能看起来有点吓人。事实上,它主要围绕四个接口来组织。
用Supplier
创建或提供对象
实现Supplier
接口
第一个接口是Supplier
接口。简单说,Supplier 不接受任何参数并返回一个对象。
也可以说:实现 Supplier 接口的 lambda 不接受任何参数并返回一个对象。捷径使事情更容易记住,只要不令人困惑。
这个接口非常简单:它没有默认或静态方法,只有一个普通的 get()
方法。这是这个接口:
以下 lambda 是此接口的实现:
此 Lambda 仅返回字符串Hello Duke!
。还可以编写一个 Supplier,每次调用时都返回新对象:
调用该 Supplier 的 get()
方法将调用 random.nextInt(),
并将生成随机整数。由于这个随机生成器的种子是固定为314L
,你应该看到生成的以下随机整数:
请注意,此 lambda 正在从外围作用域中捕获一个random
变量:,使该变量实际上为 final。
使用Supplier
请注意您如何使用上一示例中的newRandom
Supplier 生成随机数:
调用Supplier
接口的 get()
方法会调用您的 lambda。
使用专用 Supplier
Lambda 用于处理应用程序中的数据。因此,Lambda 的执行速度在 JDK 中至关重要。任何 CPU 周期能保存的都必须保存,因为它可能代表实际应用程序中的重大优化。
遵循这一原则,JDK API 还提供Supplier
接口的专用优化版本。
您可能已经注意到,我们的第二个示例提供了 Integer
类型,其中 Random.nextInt()
方法返回一个int
.因此,在您编写的代码中,有两件事正在幕后发生:
由
Random.nextInt()
返回的int
首先自动装箱成一个Integer
;然后,分配给
nextRandom
变量时,此Integer
自动拆箱。
自动装箱是一种机制,通过该机制可以将int
值直接分配给 Integer
对象:
在后台,将为您创建一个对象,包装该值。
自动拆箱的作用恰恰相反。
这种装箱/拆箱不是免费的,虽然成本很小。但某些情况下,可能是不可接受的,需要尽量避免。
好消息是:JDK 为您提供了IntSupplier
接口。
您可以使用完全相同的代码来实现上面接口:
对代码的唯一修改是需要调用 getAsInt()
而不是 get():
运行的结果是相同的,但这次没有装箱/拆箱:此代码比前一个性能更高。
JDK 为您提供了四个这样的专用 Supplier,以避免应用程序中不必要的装箱/拆箱:IntSupplier
,BooleanSupplier
,LongSupplier
和DoubleSupplier
。
您将看到更多专用版本的函数接口来处理原始类型。他们的抽象方法有一个简单的命名约定:采用主抽象方法的名称(在 Supplier 的情况下为
get(),
并将返回的类型添加到其中。因此,对于 Supplier 接口,我们有:getAsBoolean()
,getAsInt
(),getAsLong()
和getAsDouble()。
用 Consumer
消费对象
实现和使用 Consumer
第二个接口是Consumer
接口。Consumer 与 Supplier 相反:它接受参数但不返回任何东西。
这个接口稍微复杂一些:其中有默认方法,本教程稍后将介绍这些方法。让我们专注于它的抽象方法:
您已经实现过 Consumer:
可以使用 Consumer 更新前面的示例:
使用专用 Consumer
假设您需要打印整数。然后你可以编写以下 Consumer:
然后,您可能会遇到与 Supplier 示例相同的自动装箱问题。在性能方面,这种装箱/拆箱在您的应用程序中是否可以接受?
如果不能,请不要担心,JDK 为您提供了三个专用 Consumer:IntConsumer
,LongConsumer
和DoubleConsumer
。这三个 Consumer 的抽象方法遵循与 Supplier 相同的约定,返回的类型始终是 void
,它们都命名为 accept
。
用 BiConsumer 消费两个元素
JDK 添加了 Consumer<T>接口的另一个变体,接受两个参数,很自然地称为BiConsumer
。
下面是一个 BiConsumer 的例子:
您可以使用此 biconsumer 以不同的方式编写前面的示例:
BiConsumer<T,U>接口有三个专用版本来处理原始类型:ObjIntConsumer<T>
,ObjLongConsumer<T>
和ObjDoubleConsumer<T>
。
将 Consumer 传递给可迭代对象
集合框架的接口中添加了几个重要的方法。其中一个将 Consumer
作为参数,非常有用:Iterable.forEach()
方法。这里有一个简单的例子:
最后一行代码将 Consumer 应用于列表的所有对象。在这里,它将简单地在控制台上一一打印。您将在后面部分中看到编写此 Consumer 的另一种方法。
这个 forEach()
提供了一种访问任何Iterable
类型所有内部元素的方法,传递您需要对每个元素执行的操作。这是一种非常强大的方法,它还使您的代码更具可读性。
用Predicate
测试对象
实现和使用 Predicate
第三个接口是Predicate
。Predicate 用于测试对象。它用于筛选 Stream API 中的流,稍后你将看到这个主题。
它的抽象方法接受一个对象并返回一个布尔值。这个接口又比 Consumer
复杂一点:上面定义了默认方法和静态方法,稍后您将看到。让我们专注于它的抽象方法:
上一部分已经看到了Predicate<String>
的示例:
要测试给定的字符串,您需要做的就是调用Predicate
接口的 test()
方法:
使用专用 Predicate
假设您需要测试整数值。您可以编写以下 Predicate:
Consumer、Supplier 和这个 Predicate 也是如此。此 Predicate 作为参数的是对 Integer
类实例的引用,因此在将此值与 10 进行比较之前,此对象会自动拆箱。它非常方便,但带有开销。
JDK 提供的解决方案与 Supplier 和 Consumer 的解决方案相同:专用 Predicate。与 Predicate
是三个专用接口:IntPredicate、LongPredicate
和 DoublePredicate
。它们的抽象方法都遵循命名约定。都返回boolean
,仅被命名为 test()
并采用与接口对应的参数。
因此,您可以按如下方式编写前面的示例:
您可以看到 lambda 本身的语法是相同的,唯一的区别是i
现在是一个int
类型而不是Integer
。
使用 BiPredicate 测试两个元素
就像您在 Consumer
<T> 中看到的,JDK 还添加了一个 BiPredicate
接口,该接口测试两个元素:
下面是这种 BiPredicate 的示例:
您可以将此 BiPredicate 与以下模式一起使用:
没有专门的BiPredicate<T,U>
处理原始类型。
将 Predicate 传给集合
添加到集合框架的方法之一是接收一个 Predicate:removeIf()
方法。此方法使用此 Predicate 来测试集合的每个元素。如果测试结果为 true
,则此元素将从集合中删除。
您可以在以下示例中看到此模式的实际效果:
运行此代码将产生以下结果:
在这个例子中,有几件事值得指出:
如您所见,调用
removeIf()
会改变这个集合。因此,不应该在不可变集合上调用
removeIf(),
比如List.of()
工厂方法生成的集合。Arrays.asList()
生成一个行为类似于数组的集合。您可以改变其现有元素,但不允许添加或删除。因此,在此列表中调用removeIf()
也不起作用。
用Function
将对象映射到其他对象
实现和使用函数
第四个接口是 Function
接口。函数的抽象方法接受T
类型的对象,并将该对象的转换返回到U
类型。此接口还具有默认和静态方法。
Stream API 中使用此函数将对象映射到其他对象,稍后将介绍该主题。Predicate 可以看作是一种特殊类型的函数,它返回一个 boolean
使用专用函数
这是一个函数的示例,该函数接受字符串并返回该字符串的长度。
在这里,您可以再次发现装箱和拆箱操作的实际效果。首先,length()
方法返回一个int
.由于该函数返回一个Integer
,因此需要装箱。但随后结果被分配给一个int
类型的变量,所以Integer
被拆箱以存储在这个变量中。
如果性能在您的应用程序中不是问题,那么这种装箱和拆箱真的没什么大不了的。如果是,您可能希望避免它。
JDK 为您提供解决方案,具有Function
接口的专用版本。这组接口比我们看到的Supplier
、Consumer
或Predicate
类别的接口更复杂,根据参数类型和返回类型定义了专用函数。
输入参数和输出都可以有四种不同的类型:
参数化类型
T
;一个
int
;一个
long
;一个
double
不止于此,有一个特殊的接口:UnaryOperator
它扩展了Function
。此概念用于接受给定类型并返回相同类型结果的函数。
以下是您可以在java.util.function
包中找到的 16 种特殊类型的函数。
这些接口的所有抽象方法都遵循相同的约定:它们以该函数的返回类型命名。以下是他们的名字:
apply()
用于返回泛型类型T
applyAsInt()
返回原始类型int
applyAsLong()
forlong
applyAsDouble()
fordouble
将 UnaryOperator 传递给列表
您可以使用 UnaryOperator
转换列表的元素。人们可能想知道为什么是 UnaryOperator
而不是基本Function
。答案其实很简单:一旦声明,就不能更改列表的类型。因此,您应用的函数可以更改列表的元素,但无需更改其类型,也就不需要两个。
采用此一元运算符的方法将其传递给 replaceAll()
方法。下面是一个示例:
运行此代码将显示以下内容:
请注意,这次我们使用了使用 Arrays.asList()
模式创建的列表。实际上,您不需要在该列表中添加或删除任何元素:此代码只是逐个修改每个元素,这可以通过此特定列表来实现。
使用 BiFunction 映射两个元素
类似 Consumer 和 Predicate,Function 还有一个接受两个参数的版本:BiFunction,其中T
和U
是参数,R
是返回类型:
您可以使用 Lambda 创建一个 BiFunction:
UnaryOperator
<T> 接口还有一个带有两个参数的同级接口:BinaryOperator<
T>,它扩展了 BiFunction
。
所有可能的 BiFunction 专用版本的子集已添加到 JDK 中:
IntBinaryOperator、LongBinaryOperator 和 DoubleBinaryOperator ;
ToIntBiFunction<T>、ToLongBiFunction<T> 和 ToDoubleBiFunction<T>.
总结四类函数接口
java.util.function
包现在是 Java 的核心,因为您将在集合框架或 Stream API 中使用的所有 Lambda 都实现了该包中的一个接口。
如您所见,此软件包包含许多接口,找到自己的方式可能会很棘手。
首先,您需要记住的是有 4 类接口:
Supplier:不要参数,只返回
Consumer:要参数,不返回
Predicate:一个参数,返回一个 boolean
Function:一个参数,返回一个类型
其次:某些接口的版本采用两个参数而不是一个参数:
BiConsumer
BiPredicate
BiFunction
第三:一些接口有专门的版本,避免装箱和拆箱。太多了,无法一一列举。它们以它们采用的类型命名。例如:IntPredicate
,或者它们返回的类型,如ToLongFunction
。它们可能以两者命名:IntToDoubleFunction
。
最后:有 Function<T、R> 和 BiFunction<T、U、R> 的扩展,用于所有类型都相同的情况:UnaryOperator<T> 和 BinaryOperator<T>,以及原始类型的专用版本。
将 Lambda 编写为方法引用
您看到 Lambda 实际上是方法的实现:函数接口的唯一抽象方法。有时人们称这些 Lambda 为“匿名方法”,因为它就是这样:一个没有名称的方法,您可以在应用程序中移动,存储在字段或变量中,作为参数传递给方法或构造函数,并从方法返回。
有时,您将编写 Lambda,这些表达式只是对某个特定方法的调用。事实上,您在编写以下代码时已经这样做了:
这样写的,这个 lambda 只是对System.out
上定义的println()
方法的引用。
这就是方法引用语法。
您的第一个方法引用
有时,Lambda 只是对现有方法的引用。这种情况下,您可以将其编写为方法引用。然后,前面的代码将变为以下内容:
方法引用有四类:
静态方法引用
绑定方法引用
非绑定方法引用
构造方法引用
上面的printer
Consumer 属于非绑定方法引用。
大多数情况下,IDE 将能够告诉您是否可以将特定的 Lambda 编写为 Lambda。不要犹豫,问它!
静态方法引用
假设您有以下代码:
这个 Lambda 实际上是对静态方法 Math.sqrt()
的引用。可以这样写:
静态方法引用的一般语法为 RefType::staticMethod
。
静态方法引用可以采用多个参数。请考虑以下代码:
您可以使用方法引用重写它:
非绑定方法引用
不接受任何参数的方法
假设您有以下代码:
此函数可以编写为 ToIntFunction
。它只是对类 String
的方法 length()
的引用。因此,您可以将其编写为方法引用:
此语法起初可能会令人困惑,因为它实际上看起来像一个静态调用。但实际上并非如此:length()
方法是 String
类的实例方法。
您可以使用这样的方法引用从普通 Java Bean 调用任何 getter。假设您有一个定义了getName()
的User
类。然后,您可以将以下函数:
改为以下方法引用:
不接受任何参数的方法
这是您已经看到的另一个示例:
这个 lambda 实际上是对 String
类的 indexOf()
方法的引用,因此可以写成以下方法引用:
此语法可能看起来更令人困惑。重建经典方式编写的 lambda 的一个好方法是检查此方法引用的类型。这将为您提供此 lambda 的参数。
非绑定方法引用的一般语法如下:RefType:instanceMethod
,其中RefType
是类型的名称,instanceMethod
是实例方法。
绑定方法引用
您看到的方法引用的第一个示例如下:
此方法引用称为绑定方法引用。因为调用该方法的对象是在方法引用本身中定义的。因此,此调用绑定到方法引用中给出的对象。
如果考虑非绑定语法:Person::getName
,则可以看到调用该方法的对象不是此语法的一部分:它是作为 Lambda 的参数提供的。请考虑以下代码:
您可以看到该函数已应用于传递给该函数的 的特定实例User
。然后,此函数在该实例上运行。
在前面的 Consumer 示例中不是这种情况:println()
方法在 System.out
对象上调用,该对象是方法引用的一部分。
绑定方法引用的一般语法如下:expr:instanceMethod
,其中 expr
是返回对象的表达式,instanceMethod
是实例方法。
构造方法引用
您需要知道的最后一种是构造方法引用。假设您有以下Supplier<List<String>>
:
您可以以与其余方法相同的方式看到这一点:这归结为 ArrayList
的空构造函数的引用。好吧,方法引用可以做到这一点。但由于构造函数不是方法,因此这是另一类方法引用。语法如下:
您可以注意到此处不需要钻石运算符。如果要放置它,则还需要提供类型:
您需要注意这样一个事实,即如果您不知道方法引用的类型,那么您就无法确切地知道它的作用。下面是一个示例:
这两个变量都可以用相同的语法ArrayList::new
编写,但它们不引用相同的构造函数。你只需要小心这一点。
总结方法引用
下面是四种类型的方法引用。
组合 Lambda
您可能已经注意到 java.util.function
包的函数接口中存在默认方法。添加这些方法是为了允许 Lambda 的组合和链接。
为什么要做这样的事情?只是为了帮助您编写更简单、更具可读性的代码。
使用默认方法链接 Predicate
假设您需要处理字符串列表,以仅保留非 null、非空且少于 5 个字符的字符串。这个问题的陈述方式如下。您对给定字符串进行了三个测试:
非空;
非空;
少于 5 个字符。
这些测试中的每一个都可以用一个非常简单的单行 Predicate 轻松编写。也可以将这三个测试组合成一个 Predicate。它将看起来像下面的代码:
但是 JDK 允许你以这种方式编写这段代码:
隐藏技术复杂性并公开代码的意图是组合 Lambda 的意义所在。
如何在 API 级别实现此代码?在不深入细节的情况下,您可以看到以下内容:
由于函数接口上只允许使用一个抽象方法,因此此and()
方法必须是默认方法。因此,从 API 设计的角度来看,您拥有创建此方法所需的所有元素。好消息是:Predicate
接口有一个 and()
默认方法,所以你不必自己做。
顺便说一下,还有一个 or(
) 将另一个 Predicate 作为参数,还有一个不带任何东西的 negate()
。
使用这些,您可以按这种方式编写前面的示例:
当然此示例可能有点过,也可以利用方法引用和默认方法显著提高代码的表达能力。
使用工厂方法创建 Predicate
通过使用函数接口中定义的工厂方法,可以进一步提高表现力。在Predicate
接口上有两个。
在下面的示例中,Predicate 测试字符串。当测试的字符串等于“Duke”时,测试为真。此工厂方法可以为任何类型的对象创建 Predicate。isEqualToDuke
第二个工厂方法否定参数给出的 Predicate。
使用默认方法链接 Consumer
Consumer
接口也具有链接 Consumer 的方法。您可以使用以下模式链接 Consumer:
在此示例中,printAndLog
是一个 Consumer,它将首先将消息传递给log
Consumer,然后将其传递给print
Consumer。
使用默认方法链接和组合 Fuction
链接和组合之间的区别有点微妙。这两个操作的结果实际上是相同的。不同的是你写它的方式。
假设您有两个函数 .您可以通过调用 f1.andThen(f2)
来链接它们。将结果函数应用于对象将首先将此对象传递给f1
,并将结果传递给 f2
。
该接口具有第二个默认方法:f2.compose(f1)。
以这种方式编写,生成的函数将首先通过将对象传递给f1
函数来处理对象,然后将结果传递给f2
.
你需要意识到的是,要获得相同的结果函数,你需要调用 andThen(
) onf1
或 compose()
on f2
您可以链接或组合不同类型的函数。但是有明显的限制:f1
生成的结果的类型应与``f2` 使用的类型兼容。
创建恒等函数
Function
接口还有一个工厂方法来创建标识函数,称为 identity()。
因此,可以使用以下简单模式创建恒等函数:
此模式适用于任何有效类型。
编写和组合 Comparator
使用 Lambda 实现 Comparator
由于函数接口的定义,JDK 2 中引入的老式 Comparator
接口也变得函数化。因此,可以使用 Lambda 实现 Comparator。
以下是Comparator
接口的唯一抽象方法:
Comparator 的规则如下:
如果
o1 < o2
然后compare(o1,o2)
应该返回一个负数如果
o1 > o2
然后compare(o1,o2)
应该返回一个正数在所有情况下, 和
compare(o2, o1)
应该有相反的符号。
如果 o1.equals(o2)
为true
,并不严格要求compare(o1, o2)
返回 0。
如何创建一个整数 Comparator,以实现自然顺序?好吧,您可以使用本教程开头看到的方法:
您可能已经注意到,这个 Lambda 也可以用一个非常好的绑定方法引用来编写:
避免使用
(i1 - i2)
实现此 Comparator,极端情况下不一定产生正确结果。
此模式可以扩展到您需要比较的任何内容,只要您遵循 Comparator 的规则即可。
Comparator
API 更进一步,提供了一个非常有用的 API,以更具可读性的方式创建 Comparator。
使用工厂方法创建 Comparator
假设您需要创建一个 Comparator 以非自然的方式比较字符串:最短的字符串小于最长的字符串。
这样的 Comparator 可以这样写:
上一部分学习了可以链接和组合 Lambda。此代码是此类组合的另一个示例。事实上,你可以用这种方式重写它:
现在您可以看到,该Comparator
的代码仅取决于名为 的toLength
的函数。因此,可以创建一个工厂方法,该方法将此函数作为参数并返回相应的 Comparator
。
函数toLength
的返回类型仍然存在约束:它必须是可比较的。在这里它运行良好,因为您始终可以将整数与其自然顺序进行比较,但您需要牢记这一点。
JDK 中确实存在这样的工厂方法:它已直接添加到Comparator
接口中。因此,您可以通过这种方式编写前面的代码:
这个 comparing()
方法是Comparator
接口的静态方法。它接受一个Function
作为参数,该参数应返回一个类型,该类型是Comparable
扩展。
假设你有一个带有 gettergetName()
的User
类,你需要根据用户的名称对用户列表进行排序。您需要编写的代码如下:
链接 Comparator
公司目前对您交付的Comparable<User>
非常满意。但是版本 V2 中有一个新要求:User
类现在有个firstName
和 lastName
,您需要生成一个新的Comparator
来处理此更改。
编写每个 Comparator 遵循与前一个 Comparator 相同的模式:
现在,您需要的是一种链接它们的方法,就像链接Predicate
或Consumer
的实例一样。Comparator API 为您提供了一个解决方案来执行此操作:
thenComparing()
方法是 Comparator
接口的默认方法,它将另一个 Comparator 作为参数并返回一个新的 Comparator。当应用于两个用户时,Comparator 首先使用byFirstName
Comparator 比较这些用户。如果结果为 0,则它将使用byLastName
Comparator 比较它们。简而言之:它按预期工作。
Comparator API 更进一步:由于byLastName
仅依赖于User::getLastName
函数,因此 thenComparing()
方法的重载已添加到 API 中,该方法将此函数作为参数。因此,模式变为以下内容:
使用 Lambda、方法引用、链接和组合,创建 Comparator 从未如此简单!
专用 Comparator
Comparator 也可能发生装箱和拆箱或原始类型,从而导致与 java.util.function
包的函数接口相同的性能影响。为了解决这个问题,添加了 comparing(
) 工厂方法和 thenComparing()
默认方法的专用版本。
您还可以使用以下内容创建 Comparator
的实例:
如果需要使用原始类型的属性比较对象,并且需要避免此原始类型的装箱/拆箱,则可以使用这些方法。
还有相应的方法可以链接Comparator
:
思路是相同的:使用这些方法,您可以将比较与基于返回原始类型的专用函数构建的 Comparator 链接起来,而不会因装箱/拆箱而对性能造成任何影响。
使用自然顺序比较可比较对象
本教程中有几个工厂方法值得一提,它们将帮助您创建简单的 Comparator。
JDK 中的许多类,可能还有应用程序中的许多类都在实现 JDK 的一个特殊接口:Comparable
接口。此接口有一个方法:compareTo(T other),
返回一个int
.此方法用于在 Comparator
接口的规则中,此T
实例与 other
进行比较。
JDK 的许多类已经实现此接口。原始类型的所有包装类(Integer
、Long
等)、String
类以及日期和时间 API 中的日期和时间类都是如此。
您可以使用这些类的自然顺序(即使用此 compareTo(
) 方法)比较这些类的实例。Comparator API 为您提供了一个 Comparator.naturalOrder()
工厂类。它构建的 Comparator 正是这样做的:它使用其 compareTo()
方法比较任何Comparable
对象。
当您需要链式 Comparator 时,拥有这样的工厂方法非常有用。下面是一个示例,您希望将字符串与其长度进行比较,然后比较其自然顺序(此示例使用 naturalOrder()
方法的静态导入以进一步提高可读性):
运行此代码将产生以下结果:
反转 Comparator
Comparator 的一个主要用途当然是对象列表的排序。JDK 8 在 List
接口上特别增加了一个方法:List.sort()。
此方法将 Comparator 作为参数。
如果你需要以相反的顺序对前面的列表进行排序,你可以从 Comparator
接口使用 reversed()
方法。
运行此代码将产生以下结果:
处理 null 值
比较 null 对象可能会导致在运行代码时出现令人讨厌的 NullPointerException
,这是您希望避免的。
假设您需要编写一个 null 安全的整数 Comparator 来对整数列表进行排序。您决定遵循的规则是将所有 null 值推送到列表末尾,这意味着 null 值大于任何其他非 null 值。然后,您希望按自然顺序对非空值进行排序。
下面是为实现此行为而编写的代码类型:
您可以将此代码与您在本部分开头编写的第一个 Comparator 进行比较,并发现可读性受到了很大的影响。
幸运的是,有一种更简单的方法可以编写此 Comparator,使用Comparator
接口的另一种工厂方法。
nullsLast()
及其同级方法 nullsFirst()
是 Comparator
接口的工厂方法。两者都将 Comparator 作为参数并做到这一点:为您处理 null 值,将它们推到末尾,或者将它们放在排序列表中的第一个。
下面是一个示例:
运行此代码将产生以下结果:
使用 Stream API 处理内存中的数据
Stream API 简介
Stream API 可能是 Java SE 8 中仅次于 lambda 表达式的第二重要功能。简而言之,Stream API 是关于向 JDK 提供众所周知的 map-filter-reduce 算法的实现。
集合框架是关于在 JVM 的内存中存储和组织数据。可以将 Stream API 视为集合框架的配套框架,以非常有效的方式处理此数据。实际上,您可以在集合上打开流来处理它包含的数据。
不止于此:Stream API 可以为您做更多事情。JDK 为您提供了几种模式,用于在其他源(包括 I/O 源)上创建流。此外,您可以毫不费力地创建自己的数据源以完全满足您的需求。
当你掌握了 Stream API 时,你能够编写非常富有表现力的代码。这里有一个小片段,你可以使用正确的静态导入进行编译:
此代码打印出以下内容。
它通过
groupingBy(String::length)
按长度对字符串进行分组它使用
counting()
计算每个长度的字符串数量然后,它创建一个
Map
来存储结果
运行此代码将生成以下结果。
即使你不熟悉 Stream API,阅读使用它的代码也能让你一目了然地了解它在做什么。
map-filter-reduce 算法简介
在深入了解 Stream API 本身之前,让我们看看你正在执行的 map-filter-reduce 算法的元素。
该算法是一种非常经典的数据处理算法。让我们举一个例子。假设您有一组具有三个属性的Sale
对象:日期、产品和金额。为了简单起见,我们假设金额只是一个整数。这是你的Sale
类。
假设您需要计算 3 月份销售额的总金额。您可能会编写以下代码。
您可以在这种简单的数据处理算法中看到三个步骤。
第一步包括仅考虑 3 月份发生的销售。您正在根据给定的条件过滤元素。这正是 filter 步骤。
第二步包括从对象中提取属性。你对整个sale
对象不感兴趣;你需要的是它的amount
属性。您正在将sale
对象映射为数量,即一个int
值。这是 map 步骤;它包括将您正在处理的对象转换为其他对象或值。
最后一步包括将所有这些金额相加为一个金额。如果您熟悉 SQL 语言,您可以看到最后一步看起来像一个聚合。事实上,它也是这样做的。此金额是将单个金额 reduce 为一个金额。
顺便说一下,SQL 语言在以可读的方式表达这种处理方面做得很好。你需要的 SQL 代码真的非常容易阅读:
指定结果而不是对算法进行编程
您可以看到,在 SQL 中,您正在编写的是所需结果的描述:三月份所有销售金额的总和。数据库服务器有责任弄清楚如何有效地计算它。
计算此数的 Java 代码是对过程的分步说明。它以命令式的方式精确描述。几乎没有空间给 Java 运行时优化此计算。
Stream API 的两个目标是使您能够创建更具可读性和表现力的代码,并为 Java 运行时提供一些回旋余地来优化您的计算。
将对象 map 到其他对象或值
map-filter-reduce 算法的第一步是 map 步骤。map 包括转换正在处理的对象或值。map 是一对一的转换:如果 map 包含 10 个对象的列表,则将获得包含 10 个转换对象的列表。
在 Stream API 中,map 步骤又添加一个约束。假设您正在处理有序对象的集合。它可以是一个列表,也可以是有序对象的某个其他源。map 该列表时,获得的第一个对象应该是源中第一个对象的 map。换句话说:map 步骤遵循对象的顺序;它不会打乱它们。
map 会更改对象的类型;它不会改变他们的号牌。
map 由Function
函数接口建模。实际上,函数可以接受任何类型的对象并返回其他类型的对象。此外,专用函数可以将对象 map 到原始类型,反之亦然。
filter 掉对象
另一方面,filter 不会改变您正在处理的对象。它只是选择其中一些,并删除其他。
filter 会更改对象的数量;它不会更改它们的类型。
filter 由Predicate
功能接口建模。实际上,Predicate 可以接受任何类型的对象或原始类型,并返回布尔值。
reduce 对象以产生结果
reduce 步骤比看起来更棘手。现在,我们将接受这个定义,即它与 SQL 聚合相同。想想计数、求和、最小值、最大值、平均值。顺便说一下 Stream API 支持所有这些聚合。
只是为了给你一个提示,在这条道路上等待你的是什么:reduce 步骤允许你用你的数据构建复杂的结构,包括列表、集合、任何类型的 map,甚至是你可以自己构建的结构。看看这个页面上的第一个例子:你可以看到对 collect()
方法的调用,该方法采用由 groupingBy()
工厂方法构建的对象。此对象是 collector。reduce 可能包括使用 collector 收集数据。本教程稍后将详细介绍 collector。
优化 map-filter-reduce 算法
让我们再举一个例子。假设您有一个城市集合。每个城市都由一个City
类建模,该类具有两个属性:名称和人口,即居住在其中的人数。您需要计算居住在居民超过 100k 的城市中的总人口。
如果不使用 Stream API,您可能会编写以下代码。
您可以在城市列表中识别另一个 map-filter-reduce 处理。
现在,让我们做一个小的头脑风暴:假设 Stream API 不存在,并且 Collection
接口上存在map()
和filter()
方法,以及 sum()
方法。
使用这些(虚构的)方法,以前的代码可能会变成以下内容。
从可读性和表现力的角度来看,这段代码非常容易理解。所以你可能想知道:为什么这些 map 和 filter 方法不添加到Collection
接口中?
让我们更深入地挖掘:这些map()
和filter()
方法的返回类型是什么?好吧,由于我们处于集合框架中,因此返回集合似乎是很自然的。因此,您可以通过这种方式编写此代码。
即使链接调用提高了可读性,此代码仍应正确。
现在让我们分析这段代码。
第一步是 map 步骤。您看到,如果您必须处理 1,000 个城市,则此 map 步骤将生成 1,000 个整数并将它们放入集合中。
第二步是 filter 步骤。它遍历所有元素并按照给定的标准删除其中一些元素。这是另外 1,000 个要测试的元素和另一个要创建的集合,可能更小。
由于此代码返回集合,因此它会 map 所有城市,然后 filter 生成的整数集合。这与你最初编写的 for 循环非常不同。存储此整数的中继集合可能会导致大量开销,尤其是在要处理大量城市的情况下。for 循环没有这种开销:它直接汇总结果中的整数,而不将它们存储在中继结构中。
这种开销很糟糕,在某些情况下可能会更糟。假设您需要知道集合中是否有超过 100k 居民的城市。也许集合的第一个城市就是这样一个城市。这种情况下,您将几乎可以毫不费力地产生结果。先建立来自城市的所有人口的集合,然后 filter 它并检查结果是否为空将是荒谬的。
出于明显的性能原因,创建在Collection
接口上返回Collection
的map()
方法并不正确。您最终会创建不必要的中继结构,在内存和 CPU 上都有很高的开销。
这就是尚未将map()
和filter()
方法添加到Collection
接口的原因。相反,它们是在Stream
接口上创建的。
正确的模式如下。
Stream
接口避免创建中继结构来存储 map 或 filter 的对象。在这里,map()
和 filter()
方法仍在返回新的流。因此,为了使此代码正常工作且高效,不应在这些流中存储任何数据。在此代码中创建的流,streamOfCities
,populations
,filteredPopulations
必须全部为空对象。
它导致了流的一个非常重要的属性:
流是一种对象,它不存储任何数据。
Stream API 的设计方式是,只要您不在流模式中创建任何非流对象,就不会对数据进行计算。在前面的示例中,您的元素总和计算由流来处理。
sum 操作会触发计算:cities
列表的所有对象通过流的所有操作逐个拉取。首先对它们进行 map,然后进行 filter,如果它们通过了 filter 步骤,则进行汇总。
流处理数据的顺序与编写等效的 for 循环的顺序相同。这样就没有内存开销。此外,某些情况下,您无需遍历集合的所有元素即可产生结果。
使用流就是创建操作管道。在某个时候,您的数据将通过此管道传输,并将被转换、filter,然后参与结果的生成。
管道由流上的一系列方法调用组成。每个调用都会生成另一个流。然后在某个时候,最后一次调用会产生结果。返回另一个流的操作称为中继操作。同时,返回其他内容(包括 void)的操作称为末端操作。
创建具有中继操作的管道
中继操作是返回另一个流的操作。调用此类操作会在现有操作管道上再添加一个操作,而不处理任何数据。它由返回流的方法建模。
使用末端操作计算结果
末端操作是不返回流的操作。调用此类操作会触发流的源元素的使用。然后,这些元素由中继管道处理,一次一个元素。
末端操作由返回除流(包括 void)以外的任何内容的方法建模。
不能在流上调用多个中继方法或末端方法。如果这样做,您将收到一个 IllegalStateException
,其中包含以下消息:“流已操作或关闭”。
使用专门的数字流避免装箱
Stream API 提供了四个接口。
第一个是 Stream
,可用于定义对任何类型的对象的操作管道。
然后有三个专门的接口来处理数字流:IntStream
,LongStream
和DoubleStream
。这三个流对数字使用原始类型而不是包装器类型,以避免装箱和取消装箱。它们具有与 Stream
中定义的方法几乎相同的方法,但有一些例外。由于它们正在处理数字,因此它们具有一些 Stream
中不存在的末端操作:
sum()
:计算总和average()
:计算数字的平均值summaryStatistics()
:此调用生成一个特殊对象,该对象携带多个统计信息,所有这些统计信息都是在一次传递数据时计算的。这些统计信息是该流处理的元素数、最小值、最大值、总和和平均值。
遵循良好做法
如您所见,您只能在流上调用一个方法,即使此方法是中继方法。因此,将流存储在字段或局部变量中是无用的,有时甚至是危险的。编写将流作为参数的方法也可能很危险,因为您无法确定收到的流尚未作。应当场创建和使用流。
流是连接到源的对象。它从此源中提取它处理的元素。此源不应由流本身修改。这样做将导致未指定的结果。在某些情况下,此源是不可变的或只读的,因此您将无法执行此操作,但在某些情况下可以。
Stream
接口中有很多可用的方法,您将在本教程中看到其中的大多数方法。编写修改流本身之外的某些变量或字段的操作是一个坏主意,总是可以避免的。流不应有任何副作用。
版权声明: 本文为 InfoQ 作者【烧霞】的原创文章。
原文链接:【http://xie.infoq.cn/article/b7cd897fc16d7450a399111e8】。文章转载请联系作者。
评论