「Java 进阶之路」系列 Day39
写在前面
上一篇讲Stream时用到了Map.Entry::getKey这类写法,一带而过说它是Lambda的简写。但方法引用远不止"省去手写参数转发"这么简单------它有四种形式,写法看着差不多,编译器怎么区分参数该传给谁,规则并不直观。这篇把四种形式和它们各自对应的Lambda等价写法一次讲清楚。
一、是什么:四种方法引用及其Lambda等价形式
方法引用统一用::分隔"类或对象"和"方法名",按引用的方法类型和调用方式,分成四种:
| 形式 | 语法 | 等价Lambda | 例子 |
|---|---|---|---|
| 静态方法引用 | 类名::静态方法 |
x -> 类名.静态方法(x) |
Integer::parseInt |
| 特定对象的实例方法引用 | 对象::实例方法 |
x -> 对象.实例方法(x) |
System.out::println |
| 特定类型的任意对象的实例方法引用 | 类名::实例方法 |
(x, y) -> x.实例方法(y) |
String::compareTo |
| 构造方法引用 | 类名::new |
x -> new 类名(x) |
ArrayList::new |
java
// 1. 静态方法引用:Integer.parseInt(s)本身就是静态方法
Function<String, Integer> parse = Integer::parseInt;
// 2. 特定对象的实例方法引用:out是System类里一个已经存在的具体对象
Consumer<String> printer = System.out::println;
// 3. 特定类型的任意对象的实例方法引用:a、b是调用时传入的两个字符串
BiFunction<String, String, Integer> compare = String::compareTo;
// 4. 构造方法引用:new ArrayList<>()
Supplier<List<String>> creator = ArrayList::new;
四种形式里最容易搞混的是第二种和第三种------两者都写成X::方法名的样子,但含义完全不同:第二种的X是一个已经创建好的具体对象 (比如System.out),第三种的X是一个类型 (比如String),实际调用时的对象是Lambda参数列表里传进来的那个。
二、为什么:区分依据是函数式接口的方法签名,不是::前面写的是什么
方法引用能不能编译通过,本质上还是回到Day37讲过的规则------Lambda(以及方法引用)的类型由它要实现的目标函数式接口决定 。编译器拿到X::method这样一个方法引用,会去看目标接口抽象方法的参数个数和类型,反推这个方法引用应该按哪种形式解析。
以BiFunction<String, String, Integer> compare = String::compareTo为例,目标接口BiFunction的抽象方法是Integer apply(String t, String u),需要两个String参数。而String类里的compareTo方法签名是int compareTo(String other)------它是一个实例方法,调用时需要一个隐式的调用者this加一个显式参数other,正好凑够两个String。于是编译器把BiFunction需要的两个参数,理解成"第一个参数是调用compareTo的对象本身,第二个参数是传给compareTo的参数",这就还原出了等价Lambda(a, b) -> a.compareTo(b)。
反过来看Consumer<String> printer = System.out::println,Consumer的抽象方法是void accept(String t),只需要一个参数。而System.out已经是一个具体存在的PrintStream对象,println(String x)只差一个参数,这个参数正好对应accept传进来的那个值------这就是第二种"特定对象的实例方法引用",::前面的System.out已经把调用者锁定了,不需要再从Lambda参数里匀出一个当调用者。
一句话总结这套判断逻辑:看::左边写的是类型还是具体对象。是具体对象,说明调用者已经确定,目标接口的全部参数都用来匹配方法自身的参数;是类型名,且方法是实例方法,说明调用者还没确定,目标接口的第一个参数会被拿去当调用者,剩下的参数才匹配方法自身的参数。
三、怎么用:真实场景代码 + 常见坑
场景:订单排序与去重
java
class Order {
private String orderId;
private BigDecimal amount;
public int compareToByAmount(Order other) {
return this.amount.compareTo(other.amount);
}
// getter省略
}
List<Order> orders = new ArrayList<>();
// 静态方法引用:BigDecimal.ZERO这类静态字段访问不算方法引用,但工具类的静态方法很常用
orders.removeIf(order -> order.getAmount().compareTo(BigDecimal.ZERO) <= 0);
// 特定类型的任意对象的实例方法引用:按金额排序,两个Order对象都来自遍历过程
orders.sort(Order::compareToByAmount);
// 特定对象的实例方法引用:日志对象logger已经创建好,只是把打印动作交给它
orders.forEach(order -> log(order));
Consumer<Order> logger = System.out::println;
// 构造方法引用:Stream.collect时用来创建承载结果的容器
List<String> orderIds = orders.stream()
.map(Order::getOrderId)
.collect(Collectors.toCollection(ArrayList::new));
Order::compareToByAmount看着是"类名::实例方法",很容易以为它是给Comparator<Order>用的静态比较逻辑,但其实编译器把它还原成(a, b) -> a.compareToByAmount(b)------第一个Order参数被当成了调用者,第二个才是传给方法的参数,这也是为什么compareToByAmount只写了一个参数、却能匹配Comparator需要两个参数的compare方法。
坑一:重载方法引用时,目标接口没写清楚会导致编译器无法判断用哪个重载
java
class Printer {
void print(String s) { }
void print(int i) { }
}
Printer printer = new Printer();
// 如果只写Printer::print,没有一个明确的目标函数式接口告诉编译器参数类型,会直接编译报错:不知道选哪个重载
Consumer<String> c = printer::print; // 这样写没问题,Consumer<String>已经把参数类型锁定成String
方法引用本身不携带参数类型信息,它依赖目标函数式接口的抽象方法签名反推。如果方法引用被赋值的上下文里,目标类型的参数类型是明确的(比如Consumer<String>),编译器就能据此从重载方法里选出匹配的那个;但如果上下文本身也含糊(比如直接把printer::print传给一个用泛型擦除、参数类型不明确的位置),就会因为无法判断到底该匹配哪个重载而报错。
坑二:构造方法引用对应的目标接口,参数个数必须完全匹配某个构造方法
java
class Order {
public Order() { }
public Order(String orderId) { }
}
Supplier<Order> noArg = Order::new; // 匹配无参构造方法
Function<String, Order> withId = Order::new; // 匹配单参数构造方法
同一个Order::new写法,会根据目标函数式接口的抽象方法参数个数,自动匹配对应参数个数的构造方法------这一点和方法引用的其他三种形式规则是一致的:永远是先看目标接口需要几个参数、参数类型是什么,再去反向匹配::右边对应个数和类型的方法(或构造方法),不存在"构造方法引用是特殊情况"这回事。
坑三:把"特定对象的实例方法引用"错当成"特定类型的任意对象的实例方法引用"
java
List<String> names = List.of("Alice", "Bob");
String prefix = "Mr. ";
// 错误理解:望文生义,以为map时传入的每个name会被当成调用者去调用concat,
// 也就是以为效果是 name -> name.concat(prefix)(每个name反过来拼上prefix)
List<String> wrong = names.stream()
.map(prefix::concat)
.collect(Collectors.toList());
System.out.println(wrong); // 实际输出[Mr. Alice, Mr. Bob],而不是[Alice Mr. , Bob Mr. ]
prefix是一个已经创建好的具体对象 ,不是类型名,调用者早被锁定成prefix了,map传入的每个name只是作为参数拼在prefix后面,不会反过来把name当调用者------这就是为什么结果是prefix.concat(name)("Mr. Alice"),而不是想象中的name.concat(prefix)("Alice Mr. ")。判断依据还是回到第一节讲过的规则:::左边写的是具体对象还是类型名。
四、面试追问
Q1:方法引用一共有几种形式?怎么区分?
四种:静态方法引用(类名::静态方法)、特定对象的实例方法引用(对象::实例方法)、特定类型的任意对象的实例方法引用(类名::实例方法)、构造方法引用(类名::new)。区分关键看::左边是具体对象还是类型名:是具体对象,调用者已确定,属于第二种;是类型名且引用的是实例方法,调用者来自目标函数式接口传入的第一个参数,属于第三种。
Q2:String::compareTo能赋值给BiFunction<String, String, Integer>,这个过程编译器是怎么推导的?
编译器先看目标接口BiFunction的抽象方法apply需要两个String参数,再去看compareTo的方法签名int compareTo(String other)------它是实例方法,调用需要一个隐式调用者加一个显式参数。于是编译器把BiFunction传入的第一个参数当作调用compareTo的对象,第二个参数当作传给compareTo的参数,还原出等价Lambda(a, b) -> a.compareTo(b),参数个数和类型都对得上,编译通过。
Q3:System.out::println和String::compareTo都是类::方法的写法,为什么参数个数需求不一样?
区别在于::左边是具体对象还是类型。System.out是已经存在的PrintStream对象,调用者已经确定,目标接口Consumer只需要提供println自身要的那一个参数;而String是类型名,代表调用者还没确定,目标接口BiFunction需要多提供一个参数专门用来充当调用compareTo的对象,所以两者对目标接口的参数个数要求不同。
Q4:为什么方法引用不能脱离目标函数式接口单独存在?
因为方法引用本身不携带参数类型和个数的信息,它是通过反向匹配目标函数式接口的抽象方法签名,才能确定具体要引用哪个方法(包括方法重载的场景下选中哪一个重载版本、构造方法引用要匹配哪个构造器)。这跟Day37讲的Lambda表达式类型由上下文决定是同一套原理------方法引用只是Lambda的一种更紧凑的写法,没有独立于目标接口的类型。
下一篇预告
Day40 讲Optional------它并不是简单地把null判断换个写法,而是用一套链式API从设计上强迫调用方处理"值可能不存在"这件事,把NPE的隐患提前暴露在编译期能看到的地方。