为什么将 <T extends Animal> 的 List<List<Animal>> 强制转换为 List<List<T>> 会导致编译错误,而将 List<Animal> 强制转换为 List<T> 则只会出现警告?

4

问题灵感来源于 嵌套泛型集合的强制类型转换问题

链接问题的简化版本:
(请不要关注下面方法的目的,除了为了展示问题外,没有其他意义)

//class Animal may have subclasses like Dog, Cat, etc..

public static <T extends Animal> void foo(){
    List<List<Animal>> animalGroups = new ArrayList<>();
    List<List<T>> list = (List<List<T>>) animalGroups; //ERROR about incompatible types
}

public static <T extends Animal> void bar(){
    List<Animal> animals = new ArrayList<>();
    List<T> list = (List<T>) animals; //WARNING about unchecked cast
}

在上面的例子中:

  • foo方法中,我们在(List<List<T>>) animalGroups处得到编译错误
  • bar方法中,我们只得到警告,在(List<T>) animals处。

为什么编译器对这些情况有不同的反应?

注意:

只是为了澄清,我并不是在问为什么这些例子不能编译没有问题

我理解List<Dog> 不是 List<Animal>

所以像这样的代码

List<Dog> dogs = animals;

不能编译,因为这会破坏类型安全性,我们可以将 Cat 添加到 animals 列表中,这意味着我们将该 Cat 添加到 dogs 列表中(因为 animals 和 dogs 将引用相同的列表)。

同时进行显式转换也不行。

List<Dog> dogs = (List<Dog>) animals;

这并不会“说服”编译器,让我们知道我们在做什么,因为我们仍然可能在类型安全性方面存在同样的漏洞——猫仍然可以通过animals添加到dogs中。


我的猜测

(List<T>) animals 的情况下,我们收到警告而不是错误,因为这样的代码有可能安全地工作。具体来说,当 <T extends Animal> 表示 Animal 类型本身时。

因此,如果我们像这样调用方法 bar:MyClass.<Animal>bar();,那么我们会陷入以下情况:

List<Animal> list = (List<Animal>) animals;

这是可以的(强制转换是多余的,但是被允许的)。

我的猜测有问题

如果我的假设是正确的,那么为什么将(List<List<T>>) animalGroups进行强制转换会产生错误而不是警告呢?

应该适用相同的逻辑:

  1. 注意到<T extends Animal>可以接受Animal类型本身作为值
  2. 注意到在这种情况下,List<List<Animal>> list = (List<List<Animal>>) animalGroups;是可以的。

但是由于(List<T>) animals(List<List<T>>) animalGroups的结果不同,我个人认为

  1. 编译器在 List<List<T>> 中的 T 不像在 List<T> 中那样进行分析(可能是因为它是 内部泛型类型 的一部分)。
  2. (List<List<T>>) animalGroups 存在一个问题,而在 (List<T>) animals 中不存在,我错过了它。

问题在于 List<Animal>T 不兼容,因为 T 扩展了 Animal,而不是 Animal 的列表。我可以理解你为什么对这段代码的目的进行了概括,因为似乎没有人会有意地编写这样的代码。 - Robert Harvey
你可以尝试查看JLS(强制类型转换窄化转换似乎相关)。老实说,从我的阅读来看,这看起来像是一个javac错误(即,你的怀疑基本上是正确的,两种情况都应该是警告)。 - HTNW
1
@shmosel 不,这很重要。强制转换(List<T>)listOfAnimals是允许的,正是因为(请参见我的JLS链接)可能会出现T = Animal的情况,因此这种转换不像(List<Dog>)listOfAnimals那样“愚蠢”(总是错误)。 (从List<T>List<Animal>存在缩小的引用转换,因为(各种超类型的)这些类型不能被证明是不同的,这是因为TAnimal不能被证明是不同的。)所以问题是,为什么相同的推理不允许(List<List<T>>)listsOfAnimals,因为在T = Animal的情况下,这种转换也不是错误的? - HTNW
我认为你的怀疑是错误的,编译器允许强制转换是因为List<Animal>的所有元素都可能是Ts,而不是T可能是Animal。 - Turo
@Turo我完全同意这些示例在当前形式下似乎没有任何用处。但就像我在问题开始时所说的那样,它们受到其他问题的启发,而且作者解释了他的真实用例:https://dev59.com/CsXsa4cB1Zd3GeqPo6bV#meKO3IgBF4m1g3Yk80wX。无论如何,“foo成功当且仅当T是动物”的问题是*它不是*,因为编译器决定在这里生成错误,与`bar`相反,在这里它生成*警告*。 - Pshemo
显示剩余11条评论
1个回答

