很久没写博客了,因为项目和一些个人原因。最近复习找工作,看书+回想项目后有一些心得,加上博客停更这么长时间以来的积累,很是有些东西可写。从今儿开始,慢慢把之前积累的东西补上来,方便以后查漏补缺。
先从最近的开始。昨天看到Java泛型相关的内容,有些疑惑,查资料之后发现这部分很有些有意思的东西,比如类型擦除带来的重写问题等等,一并记录在这篇文章里。
1. 泛型定义
看了很多泛型的解释百度百科,解释1,解释2,都不是我想要的“以用为本”答案(没讲明白泛型的作用或者说设计目的),这里我自己总结一下:
泛型编程是一种通过参数化的方式将数据处理与数据类型解耦的技术,通过对数据类型施加约束(比如Java中的有界类型)来保证数据处理的正确性,又称参数类型或参数多态性。
泛型最著名的应用就是容器,C++的STL、Java的Collection Framework。
2. 泛型的实现方式
不同的语言在实现泛型时采用的方式不同,C++的模板会在编译时根据参数类型的不同生成不同的代码,而Java的泛型是一种违反型,编译为字节码时参数类型会在代码中被擦除,单独记录在Class文件的attributes域内,而在使用泛型处做类型检查与类型转换。
假设参数类型的占位符为T,擦除规则如下:
- <T>擦除后变为Obecjt
- <? extends A>擦除后变为A
- <? super A>擦除后变为Object
上述擦除规则叫做保留上界
。
3. 擦除带来的问题
对于<? extends A>和<? super A>的擦除,因为保留上界
,所以擦除后并没有破坏里氏替换原则
。
设有类Super与Sub:
class Super{}
class Sub extends Super{}
对于有界类型
的协变性与逆变性:
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中的逆变与协变这篇博文的作者,让我很好理解了协变与逆变、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类的字节码:
注意到,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的常量池见下表:
可以看到,桥方法首先判断了Object
到String
的类型转换是否正确。invokevirtual
是调用对象方法的指令,根据对象的实际类型进行分派。从常量池中可以看出,桥方法调用了Sub类中原有的重写方法。这样就保证了情况5下的类型擦除不会破坏方法重写的语义。
4. 协变返回类型的桥方法
协变返回类型
也是使用桥方法来实现的,下图是字节码:
有趣的是:一个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),所以就可以允许这种情况存在了。
5. 总结
通篇讲了两个问题:Java泛型是如何实现的(简单讲),这种实现会带来什么问题(着重在讲)。据此牵扯出了里氏替换原则
、协变与逆变
、协变返回类型
、两同两小一大口诀
(就这个名字最low…)、桥方法
这些概念。
这篇博文旨在讲解原理,对实际应用太多帮助。后面还会陆续整理一些博文,比如Arrays.sort的源码、比如泛型如何实现协变与逆变。