Java“禁止”泛型数组

时间:2022-09-10 11:46:48

Java“禁止”泛型数组

原文:https://blog.csdn.net/yi_Afly/article/details/52058708

1. 泛型定义
泛型编程是一种通过参数化的方式将数据处理与数据类型解耦的技术,通过对数据类型施加约束(比如Java中的有界类型)来保证数据处理的正确性,又称参数类型或参数多态性。
泛型最著名的应用就是容器,C++的STL、Java的Collection Framework。

2. 泛型的实现方式
不同的语言在实现泛型时采用的方式不同,C++的模板会在编译时根据参数类型的不同生成不同的代码,而Java的泛型是一种违反型,编译为字节码时参数类型会在代码中被擦除,单独记录在Class文件的attributes域内,而在使用泛型处做类型检查与类型转换。
假设参数类型的占位符为T,擦除规则如下:

  • <T>擦除后变为Obecjt
  • <? extends A>擦除后变为A,设置泛型的上限为 A
  • <? super A>擦除后变为Object ,设置泛型的下限为 A

上述擦除规则叫做保留上界。

使用 ? 表示:可以接受任意的泛型类型,但是不知道当前泛型设置的具体类型,自然也就不能设置当前泛型的类型的数据,只能进行读取数据操作。

3. 擦除带来的问题
对于<? extends A>和<? super A>的擦除,因为保留上界,所以擦除后并没有破坏里氏替换原则。
设有类Super与Sub:

class Super{}
class Sub extends Super{}

对于有界类型的协变性与逆变性:

父类引用或指针指向子类对象(参数类型的逆变和返回类型的协变)
// 参数类型的逆变(contravariance)是指实现的参数类型是接口或委托定义的参数类型的父类。
// 返回类型的协变(covariance)指返回类型是接口或委托定义返回类型的子类

List<? extends Super> list = new ArrayList<Sub>(); //协变 用一个子类对象去替换相应的一个父类对象
List<? super Sub> list2 = new ArrayList<Super>(); //逆变 用一个父类对象去替换相应的一个子类对象

类型擦除后,等价于:

List<Super> list = new ArrayList<Sub>();
List<Object> list2 = new ArrayList<Super>();

可以看出,参数类型的擦除并没有破坏里氏替换原则,这也是保留上界的原因。

