泛型究竟是如何工作的?

19

在查找(测试)另一个问题的信息时,我偶然发现了一些事情,完全不知道它为什么会发生。现在,我知道没有实际理由这样做,而且这是绝对可怕的代码,但为什么这个代码能够工作:

ArrayList<Quod> test=new ArrayList<Quod>();
ArrayList obj=new ArrayList();
test=obj;
obj.add(new Object());

System.out.println(test.get(0));

所以,基本上,我正在将一个对象添加到Quods的ArrayList中。现在,我明白Java无法有效地检查这一点,因为它必须查看所有引用,这些引用可能甚至未存储在任何地方。但是为什么get()方法可以工作呢?当你把鼠标放在Eclipse上时,它不是应该返回Quod的实例吗?如果它可以返回一个只是对象的对象,而承诺返回Quod类型的对象,为什么我不能在说要返回int时返回String?

而且事情变得更加奇怪。这会像它应该的那样因为运行时错误(java.lang.ClassCastException错误)而崩溃(!?!):

ArrayList<Quod> test=new ArrayList<Quod>();
ArrayList obj=new ArrayList();
test=obj;
obj.add(new Object());

System.out.println(test.get(0).toString());
为什么我不能在Object上调用toString?为什么println()方法可以调用它的toString,但是我不能直接调用它?

编辑:我知道我没有对我创建的第一个ArrayList实例做任何事情,所以它基本上只是浪费处理时间。


编辑:我正在使用Java 1.6上的Eclipse。其他人说他们在运行Java 1.8的Eclipse中获得相同的结果。然而,在一些其他编译器上,无论哪种情况都会抛出CCE错误。


2
你没有向 ArrayList<Quod> 添加任何内容,当你重新分配 test 时,你正在丢弃对它的引用。 - azurefrog
1
在我的eclipse中,使用Java 1.6时,顶部的输出为java.lang.Object@1e63e3d,并且没有抛出任何错误。 - WiErD0
2
你为什么删除了你的答案?它是迄今为止最好的答案,我本来打算在5分钟计时器结束后立即接受它。 - WiErD0
1
这还不是那么简单。(使用sun-jdk-7) - Sotirios Delimanolis
1
@pbabcdefp,这是因为有一个特殊版本的println接受一个String参数。所有其他类都使用接受Object的版本。 - Ian McLaird
显示剩余7条评论
2个回答

19

Java泛型是通过类型擦除来实现的,即类型参数仅用于编译和链接,但在执行时被擦除。也就是说,编译时类型和运行时类型之间没有一对一的对应关系。特别地,一个泛型类型的所有实例共享同一个运行时类:

new ArrayList<Quod>().getClass() == new ArrayList<String>().getClass();
在编译时类型系统中,存在类型参数并用于类型检查。在运行时类型系统中,类型参数不存在,因此不会被检查。
这本来不是问题,但涉及到强制类型转换和原始类型就会有问题。强制类型转换是类型正确性的断言,并将类型检查从编译时延迟到运行时。但是如前所述,编译时类型和运行时类型之间没有一对一的对应关系;类型参数在编译期间被擦除。因此,运行时不能完全检查包含类型参数的强制类型转换的正确性,错误的强制类型转换可能会成功,违反编译时类型系统。Java语言规范将此称为堆污染。
因此,运行时无法依赖于类型参数的正确性。尽管如此,它必须执行运行时类型系统的完整性以防止内存损坏。它通过延迟类型检查直到实际使用泛型引用来实现这一点,在此时,运行时知道它必须支持的方法或字段,并可以检查它是否实际上是声明该字段或方法的类或接口的实例。
回到您的代码示例,我稍微简化了一下(但不改变其行为)。
ArrayList<Quod> test = new ArrayList<Quod>();
ArrayList obj = test; 
obj.add(new Object());
System.out.println(test.get(0));
声明的obj类型是裸类型ArrayList。 裸类型禁用编译时类型参数检查。 因此,尽管在编译时类型系统中ArrayList只能保存Quod实例,但我们可以向其add方法传递一个Object。 也就是说,我们成功地欺骗了编译器并完成了堆污染。
这留给了运行时类型系统。 在运行时类型系统中,ArrayList与Object类型的引用一起工作,因此将Object传递给add方法是完全可以的。 调用get()同样如此,它也返回Object。 这里有些许不同: 在您的第一个代码示例中,您有:
System.out.println(test.get(0));
test.get(0) 的编译时类型是 Quod,唯一匹配的 println 方法是 println(Object),因此该方法的签名被嵌入到类文件中。在运行时,我们因此将一个 Object 传递给 println(Object) 方法。这是完全可以的,因此不会抛出异常。
在您的第二个代码示例中,您有:
System.out.println(test.get(0).toString());

