前言
Lambda 表达式是 Java 8 发布的新特性,本章通过介绍“Lambda 表达式的语法”,“函数式接口”,“方法引用”,“构造器引用”,“变量的作用域”等概念来帮助我们更好的理解和使用 Lambda 表达式。
Lambda 表达式的使用比较灵活,在一定意义上脱离了我们对 Java 对象的理解,所以一定要吃透文中给出的相关概念,否则我们将很难看懂使用 Lambda 表达式的代码,因为 Lambda 表达式其实是降低了代码的易读性。
版本约定
- JDK Version:11.0.12
- Java SE API Documentation:https://docs.oracle.com/en/java/javase/11/docs/api/index.html
正文
在介绍之前,先引入一段《Java 核心技术 卷1 基础知识 第10版》中的描述:
到目前为止,在 Java 中传递一个代码段并不容易,不能直接传递代码段。Java 是一种面向对象语言,所以必须构造一个对象,这个对象的类需要有一个方法能包含所需的代码。
在其他语言中,可以直接处理代码块。Java 设计者很长时间以来一直拒绝增加这个特性。毕竟,Java 的强大之处就在于其简单性和一致性。如果只要一个特性能够让代码稍简洁一些,就把这个特性增加到语言中,这个语言很快就会变得一团糟,无法管理。不过,在另外那些语言中,并不只是创建线程或注册按钮点击事件处理器更容易;它们的大部分 API 都更简单、更一致而且更强大。在 Java 中,也可以编写类似的 API 利用类对象实现特定的功能,不过这种 API 使用可能很不方便。
就现在来说,问题已经不是是否增强 Java 来支持函数式编程,而是要如何做到这一点。设计者们做了多年的尝试,终于找到一种适合 Java 的设计。下一节中,你会了解 Java SE 8 中如何处理代码块。
Lambda 表达式的语法
Lambda 表达式就是一个代码块,以及必须传入代码的变量规范。Lambda 表达式的形式:参数,箭头(->)以及一个表达式。
如果代码要完成的计算无法在一个表达式中,就可以像写方法一样,把这些代码放在 {} 中,如果需要返回值,则会在 {} 中包含一个 return 语句。
例如,我们需要比较两个字符串的长度。
(String first, String second) -> {
if (first.length() < second.length()) {
return -1;
} else if (first.length() > second.length()) {
return 1;
} else {
return 0;
}
}
如果可以推导出 Lambda 表达式的参数类型,则可以忽略其类型。例如:
Comparator<String> comp = (first, second) -> {
if (first.length() < second.length()) {
return -1;
} else if (first.length() > second.length()) {
return 1;
} else {
return 0;
}
};
在这里,编译器可以推导出 first 和 second 必然是字符串,因为这个 Lambda 表达式将赋给一个字符串比较器。
如果方法只有一个参数,而且这个参数的类型可以推导得出,那么甚至还可以省略小括号:
ActionListener listener = event -> System.out.println("The time is " + new Date());
// Instead of (event) -> ... or (ActionEvent event) -> ...
无需指定 Lambda 表达式的返回类型。Lambda 表达式的返回类型总是会由上下文推导得出。例如,下面的表达式:
(String first, String second) -> first.length() - second.length()
可以在需要 int 类型结果的上下文中使用。
另外,即使 Lambda 表达式没有参数,仍然要提供空括号,就像无参数方法一样:
() -> { for (int i = 100; i >= 0; i--) System.out.println(i); }
Lambda 表达式的各种形式,上面已经基本上都给出了,在使用 Lambda 表达式的时候可以作为参考。
最后,需要注意:如果一个 Lambda 表达式只在某些分支有返回值,而在另外一些分支不返回值,这是不合法的,我们没法确定这个 Lambda 表达式是属于有返回值的,还是没有返回值的。例如,下面的表达式就不合法:
(int x) -> { if (x >= 0) return 1; }
函数式接口
函数式接口(functional interface)是指只有一个抽象方法的接口,当然接口中还可以包含其他类型的方法,比如默认方法,静态方法。
为什么函数式接口只能有一个抽象方法?
函数式接口是伴随着 Lambda 表达式一起提出的,它是用来承载 Lambda 表达式的,如果它存在多个抽象方法,Lambda 表达式将很难对应。
Java 已经有很多可以封装 Lambda 表达式的接口了,比如 Comparator,Runnable 等等,Lambda 表达式与这些接口是兼容的。另外,Java API 在 java.util.function 包中新定义了很多非常通用的函数式接口。
我们注意到在 Comparator 接口中不止一个抽象方法,它还声明了 equals 方法,这样不是和函数式接口的定义矛盾么?
Java 允许接口重新声明 Object 中的方法来附加 javadoc 注释,比如 Comparator 接口重新定义了 equals 方法的注释。
/**
* Indicates whether some other object is "equal to" this
* comparator. This method must obey the general contract of
* {@link Object#equals(Object)}. Additionally, this method can return
* {@code true} <i>only</i> if the specified object is also a comparator
* and it imposes the same ordering as this comparator. Thus,
* {@code comp1.equals(comp2)} implies that {@code sgn(comp1.compare(o1,
* o2))==sgn(comp2.compare(o1, o2))} for every object reference
* {@code o1} and {@code o2}.<p>
*
* Note that it is <i>always</i> safe <i>not</i> to override
* {@code Object.equals(Object)}. However, overriding this method may,
* in some cases, improve performance by allowing programs to determine
* that two distinct comparators impose the same order.
*
* @param obj the reference object with which to compare.
* @return {@code true} only if the specified object is also
* a comparator and it imposes the same ordering as this
* comparator.
* @see Object#equals(Object)
* @see Object#hashCode()
*/
boolean equals(Object obj);
根据接口章节的描述,接口在解决默认方法冲突的时候,根据“类优先”规则,Comparator 接口中的 equals 方法会被忽略,所以只需要实现 compare 方法。
在 Java 中,可以将 Lambda 表达式转换为函数式接口。比如我们用 Arrays.sort 对数组排序,它的第二个参数需要一个 Comparator 实例,Comparator 是一个函数式接口,所以可以提供一个 Lambda 表达式:
Arrays.sort(words, (first, second) -> first.length() - second.length());
// or
Comparator<String> strComparator = (first, second) -> first.length() - second.length();
Arrays.sort(words, strComparator);
Arrays.sort 方法接收实现了 Comparator
Java API 在 java.util.function 包中定义了很多非常通用的函数式接口,这里列出一些常用的函数式接口。
函数式接口 | 参数 类型 |
返回 类型 |
抽象 方法名 |
描述 | 其他方法 |
---|---|---|---|---|---|
Runnable | 无 | void | run | 作为无参数或返回值的动作运行 | |
Supplier |
无 | T | get | 提供一个 T 类型的值 | |
Consumer |
T | void | accept | 处理一个 T 类型的值 | andThen |
BiConsumer |
T, U | void | accept | 处理 T 和 U 类型的值 | andThen |
Function |
T | R | apply | 有一个 T 类型参数的函数 | compose, andThen, identity |
BiFunction |
T, U | R | apply | 有 T 和 U 类型参数的函数 | andThen |
UnaryOperator |
T | T | apply | 类型 T 上的一元操作符 | compose, andThen, identity |
BinaryOperator |
T, T | T | apply | 类型 T 上的二元操作符 | andThen, maxBy, minBy |
Predicate |
T | boolean | test | 布尔值函数 | and, or, negate, isEqual |
BiPredicate |
T, U | boolean | test | 有两个参数的布尔值函数 | and, or, negate |
另外,Java API 还提供了一些基本类型的函数式接口,可以减少自动装箱的操作。
函数式接口 | 参数类型 | 返回类型 | 抽象方法名 |
---|---|---|---|
BooleanSupplier | none | boolean | getAsBoolean |
_P_Supplier | none | p | getAsP |
_P_Consumer | p | void | accept |
Obj_P_Consumer |
T, p | void | accept |
_P_Function |
p | T | apply |
_P_To_Q_Function | p | q | applyAsQ |
To_P_Function |
T | p | applyAsP |
To_P_BiFunction |
T, U | p | applyAsP |
_P_UnaryOperator | p | p | applyAsP |
_P_BinaryOprator | p, p | p | applyAsP |
_P_Predicate | p | boolean | test |
注:p,q 为 int,long,double;P,Q 为 Int,Long,Double
关于基本类型的函数式接口,当参数是基本类型时,推荐使用,可以减少自动装箱的操作。比如我们需要这样一种函数式接口,它包含一个方法,这个方法有一个 int 类型的参数,返回值类型为 void,可以使用 IntConsumer 代替 Consumer
如果需要自定义一个函数式接口,可以用 @FunctionalInterface 注解来标记这个接口。这样做有两个优点。如果你无意中增加了另一个非抽象方法,编译器会产生一个错误消息。另外 javadoc 页里会指出你的接口是一个函数式接口。
在 Java 中,对 Lambda 表达式所能做的也只是能转换为函数式接口。在其他支持函数字面量的程序设计语言中,可以声明函数类型(如(String, String) -> int)、声明这些类型的变量,还可以使用变量保存函数表达式。不过,Java 设计者还是决定保持我们熟悉的接口概念,没有为 Java 语言增加函数类型。
方法引用
有时,可能已经有现成的方法可以完成你想要传递到其他代码的某个动作。
例如,假设你希望只要出现一个定时器事件就打印这个事件对象。我们可以这样写:
Timer t = new Timer(1000, event -> System.out.println(event));
我们也可以直接把 println 方法传递到 Timer 构造器中:
Timer t = new Timer(1000, System.out::println);
表达式System.out::println
是一个方法引用(method reference),它等价于 Lambda 表达式x -> System.out.println(x)
。
再来看一个例子,假设你想对字符串排序,而不考虑字母的大小写。可以使用以下方法表达式:
Arrays.sort(strings, String::conpareToIgnoreCase)
我们在使用方法引用表达式的时候,主要有以下三种情况:
**object::instanceMethod**
**Class::staticMethod**
**Class::instanceMethod**
前 2 种情况,方法引用等价于提供方法参数的 lambda 表达式。例如,System.out::println
等价于x -> System.out.println(x)
;Math::pow
等价于(x,y) -> Math.pow(x, y)
。
对于第 3 种情况,第 1 个参数会成为方法的目标。例如,String::compareToIgnoreCase
等同于(x, y)-> x.compareToIgnoreCase(y)
。
如果有多个同名的重载方法,编译器就会尝试从上下文中找出你指的那一个方法。例如,Math.max 方法有两个版本,一个用于整数,另一个用于 double 值。选择哪一个版本取决于 Math::max 转换为哪个函数式接口的方法参数。类似于 Lambda 表达式,方法引用不能独立存在,总是会转换为函数式接口的实例。
可以在方法引用中使用 this,super 参数。例如,this::equals
等同于x -> this.equals(x)
。使用 super 参数super::instanceMethod
,会调用父类的方法。
下面给出一个 super 的使用例子:
class Greeter {
public void greet(Object obj) {
System.out.println("Hello, world!");
}
}
class TimedCreeter extends Greeter {
public void greet() {
Timer t = new Timer(1000, super::greet) ;
t.start();
}
}
TimedGreeter.greet 方法开始执行时,会构造一个 Timer,它会在每次定时器滴答时执行 super::greet 方法。这个方法会调用超类的 greet 方法。
构造器引用
构造器引用与方法引用很类似,只不过方法名为 new。例如,Person::new
是 Person 构造器的一个引用,它等价于 Lambda 表达式x -> new Person(x)
。至于使用哪一个构造器?这取决于上下文。
假设有一个字符串列表,需要将它转换为一个 Person 对象是数组,代码如下:
ArrayList<String> names = ......;
Stream<Person> stream = names.stream().map(Person::new);
List<Person> people = stream.col1ect(Col1ectors.toList());
map 方法会为各个列表元素调用 Person(String) 构造器,如果有多个 Person 构造器,编译器会选择有一个 String 参数的构造器,因为它从上下文推导出这是在对一个字符串调用构造器。
数组也可以建立构造器引用。例如,int[]::new
是一个构造器引用,它有一个参数:即数组的长度。这等价于 Lambda 表达式x -> new int[x]
。
Java 有一个限制,无法构造泛型类型 T 的数组。数组构造器引用对于克服这个限制很有用。表达式 new T[n]
会产生错误,因为这会改为 new Object[n]
。 对于开发类库的人来说,这是一个问题。例如,假设我们需要一个 Person 对象数组。Stream 接口有一个 toArray 方法可以返回 Object 数组:
Object[] people = stream.toArray();
不过,这并不能让人满意,用户希望得到一个 Person 引用数组。Stream 接口提供了一个 toArray 的重载方法,利用构造器引用解决了这个问题。可以把Person[]::new
传入 toArray 方法:
Person[] men = people.stream()
.filter(p -> p.getGender() == MALE)
.toArray(Person[]::new);
toArray 方法调用这个构造器来得到一个正确类型的数组,然后填充这个数组并返回。
变量作用域
通常,我们希望能够在 Lambda 表达式中访问外围方法或类中的变量。比如下面的例子:
public static void repeatMessage(String text, int delay) {
ActionListener listener = event -> {
System.out.println(text);
Toolkit.getDefaultToolkit().beep();
};
new Timer(delay, listener).start();
}
来看这样一个调用:
repeatMessage("Hello", 1000); // Prints Hello every 1,000 milliseconds
现在来看 Lambda 表达式中的变量 text。注意这个变量并不是在这个 Lambda 表达式中定义的。实际上,这是 repeatMessage 方法的一个参数变量。
如果再想想看,这里好像会有问题,尽管不那么明显。Lambda 表达式的代码可能会在 repeatMessage 调用返回很久以后才运行,而那时这个参数变量已经不存在了。如何保留 text 变量呢?
要了解到底会发生什么,下面来巩固我们对 Lambda 表达式的理解 Lambda 表达式有 3 个部分:
- 一个代码块。
- 参数。
- 自由变量的值,这是指非参数且不是在代码块中定义的变量。
在我们的例子中,这个 Lambda 表达式有 1 个自由变量 text。表示 Lambda 表达式的数据结构必须存储自由变量的值,在这里就是字符串 “Hello”。我们说它被 Lambda 表达式捕获(captured)。
关于代码块以及自由变量值有一个术语:闭包(closure)。在 Java 中,Lambda 表达式就是闭包。
可以看到,Lambda 表达式可以捕获外围作用域中变量的值。在 Java 中,要确保所捕获的值是明确定义的,这里有两点重要的限制。
在 Lambda 表达式中,只能引用值不会改变的变量。例如,下面的做法是不合法的:
public static void countDown(int start, int delay) {
ActionListener listener = event -> {
start--; // Error: Can't mutate captured variable
System.out.println(start);
};
new Timer(delay, listener).start();
}
之所以有这个限制是有原因的。如果在 Lambda 表达式中改变变量,并发执行多个动作时就会不安全。对于目前为止我们看到的动作不会发生这种情况,不过一般来讲,这确实是一个严重的问题。
另外如果在 Lambda 表达式中引用变量,而这个变量可能在外部改变,这也是不合法的。例如,下面就是不合法的:
public static void repeat(String text, int count) {
for (int i = 1; i <= count; i++) {
ActionListener listener = event -> {
System.out.println(i + ": " + text); // Error: Cannot refer to changing i
};
new Timer(1000, listener).start();
}
}
所以,对于自由变量,有一条规则:Lambda 表达式中捕获的变量必须实际上是最终变量(effectively final)。
实际上的最终变量是指,这个变量初始化之后就不会再为它赋新值。在这里,text 总是指向同一个 String 对象,所以捕获这个变量是合法的。不过,i 的值会改变,因此不能捕获 i。
Lambda 表达式的体与嵌套块有相同的作用域。这里同样适用命名冲突和遮蔽的有关规则。在 Lambda 表达式中声明与一个外围局部变量同名的参数或局部变量是不合法的。
Path first = Paths.get("/usr/bin");
Comparator<String> comp =
(first, second) -> first.length() - second.length();
// Error: Variable first already defined
在一个 Lambda 表达式中使用 this 关键字时,是指创建这个 Lambda 表达式的方法的 this 参数。例如,下面的代码:
public class Application {
public void init() {
ActionListener listener = event -> {
System.out.println(this.toString());
......
}
......
}
}
表达式this.toString()
会调用 Application 对象的 toString 方法,而不是 ActionListener 实例的方法。在 Lambda 表达式中,this 的使用并没有任何特殊之处。Lambda 表达式的作用域嵌套在 init 方法中,与出现在这个方法中的其他位置一样,Lambda 表达式中 this 的含义并没有变化。
转载
- Java 核心技术 卷1 基础知识 第10版
作者:殷建卫 链接:https://www.yuque.com/yinjianwei/vyrvkf/oxtymq 来源:殷建卫 - 开发笔记 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。