Java 8泛型方法引用与类型推断完全指南

最近有个读者在Stack Overflo.

最近有个读者在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>,失败。

从内到外的尝试

好的,换一头:

  • parentListList<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>(毕竟ParentObject的子类)。然后就悲剧了: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()就完事儿了。没那么复杂。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注