感谢 Java中的逆变与协变(http://www.cnblogs.com/en-heng/p/5041124.html) 这篇文章,让我很好理解了协变与逆变、PECS规则。

对于<T>的擦除,根据T在类中出现位置的不同,分以下5种情况讨论:

  • T是成员变量的类型
  • T是泛型变量(无论成员变量还是局部变量)的类型参数,常见如Class<T>,List<T>。
  • T是方法抛出的Exception(要求<T extends Exception>)
  • T是方法的返回值
  • T是方法的参数

情况1的擦除不会有任何影响,因为编译器会在泛型被调用的地方加上类型转换;
情况2的擦除也不会有问题,这个问题有点像“要实现不可变类,就要保证成员变量中引用指向的类型也是不可变的”,是个递归定义;
情况3的擦除,我认为讨论这种情况意义不大。想在方法中抛出T,那得先实例化T,而如何通过泛型进行实例化,原谅我不知道怎么能做到(有人说反射能做到,怪我反射学的不好……);假设现在得到并可以抛出泛型T的实例,来看一下会出现什么情况。
设有类Super与Sub:

class Super<T extends SQLException>{
public void test() throws T{} //别怀疑,这段代码是可以编译通过的......
} class Sub extends Super<BatchUpdateException>{
@Override
public void test() throws BatchUpdateException{} //这里必须与参数类型保持一致,否则编译不通过。
}

Super的参数类型被擦除之后,变成了:

class Super<SQLException>{
public void test() throws SQLException{}
}

与Sub类对比后,发现并没有违背Java中方法重写(Override)的规则。

Java中Override的规则有一个好记的口诀,叫“两同两小一大”(其实叫“两同两窄一宽”我觉得更好),说的是子类方法与父类方法的异同:

  • - 子类方法的方法名&参数列表与父类方法的相同。
  • - 子类方法的返回类型是父类方法返回类型的子类(协变返回类型,范围更窄);
  • - 子类方法抛出的异常少于父类方法抛出的异常(范围更窄);
  • - 子类方法的访问控制权限大于父类方法(访问范围更宽)。

这个规则可以很方便的用里氏替换原则反推出来。显然这里类型擦除后并没有违反重写时对异常的规定。

情况4是讲T作为返回类型时的被擦除,因为协变返回类型的存在,它同样不会有问题。
设有两个类Super与Sub:

class Super<T>{
T test(){}
}
class Sub extends Super<String>{
@Override
protected String test(){} //这里抖个包袱:protected拥有比package更高的访问权限,可以被同一包内的类访问
}

类型擦除后,Super变为:

class Super{
Object test(){}
}

与Sub类对比后,能看到它并没有违反“两同两小一大”口诀,所以也不会有问题。

这个叫做协变返回类型,即子类方法的返回值是父类方法的子类(绕口令一样…)。JVM在实现它时用到了桥方法(ACC_BRIDGE),后面会有介绍。

情况1,2,3,4都做了分析,发现在现有的语言规范下,类型擦除并不会带来影响,而情况5会有些不一样。
设有类Super与Sub

class Super<T>{
public void test(T arg){}
}
class Sub extends Super<String>{
@Override
public void test(String str){}
}

再来看Super的参数类型被擦除后:

class Super{
public void test(Object arg){}
}
class Sub extends Super{
@Override
public void test(String str){}
}

这次我连Sub一并写出来了,是为了方便对比:上述代码编译时不通过的,因为子类重写方法的参数列表与父类的不一致了!子类是String而父类是Object。
但是,我们按照类型擦除前的写法来写,编译器并没有报错,执行结果也证明我们真的重写了方法,那么Java(准确的说编译器)是怎么做到的呢?请看下面一张图,是Sub类的字节码:

Java“禁止”泛型数组

注意到,Sub类有两个test方法,一个的参数类型是String,这是Sub中重写的方法;另外一个的参数类型是Object,并且flags中多了ACC_BRIDGE与ACC_SYNTHETIC两个标签。查看深入理解Java虚拟机 6.3.6节 表6-12,ACC_BRIDGE表示这是由编译器生成的桥方法,ACC_SYNTHETIC表示这个方法是由编译器自动生成的。注意看这个方法都做了什么:

checkcast #19
invokevirtual #21

Sub的常量池见下表:

Java“禁止”泛型数组

可以看到,桥方法首先判断了Object到String的类型转换是否正确。invokevirtual是调用对象方法的指令,根据对象的实际类型进行分派。从常量池中可以看出,桥方法调用了Sub类中原有的重写方法。这样就保证了情况5下的类型擦除不会破坏方法重写的语义。

4. 协变返回类型的桥方法
协变返回类型也是使用桥方法来实现的,下图是字节码:

Java“禁止”泛型数组

有趣的是:一个Class文件中出现了两个签名一样,只是返回值不一样的方法。如果是在Java源代码中出现这种情况,编译是不会通过的。为什么编译之后的Class文件中就可以了呢?
仔细想一下,Java源代码中之所以不允许这么重载方法,是为了避免方法调用时产生歧义,比如:

public Object test(){
return "obj";
}
public String test(){
return "str";
}
public static void main(String[] args){
System.out.println(new Super().test());
}

此时编译器是无法确定调用哪个test()的,所以干脆禁止出现这种情况。而在运行期,JVM有足够的方法去区分这种二义性(比如用ACC_BRIDGE或ACC_SYNTHETIC这两个flag),所以就可以允许这种情况存在了。

讲了两个问题:Java泛型是如何实现的(简单讲),这种实现会带来什么问题(着重在讲)。据此牵扯出了里氏替换原则、协变与逆变、协变返回类型、两同两小一大口诀(就这个名字最low…)、桥方法这些概念。
这篇博文旨在讲解原理,对实际应用太多帮助。后面还会陆续整理一些博文,比如Arrays.sort的源码、比如泛型如何实现协变与逆变。

--------------------------------------------------------

协变和逆变都是术语,前者指能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型,后者指能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型。协变和逆变是指宽类型和窄类型在某种情况下(如参数、泛 型、返回值)替换或交换的特性。

简单地说A和B是类型,f表示类型转换,≤表示子类型关系:

协变:你可以用一个子类对象去替换相应的一个父类对象。如果A≤B 则f(A) ≤ f(B) 那么 f是协变的 。如果能在泛型接口或者委托中保证,类型参数只能外部取出而不允许外部传入。那么就可以避免将类型参数作为参数传入方法的情况。可以通过在类型参数前面加out来解决这个问题。

逆变:你可以用一个父类对象去替换相应的一个父类对象。如果A≤B 则f(B) ≤ f(A) 那么 f 是逆变的。如果能在泛型接口或者委托中保证,类型参数只能作为参数从外部传入而不允许将其取出。那么就不存在将类型参数作为返回值返回的情况了。可以通过在类型参数前面加in来解决这个问题。

不变:对于不支持协变和逆变的情况称为不变性。上面两种都不成立,那么f是无关的。

Java从1.5开始支持泛型[1],Martin Odersky[2]:“当Java刚出现时,Bill Joy和James Gosling以及其他Java组成员都认为,Java应该有泛型,只是他们没有足够的时间做出详细设计。所以由于Java中没有泛型,至少最初阶段没有,他们就认为,数组不得不是协变的。例如,这意味着一个字符串(String)数组是一个对象(Object)数组的子类型。其原因是他们希望能够重写,比如,一个“通用”排序方法,采用了一个对象数组和一个用来排序该数组的比较器,然后让你传送一个字符串数组的参数给它。通常情况下这属于类型不健全。这就是为什么在Java中你会获得一个数组存储例外。这实际上也证明,这种同样的事情引起了对于数组泛型实现的需求。这就是为什么在Java中泛型并不好使。你不能定义一个字符串的列表数组,这是不可能的。你只能*使用难看的原始类型,永远都只能是一个列表数组。因此,这有点类似原罪。他们对此做出了非常迅速的回应,认为这是一个快速破解。但随后实际上每一个设计决定都被毁灭了。因此,为了不陷入同样的陷阱,我们不得不中断,并提出现在我们将不向上兼容Java,我们也想做一些不同的事情。”

Java中使用通配符处理泛型的variance(变体、变型、可变性)问题。“通配符无疑非常复杂:由 Java 编译器产生的一些令人困惑的错误消息都与通配符有关,Java 语言规范中最复杂的部分也与通配符有关。”[3]

  • 如果你想从一个数据类型里获取数据,使用 ? extends 通配符
  • 如果你想把对象写入一个数据结构里,使用 ? super 通配符
  • 如果你既想存,又想取,那就别用通配符。

“这就是Maurice Naftalin在他的《Java Generics and Collections》这本书中所说的存取原则,以及Joshua Bloch在他的《Effective Java》这本书中所说的PECS法则。”[4]“即 PECS 原则 (producser-extends, consumer-super) 或者也叫 Get and Put 原则”[5]。

来实际说明一下协变性和不可性在java中的具体体现。

一、java的数组就具有协变性

数组的协变性是指如果类Base是类Sub的基类,那么Base[]就是Sub[]的基类。数组是协变的导致了很多问题的出现,Object[]类型的引用可以指向一个String[]类型的对象。但是运行的时候是会报出如下异常的:Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer 试图将错误类型的对象存储到一个对象数组时抛出的异常。这样导致数组的类型并不安全。数组则是在运行时做类型检查,因为能够记得数组内部元素的具体类型。

二、java的泛型就具有不变性

泛型是不可变的,List<Base>不会是List<Sub>的基类,更不会是它的子类。为了遵守泛型类型安全原则,所以在泛型“编译时进行类型检查”特性决定了其不可协变。因为在泛型进行类型擦除,所以只能选择在编译期进行类型检查,因为一旦运行起来这个类型就会被擦除掉,如果类型不一致编译就不会通过。为了让泛型更灵活也可以具备协变性,就可以使用通配符来模拟协变。

Java“禁止”泛型数组
查阅资料,找到了The Java™ Tutorials: Generics (http://docs.oracle.com/javase/tutorial/extra/generics/fineprint.html) ,其中讲到了泛型数组,并说道:除非使用通配符,否则一个数组对象的元素不能是泛型。这么做的原因,是为了防止下述代码产生的类型安全问题:

// Not really allowed.
List<String>[] lsa = new List<String>[10]; //
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
// Unsound, but passes run time store check
oa[1] = li; // Run-time error: ClassCastException.
String s = lsa[1].get(0); //

如果允许泛型数组的存在(第1处代码编译通过),那么在第2处代码就会报出ClassCastException,因为lsa[1]是List<Integer>。Java设计者本着首要保证类型安全(type-safety)的原则,不允许泛型数组的存在,使得编译期就可以检查到这类错误。

解决方案
但是连Java的设计者也承认,这样在使用上很令人恼火(原文是annoying),所以提供了变向的解决方案:显式类型转换。

1 通配符

The Java™ Tutorials: Generics给出的解决方案如下:

List<?>[] lsa = new List<?>[10];                //
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
// Correct.
oa[1] = li;
// Run time error, but cast is explicit.
String s = (String) lsa[1].get(0); //

在第1处,用?取代了确定的参数类型。根据通配符的定义以及Java类型擦除的保留上界原则,在2处lsa[1].get(0)取出的将会是Object,所以需要程序员做一次显式的类型转换。

2 反射
使用java.util.reflect.Array,可以不使用通配符,而达到泛型数组的效果:

List<String>[] lsa = (List<String>[])Array.newInstance(ArrayList.class, 4);     //
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
// Correct.
oa[1] = li;
// Run time error, but cast is explicit.
String s = lsa[1].get(0); //

可以看到,我们利用了Array.newInstance()生成了泛型数组,这里没有使用任何通配符,在第2处也没有做显式的类型转换,但是在第1处,仍然存在显式类型转换。我在牛客网刷题时遇到的问题,就是通过这种方式来解决的。

要想使用泛型数组,要求程序员必须执行一次显示的类型转换,也就是将类型检查的问题从编译器交给了程序员。实际上Java的设计者正是此意,在(List<String>[])Array.newInstance(ArrayList.class, 4)处会有一个unchecked warning,正是编译器在提醒程序员:这个地方,我不会帮你做类型检查,你要自己小心!。

有趣的问题
在探究泛型数组的过程中,我写了一些实验代码,其中有一段:

List<Integer>[] lsa = (List<Integer>[])Array.newInstance(ArrayList.class, 4);
Object o = lsa;
Object[] oa = (Object[]) o;
List<String> li = new ArrayList<String>();
li.add("asdf");
// Correct.
oa[1] = li;
// Run time error, but cast is explicit.
System.out.println(lsa[1].get(0)); //

这段代码不会在2处抛出ClassCastException,相反,它运行正常,输出的结果是asdf。这打破了我的理解,为什么从一个List<String>中取出一个Integer不会报错呢?

如果将上述代码中参数类型的String与Integer互换:

List<String>[] lsa = (List<String>[])Array.newInstance(ArrayList.class, 4);
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
// Correct.
oa[1] = li;
// Run time error, but cast is explicit.
System.out.println(lsa[1].get(0)); //

2处的ClassCastException就会正常抛出。
为了找到原因,我查看了这两段代码的字节码。

问题的原因找到了:第一段代码中,lsa[1].get(0)取出的Integer没有做类型检查(checkcast),而在第二段代码中做了类型检查。
为什么会这样呢?我分析的原因是:第一段代码中,编译器发现下一行调用PrintStream.println,参数类型还是Object(因为System.out.println()这个方法,对于引用类型只有char[]、String、Object三类参数,没有Integer),所以编译器在此处做了优化,免去了类型检查和类型转换的处理。

  • Java为了保证类型安全,牺牲掉了泛型数组的使用灵活性,程序员想使用的话,必须进行显式的类型转换。
  • 编译器并不是在泛型被使用的每一处都进行了类型检查与类型转换,还是存在一定优化的。所以说“Java的泛型是一种违泛型,编译为字节码时参数类型会在代码中被擦除,单独记录在Class文件的attributes域内,而在使用泛型处做类型检查与类型转换”,这句话是不严谨的。

参考:

[1] Java1.5泛型指南中文版(Java1.5 Generic Tutorial)
http://blog.csdn.net/explorers/article/details/454837

[2] Scala创始人:创造比Java更好的语言
http://developer.51cto.com/art/200905/124636_all.htm

[3] Java 理论与实践: 使用通配符简化泛型使用
http://www.ibm.com/developerworks/cn/java/j-jtp04298.html

[4] Java泛型简明教程
http://www.vaikan.com/java-generics-quick-tutorial/

[5] java泛型的理解
http://hongjiang.info/java-generics/

[6] 数组协变带来的静态类型漏洞
http://rednaxelafx.iteye.com/blog/379703

================= End