2

这是Java的工作原理,因为这就是泛型的意思,正如您已经了解的一样(方差的奇怪性质意味着允许List<Animal> = listOfDogs;让你向你的狗列表中添加猫那是不好的 - 这就是泛型不变的要点),而且至关重要的是,因为泛型是javac想象出来的东西!

Java没有使用泛型。在Java 1.5之前,泛型根本不是一种东西。我们只是这样编写:

/**
 * Adopts a {@link com.foo.animals.Dog dog}; a dog will be taken from the kennel and added to your list of pets.
 * @param list List of pets.
 */
void adoptDog(List pets) { ... }

即将要处理的是,pets 被视为动物列表的概念是基于文档和上下文线索中的名称来确定的,并通过“跟随链接” - 即意识到 Dog 被定义为 extends Animal,因此可以假设将几只狗收养到新建的列表中意味着任何假定 pets 仅包含 Animal 实例的代码都没问题。

Java1.5 引入了泛型,但泛型是完全由 javac 控制的 - 它只是使其官方化:编译器检查的文档。字面上:JVM 规范(JVM Specification,即 java.exe不知道泛型是什么。Javac 消除了大部分内容;它进入类文件的少数地方是 java.exe 完全忽略的。它按照规范应该对它们进行的操作:它知道如何读取它们(否则它甚至无法理解类文件),但它直接跳过这些内容。

当你写下这个:

class Foo<T extends Bar> {
  public <Z super Foo> void foo() {}
}

泛型确实会出现在类文件中,但唯一的原因是因为javac可以基于源文件和类文件混合运行,并且需要知道这些类文件中的泛型才能发挥其作用。

你应该尝试玩弄一下以下代码:

Javac为您注入转换,而java.exe不关心损坏

您无法使Java编译此代码:

void foo(String str) {
  int foo = str;
}

作为例子,尝试以某种方式分配内存位置/压缩ref,str在JVM级别下或底层直接访问它作为一个int(或者在64位架构上的long)。虽然您可以尝试操作类文件并编辑字节码来完成此操作:
NB: Not real bytecode, just serves to explain the idea

PUSH // push a string ref from the constant pool
POPI 1 // pop the top of the stack into an int-typed local var slot

但是,如果你尝试这样做,一旦类文件被加载,你会得到一个ClassVerifierError。JVM实际上需要一些努力(并且JVM规范要求它这样做)来确保这样的花招不在其中。如果有,整个类文件本身就会被直接拒绝。

相比之下,如果你尝试使用泛型进行精确的相同戏法,java.exe根本不在乎,并且会很愉快地运行它。这是因为javac -它自己- 这样做了。在运行时没有泛型。

void foo(List<String> list) {
  return list.get(0).toLowerCase();

编译成完全相同的字节码为:

void foo(List list) { // raw type - anything goes.
  return ((String) list.get(0)).toLowerCase();

试试吧!运行javap -c -v CompiledClassFile来查看字节码。

这两者之间在字节码级别上唯一的区别是一个微小的变化:参数类型为List<String> 被存储在类文件中,而JVM并不知道这意味着什么。但是javac知道,并且如果javac尝试编译调用此foo方法的某些代码,则会考虑到它。

那么为什么这完全没有问题,而将字符串引用“pop”到int变量中的尝试如此严重,以至于JVM花费时间扫描类字节码,找到它,并拒绝整个文件?

因为javac——注入了——那个强制转换,而强制转换JVM本身(java.exe)知道的东西,严格应用,因此可以防止核心转储或其他严重的堆损坏;如果你通过某种方式调用包含非字符串的列表的方法,你只会得到一个ClassCastException。让我们试试吧!

List list = new ArrayList(); // raw warning, we'll ignore it.
list.add(Integer.valueOf(5)); // not a string!
foo(list);

上面的代码将抛出ClassCastException异常。奇怪的是,它是在一个根本不包含任何转换的行上抛出的。这是因为javac插入了它。javac编译它,而java.exe的验证器也完全没有问题。

Javac还应用了一个检查,纯粹作为编译器的一步;如果您尝试调用foo方法并传递一个List<Integer>,例如,javac本身将拒绝编译并发出错误。但仅此而已 - javac只是拒绝了。它可以生成JVM将接受和运行的合法字节码。如果列表为空,您永远不会知道它已损坏。如果它不为空,那么就会发生ClassCastException,这不是安全或堆栈损坏问题。异常是“安全”的,它们不会导致核心转储、内存损坏、潜在的缓冲区溢出等问题。如果您修补javac以不抱怨,则生成的字节码将很好(如果调用它,则可能会引发异常,但这没关系)。

编译器允许在存在安全代码的情况下进行转换。就泛型而言,是正确的,但并不能解释你所见的现象。这只是为了方便你们而已。何必让你写毫无意义的代码呢?那只会让你写出错误的程序。
对于`(List someListOfListsOfAnimals)//编译错误`,是个怪异的问题,不是吗?你总是可以强制执行。这个代码可以编译:
List<List<Animal>> a = new ArrayList<>();
List /* raw */ temp = a;
List<List<T>> b = (List<List<T>>) temp;

我们甚至可以将这些转换组合成一个怪物:

List<List<T>> a = (List<List<T>>) listOfListsOfAnimals; // error!
List<List<T>> a = (List<List<T>>) (List) listOfListsOfAnimals; // warning!

那第二行,看起来很愚蠢,实际上“有效”(编译;它仍然是一个警告;每当你告诉javac关于泛型的事情时,你都会得到一个警告,因为javac是这些东西的第一道防线和最后一道防线;如果javac被告知不关心它,没有任何人会关心它,因此,javac告诉你:好吧,伙计,你自己处理吧,在这里,一个警告可以确保你完全理解这些内容的承诺,这是唯一防止一些非常奇怪的错误出现在你的代码库中的东西!)。

那么,为什么?主要是“因为规范这样说”。 “为什么规范这样说”?因为……它就是这样,在某个时候你会问:当他们写下这些内容时,规范的作者在想什么,这是无法回答的,除非可能是作者本人,但据我所知,他们不经常访问Stack Overflow。

一个人可能会猜测。使用“raw”可以,因为“raw”模式允许任何内容,这正是“raw”模式的目的。仅看泛型中的内容,你正在尝试将List<Animal>强制转换为List<T>,这些类型根本没有关系(因为泛型是不变的,因此它们与彼此不同,就像IntegerString一样 - 两者都不是对方的超类型)。规范简单地说,不允许将同级别(不相关的类型)强制转换为彼此。因此,对于指定此事项的同一行规范:
Integer i = ...;
String s = (Integer) i;

这应该被视为编译器错误,因为它是无意义的,现在你的强制类型转换也被标记为直接错误,尽管在这里不太清楚,因为即使类型名义上完全不相关,T extends Animal定义为T的List<List<T>>List<List<Animal>>感觉非常奇怪,因此不能像这样将一个转换为另一个。


谢谢你的回答(抱歉晚了,阅读和理解其他地方的回答和评论并不容易)。请给我一些时间。无论如何,在“因此,人们可以假设将几只狗收养到新建的列表中意味着任何假定pets仅包含Animal实例的代码都没问题。” 我认为你的意思是“...假定pets仅包含Dog实例的代码也没问题*”(即使Dog在技术上是Animal)。 - Pshemo
这个答案的前半部分似乎不相关:它解释了为什么问题中的强制转换是不安全的(因此为什么两者至少应该是警告),而 OP 知道这一点。后半部分至少有点错误。首先,规范并不是魔法,它的意图通常相当明显。(它甚至在其中加入了一些注释来解释它的决定!)其次,我对规范的阅读告诉我,它将问题中的两个强制转换都指定为警告。如果您能支持“规范如此”(我很想听到 javac 没有 bug!)。 - HTNW
@Pshemo 是的,无论哪种方式 - 假设我稍后将同一列表交给 adoptCat,关键是,在泛型之前,了解列表中对象的类型的唯一方法是检查您自己添加到其中的内容以及您提供给列表的任何方法的文档。_使用_泛型,实际上是相同的事情,只是现在是 javac 为您执行此检查。关键是,这仍然是一个单独的检查,并且您可以通过玩快速和松散来“打破”它 - 例如,忽略那些警告。与 Dog foo = method(); 相反,其中 foo 不能 指代猫。期。 - rzwitserloot
再次感谢您的回答。关于“为什么要使用这样的代码”的想法,不用担心,我从未需要也没有计划使用它。但是,由于我认为这是一个有趣的现象,我决定询问一下,也许其他人也会觉得有趣。此外,我意识到我不会得到作者每个想法的完整答案。但是JLS也可以。如果它包括一些有道理的推理,那就更好了,因为JLS作者可能有许多编写它的原因,我只需要一个能让我理解这样的决定而不是记住它的原因。 - Pshemo
可能是在1.5版本之前,我们在语言规范中写明了“同级转换”(例如从String到Integer)是非法的,因为没有必要。在引入泛型之后,尽管同级转换有时仍然有意义,但没有人费心去复杂化同级转换规则,也没有人决定摆脱它。 - rzwitserloot

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接