Java SE基础巩固(十):泛型

828 阅读16分钟

Java泛型是Java5推出的一个强大的特性,那什么是泛型?下面是从维基百科上摘下来的定义:

泛型的定义主要有以下两种:

  1. 在程序编码中一些包含类型参数的类型,也就是说泛型的参数只可以代表类,不能代表个别对象。(这是当今较常见的定义)
  2. 在程序编码中一些包含参数的。其参数可以代表类或对象等等。(现在人们大多把这称作模板

不论使用哪个定义,泛型的参数在真正使用泛型时都必须作出指明。

一些强类型程序语言支持泛型,其主要目的是加强类型安全及减少类转换的次数,但一些支持泛型的程序语言只能达到部分目的。

Java中的泛型适用于第一种定义,即:在程序编码中一些包含类型参数的类型,也就是说泛型的参数只可以代表类,不能代表个别对象。

什么是类型参数?假设你手上有两个完全相同容器(自行想象,锅碗瓢盆什么的),现在俩都还是空的,但你也不想什么乱七八糟的东西都往里面扔,所以搞了两个小纸条,上面写的“T恤”,一个写的“鞋子”,分别贴到两个容器上,以后贴有T恤的容器就只装T恤,贴有鞋子的容器就只装鞋子。在这个小例子中,小纸条上的内容就是所谓的“类型参数”。

上面的例子可能不太合适(实在是不太好举例),但不用担心,到下面看到Java泛型的“样子”时,再回想这个例子,就会明白了。

1 Java中的泛型的使用

Java泛型有三种使用方式,分别是:泛型类、泛型接口、泛型方法,下面将就这三种方式逐一介绍。

1.1 泛型类

当泛型作用在类定义的时候,该类就是泛型类,JDK里(1.5之后)有很多泛型类,例如ArrayList,HashMap,ThreadLocal等,如下所示:

public class MyList<T> {
   //.....
}

中的T是泛型标识,可以是任意字符,不过一般会采用一些通用的单字符或者双字符,例如T、K、V、E等。在编写类定义的时候可以使用T来代替类型,例如:

//用在方法参数上和返回值上
//合法的
public T method1(T val) {
    //do something
    return (一个T类型的对象);
}

//不合法,不能用在静态方法
public static T method1(T val) {
    //do something
    return (一个T类型的对象);
}


//用在字段声明
private T val; //ok
private static T staticVal; //不合法,不能用在静态字段上

至于为什么不能用在静态字段或者方法上,后面讲到泛型的实现时会讲到,这里先把这个问题放着。

1.2 泛型接口

JDK里也有很多泛型接口,例如List,Map,Set等,当泛型作用在接口定义的时候,这个接口就是一个泛型接口,例如:

public interface MyGenericInterface<T> {
	//用在抽象方法上
    T method1(T t);

    //或者默认方法也是可以的
    default T method2(T val) {
        
    }
    //但仍然不能作用在静态方法和静态字段上
    //不合法
    static T method3() {
        
    }
    
    //字段就很好理解了,怎么写都不像合法的
    T message = "MESSAGE"; //语法规定了接口里的字段默认是static final的,所以必须要有初始化值,但T不代表某个具体的类型,所以泛型字段根本不合理。
}

代码注释写的比较清楚了,不多做说明了,接下来看看泛型方法。

1.3 泛型方法

当泛型作用在方法上时,该方法就是一个泛型方法。注意,这里和之前在泛型类或者泛型接口中的方法里使用泛型是不同的,我们既可以在一个泛型类或者泛型接口中定义泛型方法,也可以在普通类或者接口中定义泛型方法。泛型方法较泛型类和泛型接口的定义稍微复杂一些,如下所示:

public <E>  E method1(E val) {
    return val;
}

//静态方法也是合法的
public static  <E>  E method2(E val) {
    return val;
}

这里的泛型标识要在修饰符之后,返回值之前的位置,不能放错,这里的泛型标识E的作用范围仅限于方法内部,即可以简单的将该泛型标识是一个局部变量(实际上不是)。但为什么这时候泛型可以作用在静态方法上了呢?还是和之前一样,留到后面解释。

2 泛型的作用

上面三个小结介绍了泛型类,泛型接口和泛型方法,但仅仅是介绍了如何定义,没有介绍到如何使用泛型,在实践的过程中,会接触到文章最开始说到的“类型参数”的概念,希望能对读者有帮助。

public class Main {

    public static void main(String[] args) {
        List<Integer> integers = new ArrayList<>();
        integers.add(1);
        integers.add(2);
        for (Integer integer : integers) {
            System.out.println(integer);
        }
    }
}

代码非常非常简单,使用了List接口和ArrayList实现类,注意这一行:

List<Integer> integers = new ArrayList<>();

Integer即所谓的“类型参数”,表示这个List容器只能存放Integer类以其子类对象实例,类型参数只能是引用类型,不能是基本类型(例如int,double,char等),JVM会在编译期会通过类型检查来保证这一点。赋值号后面的<>称作“菱形操作符”,是Java7提供的一个语法糖,用于简化泛型的使用,编译器会自动推断出类型参数,例如在这里,编译器会自动推断出类型参数是Integer,而不用在显式指明ArrayList的类型参数,在Java7之前,上面那一行语句不得不这样写:

List<Integer> integers = new ArrayList<Integer>();

在声明并赋值完成之后,我们往容器里“扔”了两个元素1和2,因为自动装箱的原因,1和2会被包装成Integre类的实例,所以并不会发生类型安全问题,假设现在加入如下语句:

integers.add("yeonon");

会发生什么情况?编译会报错,错误提示的意思大概是类型不匹配。为什么呢?其实在刚刚已经说了,这个容器有一个类型参数Integer,这就表明了该容器只能存放Integer类以其子类对象实例,如果强行放入其他类型的实例,因为类型检查机制的存在,所以会发生类型匹配异常,这个就是泛型最重要的一个特性:保证类型安全。在没有泛型机制之前,我们会这样使用容器类:

List integers = new ArrayList();
integers.add(1);
integers.add(2);
integers.add("yeonon");

编译一下,发生编译通过,只不过有一些警告而已。这是有类型安全问题的,为什么?例如现在我要从容器中提取元素,就不得不进行强制类型转换,如下所示:

Integer i1 = (Integer) integers.get(0);
Integer i2 = (Integer) integers.get(1);
String s1 = (String) integers.get(2);

当然,完全可以不做类型转换,直接使用Object类来接收元素,但那有什么意义呢?光有一个Object引用,几乎没什么操作空间,最终还是要做类型转换的。

幸好这里只有三个元素,而且都明确知道元素的顺序,第1,2个是Integer类型的,第3个是String类型的,所以可以准确的做出类型转换。那如果是下面这种情况呢?

public processList(List list) {
    //如何处理元素?
}

在processList方法中,List是从外部传进来的,完全不知道这个List里是些什么东西,如果鲁莽的将元素强转成某种类型,就非常有可能出现强转异常,而且该异常还是运行时异常,即不确定什么时候会发生异常!可能你会说,那给方法写个文档说明,说明List里存的元素是Integer类型,然后要求客户端也必须传入元素全是Integer的List,这不就完事儿了?确实,这是一个解决方案,但这其实只是在制定“协议”,而且这个协议属于“君子协议”,客户端完全可能会出于各种各样的原因违反这个协议(例如客户端被入侵了,或者调用者没有注意到这个“协议”),所以,还是有可能发生类型安全问题。

通过这个例子,我想读者已经能感受到泛型带来的好处了,泛型可以在编译期发现类型错误,并发出错误报告,提示程序员!这使得类型安全问题不会出现在不可控的运行时,而是出现在可控的编译期,这个特性使Java语言的安全性大大提高。

那Java中的泛型是如何实现的呢?答案是通过“擦除”来实现的。

3 泛型擦除

经常在论坛、社区里听到Java的泛型实现是伪泛型,而C#、C++的泛型实现才是真正的泛型。这么说是有原因的,因为Java源码编译后的字节码里不存在什么类型参数。举个例子,现有如下代码:

public class Main {

    public static void main(String[] args) {
        List<Integer> integers = new ArrayList<>();
        integers.add(1);
        integers.add(2);
        for (Integer integer : integers) {
            System.out.println(integer);
        }
    }
}

使用Javac编译,编译后的.class文件内容如下(我使用的是IDEA来打开的,如果使用其他工具,可能会略有差别):

public class Main {
    public Main() {
    }

    public static void main(String[] var0) {
        ArrayList var1 = new ArrayList();
        var1.add(1);
        var1.add(2);
        Iterator var2 = var1.iterator();

        while(var2.hasNext()) {
            Integer var3 = (Integer)var2.next();
            System.out.println(var3);
        }

    }
}

发现,确实没有类似的字符出现了,换句话说,类型参数被“擦除”了。取而代之的是,当有需要进行类型转换的时候,编译器帮我们加上了强制类型转换的语法,例如这句:

Integer var3 = (Integer)var2.next();

从这里可以看出,JVM是不知道类型参数的信息的(JVM只认字节码),知道了这一点之后就可以回答上面留下的两个问题了。

为什么在泛型类和泛型接口中,泛型不能作用在静态方法或者静态字段上?

静态方法或者静态字段是属于类信息的一部分,存储在方法区且只有一份,可被类的多个不同实例共享,因此即使编译器知道类型信息,可以做特殊处理,也无法为静态量确定某一种类型。假设允许静态方法或者静态字段,如下代码所示:

public A<T> {
    public static T val;
}

public static void main(String[] args) {
    A<Integer> a1 = new A<>();
    A<String> a2 = new A<>();
    System.out.println(a1.val);
    System.out.println(a2.val);
}

这里的val到底应该是什么类型呢?如果该程序能正常运行,那么只有一种可能,就是有两份不同类型的静态量,但虚拟机的知识告诉我们,这显然是不符合规范的,所以这种使用方法是不被允许的。

反过来看一下普通实例方法和字段,因为普通实例方法和字段是可以有多份的(每个对象一份),所以编译器完全可以根据类型参数来确定对象实例里的实例方法和字段的类型。需要注意的是,这里的类型信息是编译器知道的,虚拟机是不知道的,编译器可以为每个不同参数类型的实例对象做类型检查、类型转换等操作。例如上面的a1和a2对象,编译器知道他们的类型参数分别是Integre和String,所以在编译的时候可以对他们做类型检查、类型转换等。

为什么泛型方法就可以使得泛型作用在静态量上呢?

其实这还是编译器的“把戏”。来看个例子:

public class Main {

    public static void main(String[] args) {
        MyList<Integer> list1 = new MyList<>();
        MyList<String> list2 = new MyList<>();

        MyList.method2(1);
        MyList.method2("String");
    }
}

用javac编译后,用javap来查看字节码信息,大致内容如下(省略了无关部分):

   #21 = NameAndType        #28:#29        // method2:(Ljava/lang/Object;)Ljava/lang/Object;
   
 
 		20: invokestatic  #5                  // Method top/yeonon/generic/MyList.method2:(Ljava/lang/Object;)Ljava/lang/Object;
        23: pop
        24: ldc           #6                  // String String
        26: invokestatic  #5                  // Method top/yeonon/generic/MyList.method2:(Ljava/lang/Object;)Ljava/lang/Object;

发现在序号20和26调用了method2方法,从常量池#21号可以看到,method2的参数是Object类型,说明在虚拟机中,泛型参数的类型实际只是Object类型,没有违背虚拟机规范。什么类型检查啊、自动类型推断、类型转换啊都是编译器自己加上去的。

更多关于泛型擦除的知识,建议多多参考资料,并结合javac、javap等工具进行研究。

4 泛型通配符

在泛型系统中,大致有以下几种声明泛型的方式:

  • 。最简单的声明,T可以代表任何类型,但是当类型确定下来之后就只能代表某个类型,例如List中,T就代表了String,不能再代表其他类型。
  • 。无界通配符形式,这种情况下,类型参数可以是任意类型,例如Class,但是这种形式是只读的,即不能改变值,一般用在方法返回值或者方法参数中。
  • 。有界通配符形式,其类型参数可以是T类型及其子类型。例如有List的声明,那么这个list能插入int类型,也能插入long类型,在这里T就是Number,其子类型例如Interger,Long等都是Number的子类型。如下代码所示: ```java //E是类声明时候的泛型。我们在该方法中使用有界通配符,使得可以接受多种类型的值 public void pushAll(Iterable iterable) { for (E e : iterable) { push(e); } } //测试类,创建了类型参数为Integer和Double的List,使用pushAll方法,都可以正常运行,如果pushAll方法没有使用泛型通配符,那么就只能插入一种类型的元素。 public static void main(String[] args) { MyStack myStack = new MyStack<>(); List integers = new ArrayList<>(); integers.add(1); integers.add(2); List doubles = new ArrayList<>(); doubles.add(1.0); doubles.add(2.0); myStack.pushAll(integers); myStack.pushAll(doubles); while (!myStack.isEmpty()) { System.out.println(myStack.pop()); } } ```
  • 。和上面那种差不多,只是适配的类型只能是T或者T的父类。

使用有界通配符能提升泛型的灵活性,使得泛型可以同时为多种类型而工作,从而使得我们不需要为多种类型编写相似的代码,从另一方面提供了代码的复用性。但是正确使用有界通配符会比较困难,其中最麻烦的是如何确定使用有上界的通配符还是有下界的通配符?《Effective Java》一书中给出了一个原则:PECS(producer-extends,consumer-super)。即对于生产者,使用有上界的通配符(extends,上界是T),对于消费者,使用有下界的通配符(super,下界是T)。

现在又有了新的问题,如何区分消费者和生产者。简单来说,对于集合,消费者就是使用容器里的元素,例如List.sort(Comparator<? super E> c),sort需要使用到list内部的元素,所以这个方法是消费者,根据PECS原则,方法声明的参数应该是有下界的通配符。又例如List.addAll(Collection<? extends E> c)方法,addAll是将元素插入到容器中,属于生产者,根据PECS原则,方法参数应该使用有上界的通配符。

虽然有界通配符能提高API的灵活性,但是如果该方法不是消费者或是是生产者,那么就不要使用有界通配符了,直接使用即可,尽量保持API的简单也是我们的设计原则。

总之,使用有界通配符可以大大提供API的灵活性,不过在设计API时,应该尽量保持简单,而且遵循PECS原则。

5 泛型数组

数组和泛型容器类是有很大区别的,JVM把A[]数组和B[]数组看成两种不同的类型,而将List和List看成同一个类型List,假设能创建泛型数组,如下代码所示:

public class Main {

    public static void main(String[] args) {
        List<String>[] stringLists = new List<String>[1]; //1
        List<Integer> integerList = new ArrayList<>();  //2
        integerList.add(0);  //3
        Object[] objects = stringLists; //4
        objects[0] = integerList; //5
        String s = stringLists[0].get(0); //6
    }
}

代码有些绕,我们一行一行分析:

  1. 第1行,创建了一个泛型数组stringLists,数组元素的类型是List,合法的(我们的假设前提)。
  2. 第2行,创建了一个List容器对象integerList。
  3. 第3行,往integerList里插入一个元素。
  4. 第4行,将stringLists赋值给Object[]类型的数组。这里的赋值是允许的,属于向上类型转换。
  5. 第5行,设置obejcts数组的第一个元素为integerList,这也是合法的,因为List的最顶层父类是Object,注意这里的integerList是List类型。
  6. 第6行,问题来了,获取stringLists的第一个List元素(其实是integerList),并获取该List的第一个元素(该元素的类型其实是Integer),但编译器认为既然从stringLists里获取,里面的List存储的应该是String类型的元素,所以这里赋值给String引用就没有必要进行类型转换。但实际上,这里应该是Integer类型,但要在运行时才会抛出类型转换异常。

这就是泛型数组带来的问题,最根本的原因还是因为泛型擦除的机制,虚拟机无法区分List和List,所以为了避免这种难以发觉的问题,就干脆禁止创建泛型数组了。

虽然有一些办法可以绕开创建泛型数组的限制,但最好不要这样干,因为这样就失去了泛型带来的在编译期发现类型安全问题的好处,得不偿失。

6 小结

本文简单介绍了泛型,也讲了一下泛型的实现方式:擦除。说实话,泛型是比较复杂难懂的知识点,想理解透彻,需要有一定的泛型使用经验,或者说是真真切切被坑过,否则会总觉得泛型这玩意有点“虚无缥缈”。至于如何学习,我的经验是阅读JDK的源码,注意JDK是如何使用泛型的。

7 参考资料

《Effective Java》第三版(英文版)