Java 泛型(generics)是 JDK 5 中引入的一个新特性,允许在定义类和接口的时候使用类型参数(type parameter)。声明的类型参数在使用时用具体的类型来替换。
从好的方面来说,泛型的引入可以解决之前的集合类框架在使用过程中通常会出现的运行时刻类型错误,因为编译器可以在编译时刻就发现很多明显的错误。而从不好的地方来说,为了保证与旧有版本的兼容性,Java 泛型的实现上存在着一些不够优雅的地方。当然这也是任何有历史的编程语言所需要承担的历史包袱。后续的版本更新会为早期的设计缺陷所累。
List 作为形式参数,那么如果尝试将一个 List 的对象作为实际参数传进去,却发现无法通过编译。虽然从直觉上来说,Object 是 String 的父类,这种类型转换应该是合理的。但是实际上这会产生隐含的类型转换问题,因此编译器直接就禁止这样的行为。
正确理解泛型概念的首要前提是理解类型擦除(type erasure)。
在生成的 Java 字节代码中是不包含泛型中的类型信息的。使用泛型的时候加上的类型参数,会被编译器在编译的时候去掉。这个过程就称为类型擦除。
如在代码中定义的 List 和 List 等类型,在编译之后都会变成 List。JVM 看到的只是 List,而由泛型附加的类型信息对 JVM 来说是不可见的。Java 编译器会在编译时尽可能的发现可能出错的地方,但是仍然无法避免在运行时刻出现类型转换异常的情况。类型擦除也是 Java 的泛型实现方式与 C++ 模板机制实现方式之间的重要区别。
1. 泛型类并没有自己独有的 Class 类对象。比如并不存在 List.class 或是 List.class,而只有 List.class。
2. 静态变量是被泛型类的所有实例所共享的。对于声明为 MyClass 的类,访问其中的静态变量的方法仍然是 MyClass.myStaticVar。不管是通过 new MyClass 还是 new MyClass 创建的对象,都是共享一个静态变量。
3. 泛型的类型参数不能用在 Java 异常处理的 catch 语句中。因为异常处理是由 JVM 在运行时刻来进行的。由于类型信息被擦除,JVM 是无法区分两个异常类型 MyException 和 MyException 的。对于 JVM 来说,它们都是 MyException 类型的。也就无法执行与异常对应的 catch 语句。
类型擦除的基本过程也比较简单,首先是找到用来替换类型参数的具体类。这个具体类一般是 Object。如果指定了类型参数的上界的话,则使用这个上界。把代码中的类型参数都替换成具体的类。同时去掉出现的类型声明,即去掉 <> 的内容。比如 T get()方法声明就变成了 Object get();List 就变成了 List。接下来就可能需要生成一些桥接方法(bridge method)。这是由于擦除了类型之后的类可能缺少某些必须的方法。
了解了类型擦除机制之后,就会明白编译器承担了全部的类型检查工作。编译器禁止某些泛型的使用方式,正是为了确保类型的安全性。以上面提到的 List 和 List 为例来具体分析:
- public void inspect(List < Object > list) {
- for (Object obj: list) {
- System.out.println(obj);
- }
- list.add(1); //这个操作在当前方法的上下文是合法的。
- }
- public void test() {
- List < String > strs = new ArrayList < String > ();
- inspect(strs); //编译错误
- }
这段代码中,inspect 方法接受 List 作为参数,当在 test 方法中试图传入 List 的时候,会出现编译错误。假设这样的做法是允许的,那么在 inspect 方法就可以通过 list.add(1) 来向集合中添加一个数字。这样在 test 方法看来,其声明为 List 的集合中却被添加了一个 Integer 类型的对象。这显然是违反类型安全的原则的,在某个时候肯定会抛出 ClassCastException。因此,编译器禁止这样的行为。编译器会尽可能的检查可能存在的类型安全问题。对于确定是违反相关原则的地方,会给出编译错误。当编译器无法判断类型的使用是否正确的时候,会给出警告信息。
容器类应该算得上最具重用性的类库之一。先来看一个没有泛型的情况下的容器类如何定义:
- public class Container {
- private String key;
- private String value;
- public Container(String k, String v) {
- key = k;
- value = v;
- }
- public String getKey() {
- return key;
- }
- public void setKey(String key) {
- this.key = key;
- }
- public String getValue() {
- return value;
- }
- public void setValue(String value) {
- this.value = value;
- }
- }
Container 类保存了一对 key-value 键值对,但是类型是定死的,也就说如果我想要创建一个键值对是 String-Integer 类型的,当前这个 Container 是做不到的,必须再自定义。那么这明显重用性就非常低。
当然,我可以用 Object 来代替 String,并且在 Java SE5 之前,我们也只能这么做,由于 Object 是所有类型的基类,所以可以直接转型。但是这样灵活性还是不够,因为还是指定类型了,只不过这次指定的类型层级更高而已,有没有可能不指定类型?有没有可能在运行时才知道具体的类型是什么?
所以,就出现了泛型。
- public class Container < K,
- V > {
- private K key;
- private V value;
- public Container(K k, V v) {
- key = k;
- value = v;
- }
- public K getKey() {
- return key;
- }
- public void setKey(K key) {
- this.key = key;
- }
- public V getValue() {
- return value;
- }
- public void setValue(V value) {
- this.value = value;
- }
- }
在编译期,是无法知道 K 和 V 具体是什么类型,只有在运行时才会真正根据类型来构造和分配内存。可以看一下现在 Container 类对于不同类型的支持情况:
- public class Main {
- public static void main(String[] args) {
- Container<String, String> c1 = new Container<String, String>("name", "findingsea");
- Container<String, Integer> c2 = new Container<String, Integer>("age", 24);
- Container<Double, Double> c3 = new Container<Double, Double>(1.1, 2.2);
- System.out.println(c1.getKey() + " : " + c1.getValue());
- System.out.println(c2.getKey() + " : " + c2.getValue());
- System.out.println(c3.getKey() + " : " + c3.getValue());
- }
- }
- 输出:
- name : findingsea
- age : 24
- 1.1 : 2.2
在泛型接口中,生成器是一个很好的理解,看如下的生成器接口定义:
- public interface Generator<T> {
- public T next();
- }
- 然后定义一个生成器类来实现这个接口:
- public class FruitGenerator implements Generator<String> {
- private String[] fruits = new String[]{"Apple", "Banana", "Pear"};
- @Override
- public String next() {
- Random rand = new Random();
- return fruits[rand.nextInt(3)];
- }
- }
- 调用:
- public class Main {
- public static void main(String[] args) {
- FruitGenerator generator = new FruitGenerator();
- System.out.println(generator.next());
- System.out.println(generator.next());
- System.out.println(generator.next());
- System.out.println(generator.next());
- }
- }
- 输出:
- Banana
- Banana
- Pear
- Banana
一个基本的原则是:无论何时,只要你能做到,你就应该尽量使用泛型方法。也就是说,如果使用泛型方法可以取代将整个类泛化,那么应该有限采用泛型方法。下面来看一个简单的泛型方法的定义:
- public class Main {
- public static < T > void out(T t) {
- System.out.println(t);
- }
- public static void main(String[] args) {
- out("findingsea");
- out(123);
- out(11.11);
- out(true);
- }
- }
可以看到方法的参数彻底泛化了,这个过程涉及到编译器的类型推导和自动打包,也就说原来需要我们自己对类型进行的判断和处理,现在编译器帮我们做了。这样在定义方法的时候不必考虑以后到底需要处理哪些类型的参数,大大增加了编程的灵活性。
再看一个泛型方法和可变参数的例子:
- public class Main {
- public static < T > void out(T...args) {
- for (T t: args) {
- System.out.println(t);
- }
- }
- public static void main(String[] args) {
- out("findingsea", 123, 11.11, true);
- }
- }
通配符与上下界
在使用泛型类的时候,既可以指定一个具体的类型,如 List 就声明了具体的类型是 String;也可以用通配符? 来表示未知类型,如 List
在 Java 中,大家比较熟悉的是通过继承机制而产生的类型体系结构。比如 String 继承自 Object。根据 Liskov 替换原则,子类是可以替换父类的。当需要 Object 类的引用的时候,如果传入一个 String 对象是没有任何问题的。但是反过来的话,即用父类的引用替换子类引用的时候,就需要进行强制类型转换。编译器并不能保证运行时刻这种转换一定是合法的。这种自动的子类替换父类的类型转换机制,对于数组也是适用的。 String[] 可以替换 Object[]。但是泛型的引入,对于这个类型系统产生了一定的影响。正如前面提到的 List 是不能替换掉 List 的。
一个是类型参数自身的继承体系结构,另外一个是泛型类或接口自身的继承体系结构。第一个指的是对于 List 和 List 这样的情况,类型参数 String 是继承自 Object 的。而第二种指的是 List 接口继承自 Collection 接口。对于这个类型系统,有如下的一些规则:
即 List 是 Collection 的子类型,List 可以替换 Collection。这种情况也适用于带有上下界的类型声明。
当泛型类的类型声明中使用了通配符的时候, 其子类型可以在两个维度上分别展开。如对 Collection
为了更好地去理解泛型,我们也需要去理解 java 泛型的命名规范。为了与 java 关键字区别开来,java 泛型参数只是使用一个大写字母来定义。各种常用泛型参数的意义如下:
E — Element,常用在 java Collection 里,如:List,Iterator,Set K,V — Key,Value,代表 Map 的键值对 N — Number,数字 T — Type,类型,如 String,Integer 等等 S,U,V etc. – 2nd, 3rd, 4th 类型,和 T 的用法一样
我有一个微信公众号,经常会分享一些 Java 技术相关的干货。如果你喜欢我的分享,可以用微信搜索 "Java 团长" 或者 "javatuanzhang" 关注。
来源: https://cloud.tencent.com/developer/article/1007025