作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
首先,请大家去在线代码编辑器执行一段JS代码:
// Math.abs()是JavaScript的内置函数
let result = Math.abs(-10)
console.log('result=' + result)
// JavaScript允许将Function赋值给变量。注意,不能带(),否则就是方法调用
let func = Math.abs
// 更神奇的是,func变量拼上()就可以当方法调用了
console.log('result=' + func(-20))
// 其实,在JavaScript中不仅允许方法赋值,还允许直接传递Function
function sum(x, y, func) {
return func(x) + func(y)
}
result = sum(-10, -20, Math.abs)
console.log('result=' + result)
你会得到以下结果:
result=10
result=20
result=30
sandbox> exited with status 0
这种写法对于Java程序员来说第一感觉是“神奇”,紧接着能感受到JS的“轻巧”。
其实不仅是JS,Python也是如此:
# encoding: utf-8
# abs是Python的内置函数
result = abs(-1)
print(result)
# 定义add()方法,f显然是一个函数
def add(x, y, f):
return f(x) + f(y)
print(add(-1, -2, abs))
运行结果是:
1
3
sandbox> exited with status 0
Python把上面的表达式称为高阶函数:
PHP也支持,就不演示了。
在JS、Python等脚本语言的世界里,这种现象似乎很普遍,因为Function是那个世界的一等公民,而对于Java来说对象才是一等公民。比如,有时为了在A对象的方法中调用B对象的某个方法,我们不得不new一个B对象并把它作为参数传入:
class A {
public void methodA(B b) {
System.out.println("methodA is called");
b.methodB();
}
}
class B {
public void methodB() {
System.out.println("methodB is called");
}
}
在设计模式中,上面这种写法美其名曰:策略模式。
当然,广义上策略模式关注的是可替换的多种实现,狭义上可以理解为传递不同的方法。
幸运的是Oracle紧跟潮流,终于在2014年发布Java8并宣布支持Lambda表达式,弥补了Java函数式编程的遗憾。
Java8有两个非常重要的新特性:Lambda和Stream API,而一旦你了解了Lambda的来龙去脉,Stream API几乎是信手拈来。本系列文章会把绝大部分笔墨花在Lambda上,意在让初学者在不知不觉中将Lambda内化为自己的知识体系的一部分。
快过年了,很多人会选择年底旅游,比如日本、泰国,或者国内的海南省。
现在交通这么发达,出行的方式可以有很多种:坐火车、坐飞机、骑自行车等等。
接下来,我们就以出行作为主题展开。
假设我有女朋友,因为我和我女朋友算是两个独立个体,是分工进行的。在程序里,我们相当于两个线程。
MyThread(模仿JDK的Thread,最大的区别是最终调用的还是主线程而不是异步线程)
public class MyThread {
public void run() {
System.out.println("去12306买了一张票");
System.out.println("坐火车...");
}
public void start() {
run();
}
}
Demo
public class Demo {
public static void main(String[] args) {
// new一个MyThread对象,调用start()
new MyThread().start();
}
}
输出:
去12306买了一张票
坐火车...
上面的代码有个问题:要运行的代码在MyThread的run里面写死了,我只能坐火车,不能坐飞机。 分析:
我们知道,Java是面向对象的,一个事物一般可以最终抽象成 属性+方法。
属性代表你有什么,方法代表你能干什么。
比如 我有一张嘴,我能吃饭:
class Person {
private String mouth;
public void eat(){}
}
在这个案例中,“坐火车”、“坐飞机”都是一个动作。按上面分析,应该是一个方法。我们要动态地传入一个动作,也就是要动态地传入一个方法。但是我们知道,在Java中只能传递数据(参数),不能传递方法。JS之所以可以传递function,是因为function本身是JS的一种数据类型。
抽取一下上面的观点:
Java只能传递规定的数据类型的数据,比如int型的age, String型的name, 引用类型的person。
等等!上面说对象包含属性和方法,你又说Java可以传递person这样的引用类型。
那么,我完全可以传递一个对象(包含方法),然后在内部调用对象的方法,这和直接传递方法是异曲同工的。
所以,我打算把“坐火车”、“坐飞机”这些交通策略单独抽取成一个个类,在new MyThread()的时候通过构造函数传递这些类的实例进去。
但同时可以预见,“坐火车”、“坐飞机”等等对象会非常多,MyThread的有参构造的形参类型需要形成多态。于是我写了一个MyRunnable接口,让每个策略类都去实现它(接口多态)。
MyRunnable
public interface MyRunnable {
void run();
}
ByTrain
public class ByTrain implements MyRunnable {
@Override
public void run() {
System.out.println("去12306买了一张票");
System.out.println("坐火车...");
}
}
ByAir
public class ByAir implements MyRunnable {
@Override
public void run() {
System.out.println("在某App上订了飞机票");
System.out.println("坐飞机...");
}
}
改写MyThread,接收不同的出行策略并调用方法
public class MyThread {
// 成员变量
private MyRunnable target;
public MyThread() {}
// 构造方法,接收外部传递的出行策略
public MyThread(MyRunnable target) {
this.target = target;
}
// MyThread自己的run(),现在基本不用了
public void run() {
System.out.println("去12306买了一张票");
System.out.println("坐火车...");
}
// 如果外部传递了出行策略,就会调用该策略里的run()
public void start() {
if (target != null) {
target.run();
} else {
this.run();
}
}
}
Demo
public class Demo {
public static void main(String[] args) {
ByTrain byTrain = new ByTrain();
new MyThread(byTrain).start();
ByAir byAir = new ByAir();
new MyThread(byAir).start();
}
}
输出:
去12306买了一张票
坐火车...
在某App上订了飞机票
坐飞机...
尽管使用了策略模式,上面的代码还是有问题。如果后面还有“坐电瓶车”、“骑自行车”等出行方式,那么出行策略类就太多了,会发生所谓的“类爆炸”。我们可以使用匿名类的方式继续改进Demo:
public class Demo {
public static void main(String[] args) {
new MyThread(new MyRunnable() {
@Override
public void run() {
System.out.println("不用买票");
System.out.println("骑电瓶车...");
}
}).start();
}
}
嗯,没错,到此为止这大概是JDK8之前我们能想到的最好的解决办法,不仅运用了策略模式,还使用匿名类的方式成功阻止了类的暴增。
但是,程序永远存在可优化的地方。于是,JDK8又帮我们向前迈进了一大步。
如果大家边看文章边复制代码运行的话,会发现上面new MyRunable变灰了。鼠标移动上去会提示你:匿名类对象可以用Lambda表达式代替。
来,一起看看JDK8怎么玩:
public class Demo {
public static void main(String[] args) {
new MyThread(() -> {
System.out.println("不用买票");
System.out.println("骑电瓶车...");
}).start();
}
}
非常优雅,非常简洁。
为什么Java要引入Lambda表达式呢?两个原因。
Java从诞生那天起,就是纯正的面向对象语言。而从1995年诞生至今,已经过去24年。期间程序届又诞生了很多优秀的语言。别看现在Java是霸主,就觉得Java语言是世界最好的语言。
长江后浪推前浪,一般来说后续的新语言在创建之初一定会吸取之前语言的教训,尽量规避之前的设计缺陷。所以,单从语言层面上讲,老的语言往往是没有优势的,甚至存在代码冗余,不够优雅等弊病。
Lambda表达式可以看做函数式编程的子集,但函数式编程绝对不是Java首创的,它只是看到别的语言用的挺好,自己也引入了而已。
这里我们先帮大家引入一个概念,可能未必准确,但绝对好理解:
Lambda表达式,其实是一段可传递的代码。
更精确的描述是:
Lambda表达式,其实是一段可传递的代码。它的本质是以类的身份,干方法的活。
什么意思呢?看代码:
public class Demo {
public static void main(String[] args) {
new MyThread(() -> System.out.println("哈哈哈")).start();
}
}
分析上面的代码,你会发现new MyThread()这个构造方法原本需要传递一个MyRunnable接口的子类对象(匿名类对象)。 但我们反手就扔了一个Lambda表达式进去,它还真吃下去了。说明什么?
说明Lambda表达式在身份上与匿名类对象等价。
但是Lambda表达式这么一串代码扔进去后,实际干活的也就->后的System.out.println("哈哈哈")这段代码(可以看做一个方法,因为方法是对代码块的封装)。这又说明了什么?
说明Lambda表达式在作用上与方法等价。
再次回味一下上面那句话:
Lambda表达式,其实是一段可传递的代码。Lambda本质是以类的身份,干方法的活。
如果我们把方法看做行为,那么Lambda表达式其实就是把行为参数化(方法作为参数传递)。
我写了一个compareString()方法,形参有三个,str1 str2 comparator,本质和文章开头的JS案例一样:
public class Demo {
public static void main(String[] args) {
String str1 = "abc";
String str2 = "abcd";
int i = compareString(str1, str2, new Comparator() {
@Override
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
});
}
public static int compareString(String str1, String str2, Comparator comparator) {
return comparator.compare(str1, str2);
}
}
经过上面文章的调教,相信大家已经非常敏感:
嗯?传递方法?可以用Lambda表达式!
满足你:
public class Demo {
public static void main(String[] args) {
String str1 = "abc";
String str2 = "abcd";
// 上面推导得出Lambda表达式与匿名类对象等价,所以我们可以把Lambda表达式赋值给Comparator接口
Comparator comparator = (String s1, String s2) -> {
return s1.length() - s2.length();
};
// 调用
int k = compareString(str1, str2, comparator);
}
public static int compareString(String str1, String str2, Comparator comparator) {
return comparator.compare(str1, str2);
}
}
如果你是在IDEA下编程,上面代码会出现灰色,提示代码有优化空间:
public class Demo {
public static void main(String[] args) {
String str1 = "abc";
String str2 = "abcd";
// 上面推导得出Lambda表达式与匿名类对象等价,所以我们可以把Lambda表达式赋值给Comparator接口
Comparator comparator = (String s1, String s2) -> {
return s1.length() - s2.length();
};
// 调用
int k = compareString(str1, str2, comparator);
// 改进一下,跳过赋值这一步,直接把整个Lambda传给compareString()方法:
compareString(str1, str2, (String s1, String s2) -> {
return s1.length() - s2.length();
});
// 上面的代码虽然能运行,但是idea一直显示灰色,说有更优雅的写法。好吧,我改改。
int x = compareString(str1, str2, (s1, s2) -> s1.length() - s2.length());
// 不对,还是不够精简,再改改(方法引用):
x = compareString(str1, str2, Comparator.comparingInt(String::length));
// 完美。
}
public static int compareString(String str1, String str2, Comparator comparator) {
return comparator.compare(str1, str2);
}
}
什么是方法引用,我们后面再介绍,可以单纯地理解为可复用的Lambda(把其他类已经定义的方法作为Lambda),实际开发时依靠IDEA提示改进即可,并不需要我们记忆。
如果你足够细心,就会发现:
类名、方法名、形参类型、返回值类型、return关键字及方法体的{}都被省略了。
从语法格式来讲,Lambda表达式其实就是对一个方法掐头去尾,只保留形参列表和方法体。可以粗略认为:
Lambda表达式 = 形参列表 + 方法体
对于匿名内部类而言,类名、方法名真的没什么用。只有当一个方法需要被使用多次时,我们才需要为它命名,以便其他程序通过方法名重复调用。而匿名内部类的性质就是仅调用一次,所以名字对它来说是可有可无的。至于返回值与形参类型,Lambda都可以通过上下文推断,所以也可以省略:
// 基于上下文,很容易推算出返回值类型就是String。形参同理。
public someType get() {
return "Hello World!";
}
至于方法体的{}能不能省略,本质和if是否需要{}一样:
if(true)
System.out.println("可以省略{}");
if(true) {
int i = 1;
System.out.println("不可以省略{}");
}
至于是否需要return,看方法本身是否需要返回值。回顾上面的推演过程,能省略的都省略后就成了现在的Lambda表达式。
那实际使用Lambda表达式需要注意什么呢?
比如:
public static void main(String[] args) {
// 原始数据
List list = new ArrayList<>(Arrays.asList(1,2,3));
// filter()方法需要传入一个过滤逻辑
List result = list.stream().filter(value -> value > 2).collect(Collectors.toList());
System.out.println(result);
}
结果是:
[3]
Process finished with exit code 0
如果你只是调用者,尽量不要去想filter()方法内部代码是怎么写的,只需考虑如何实现过滤规则:大于2,所以你只要写一个能判断value是否大于2的Lambda并传入即可(value->value>2 就是 入参->方法体)。初期不熟练时可以先写匿名内部类,IDEA会提示你优化的。有了这么智能的工具,就让大脑稍微休息下吧。
介绍完Lambda表达式,最后提一下函数式接口。大家肯定会有疑问:难道所有接口都可以接收Lambda表达式吗?
显然不是的,接口要想接收Lambda表达式,必须是一个函数式接口。所谓函数式接口,最核心的特征是:有且只有一个抽象方法。
这句话有两个重点:抽象方法、唯一。
你可能觉得:啥玩意,Java的接口不就抽象方法吗?难道还有别的方法?
是的,Java8的接口可以添加静态方法和默认方法,越来越像一个类。关于Java8为什么需要静态方法和默认方法,后面介绍Stream流操作时我们再来介绍。
除了抽象方法需要注意,我们再看看函数式接口的另一个要求:唯一。也就是说,接口里只能有一个抽象方法。现在我们给上面的MyRunnable接口加一个抽象方法:
Lambda表达式不能用了:
只能用匿名类对象,把两个方法都实现:
因为Lambda的本质是传递一个方法体,而MyRunnable此时有两个方法需要实现,那么你这个Lambda表达式到底是给哪个方法的呢?另一个又该怎么办呢?此时只能用匿名类对象,把两个方法都实现。
当然,我们还可以从另一个角度理解:Java8的Lambda都是基于上下文推导的,当一个接口只有一个方法时,推导结果是唯一确定的,但是方法不唯一时,无法推导得到唯一结果。
为了提醒程序员编写Lambda接口时不要写多个抽象方法,Java8提供了一个新注解:
当你在接口上加上这个注解,编译器会检测当前接口是否仅有唯一的抽象方法:
去掉eat():
也就是说,如果你希望一个接口能接收Lambda表达式充当匿名类对象,那么接口必须仅有一个抽象方法,这是函数式接口的定义。通常我们可以在接口上加一个@FunctionalInterface检测,作用于@Override一样。但函数式接口和@FunctionalInterface注解没有必然联系,只要这个接口符合函数式接口的定义,它就是函数式接口。
上面的案例一直在对比匿名内部类和Lambda,很多人可能在心里已经自动把Lambda等同于匿名内部类,认为Lambda是匿名内部类的语法糖。
然而并不是。
复制下方代码运行看看:
@SpringBootTest
public class LambdaTest {
@Test
public void testClosure() throws InterruptedException {
// 在匿名内部类的外面定义一个String变量
final String str = "hello";
// 构造一个匿名内部类对象
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println(str);
}
};
new Thread(r).start();
TimeUnit.SECONDS.sleep(1);
}
}
接下来,我们给上面的代码加上一句:
打印结果:
注意,带$表示匿名内部类,比如LambdaTest$1表示LambdaTest中的匿名内部类,编译后会产生两个文件。
但如果把Runnbale换成Lambda表达式实现:
你会发现Lambda表达式方法体内部的this指向了LambdaTest,而且编译后也只有一个class文件。
其实也难怪啦,Lambda方法体外部并没有匿名内部类,当然只能指向LambdaTest。更准确地说,this是指向方法的调用者,是隐式传递的。从这个角度看,Lambda和匿名内部类本质上还是不同的。
以后编码时,再遇到这种编译错误就不会迷惑了:
改成Lambda表达式即可,因为Lambda表达式外层就是当前类的实例:
根据IDEA提示,做一下简化:
关于方法引用,后面会介绍。
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