再次强调,test.get(0) 的编译时类型仍然是 Quod,但这次我们调用了它的 toString() 方法。因此编译器指定要调用类型 Quod 中声明的(或继承的)toString 方法。显然,该方法需要 this 指向一个 Quod 实例,这就是为什么编译器在调用方法之前会在字节码中插入一个额外的 Quod 强制转换 - 而这个强制转换会抛出一个 ClassCastException

也就是说,第一个代码示例在运行时被允许,因为引用没有以特定于 Quod 的方式使用,但第二个被拒绝,因为引用用于访问类型 Quod 的方法。

话虽如此,您不应该依赖编译器何时插入这个合成转换,而是通过编写正确类型的代码来防止堆污染发生。Java编译器有责任在您的代码可能导致堆污染时发出未经检查的和原始类型警告来协助您。消除这些警告,您就不必理解这些细节;-)。


2
我真的不太理解这个。toString() 方法并不是特定于 Quod 类的,它是 Object 类的一个方法。在运行时,根据 Quod 实例的运行时类型动态选择 toString() 方法。例如,如果类 Rod 继承自 Quod,编译器无法知道将调用哪个 toString() 方法(Quod 中的还是 Rod 中的),因此编译器无法指定要调用哪个 toString() 方法。那么首先将其转换为 Quod 的目的是什么呢? - Paul Boddington
3
Java语言支持接口和类的演进,并尽可能保留二进制兼容性。在将源代码转换为类文件时,Java假设其他类型的存在、子类型关系和成员尽可能少。具体而言,如果编译器将最不具体超类的名称(如您所建议的)编码成方法声明,那么如果该类型不再是超类型或不再声明该方法(例如,因为该方法已移至子类),链接该方法调用就会失败。 - meriton
1
这将特别令人烦恼,因为源代码不需要更改。 - meriton
2
可以说,对于toString()方法可以做一个例外,因为它的存在是由Java语言规范所要求的。虽然这个特殊情况可能不值得额外增加复杂性。 - meriton
这个答案没有解释为什么println()方法调用它的toString是可以的,但我直接调用就不行? - Kshitiz Sharma
显示剩余4条评论

4

问题的核心是:

为什么println()方法调用其toString没有问题,但我直接调用就有问题?

ClassCastException异常并不是由于调用toString()而发生的,而是由编译器添加的显式转换引起的。

一图胜千言,让我们看一下反编译的代码。

考虑以下代码:

public static void main(String[] args) {
    List<String> s = new ArrayList<String>();
    s.add("kshitiz");
    List<Integer> i = new ArrayList(s);

    System.out.println(i.get(0)); //This works
    System.out.println(i.get(0).toString()); // This blows up!!!
}

现在看一下反编译的代码:
public static void main(String[] args) {
    ArrayList s = new ArrayList();
    s.add("kshitiz");
    ArrayList i = new ArrayList(s);
    System.out.println(i.get(0));
    System.out.println(((Integer)i.get(0)).toString());
}

看到对 Integer 的显式转换了吗?为什么编译器在上一行没有添加转换呢?方法 println() 的签名是:
public void println(Object x)

由于println期望一个Object,并且i.get(0)的结果是Object,因此不需要添加转换。

如果您像这样调用toString(),那么也可以:toString()

public static void main(String[] args) {
    List<String> s = new ArrayList<String>();
    s.add("kshitiz");
    List<Integer> i = new ArrayList(s);

    myprint(i.get(0));
}

public static void myprint(Object arg) {
    System.out.println(arg.toString()); //Invoked toString but no exception
}

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