前言
首先说一下写这篇博客的原因。最近在学习Vert.x,用 Vert.x 写代码会大量使用回调函数,而且更推荐函数式的写法。在实践过程中遇到了几个问题,所以决定系统地总结一下函数式编程。
先说一下自己遇到的问题:
package org.xqd.learning.deployer;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.AsyncResult;
public class Deployer extends AbstractVerticle {
@Override
public void start() throws Exception {
int delay = 2000;
for (int i = 0; i < 50; i++) {
vertx.setTimer(delay, data -> deployer());
delay += 1000;
}
}
private void deployer() {
vertx.deployVerticle(new EmptyVerticle(), this::checkDeployer);
}
private void checkDeployer(AsyncResult<String> ar) {
if (ar.succeeded()) {
String id = ar.result();
vertx.setTimer(2000, tid -> undeployer(id));
// vertx.setTimer(2000, this::undeployer);
// vertx.setTimer(2000, tid -> undeployer(tid));
// System.out.println("timer 返回的ID: " + l);
} else {
System.out.println("部署失败");
}
}
private void undeployer(String id) {
vertx.undeploy(id, ar -> {
if (ar.succeeded()) {
System.out.println("这个ID已经下线: " + id);
} else {
System.out.println("这个ID下线失败: " + id);
}
});
}
}
介绍一下各个方法:
-
deployer:部署一个Verticle,然后回调checkDeployer方法;
-
checkDeployer:
- 入参是一个AsyncResult类型的数据。部署Verticle成功后,可以通过ar获取到部署的结果,比如Verticle对应的ID;
- 如果verticle部署成功,那么设置一个定时器,2秒钟后将对应的Verticle下线,回调undeployer方法;
-
undeployer:
- 接收一个Verticle的ID,然后下线一个Verticle;
这段代码来自于《Vert.x in action》一书。我的疑问是下面这段代码:
vertx.setTimer(2000, tid -> undeployer(id));
我看了一下源码:
long setTimer(long delay, Handler<Long> handler);
Handler接口:
@FunctionalInterface
public interface Handler<E> {
void handle(E event);
}
结合setTimer方法和Handler接口的定义,setTimer方法的handler签名是:入参是Long类型,方法没有出参。
//vertx.setTimer(2000, this::undeployer);
vertx.setTimer(2000, tid -> undeployer(tid));
private void undeployer(Long aLong) {
}
这两种写法没有区别。
但是明显和上面的代码有区别:
vertx.setTimer(2000, tid -> undeployer(id));
private void undeployer(String id) {
}
undeployer方法的入参是String类型,并不是Long类型。
但是方法可以正常地执行,很是不解。
所以想系统地学习和总结一下Java的函数式编程。
下面是文心一言的一个总结:
- Lambda表达式的参数:在 tid -> undeployer(id) 中,tid 是Lambda表达式的参数,它的类型由 Handler 接口决定,因此 tid 是一个 Long 类 型的变量。
- Lambda表达式体内的方法调用:在Lambda表达式体内调用 undeployer(id)。这里的 id 并不是Lambda表达式的参数 tid,而是Lambda表达式外部作用域中的一个变量(很可能是这个Lambda表达式被调用时所在方法的局部变量,或者是一个类成员变量)。
- 编译器的行为:编译器在检查这个Lambda表达式时,会验证Lambda表达式的签名是否与它实现的函数式接口(在这里是 Handler)兼容。由于Lambda表达式接受一个 Long 类型的参数(tid),并且没有返回值(与 void handle(Long event) 方法兼容),因此从签名的角度来看,这个Lambda表达式是有效的。
本篇文章的代码基本来自于《Java 8 in action》,代码地址:github.com/java8/Java8…
如果想了解更多,推荐阅读该书。
一个例子
假设有一个Apple类,它有一个getColor方法,还有一个变量inventory保存着一个Apples的列表。如果想要选出所有的绿苹果,并返回一个列表。在Java 8之前可能会写这样一个方法filterGreenApples:
public static List<Apple> filterGreenApples(List<Apple> inventory){
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory){
if ("green".equals(apple.getColor())) {
result.add(apple);
}
}
return result;
}
但是如果有其他的新需求,比如:筛选超过150克的苹果:
public static List<Apple> filterHeavyApples(List<Apple> inventory){
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory){
if (apple.getWeight() > 150) {
result.add(apple);
}
}
return result;
}
如果这两个方法之间的差异仅仅是if条件不同,那么只要把if条件作为参数传递给filter就行了,比如指定 (150, 1000)来选出重的苹果(超过150克),或者指定(0, 80)来选出轻的苹果(低于80克)。
在 Java 8会把条件代码作为参数传递进去,这样可以避免filter方法出现重复的代码。现在可以写:
public static boolean isGreenApple(Apple apple) {
return "green".equals(apple.getColor());
}
public static boolean isHeavyApple(Apple apple) {
return apple.getWeight() > 150;
}
public interface Predicate<T>{
boolean test(T t);
}
static List<Apple> filterApples(List<Apple> inventory, Predicate<Apple> p) {
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory){
if (p.test(apple)) {
result.add(apple);
}
}
return result;
}
也可以这样写:
filterApples(inventory, Apple::isGreenApple);
或者这样写:
filterApples(inventory, Apple::isHeavyApple);
上面从繁到简的过程就是Java8函数式的一个体现。
下面开始讲解如何化繁为简。
再续前例
前面的例子是筛选绿苹果,如果要筛选红苹果该怎么做呢?简单的解决办法就是复制这个方法,把名字改成filterRedApples,然后更改 if条件来匹配红苹果。然而,如果继续筛选多种颜色:浅绿色、暗红色、黄色等,这种方法就应付不了了。
一个良好的原则是在编写类似的代码之后,尝试将其抽象化。
再展身手:把颜色作为参数
一种做法是给方法加一个参数,把颜色变成参数,这样就能灵活地适应变化了:
public static List<Apple> filterApplesByColor(List<Apple> inventory, String color) {
List<Apple> result = new ArrayList<Apple>();
for (Apple apple: inventory){
if (apple.getColor().equals(color)) {
result.add(apple);
}
}
return result;
}
现在可以根据传递的参数控制筛选的苹果:
List<Apple> greenApples = filterApplesByColor(inventory, "green");
List<Apple> redApples = filterApplesByColor(inventory, "red");
如果现在要求根据重量进行筛选,可以支持不同的重量:
public static List<Apple> filterApplesByWeight(List<Apple> inventory, int weight) {
List<Apple> result = new ArrayList<Apple>();
For (Apple apple: inventory){
if (apple.getWeight() > weight){
result.add(apple);
}
}
return result;
}
在日常的快速迭代开发中,我自己就会这么写😂,毕竟不需要过多地思考和设计,可以很快地完成功能。
第三次尝试:对属性做筛选
一种把所有属性结合起来的笨拙尝试如下所示:
public static List<Apple> filterApples(List<Apple> inventory, String color, int weight, boolean flag) {
List<Apple> result = new ArrayList<Apple>();
for (Apple apple: inventory){
if ((flag && apple.getColor().equals(color)) || (!flag && apple.getWeight() > weight)){
result.add(apple);
}
}
return result;
}
代码看上去糟透了。true和false是什么意思?
此外,这个解决方案还是不能很好地应对变化的需求。如果要求对苹果的不同属性做筛选,比如大小、形状、产地等,又怎么办?而且,如果要求组合属性,做更复杂的查询, 比如绿色的重苹果,又该怎么办?
如果继续按照上面的写法则会有好多个重复的filter方法,或一个巨大的非常复杂的方法。
行为参数化
一种可能的解决方案是选择标准建模:考虑的是苹果,需要根据Apple的某些属性(比如它是绿色的吗?重量超过150克吗?)来返回一个 boolean值。我们把它称为谓词(即一个返回boolean值的函数)。让我们定义一个接口来对选择标准建模:
public interface ApplePredicate{
boolean test (Apple apple);
}
现在可以用ApplePredicate的多个实现代表不同的选择标准了:
仅仅筛选苹果的重量:
public class AppleHeavyWeightPredicate implements ApplePredicate{
public boolean test(Apple apple){
return apple.getWeight() > 150;
}
}
仅仅选出绿色的苹果:
public class AppleGreenColorPredicate implements ApplePredicate{
public boolean test(Apple apple){
return "green".equals(apple.getColor());
}
}
可以把这些实现ApplePredicate接口做了不同实现的类看做filter方法的不同行为。
上面做的这些和“策略设计模式”相关, 它定义一族算法,把它们封装起来(称为“策略”),然后在运行时选择一个算法。在这里,算法族就是ApplePredicate,不同的策略就是AppleHeavyWeightPredicate和AppleGreenColorPredicate。
该怎么利用ApplePredicate的不同实现呢?需要filterApples方法接受 ApplePredicate 对象,对Apple做条件测试。
这就是行为参数化:让方法接受多种行为(或战略)作为参数,并在内部使用,来完成不同的行为。
要在我们的例子中实现这一点,要给filterApples方法添加一个参数,让它接受 ApplePredicate对象。
这在软件工程上有很大好处:现在把 filterApples 方法迭代集合的逻辑与要应用到集合中每个元素的行为(这里是一个谓词)区分开了。
第四次尝试:根据抽象条件筛选
利用ApplePredicate改过之后,filter方法看起来是这样的:
public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p){
List<Apple> result = new ArrayList<>();
for(Apple apple: inventory){
if(p.test(apple)){
result.add(apple);
}
}
return result;
}
现在可以创建不同的ApplePredicate对象,并将它们传递给filterApples 方法。
如果要找出所有重量超过150克的红苹果,则只需要创建一个类来实现 ApplePredicate 就行了:
public class AppleRedAndHeavyPredicate implements ApplePredicate{
public boolean test(Apple apple){
return "red".equals(apple.getColor()) && apple.getWeight() > 150;
}
}
List<Apple> redAndHeavyApples = filterApples(inventory, new AppleRedAndHeavyPredicate());
filterApples方法的行为取决于你通过ApplePredicate对象传递的代码。换句话说,filterApples 方法的行为参数化!
但令人遗憾的是,由于该filterApples方法只能接受对象, 所以必须把代码包裹在ApplePredicate对象里。
但是实现类中大部分代码都是冗余的,只有下面截图的红框内才是真正发生变化的部分:
行为参数化的好处在于可以把迭代要筛选的集合的逻辑与对集合中每个元素应用的行为区分开来。这样可以重复使用同一个方法,给它不同的行为来达到不同的目的。
对付啰嗦
目前,当要把新的行为传递给 filterApples方法的时候,不得不声明好几个实现ApplePredicate接口的类,然后实例化好几个只会提到一次的ApplePredicate对象。
public class AppleHeavyWeightPredicate implements ApplePredicate{
public boolean test(Apple apple){
return apple.getWeight() > 150;
}
}
public class AppleGreenColorPredicate implements ApplePredicate{
public boolean test(Apple apple){
return "green".equals(apple.getColor());
}
}
public class FilteringApples{
public static void main(String...args){
List<Apple> inventory = Arrays.asList(new Apple(80,"green"), new Apple(155, "green"), new Apple(120, "red"));
List<Apple> heavyApples = filterApples(inventory, new AppleHeavyWeightPredicate());
List<Apple> greenApples = filterApples(inventory, new AppleGreenColorPredicate());
}
public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory){
if (p.test(apple)){
result.add(apple);
}
}
return result;
}
}
费这么大劲儿真没必要,能不能做得更好呢?
Java有一个机制称为匿名类,它可以让你同时 声明和实例化一个类。它可以帮助你进一步改善代码,让它变得更简洁。
第五次尝试:使用匿名类
通过匿名类过滤红色苹果:
List<Apple> redApples = filterApples(inventory, new ApplePredicate() {
public boolean test(Apple apple){
return "red".equals(apple.getColor());
});
}
第六次尝试:使用 Lambda 表达式
上面的代码在Java 8里可以用Lambda表达式重写为下面的样子:
List<Apple> result = filterApples(inventory, (Apple apple) -> "red".equals(apple.getColor()));
这段代码:
(Apple apple) -> "red".equals(apple.getColor())
就是前面test()方法的一个实现。
为截止到目前的代码做一个小结:
第七次尝试:将 List 类型抽象化
在通往抽象的路上还可以更进一步。目前,filterApples 方法还只适用于Apple。还可以将List类型抽象化,从而超越眼前要处理的问题:
public interface Predicate<T>{
boolean test(T t);
}
public static <T> List<T> filter(List<T> list, Predicate<T> p){
List<T> result = new ArrayList<>();
for(T e: list){
if(p.test(e)){
result.add(e);
}
}
return result;
}
现在可以把filter方法用在香蕉、桔子、Integer或是String的列表上了。这里有一个使用Lambda表达式的例子:
List<Apple> redApples = filter(inventory, (Apple apple) -> "red".equals(apple.getColor()));
List<Integer> evenNumbers = filter(numbers, (Integer i) -> i % 2 == 0);
从Lambda表达式到方法引用
在前面第七次尝试中使用了Lambda表达式,它可以让你很简洁地表示一个行为或传递代码。
现在可以把 Lambda 表达式看作匿名功能,它基本上就是没有声明名称的方法,但和匿名类一样,它也可以作为参数传递给一个方法。
Lambda 管中窥豹
可以把Lambda表达式理解为简洁地表示可传递的匿名函数的一种方式:它没有名称,但它 有参数列表、函数主体、返回类型,可能还有一个可以抛出的异常列表。
理论上来说,在Java 8之前做不了的事情,Lambda也做不了。但是,现在用不着再用匿名类写一堆笨重的代码,可以直接使用Lambda表达式实现。最终结果代码变得更清晰、更灵活。
Lambda的基本语法:
(parameters) -> expression
或者(请注意花括号)
(parameters) -> { statements; }
在哪里以及如何使用 Lambda
在上一章中实现的filter方法中使用Lambda:
List<Apple> greenApples = filter(inventory, (Apple a) -> "green".equals(a.getColor()));
那到底在哪里可以使用Lambda呢?可以在函数式接口上使用Lambda表达式。
在上面的代码中,可以把Lambda表达式作为第二个参数传给filter方法,因为它这里需要 Predicate,而这是一个函数式接口。
函数式接口
什么是函数式接口呢?就是只定义一个抽象方法的接口。
public interface Predicate<T>{
boolean test (T t);
}
那么用函数式接口可以干什么呢? Lambda表达式允许直接以内联的形式为函数式接口的抽象方法提供实现,并把整个表达式作为函数式接口的实例(具体说来,是函数式接口一个具体实现的实例)。
用匿名内部类也可以完成同样的事情,只不过比较笨拙,需要提供一个实现,然后再直接内联将它实例化。
现在有了Lambda表达式,直接跳过实现类、匿名内部类,而是直接为函数式接口的方法提供一个实现(也可以说直接过函数式接口的方法提供一个实例)。
函数描述符
函数式接口的抽象方法的签名基本上就是Lambda表达式的签名,可以将这种抽象方法叫作函数描述符。
如何判断Lambda表达式的签名是什么呢?以Runnable接口为例:
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
Runnable接口可以看作一个什么也不接受什么也不返回(void)的函数的签名,因为它只有一个叫作run的抽象方法,这个方法什么也不接受,什么也不返回(void)。
它的Lambda表达式签名可以写作:() → void
只要Lambda表达式的签名和函数式接口的抽象方法签名保持一致,那么就可以把Lambda表达式看做函数式接口的抽象方法的实现,可以传给对应的参数。
-
签名看哪几个部分
- 入参和出参
类型检查、类型推断以及限制
当第一次提到Lambda表达式时,说它可以为函数式接口生成一个实例。然而,Lambda 表达式本身并不包含它在实现哪个函数式接口的信息。
为了全面了解Lambda表达式需要知道Lambda的实际类型是什么。
类型检查
Lambda的类型是从使用Lambda的上下文推断出来的。上下文(比如,接受它传递的方法的参数,或接受它的值的局部变量)中Lambda表达式需要的类型称为目标类型。
下面以filter方法为例:
List<Apple> heavierThan150g = filter(inventory, (Apple a) -> a.getWeight() > 150);
filter 方法要求它第二个参数是Predicate(目标类型)对象;
Predicate是一个函数式接口,定义了一个叫作test的抽象方法。
test方法描述了一个函数描述符,它可以接受一个Apple,并返回一个boolean。
filter的任何实际参数都必须匹配这个要求。
有了目标类型的概念,同一个Lambda表达式就可以与不同的函数式接口联系起来,只要它们的抽象方法签名能够兼容。
使用局部变量
迄今为止所介绍的所有Lambda表达式都只用到了其主体里面的参数。但Lambda表达式 也允许使用自由变量(不是参数,而是在外层作用域中定义的变量),就像匿名类一样。 它们被 称作捕获Lambda。
例如,下面的Lambda捕获了portNumber变量:
int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
Lambda可以没有限 制地捕获(也就是在其主体中引用)实例变量和静态变量。但局部变量必须显式声明为final, 或事实上是final。换句话说,Lambda表达式只能捕获指派给它们的局部变量一次。(注:捕获 实例变量可以被看作捕获最终局部变量this。) 例如,下面的代码无法编译,因为portNumber 变量被赋值两次:
int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
portNumber = 31337;
方法引用
方法引用让你可以重复使用现有的方法定义,并像Lambda一样传递它们。在一些情况下, 比起使用Lambda表达式,它们似乎更易读,感觉也更自然。
借助Java 8 API,用方法引用写的一个排序的例子:
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
之后(使用方法引用和java.util.Comparator.comparing):
inventory.sort(comparing(Apple::getWeight));
方法引用可以被看作仅仅调用特定方法的Lambda的一种快捷 写法。它的基本思想是,如果一个Lambda代表的只是“直接调用这个方法”,那最好还是用名称 来调用它,而不是去描述如何调用它。事实上,方法引用就是根据已有的方法实现来创建 Lambda表达式。但是,显式地指明方法的名称,代码的可读性会更好。
当需要使用方法引用时,目标引用放在分隔符::前,方法的名称放在后面。
例如, Apple::getWeight就是引用了Apple类中定义的方法getWeight。请记住,不需要括号,因为 10 你没有实际调用这个方法。方法引用就是Lambda表达式(Apple a) -> a.getWeight()的快捷 写法。
总结
回顾一下前面的问题
vertx.setTimer(2000, tid -> undeployer(id));
private void undeployer(String id) {
}
Lambda表达式:tid -> undeployer(id) 符合Handler 接口的签名。
因为入参tid是一个long类型,undeployer方法的出参是void,所以符合Hander 的抽象方法的函数描述符:
long setTimer(long delay, Handler<Long> handler);
@FunctionalInterface
public interface Handler<E> {
void handle(E event);
}
Long —> void.
那么id是怎么回事呢?就是前面提到的捕获Lambda。
如果这样写可能就便于理解:
private void checkDeployer(AsyncResult<String> ar) {
if (ar.succeeded()) {
String id = ar.result();
vertx.setTimer(2000, tid -> {
System.out.println(id);
});
} else {
System.out.println("部署失败");
}
}
在{}内可以访问局部变量id。
但是如果声明一个方法替代{}:
private void checkDeployer(AsyncResult<String> ar) {
if (ar.succeeded()) {
String id = ar.result();
vertx.setTimer(2000, tid -> undeployer(id));
} else {
System.out.println("部署失败");
}
}
private void undeployer(String id) {
vertx.undeploy(id, ar -> {
if (ar.succeeded()) {
System.out.println("这个ID已经下线: " + id);
} else {
System.out.println("这个ID下线失败: " + id);
}
});
}
如果通过参数传递的方式,那么在undeployer方法内没有办法访问id。
因为id已经不属于undeployer方法内的声明。
虽然Lambda表达式声明了tid,但是undeployer方法没有使用,这个是允许的。
但是如果使用方法引用的方式,那么就不能传递其他参数了。因为方法引用形式会按照抽象方法的声明进行传参:
private void checkDeployer(AsyncResult<String> ar) {
if (ar.succeeded()) {
String id = ar.result();
vertx.setTimer(2000, this::undeployer);
} else {
System.out.println("部署失败");
}
}
private void undeployer(Long aLong) {
}
最后的最后:
-
Lambda表达式和方法引用可以替代啰里八嗦的的匿名类,Lambda表达式其实就是函数式接口的抽象方法的实例(或实现)。
-
只要Lambda表达式的签名和函数式接口的抽象方法的函数描述符一致,就可以传递给函数式对象;
-
判断Lambda表达式的签名(或方法引用)和函数式接口的抽象方法的函数描述符是否一致只需要看两个参数:
- Lambda表达式的入参(箭头 → 前的入参)。
- 主体{}或方法的出参。
-
最重要的是入参的判断。因为如果将Lambda表达式的主体提取成一个单独的方法,可能这个方法也会有其他的入参。但是一定不是方法声明的入参,而是要看箭头 → 前的入参。
-