Java泛型机制详解
带着问题阅读
1、什么是Java泛型,有什么用处
2、Java泛型的实现机制是什么
3、Java泛型有哪些局限和限制
Java泛型介绍
引入泛型之前,试想编写一个加法器,为处理不同数字类型,就需要对不同类型参数进行重载,但其实现内容是完全一样的,如果是一个更复杂的方法,无疑会造成重复。
public int add(int a, int b) {return a + b;}
public float add(float a, float b) {return a + b;}
public double add(double a, double b) {return a + b;}
一般的类和方法,只能使用具体的类型,要么是基本类型,要么是自定义的类。如果要编写可以应用于多种类型的代码,这种刻板的限制对代码的束缚就会很大。《Java编程思想》
Java在1.5版本引入泛型,通过泛型实现的加法代码可简化为:
public <T extends Number> double add(T a, T b) {
return a.doubleValue() + b.doubleValue();
}
泛型的核心概念是参数化类型,使用参数指定方法类型,而非硬编码。泛型的出现带给我们很多好处,其中最重要的莫过于对集合类的改进,避免了任意类型都可以丢到同一个集合里的不可靠问题。
然而Python和Go的集合可以容纳任意类型,这究竟是进步还是退步呢
Java泛型使用简介
泛型一般有三种使用方式:泛型类、泛型接口和泛型方法。
泛型类
public class GenericClass<T> {
private T member;
}
...
// 初始化时指定泛型类型
GenericClass<String> instance = new GenericClass<String>();
泛型接口
public interface GenericInterface<T> {
void test(T param);
}
// 实现类指定泛型类型
public class GenericClass implements GenericInterface<String> {
@Override
public void test(String param) {...}
}
泛型方法
如前文中加法代码的实现就是泛型方法。
// 在方法前添加<T>,泛型类型可用于返回值也可用于参数
public <T> T function(T param);
...
function("123"); // 编译器自动识别T为String
深入Java泛型
Java的伪泛型和类型擦除
List<String> strList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
System.out.println(strList.getClass() == intList.getClass()); //true
对如上部分代码,相信多数人接触到泛型的第一时刻都认为这是两个不同的类型,反编译其字节码获得代码如下:
ArrayList var1 = new ArrayList();
ArrayList var2 = new ArrayList();
System.out.println(var1.getClass() == var2.getClass());
我们发现两个列表都变成ArrayList类型,如果大家对Jdk1.5之前的版本还有印象就可以看出,这一段反编译的代码就是Java集合最初的使用形式。因此,Java泛型的是通过编译期将泛型的实际类型擦除为原始类型(通常为Object)实现的伪泛型。
所谓伪泛型,是相对C++的”真泛型”(异构扩展,可见参考第三条),在Java中,由于编译后擦除了具体类型,在泛型代码内部,无法获得任何有关泛型参数类型的信息,在运行期代码所持有的也只是擦除后的原始类型,也就意味着在运行期可以通过反射的方式为泛型类传入任何原始类型的参数。
public class GenericTest {
public List<Integer> ints = new ArrayList<>();
public static void main(String[] args) {
GenericTest test = new GenericTest();
List<GenericTest> list = (List<GenericTest>) GenericTest.class.getDeclaredField("ints").get(test);
list.add(new GenericTest());
System.out.println(test.ints.get(0)); // 打印GenericTest变量地址
int number = test.ints.get(0); // 类型转换抛出异常
}
}
// 泛型代码内部是指泛型类或泛型方法内部。
public class Generic<T> {
public Class getTClass() { //无法获取 }
}
public <T> Class getParamClass(T param) { //无法获取 }
在泛型外部可以获取已指定的泛型参数类型,通过javap -v
查看Constant Pool
,可看到具体类型记录在Signature
。
public class Outer {
private List<String> list = new ArrayList<>(); //可以获取list的具体类型
}
事实上在Java推出泛型时,C++的模板泛型已经相当成熟,设计者也并非没有能力实现包含具体类型的泛型,使用类型擦除最重要的原因还是为了保持兼容性。假设ArrayList<String>
和ArrayList
编译后是不同的class,那么为了兼容旧代码正常运行,必须平行的添加一套泛型集合并在后续版本中同时维护,而集合类作为大量使用的基础工具类,开发者不得不为此承担大量代码切换的风险(参考Vector
和HashTable
的带来的遗留问题),因此相较于兼容性的取舍,采用类型擦除实现泛型算是折中方案。
思考一下,下面的类可以编译通过吗
public class Test { void test(List<String> param) {} void test(List<Integer> param) {} }
Java泛型的上下界
前面说到泛型会被擦除为原始类型,一般是Object
。如果泛型声明为<? extends Number>
,就会被擦除为Number
。
List<Number> numbers = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
numbers = integers; // compile error
考虑以上代码,numbers
可以增加Integer
类型的元素,直觉上integers
应该也可以赋值给numbers
。由于类型擦除,Java在编译期限定了只有相同类型的泛型实例才可以互相赋值,但这样就违背了Java的多态,为了解决泛型转换的问题,Java引入了上下限<? extends A>
和<? super B>
两种机制。
如果泛型声明为<? extends A>
,即声明该泛型的上界也即擦除后的原始类型为A
,同时该泛型类的实例可以引用A
子类的泛型实例。
// 上界保证取出来的元素一定是Number,但无法约束放入的类型
List<Integer> integers = new ArrayList<>();
List<Float> floats = new ArrayList<>();
List<? extends Number> numbers = integers; // numbers = floats; 也可以
numbers.get(0); // ok,总能保证取出的一定是Number
numbers.put(1); // compile error,无法保证放入的是否符合约束
如果泛型声明为<? super B>
,即声明该泛型的下界为B
,原始类型仍为Object
,同时该泛型类的实力可以引用B
父类的泛型实例。
// 假设三个继承类 Child -> Father -> GrandFather
// 下界保证写入的元素一定是Child,但无法确定取出的类型
List<Father> fathers = new ArrayList<>();
List<GrandFather> grandFathers = new ArrayList<>();
List<? super Child> childs = fathers; // childs = grandFathers; 也可以
numbers.put(new Child()); //ok,总能保证实际容器可接受Child
Child ele = (Child) numbers.get(0); // runtime error,无法确定得到的具体类型
在Java中,根据里式替换原则,向上转型是默认合法的,向下转型则需要强制转换,如不能转换则报错。在extends
的get
和super
的put
场景中,一定可以保证读取/放入的元素是可以向上转型的,而在extends
的put
和super
的get
中,则无法确认可转的类型,因此extends
只能读取,super
只能写入。
当然如果使用super时,取出的对象以Object存放,也没有问题,因为super擦除后的原始类型为Object。
参考《Effective Java》中给出的PECS
使用建议。
为了获得最大限度的灵活性,要在表示生产者或消费者的输入参数上使用通配符类型。
如果参数化类型表示一个T生产者,就使用<? extends T>。 producer-extends
如果参数化类型表示一个T消费者,就使用<? super T>。consumer-super
如果某个输入参数即是生产者又是消费者,那么通配符类型对你就没什么好处了。
这一段话笔者认为有一定迷惑性,生产者是写入的,消费者是读取的,前文介绍过extends
用于读取,而super
用于写入,恰恰相反。
个人认为对这段话的正确理解是以泛型为第一视角切入,即当泛型类型本身作为生产者提供功能(被读取)时使用extends
,反之(被写入)使用super
。而非常规意义上生产者要写入的容器采用extends
,消费者读取的容器使用super
。
// producer,此时返回值作为生产后的结果提供给消费者
List<? extends A> writeBuffer(...);
// consumer,此时返回值作为消费后的结果提供给生产者
List<? super B> readBuffer(...);
Java泛型的多态
泛型类也可以被继承,泛型类主要有两种继承方式。
public class Father<T> { public void test(T param){} }
// 泛型继承,Child依然是泛型类
public class Child<T> extends Father<T> {
@Override
public void test(T param){}
}
// 指定泛型类型,StringChild为具体类
public class StringChild extends Father<String> {
@Override
public void test(String param){}
}
我们知道@Override
是保持签名不变且重写父类方法,查看Father
类字节码,其中test方法被擦除为void test(Object param)
;在StringChild
中,方法签名为void test(String param)
。到此读者可能意识到,这根本不是重写而是重载(Overload
)。
查看StringChild
的字节码。
...
#3 = Methodref
...
public void test(java.lang.String);
...
invokespecial #3 // Method StringChild.test:(Ljava/lang.Object;) V
...
public void test(java.lang.Object);
可以看到其中实际包含了两个方法,一个参数是String
一个是Object
,后者才是对父类方法的重写,前者通过invoke转到对后者的调用。这个方法是JVM在编译时自动添加的,也叫做桥方法。同时还有一点需要提及,示例中的代码是以泛型参数作为入参,作为返回类型的话会产生Object test()
和String test()
两个方法,这两个方法在常规的编码中是无法编译通过的,但JVM为泛型多态的实现允许了这个不合规的存在。
Java泛型使用的局限
- 基本类型无法擦除为原始类型
Object
,因此范型不支持基本类型
List<int> intList = new ArrayList<>(); // 无法编译
- 由于类型擦除,泛型代码内部在运行期无法获得具体类型
T instance = new T(); // 不能直接使用泛型初始化
if (t instanceOf T); // 不能判断泛型类型
T[] array = new T[]{}; // 不能创建泛型数组
- 由于静态资源加载先于类型实例化,不能在静态代码块引用泛型
// Error
public class Generic<T> {
public static T t;
public static T test() {return t;}
}
- 异常类型的派生类不能添加泛型
// 假设继承实现一个泛型异常
class SomeException<T> extends Exception...
try {
...
} catch(SomeException<Integer> | SomeException<String> ex) {
//由于类型擦除,无法捕获多个泛型异常
...
}
参考
- 《Java编程思想》
- 《Effective Java》
- Java不能实现真正泛型的原因
- Java泛型机制详解