最近有个读者在Stack Overflow上问了一个很有意思的问题:在Java 8里,用方法引用配合泛型方法时,编译器居然推断了出Object类型。具体来说,他在父类里定义了一个带泛型的方法返回值,结果在Stream里用Parent::get直接歇菜了。
我们今天就来把这个问题掰开了揉碎了讲,顺便聊聊Java类型推断的那点事儿。
先来看看问题代码
这位兄弟的父类大概长这样:
public class Parent {
public T get() {
// ...
}
}
然后他在Stream里这么用:
List parentList = new ArrayList<>();
List collect = parentList.stream().map(Parent::get).collect(Collectors.toList()); // 编译报错
报错信息是:
no instance(s) of type variable(s) exist so that Object conforms to Parent
inference variable T has incompatible bounds: equality constraints: Parent lower bounds: Object
有意思的是,换种写法就OK了:
List parentList = new ArrayList<>();
Function func = Parent::get;
List collect = parentList.stream().map(func).collect(Collectors.toList()); // 正常运行
你说气人不气人?同样是Parent::get,换个马甲就不认识了。
Java的类型推断:两头烧的蜡烛
要理解这个问题,得先知道Java编译器的类型推断是怎么工作的。它用两套策略:从内到外(inside-out)和从外到内(outside-in)。
从内到外是标准做法。比如:
List x = someCollection.stream().map(String::toLowerCase).toList();
编译器先看someCollection是什么类型,然后推出stream()返回什么,再推出map()返回什么,一环扣一环。
从外到内呢?主要是为了搞定Lambda和方法引用。比如:
String::toLowerCase;
这玩意儿单独放那儿是没有任何意义的。编译器得知道它所在的上下文需要什么样的函数式接口,才能推断这到底是Function<String, String>还是别的什么。
问题来了:Java允许方法重载,这两种推断方式来回切换的时候,情况会变得非常复杂。编译器可不敢无限递归下去,不然分分钟给你编译一年。
到底卡在哪里了?
让我们一步步拆解下面这行代码的推断过程:
List collect = parentList.stream().map(Parent::get).toList();
从外到内的尝试
编译器看到toList()返回List<Parent>,于是反推:
toList()的签名是Stream<T>: List<T> toList(),所以T必须是Parent- 那么
.map(Parent::get)必须返回Stream<Parent> map的签名是Stream<U>: <R> Stream<R> map(Function<? super U, ? extends R> mapper)- 到这里R锁定了
Parent,但U是个未知数
推断不动了。编译器试着把U当成Object,结果Parent::get不符合Function<Object, Parent>,失败。
从内到外的尝试
好的,换一头:
parentList是List<Parent>,所以parentList.stream()是Stream<Parent>- 它的
map方法是Stream<T>: <R> Stream<R> map(Function<? super T, ? extends R> mapper) - 代入已知条件,变成
<R> Stream<R> map(Function<? super Parent, ? extends R> mapper)
到这里又卡住了——R到底是啥?
你可能会想:Parent::get本身不就告诉编译器R应该是Parent吗?
问题是,编译器必须先知道上下文需要什么函数式接口,才能解释Parent::get是什么意思。在还没确定R是什么之前,编译器没办法反过来利用方法引用来推断类型。
于是编译器只能默认R是Object——因为Parent::get可以匹配Function<Parent, Object>(毕竟Parent是Object的子类)。然后就悲剧了:toList()返回List<Object>,没法赋值给List<Parent>。
你想让编译器回溯?门都没有。编译器的回溯能力有限,不会为了这个来回跑。
两头结合也不行
理论上,如果把两种推断方式的结果结合起来,是可以推出正确结果的:
- 从外到内:R = Parent
- 从内到外:T = Parent
但Java的编译器不玩这套——它不会把两种推断结果综合起来推导一个类型。分别尝试都失败了之后,编译器会再试一次,把所有类型都默认成Object——不出所料,还是失败,最后就给你抛出那个编译错误。
怎么修?给编译器递个话
核心思路就是:给编译器一个明确的类型提示,让两头的推断能接上。
方案一:直接在map里写类型参数
List collect = parentList.stream().<Parent>map(Parent::get).toList();
这个<Parent>就是给编译器的一个hint,告诉它R就是Parent。从内到外的推断瞬间就通畅了。
方案二:先赋值给Function变量
Function<Parent, Parent> func = Parent::get;
List<Parent> collect = parentList.stream().map(func).collect(Collectors.toList());
这就是你发现的那个能工作的写法。手动指定了函数式接口的类型,编译器就不用猜了。
方案三:换一种方法设计
如果你的方法根本不需要这种泛型返回值的设计,直接改成:
public Parent get() {
// ...
}
什么问题都没了。后面那些糟心事根本不需要去面对。
⚠️ 重要警告:那个泛型方法设计有坑
最后必须说一下原题里的那个方法:
public T get() {
// ...
}
如果你真的写成这样,这代码基本没用,甚至危险。
它的意思是:”调用者随便选一个T,只要T是Parent或者Parent的子类就行,我返回的值必须符合调用者选的这个T”。
但问题在于:你根本不知道调用者选了啥。你没办法保证返回一个符合任意T的值。
这种写法唯一合法的情况是:
- 永远返回
null(因为null是所有类型的子类) - 永远不正常返回——比如抛异常、死循环、或直接
System.exit() - 或者,你硬加个类型转换
(T),然后要么忽略编译警告,要么用@SuppressWarnings强压下去
大多数情况下,如果你想让方法返回Parent,直接写public Parent get()就完事儿了。没那么复杂。