1,免责声明,本文大部分内容摘自《Java8函数式编程》。在这本书的基础上,根据自己的理解和网上一些博文,精简或者修改。本次分享的内容,只用于技术分享,不作为任何商业用途。当然这本书是非常值得一读,强烈建议买一本!
2,本次分享的样例代码均上传到github上,请点击这里。
第2章 Lambda表达式
主要内容如下:
- 2.1 第一个Lambda表达式
- 2.2 如何识别Lambda表达式
- 2.3 引用的是值,而不是变量
- 2.4 函数接口
- 2.5 类型推导
- 2.6 要点回顾
2.1 第一个Lambda表达式
Android Button 点击监听器语法如下:
// 例2-1 使用匿名内部类将行为和按钮单击进行关联
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Log.v("View", "button clicked!");
}
});
在这个例子中,我们创建一个新对象,它实现了View.OnClickListener
接口。这个接口只有一个方法onClick()
,当用户点击屏幕上的button,button就会被调用这个方法。匿名内部类实现了该方法,这实际上是一个代码即数据的例子 —— 我们给button传递了一个代表某种行为的对象。
设计匿名内部类的目的,就是为了方便Java程序员将代码作为数据传递。不过,匿名内部类还是不够简便。为了调用一行重要的逻辑代码,不得不加上4行冗繁的样板代码。
尽管如此,样板代码并不是唯一的问题:这些代码还相当难读,因为它没有清楚地表达程序员的意图。我们不想传入对象,只想传入行为。在Java8中,上述代码可以写成一个Lambda表达式,如下:
// 例2-2 使用 Lambda 表达式将行为和按钮单击进行关联
button.setOnClickListener(v -> Log.v("View", "button clicked!"));
和传入一个实现某种接口的对象不同,我们传入了一段代码块——一个没有名字的函数。v
是参数名,和上面匿名内部类示例中的是同一个参数。->
将参数和Lambda表达式的主体分开,而主体是用户点击按钮时会运行的一些代码。
和使用匿名内部类另一处不同在于声明view参数的方式。使用匿名内部类时需要显示地声明参数类型View
,而在Lambda表达式中无需指定类型,程序依然可以编译。这是因为javac根据程序的上下文(setOnClickListener
方法的签名)在后台推断出了参数View的类型。这意味着如果参数类型不言明,则无需显示指定。为了增加可读性并能够迁就我们的习惯,声明参数也可以包含类型信息,因为有时候编译器不一定能根据上下文推断出参数类型。
2.2 如何识别Lambda表达式
Lambda表达式除了基本的形式之外,还有几种变体:
- (params) -> expression
- (params) -> statement
- (params) -> { statements }
具体例子如下:
// 例2-3 编写 Lambda 表达式的不同形式
// 方式一,不包含参数,使用空括号()表示没有参数。
Runnable noArguments = () -> System.out.println("Hello World!");
// 方式二,包含且只包含一个参数,可省略参数的括号。
View.OnClickListener clickListener = view -> System.out.println("Button Click!");
// 方式三,Lambda 表达式可以包含多个参数的方法。
BinaryOperator<Long> add = (x, y) -> x + y;
// 方式四,与方式三类似,只不过指定了参数类型,如Long。
BinaryOperator<Long> add = (Long x, Long y) -> x + y;
// 方式五,Lambda 表达式的主体不仅可以是一个表达式,而且也可以是一段代码块,使用大括号{}将代码块括起来。
BinaryOperator<Long> add2 = (x, y) -> {
System.out.println("Hello BinaryOperator!");
return x + y;
};
方式一,Lambda表达式不包含参数,使用空括号()表示没有参数。此时小括号不可以省略。该Lambda表达式实现了Runnable接口,该接口也只有一个run方法,没有参数,且放回类型为void。
方式二,Lambda表达式包含且只包含一个参数,可省略参数的小括号。
方式三,Lambda表达式可以包含多个参数的方法,此时需要小括号将参数括起来。这时就有必要考虑怎样去阅读该Lambda表达式。这行代码并不是将两个数字相加,而是创建了一个函数,用来计算两个数字相加的结果。变量add的类型BinaryOperator,它不是两个数字的和,而是将两个数字相加的那行代码。
方式四,Lambda表达式参数类型由编译器推断出来,这当然不错,但有时建议显示声明参数类型。
方式五,Lambda表达式的主体不仅可以是一个表达式,而且也可以是一段代码块,使用大括号({})将代码块括起来。该代码块和普通方法遵循的规则一模一样,可以用返回或抛出异常退出。只有一行代码的Lambda表达式也可使用大括号,用以证明Lambda表达式从何时开始、到哪里结束。
目标类型:是指Lambda表达式所在上下文环境的类型。比如:将Lambda表达式赋值给一个局部变量,或传递给一个方法作为参数,局部变量或方法参数的类型就是Lambda表达式的目标类型。下面的 2.4章节会具体介绍
Lambda表达式的类型依赖于上下文环境,是由编译器推断出来的。 目标类型也不是一个全新的概念。如例 2-4 所示,Java中初始化数组,数据类型就是根据上下文推断出来的。或者null,只有将null赋值给一个变量,才能知道它的类型。如例2-4:
// 例 2-4 等号右边的代码并没有声明类型,系统根据上下文推断出类型
final String[] array = {"hello", "world"};
String str = null;
2.3 引用的是值,而不是变量
在Java匿名内部类的方法里使用外部的变量。这时,需要将变量声明为final(JDK8 之前是强制的)。将变量声明为final,意味着不能为其重复赋值,即:该变量是一个特定的值。
// 例2-5 匿名内部类中使用 final 局部变量
final String world = "Hello world!";
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Log.v("View", "button clicked! say " + world);
}
});
Java8 放松了这一限制,可以引用非final
变量,但是该变量在既成事实上必须是 final
的。虽然无需将变量声明为final
,但在Lambda表达式中,也无法用作非终态变量,否则编译器就会报错。
既成事实上的final
是指只能给该变量赋值一次。换句话说,Lambda表达式引用的是值,而不是变量。 下例number就是一个既成事实上的final变量。
// 例 2-6 Lambda 表达式中引用既成事实上的 final 变量
int number = 3;
new Thread(() ->
System.out.println("I am " + number)
).start();
final
有时省去之后代码更易读,但有些情况下,显式地使用final
代码更易懂。是否使用final
?完全取决于个人喜好。
如果你试图给该变量多次赋值,然后在Lambda表达式中引用它,编译器提示出错信息:
local variables referenced from a Lambda expression must be final or effectively final。
// 例 2-7 未使用既成事实上的 final 变量,导致无法通过编译
int number = 3;
number = 4; //重新再赋值,不应该在Lambda中使用
new Thread(() ->
System.out.println("I am " + number)
).start();
这种行为也解释了为什么Lambda表达式也被称为闭包。
2.4 函数接口
函数接口:是只有一个抽象方法的接口,用作Lambda表达式的类型。你也可以理解为对一段行为的抽象,简单点说就是将一段行为方法的参数进行传递,这个行为呢,可以是一段代码,也可以是一个方法。你可以想象在 java8 之前要将一段方法作为参数传递只能通过匿名内部类来实现,而且代码很难看,也很长,函数接口就是对匿名内部类的优化。
在Java里,所有方法参数都有固定的类型。假设将字符串 “hello world” 作为参数传给一个方法,则参数的类型是String。那么,Lambda表达式的类型又是什么呢?
使用只有一个方法的接口来表示某特定方法并反复使用,是很早就有的习惯。编写Android界面交互的同学对这种方式都不陌生,如点击监听事件,如下例。Lambda表达式也使用同样的技巧,并将这种接口称为函数接口。
// 例 2-8 OnClickListener 接口:接受 View 类型的参数,返回空
// OnClickListener接口:接收一个View类型的参数,放回空
public interface OnClickListener {
void onClick(View v);
}
OnClickListener 只有一个抽象方法: onClick
,被用来表示行为,即:接受一个参数,返回空。这就是函数接口,接口中这个方法的命名并不重要(如:onClick()),只要方法签名和Lambda表达式的类型匹配即可 (形参、返回值、抛出的异常)。在函数接口中为参数起一个有意义的名字,增加代码可读性。
这里的函数接口接受一个View类型的参数,返回空(void),但函数接口还可有其它形式。例如,函数接口可以接受两个参数,并返回一个值,还可以使用泛型,这完全取决于你要干什么(后面章节会深入介绍)。
后面将会使用图形来表示不同类型的函数接口。指向函数接口的箭头表示参数,如果箭头从函数接口射出,则表示方法的返回类型。OnClickListener的函数接口如图:
// 图 2-1:View.OnClickListener接口,接受一个View对象,返回空。
Java8提供了很多函数接口(在java.util.function
包目录下),但其实没那么复杂。只要记住六大重要函数接口,其它的函数接口是基于这六个函数接口的扩展(下面的“2.4.1 几个单词”章节会具体介绍)。表 2-1 列出了这六个重要函数接口。
// 表2-1 Java 中重要的6个函数接口
接口 | 参数 | 返回类型 | 示例 |
---|---|---|---|
Function<T,R> | T | R | 获得 Artist 对象的名字 |
Consumer<T> | T | void | 输出一个值 |
Supplier<T> | None | T | 工厂方法 |
Predicate<T> | T | boolean | 这张唱片已经发行了吗 |
UnaryOperator<T> | T | T | 逻辑非(!) |
BinaryOperator<T> | (T, T) | T | 求两个数的乘积(*) |
2.4.1 几个单词
在深入学习函数接口之前,希望大家能记住几个单词,掌握这几个单词,官方3、40个函数接口都是小问题了。这几个单词分别是:
- Function(函数)
- Consumer(消费者)
- Supplier(提供者)
- Predicate (断言)
- Operation(运算符)
- Unary (一元)
- Binary(二元,就是数学里二元一次方程那个二元,代表2个的意思)。
2.4.2 四大基础函数接口
虽然类库中的基本函数接口特别多,但其实总体可以分成四类,就好像阿拉伯数字是无限多的,但总共就10个基本数字一样,理解了这四类,其它的自然就明白了。
2.4.2.1 Function 函数接口
顾名思义,函数的意思。它接受一个参数并返回一个值。永远都是这样,是一个恒定的,状态不可改变的方法。上面说到,函数接口是对行为的抽象。Function接口是对接受一个T类型参数,返回R类型的结果的方法的抽象,通过调用apply
方法执行内容。参见例子
// 这个方法接受一个int类型参数a,返回a+1
private static int addOne(int a) {
return a + 1;
}
// 方法第二个参数接受一个function类型的行为,然后调用apply,对a执行这段行为。
private static int oper(int a, Function<Integer, Integer> action) {
return action.apply(a);
}
private static void testFunction() {
int x = 1;
int y = oper(x, z -> addOne(z)); // 将addOne方法作为参数传递
System.out.printf("result -> x= %d, y = %d \r\n", x, y); //打印结果 x=1, y=2
/* 使用lambda表达式来表示这段行为,只要保证一个参数,一个返回值就能匹配 */
y = oper(x, z -> z + 3);
System.out.printf("result -> x= %d, y = %d \n", x, y); //打印结果 x=1, y=4
y = oper(x, z -> z * 3);
System.out.printf("result -> x= %d, y = %d \n", x, y); //打印结果 x=1, y=3
}
testFunction()
函数中的x
、z ->
到底指的是什么?看下图。
这里的箭头指向的位置就是形参,可以看到第二个箭头的Lambda表达式指向了Funtion接口。
2.4.2.2 Consumer 函数接口
Consumer 消费者,该接口对应的方法类型为接收一个参数,但没有返回值。可以通俗的理解成将这个参数被“消费掉了”。一般来说,使用Consumer接口往往伴随着一些期望状态的改变或者事件的发生,例如最典型的forEach
就是使用的Consumer接口,虽然没有任何的返回值,但是却向控制台输出了语句(后期章节会提到forEach
)。Consumer 使用accept
对参数执行行为。
private static void testConsumer() {
Consumer<String> printString = s -> System.out.println(s);
printString.accept("testConsumer() -> Hello World!"); //控制台输出 helloWorld!
}
2.4.2.3 Supplier 函数接口
Supplier 提供者,和Consumer相反,该接口对应的方法类型为不接受参数,但是提供一个返回值,通俗的理解为这种接口是无私的奉献者,不仅不要参数,还返回一个值,使用get()
方法获得这个返回值。
private static void testSupplier() {
Supplier<String> getInstance = () -> "Hello World!";
System.out.println("testSupplier() -> " + getInstance.get()); // 控偶值台输出 Hello World
}
2.4.2.4 Predicate 函数接口
predicate 断言,即:“是” 或 “非”,该接口接收一个参数,返回一个Boolean类型值,用于判断与过滤,当然你可以把他理解成特殊的Function,但是为了便于区分语义,还是单独的划了一个接口,使用test()
方法执行这段行为。
private static void testPredicate() {
Predicate<Integer> oddNumber = integer -> integer % 2 == 1;
System.out.println("testPredicate() 奇数?:" + oddNumber.test(2));
}
2.4.3 其它函数接口
介绍完四种最基本函数接口,剩余的接口就可以很容易理解了,java8 中定义了几十种的函数接口,但是剩下的接口都是上面几种接口的变种,大多为限制参数类型或参数的数量。参数类型主要是int
、long
、double
这三种类型,因为使用频率最高。
1, 类型限制接口
(1),参数类型
可以理解为限制了函数的入参类型。例如:
IntPredicate
,LongPredicate
,DoublePredicate
,这几个接口,都是基于Predicate
接口,不同的就是他们的泛型类型分别变成了Integer
,Long
,Double
,也就是说参数必须传递为Integer
,Long
,Double
。IntConsumer
,LongConsumer
,DoubleConsumer
比如这几个,对应的就是Consumer<Integer>
,Consumer<Long>
,Consumer<Double>
。IntFunction
、DoubleSupplier
等等,其余的是一样的道理。
(2),返回值类型
和上面类似,只是命名的规则上多了一个To
, 例如:IntToDoubleFunction
,IntToLongFunction
, 很明显就是对应的Function<Integer, Double>
与Function<Integer, Long>
,其余同理。另外需要注意的是,参数限制与返回值限制的命名唯一不同就是To
。简单来说,前面不带To的都是参数类型限制,带To的是返回值类型限制。
2, 数量限制接口
有些接口需要接受两个参数,此类接口的所有名字前面都是附加上Bi
,是Binary的缩写,即:二元的意思,例如BiPredicate
, BiFunction
等等,而由于 java 没有多返回值的设定,所以Bi指的都是参数为两个。
3, Operator接口
此类接口只有2个,分别是UnaryOperator<T>
一元操作符接口,与BinaryOperator<T>
二元操作符接口,这类接口属于Function
接口的简写,他们只有一个泛型参数,意思是Function
的参数与返回值类型相同。一般多用于操作计算,计算 a + b的BiFunction
如果限制条件为Integer
的话,往往要这么写BiFunction<Integer, Integer, Integer>
前2个泛型代表参数,最后一个代表返回值,看起来似乎是有点繁重了,这个时候就可以用BinaryOperator<Integer>
来代替了。
下面是各种类型的接口的示意图,相信只要真正理解了,其实问题并不大。
2.5 类型推导
某些情况下,用户需要手动指明类型,建议大家根据自己或项目组的习惯,采用让代码最便于阅读的方法。有时省略类型信息可以减少干扰,更易弄清状况;而有时却需要类型信息帮助理解代码。实践证明,一开始类型信息是有用的,但随后可以只在真正需要时才加上类型信息。下面将介绍一些简单的规则,来帮助确认是否需要手动声明参数类型。
Lambda表达式中的类型推断,实际上是Java 7就引入的目标类型推断的扩展。大家可能已经知道 Java 7中的菱形操作符,它可通过 javac 推断出泛型参数的类型。参见例2-9。
// 例 2-9 使用菱形操作符,根据变量类型做推断
Map<String, Integer> oldWordCounts = new HashMap<String, Integer>();
Map<String, Integer> diamondWordCounts = new HashMap<>();
我们为变量 oldWordCounts 明确指定了泛型的类型,而变量 diamondWordCounts则使用了
菱形操作符。不用明确声明泛型类型,编译器就可以自己推断出来,这就是它的神奇之处!
当然,这并不是什么魔法,根据变量 diamondWordCounts的类型可以推断出 HashMap 的泛型类型,但用户仍需要声明变量的泛型类型。
如果将构造函数直接传递给一个方法,也可根据方法签名来推断类型。在例 2-10 中,我们传入了HashMap,根据方法签名已经可以推断出泛型的类型。
// 例 2-10 使用菱形操作符,根据方法签名做推断
useHashmap(new HashMap<>()); // 该语法目前在Java8上才支持
...
private void useHashmap(Map<String, String> values);
Java 7 中程序员可省略构造函数的泛型类型,Java 8更进一步,程序员可省略 Lambda 表达式中的所有参数类型。javac 根据 Lambda 表达式上下文信息就能推断出参数的正确类型。程序依然要经过类型检查来保证运行的安全性,但不用再显式声明类型罢了。这就是所谓的类型推断。
例2-11 都将变量赋给一个函数接口,使用 Lambda 表达式检测一个 Integer 是否大于 5。这实际上是一个 Predicate——用来判断真假的函数接口。
// 例 2-11 类型推断
Predicate<Integer> atLeast5 = x -> x > 5;
Predicate 也是一个 Lambda 表达式,它还返回一个boolean值。表达式 x > 5
是 Lambda 表达式的主体。这样的情况下,返回值就是 Lambda 表达式主体的值。
// 例 2-12 Predicate 接口的源码,接受一个对象,返回一个布尔值
public interface Predicate<T> {
boolean test(T t);
}
从例 2-12 中可以看出,Predicate 只有一个泛型类型的参数,Integer 用于其中。Lambda 表达式实现了 Predicate 接口,因此它的单一参数被推断为 Integer 类型。javac 还可检查 Lambda 表达式的返回值是不是 boolean,这正是 Predicate 方法的返回类型(如图 2-4)。
图 2-4: Predicate 接口图示,接受一个对象,返回一个布尔值。
例 2-13 是一个略显复杂的函数接口: BinaryOperator
。该接口接受两个参数,返回一个值,参数和值的类型均相同。实例中所用的类型是 Long。
// 例 2-13,略显复杂的类型推断
BinaryOperator<Long> addLongs = (x, y) -> x + y;
类型推断系统相当智能,但若信息不够,类型推断系统也无能为力。类型系统不会漫无边际地瞎猜,而会中止操作并报告编译错误,寻求帮助。比如,如果我们删掉例 2-5-6 中的某些类型信息,就会得到例 2-14 所示的代码。
// 例 2-14,没有泛型,代码则通不过编译
BinaryOperator add = (x, y) -> x + y;
编译器给出的报错信息如下:
Operator '& #x002B;' cannot be applied to java.lang.Object, java.lang.Object.
报错信息让人一头雾水,到底怎么回事? BinaryOperator 毕竟是一个具有泛型参数的函数接口,该类型既是参数 x 和 y 的类型,也是返回值的类型。上面的例子中并没有给出变量 add 的任何泛型信息,给出的正是原始类型的定义。因此,编译器认为参数和返回值都是 java.lang.Object 实例。
后面的章节还会讲到类型推断,但就目前来说,掌握以上类型推断的知识就已经足够了。
2.6 要点回顾
- Lambda 表达式是一个匿名方法,将行为像数据一样进行传递。
- Lambda 表达式的常见结构:
BinaryOperator<Integer> add = (x, y) → x + y
。- 函数接口指仅具有单个抽象方法的接口,用来表示Lambda表达式的类型。
- 常用的函数接口: Function、Consumer、Supplier、Predicate、Operation。
- 类型推导。