Peter-Java 8 中的 Lambda 表达式,java 领域的相关技术领域
(String firstStr, String secondStr) -> Integer.compare(firstStr.length(), secondStr.length())
其中int
预期作为结果类型。
请注意,在 lambda 中,您不能返回不在分支中的值。例如,(int x) -> { if (x <= 1) return -1; }
无效。
功能接口
像我们文章开头讨论的那样,Java 可以借用接口来封装代码块,比如Runnable
或Comparator
。这对 Lambdas 同样适用。
在 Java 中有所谓的功能接口 - 一个只有单个抽象方法实现的接口对象。只要需要功能接口的对象,就可以使用 lambda 表达式。
让我们考虑一下Arrays.sort
方法的例子。在这里我们可以看到用 lambda 替换功能接口。我们只是将 lambda 作为第二个参数传递给方法,该参数需要一个Comparator
对象,该接口只有一个方法。
Arrays.sort(strs,(firstStr, secondStr) -> Integer.compare(firstStr.length(), secondStr.length()));
实际上该Arrays.sort
方法接收一些类实现的对象Comparator<String>
。compare
调用该方法时,它会强制执行 lambda 表达式主体。这些对象和类的结构完全取决于实现。它不仅可以使用传统的内部类。也许最好将 lambda 表示为一个函数,而不是作为一个对象,并发现我们可以将它传递给一个功能接口。
这种对接口的转换是 lambda 表达式令人兴奋的原因。语法简短。这是另一个例子:
button.setOnAction(event ->System.out.println("The button has been clicked!"));
是不是很易读?
事实上,你在 Java 中使用 lambda 表达式唯一能做的就是转换。
Java API 中的 java.util.function 包中有几个通用的功能接口。其中之一,BiFunction<T, U, R>
代表与参数类型的函数T
和U
和返回类型R
。您可以将字符串比较 lambda 传给这样的变量:
BiFunction<String, String, Integer> compareFunc= (firstStr, secondStr) -> Integer.compare(firstStr.length(), secondStr.length());
您可以在不同的 Java 8 API 中看到 java.util.function 中的这些接口。在 Java 8 中,任何功能接口都可以用@FunctionalInterface
。这个注释是可选的,但却是一个很好的风格。首先,它强制编译器检查带注释的实体是否是具有单个抽象方法的接口。第二是告诉 javadoc 页面包含一个声明,这个接口是一个功能接口。根据定义,任何只有一个抽象方法的接口都是一个功能接口。但是,使用此关键字可以更加清晰。
顺便说一句,在将 lambda 转换为功能接口时,可能会出现已检查的异常。如果 lambda 表达式的主体抛出已检查的异常,则应在目标接口的抽象方法中声明此异常。例如,以下代码将导致错误:
Runnable sleepingRunner = () -> { System.out.println("…"); Thread.sleep(1000); };// Error: Thread.sleep can throw a checkedInterruptedException
此语句不正确,因为该run
方法不能抛出任何异常。有两种方法应对此问题。
一种方法是捕获 lambda 体中的异常。第二个是将此 lambda 分配给具有单个抽象方法的接口,该方法可以抛出异常。例如,call
接口的方法Callable
可以生成任何异常。因此,如果return null
在 lambda 主体的末尾添加,则可以将 lambda 分配给Callable<Void>
实例。
方法参考
有时你已经有了一个适合你需求的方法,你想将它传递给其他一些方案。例如,假设您希望在单击按钮时只打印事件对象。你可以这样写
button.setOnAction(event -> System.out.println(event));
将println
方法传递给setOnAction
方法更直观。以下示例显示了它:
button.setOnAction(System.out::println);
System.out::println
是一个方法引用,类似于 lambda 表达式。我们可以在这里用方法引用替换 lambda。
想象一下,你想要忽略一个字母大小写的排序字符串。您可以编写如下代码:
Arrays.sort(strs, String::compareToIgnoreCase)
运算符::
将方法名称与对象或类的名称分开。主要有三种情况:
对象的实例方法;
一个类的静态方法;
类的实例方法;
在前两种情况下,方法引用等效于带有方法参数的 lambda 表达式。如上所示,System.out::println
相当于x -> System.out.println(x)
。同样,Math::pow
相当于(x, y) -> Math.pow(x, y)
。在第三种情况下,第一个参数成为方法的目标。例如,String::compareToIgnoreCase
与...相同(x, y) -> x.compareToIgnoreCase(y)
。
众所周知,类可以有多个具有相同名称的重载方法。在这种情况下,编译器将尝试从上下文中找到要选择的内容。例如,该Math.max
方法有两个版本,一个用于 int,一个用于 double 值。调用哪一个取决于Math::max
转换的功能接口的方法签名。方法引用不是单独存在的。与幕后的 lambdas 类似,它们总是变成功能接口的实例。
您可能想到是否可以使用this
在方法参考中捕获参数。是的你可以。例如,this::equals
相当于x -> this.equals(x)
。它也可以使用super
。当我们使用super::instanceMethod
它成为目标并调用给定方法的基类版本。这是一个非常真实的例子:
class Speaker {public void speak() {System.out.println("Hello, world!");}}class ConcurrentSpeaker extends Speaker {public void speak() {Thread t = new Thread(super::speak);t.start();}}
当线程启动时,run
调用方法并super::speak
执行,调用speak
其基类的方法。请注意,在内部类中,您可以this
将封闭类的引用,捕获为EnclosingClass.this::method
或EnclosingClass.super::method
。
构造函数参考
构造函数引用与方法引用相同,只是方法名称为new
。例如,Button::new
是类的构造函数引用Button
。将调用哪个构造函数取决于上下文。想象一下,您想要将字符串列表转换为按钮数组。在这种情况下,您应该在每个字符串上调用构造函数。它可能是这样的:
List<String> strs = ...;Stream<Button> stream = strs.stream().map(Button::new);List<Button> buttons = stream.collect(Collectors.toList());
有关stream
的更多信息,您可以查看文档。在这种情况下,最重要的是该方法为每个列表元素调用构造函数。有多个构造函数,但编译器选择带有参数的构造函数,因为从上下文中可以明显看出应该调用带有字符串的构造函数。map``collect``map``Button(String)``Button``String
还可以为数组类型创建构造函数引用。例如,int
数组的构造函数引用是int[]::new
。它需要一个参数:数组的长度。它等同于 lambda 表达式x -> new int[x]
。
数组的构造函数引用对于超越 Java 的限制很有用。创建泛型类型的数组是不可能的T
。表达式new T[n]
不正确,因为它将替换为new Object[n]
。对于图书馆作者来说这是一个问题。想象一下,我们想拥有一系列按钮。有一种方法toArray
在类Stream
返回的数组Object
:
Object[] buttons = stream.toArray();
但那不是你想要的。用户不想要Objects
,只有按钮。该库使用构造函数引用解决了这个问题。你应该传递Button[]::new
给方法toArray
:
Button[] buttons = stream.toArray(Button[]::new);
该toArray
方法调用此构造函数以获取所需类型的数组,然后在填充后返回此数组。
可变范围
通常,您希望从 lambda 表达式中的封闭范围访问变量。考虑以下代码:
public static void repeatText(String text, int count) {Runnable r = () -> {for (int i = 0; i < count; i++) {System.out.println(text);Thread.yield();}};new Thread(r).start();}
看下面这个调用:
repeatText("Hi!", 2000); // Prints Hi 2000 times in a separate thread
注意变量count
和text
没有在 lambda 表达式中定义; 这些是封闭方法的参数。
如果仔细观察这段代码,你会发现幕后有某种魔力。该repeatText
方法可以在 lambda 表达式的代码运行之前返回,而那时参数count
和text
变量将消失,但它们仍然可用于 lambda。秘密是什么?
要了解发生了什么,我们需要提高对 lambda 表达式的理解。lambda 表达式由三个部分组成:
代码块
参数
自由变量不是参数,也没有在 lambda 中定义
在我们的案例中有两个自由变量,text
和count
。表示 lambda 的数据结构必须存储它们的值,“嗨!” 他们说这些值是由 lambda 表达式捕获的。(如何完成取决于实现。例如,实现可以使用一个方法将 lambda 表达式转换为对象,并将自由变量的值复制到对象的实例变量中。)
有一个特殊的术语“封闭”; 它是一个代码块和自由变量的值。Lambda 用一种方便的语法表示 Java 中的闭包。顺便说一句,内部类总是封闭的。
因此,lambda 表达式可以捕获封闭范围中变量的值,只是有一些限制。你无法更改这些捕获变量的值。以下代码不正确:
public static void repeatText(String text, int count) {Runnable r = () -> {while (count > 0) {count--; // Error: Can't modify captured variableSystem.out.println(text);Thread.yield();}};new Thread(r).start();}
这种限制是合理的,因为 lambda 表达式中的变量变量不是线程安全的。想象一下,我们有一系列并发任务,每个任务都更新一个共享计数器。
int matchCount = 0;for (Path p : files)new Thread(() -> { if (p has some property) matchCount++; }).start();// Illegal to change matchCount
如果这段代码是正确的,那将是非常非常糟糕的。increment 运算符++
不是原子的,如果多个线程同时执行此代码,则无法控制结果。
内部类也可以从外部类中捕获值。在 Java 8 之前,内部类只能访问final
局部变量。此规则已扩展为与 lambda 表达式匹配。现在,内部类可以处理其值不会发生变化的任何变量(实际上是final
变量)。
不要指望编译器捕获所有并发访问错误。您应该知道,此修改规则仅适用于局部变量。如果我们使用外部类的实例或静态变量,则不会出现错误,结果是未定义的。
您也可以修改共享对象,即使它不健全。例如,
List<Path> matchedObjs = new ArrayList<>();for (Path p : files)new Thread(() -> { if (p has some property) matchedObjs.add(p); }).start();// Legal to change matchedObjs, but unsafe
如果你仔细观察,你会发现变量matchedObjs
实际上是 final 的。(有效的 final 变量是一个在初始化后再无变化的变量。)在此代码中,matchedObjs
始终引用同一个对象。但是,该变量matchedObjs
已被修改,并且它不是线程安全的。在多线程环境中运行此代码的结果是不可预测的。
这种多线程任务有安全的机制。例如,您可以使用线程安全计数器和集合,流来收集值。
对于内部类,有一种解决方法允许 lambda 表达式更新封闭的本地范围中的计数器。想象一下,你使用长度为 1 的数组,如下所示:
int[] counts = new int[1];button.setOnAction(event -> counts[0]++);
很明显,这段代码不是线程安全的。对于按钮回调,这没关系,但一般来说,在使用这个技巧之前你应该三思而后行。
lambda 的主体与嵌套块具有相同的范围。名称冲突和阴影的规则是相同的。您不能在 lambda 中声明与封闭范围中的变量同名的参数或局部变量。
Path first = Paths.get("/usr/local");Comparator<String> comp =(first, second) -> Integer.compare(first.length(), second.length());// Error: Variable first already defined
在方法中不能有两个具有相同名称的局部变量。因此,您也不能在 lambda 表达式中引入这些变量。同样的规则适用于 lambda。在this
lambda 中使用关键字时,请参考this
创建 lambda 的方法。我们考虑一下这段代码
public class Application() {public void doSomething() {Runnable r = () -> { ...; System.out.println(this.toString()); ... };...}}
在此示例中<code>this.toString(
)调用对象的toString
方法Application
,而不是Runnable
实例。this
在 lambda 表达式中使用没有什么特别之处。lambda 表达式的范围嵌套在doSomething
方法中,并且在该方法中的任何位置都具有相同的含义。
默认方法
最后,让我们谈谈与 lambdas 没有直接关系的新功能,它也非常有趣 - 默认方法。
在许多编程语言中,函数表达式与其集合库集成在一起。这通常会导致代码比使用循环的等效代码更简单,更容易理解。我们考虑一下代码:
for (int i = 0; i < strList.size(); i++)System.out.println(strList.get(i));
我们可以改进这段代码。该库可以提供一种方便的方法forEach
,将函数应用于每个元素。所以你可以简化代码
strList.forEach(System.out::println);
如果库是从头开始设计的,那么一切都还可以,但是如果它是很久以前在 Java 中创建的呢?如果Collection
接口添加了一个新方法,例如forEach
,那么定义自己实现此接口的类的每个应用程序都将无法工作,直到它实现该方法。它在 Java 中并不好。
Java 中的这个问题通过允许具有特定实现的接口方法(命名为默认方法)来解决。您可以将此类方法安全地添加到现有接口。现在我们将更密切地研究默认方法。在 Java 8 中,使用您将在下面看到的技巧将该forEach
方法添加到Iterable
基本接口Collection
中。
考虑这个界面:
interface Person {long getId();default String getFirstName() { return "Jack"; }}
接口中有两种方法:getId
抽象方法,getFirstName
默认方法。当然,实现此接口的具体类必须提供实现getId
,但是它可以选择使用getFirstName
或覆盖它的默认实现。
这种技术使用提供接口的经典模式和实现其大部分或全部方法的抽象类来停止,例如Collection/AbstractCollection
或WindowListener/WindowAdapter
。现在,您可以在界面中实现所需的方法。
如果具有相同签名的方法在一个接口中定义为默认方法,然后再在基类或另一个接口中定义,会发生什么?其他语言(如 C ++)具有解决此类歧义的复杂规则。幸运的是,Java 中的规则要简单得多。我们来看看它们:
基础类获胜。如果基类包含具体方法,则会忽略具有相同签名的默认方法。
接口发生冲突。如果基接口具有默认方法,并且另一个接口包含具有相同签名的方法(默认与否),则必须通过覆盖该方法手动解决冲突。
让我们仔细看看第二条规则。想象一下使用getFirstName
方法的另一个接口:
interface Naming {default String getFirstName() { return getClass().getName() + "_" + hash
Code(); }}
如果您尝试创建一个实现它们的类,会发生什么?
class Student implements Person, Naming {...}
该类继承两种不同的方法getFirstName
通过接口提供的Person
和Naming
。Java 编译器报告错误而不是选择其中一个错误,而是由程序员决定是否解决了歧义。只需getFirstName
在类中实现一个方法Student
。然后在该方法中,您可以选择两种冲突方法中的一种,如下所示:
class Student implements Person, Naming {public String getFirstName() { returnPerson.super.getFirstName (); }...}
现在让我们考虑一下Naming
接口不提供默认实现的情况getFirstName
:
interface Naming {String getFirstName();}
Student
该类是否会从Person
接口继承默认方法?Java 设计者决定采用统一性,尽管这种方式可能是合理的。无论接口究竟如何冲突。如果至少一个接口包含实现,则编译器报告冲突,并且程序员必须手动解决该问题。
如果没有任何接口提供该方法的默认实现,则没有问题。最后一个类可以实现该方法,也可以不实现该方法。在第二种情况下,类本身仍然是抽象的。
我刚刚描述了两个接口之间的名称冲突。现在让我们考虑一个继承另一个类并实现接口的类,从两者继承相同的方法。例如,假设它Person
是一个类,Naming
是一个接口,类Student
定义为:
class Student extends Person implements Naming { ... }
评论