【Java】Java 泛型研读笔记

本文出自神农班,神农班宗旨及班规:https://mp.weixin.qq.com/s/Kzi_8qNqt_OUM_xCxCDlKA

在前一篇文章 【C++】C++ 中的泛型——template 浅析 中对 C++ 中的泛型进行了研读,它的核心思想是基于一套模板,根据传入的不同参数,从而在编译时生成对应的代码从而实现。而 Java 中的泛型实际上采用了一种完全不同的思路,今天就让我们对 Java 的泛型进行一系列研究。

基本语法及特性

Java 在 JDK5 中引入了泛型,关于泛型的意义在前一篇文章中已经进行了讨论。

泛型类/接口

我们可以通过在类/接口名后通过 <> 来声明一个泛型类/接口,其中以逗号分隔的方式填入泛型参数。例如下面是一个简单的泛型类,它具有两个泛型参数 K 和 V 分别代表 key 和 value 的类型,并在类中声明了对应类型的变量。

class Pair<K, V> {
    K key;
    V value;
}

泛型方法

我们也可以在方法的返回值前通过 <> 来声明一个泛型方法,其中的泛型参数用逗号分隔开,例如:

public <T> boolean equal(T a, T b) {
    return a.equals(b);
}

规则

虽然 Java 中的泛型看上去与 C++ 的泛型非常相似,但实际上还是有非常大的不同的,它具有一些额外的限制。

1. 泛型参数不能为基本类型

Java 中的泛型参数是不能使用如 intdoublechar 等基本类型的,如果需要在泛型类中使用它们必须使用它们的包装类 IntegerDoubleCharacter 等,例如这样使用 ArrayList 就是非法的:

ArrayList<int> list = new ArrayList<>();

简单来说,Java 中的泛型只能对 Object 及其子类提供支持,这样的设计与 Java 中泛型的原理有关,我们后面会进行讨论。这样的设计往往会导致一些额外的性能消耗,比如我们使用一个 ArrayList<Integer>,就会造成很多额外的装箱拆箱带来的性能浪费。

2. 只支持泛型参数的父类所具有的方法

对于不使用上下界通配符(后面会提到)的泛型类/方法来说,我们在使用这些泛型参数对应的对象时,它们的父类都会被理解为 Object,当我们使用时就只能使用 Object 类所具有的方法与成员变量。

例如下面这样的简单的泛型加法就是 Java 中泛型无法实现的:

public <T> T add(T a, T b) {
        return a + b;
}

因为编译器认为 ab 的父类都是 Object,而 Object 之间无法通过 『+』进行加法。如果想要实现具体类的方法调用,可以通过 instanceof 配合强转实现。

3. 不支持泛型的实例化

Java 中的泛型是无法进行实例化的,包括了基于 new-instance 指令的对象创建以及基于 new-array 指令的数组创建都是无法实现的,我们无法构建一个如 T[] 的数组,例如如下的一个泛型 Array 就无法通过编译:

class Array<T> {
    T[] datas;

    public Array(int size) {
        this.datas = new T[size];
    }
}

上述做法会在创建泛型数组处发生错误:Type parameter 'T' cannot be instantiated directly,说明无法直接创建泛型对象。

如果想实现泛型数组,可以通过 Object[] 数组来存放,JDK 中的 ArrayList<> 等都是通过这种方式实现。

通配符

由于 Java 泛型存在着很多限制,为了解决其中的部分限制,Java 中的泛型提供了一系列通配符

上界通配符

<? extends Type> 表示了上界通配符,其中的 Type 是任意一种类型,上界代表了 Type 是 T 的上边界。也就是T 必须是 Type 或它的子类,同时,在泛型的使用过程中对该泛型的对象也将支持 Type 类所提供的方法。

abstract class Human {
    abstract void printInfo();
}

class Student extends Human {

    @Override
    void printInfo() {
        System.out.println("I'm a student");
    }

    void teach() {
        System.out.println("I'm teaching");
    }
}

class Teacher extends Human {

    @Override
    void printInfo() {
        System.out.println("I'm a teacher");
    }
}

public <T extends Human> void printInfo(T human) {
    human.printInfo();
}

通过上界通配符,printInfo 这样的泛型方法就可以成功实现了,它调用了 Human 类中的 printInfo 方法。

如果我们想要使用其真正类型的方法,可以通过强转实现:

public static <T extends Human> void printInfo(T human) {
    human.printInfo();
    if (human instanceof Teacher) {
        ((Teacher)human).teach();
    }
}

下界通配符

<? super Type> 表示上界通配符,它指明了 Type 是 T 的下边界,也就是 T 需要为 Type 或它的父类。它只能针对 ?使用,不能指定具体的如 T 的泛型参数。

无界通配符

? 代表了无界通配符,也就是不对传递的泛型参数进行限制,可以传入任何类型。它与直接使用泛型的区别在于 T 指一个具体的类型,可以在类中存在 T a 这样一个 T 类型的变量,但不能有 ? a。同时下界通配符只能针对 ? 使用,T 这样的泛型参数不能使用

类型擦除

Java 的泛型在实现原理上与 C++ 泛型有着很大的不同,C++ 中的泛型的核心是一种元编程的思想,通过模板类/模板函数根据具体的使用实例化处具体的类/函数。而 Java 中的泛型实际上是通过类型擦除机制实现的。

无通配符的类型擦除

例如我们看到如下的这样一个类:

class Generic<T> {
    T instance;

    public Generic(T instance) {
        this.instance = instance;
    }

    public static void main(String[] args) {
        Generic<Integer> gen = new Generic<>(3);
    }
}

我们可以查看它编译出的字节码:

// ... 其他信息
{
  T instance;
    // descriptor 指定类型为 Object
    descriptor: Ljava/lang/Object;
    flags:
    Signature: #10                          // TT;

  public Generic(T);
    descriptor: (Ljava/lang/Object;)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: aload_1
         6: putfield      #2                  // Field instance:Ljava/lang/Object;
         9: return
      LineNumberTable:
        line 4: 0
        line 5: 4
        line 6: 9
    Signature: #15                          // (TT;)V

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=2, args_size=1
         0: new           #3                  // class Generic
         3: dup
         4: iconst_3
         5: invokestatic  #4                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
         8: invokespecial #5                  // Method "<init>":(Ljava/lang/Object;)V
        11: astore_1
        12: return
      LineNumberTable:
        line 9: 0
        line 10: 12
}
Signature: #18                          // <T:Ljava/lang/Object;>Ljava/lang/Object;
SourceFile: "Generic.java"

可以发现,上面的 instance 对象在 FiledInfo 中的 descriptor 变为了 Object,也就是说它运行时实际上是一个 Object 的对象,它的泛型信息在编译的过程中被擦除了。

并且可以看到下面的 Code 属性中对构造函数的调用实际上也是调用了 (Ljava/lang/Object;)V 的构造函数,也就是说构造函数中的类型信息同样是被擦除了。

Java 在编译过程中可以拿到泛型的类型信息,因此会对类型进行检查,但在程序运行时,这种类型检查变不复存在了,其类型被擦除为了 Object

这就意味着我们可以在运行期间通过反射向一个 ArrayList<String> 中插入 ArrayList<Integer>,这是非常危险的。

有通配符的类型擦除

我们接着看看在使用通配符的情况下,泛型信息在类型擦除后会怎样:

import java.util.ArrayList;
import java.util.List;

class Generic<T extends List> {
    T instance;

    public Generic(T instance) {
        this.instance = instance;
    }

    public static void main(String[] args) {
        Generic<ArrayList<Integer>> gen = new Generic<>(new ArrayList<>());
    }
}

生成的字节码如下:

 // ...
 T instance;
    descriptor: Ljava/util/List;
    flags:
    Signature: #11                          // TT;

 public Generic(T);
    descriptor: (Ljava/util/List;)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: aload_1
         6: putfield      #2                  // Field instance:Ljava/util/List;
         9: return
      LineNumberTable:
        line 7: 0
        line 8: 4
        line 9: 9
    Signature: #16                          // (TT;)V

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=4, locals=2, args_size=1
         0: new           #3                  // class Generic
         3: dup
         4: new           #4                  // class java/util/ArrayList
         7: dup
         8: invokespecial #5                  // Method java/util/ArrayList."<init>":()V
        11: invokespecial #6                  // Method "<init>":(Ljava/util/List;)V
        14: astore_1
        15: return
      LineNumberTable:
        line 12: 0
        line 13: 15
}
Signature: #19                          // <T::Ljava/util/List;>Ljava/lang/Object;
SourceFile: "Generic.java"

可以发现,使用了上界通配符后,其擦除后的类型变为了它的上界,也就是 Ljava/util/List;

对于无上界通配符的泛型实际上它们的上界就是 Object,这说明Java 的类型擦除机制会使得泛型在编译后被擦除为其上界对应的类型

运行时获取泛型参数

比较奇怪的一点是,既然泛型信息被擦除了,那么我们使用 Gson 的时候不是仍然可以通过 TypeToken 对泛型参数的类型进行获取么?这是怎么实现的呢?泛型信息真的被完全擦除了么?

我们来做个实验:

import java.util.List;

class Generic {
    List<String> mList;
}

我们的 Generic 类中有一个 List<String> 成员变量,我们查看编译后生成的字节码:

// ...
Constant pool:
     // ...
   #7 = Utf8               Ljava/util/List<Ljava/lang/String;>;
     // ...
{
  java.util.List<java.lang.String> mList;
    descriptor: Ljava/util/List;
    flags:
    Signature: #7                           // Ljava/util/List<Ljava/lang/String;>;

    // ...
}
SourceFile: "Generic.java"

可以发现,mList 的 FieldInfo 中的 descriptor 确实被擦除为了 List,但它的 Signature 指向了常量池的 #17 引用,也就是 Ljava/util/List<Ljava/lang/String;>;,这说明泛型的信息实际上没有完全被擦除,我们仍然可以通过一些特殊的方式来获取泛型参数的类型

根据参考资料指出:

根据 Java 5 开始的新 class 文件格式规范,方法与域的描述符增添了对泛型信息的记录,用一对尖括号包围泛型参数,其中普通的引用类型用『La/b/c/D;』的格式记录,未绑定值的泛型变量用『Txxx;』的格式记录,其中 xxx 就是源码中声明的泛型变量名。

Java 中为 Class 类提供了 getGenericSuperclass 方法可以获取到泛型擦除后到父类的类型,它返回的 Type 实际类型为 ParameterizedType。我们只需要通过它的 getActualTypeArguments 方法即可获取它真正的泛型参数类型数组。Gson 的 TypeToken 正是基于这套机制实现。

例如如下的这段代码:

class Generic<T> {

    public static void main(String[] args) {
        Type superclass = new Generic<Map<String, List<String>>>() {}.getClass().getGenericSuperclass();
        Type actualClass = ((ParameterizedType) superclass).getActualTypeArguments()[0];
        System.out.println(actualClass);
    }
}

通过这种方式就可以获取到其泛型信息 java.util.Map<java.lang.String, java.util.List<java.lang.String>>

不过并不是所有泛型信息都会被保留下来,大致规则为:

  • 声明一侧的泛型信息会被保留下来。

  • 使用一侧的泛型信息编译后就获取不到了。

总结

Java 中的泛型在 JDK5 中加入,它的核心原理是基于编译时类型擦除机制的,它会在编译的过程中将泛型相关信息进行擦除,变为其对应的上界的类。实际上其原先的泛型信息并不会被全部擦除,对于声明一侧的信息仍会被保留在 Class 文件的 Signature 字段中,我们可以通过 getGenericSuperclass 方法获取 ParamterizedType,之后调用其 getActualTypeArguments 获取泛型参数的信息。

显然 Java 的泛型由于类型擦除机制带来了非常多的限制,那么为什么当时 Java 的泛型没有采用另一种实现方式呢?正是因为 Java 中泛型引入时间较晚,因此为了对 JDK5 之前的代码保证兼容,因此 Java 没有采用 C++ 中的这种生成代码的原理来实现泛型。

下面是我整理的一些 C++ 中的模板与 Java 的泛型的异同:

相同

  • C++ 的模板与 Java 的泛型都是对泛型这一思想的实现
  • C++ 的模板与 Java 的泛型都是编译期的实现

不同

  • C++ 的模板是采用了一种类似宏的设计思想,在编译的过程中对模板进行实例化,从而根据模板参数生成不同的代码。而 Java 的泛型则更像是一种语法糖,它会在编译期对泛型的类型进行擦除,最终在编译出的字节码中泛型会根据规则变为 Object 或对应的上界。
  • C++ 的模板由于采用实例化的方式会造成代码膨胀,而 Java 泛型不会。
  • C++ 的模板支持整数类型的参数,而 Java 泛型则只支持类型参数。
  • C++ 的模板支持在实例化之前对未知的类成员进行使用,而 Java 的泛型则不支持,可以采用 super 等关键字实现类似的效果。
  • C++ 的一份模板对应了编译后的多份代码,而 Java 泛型则对应了同一份类型擦除后的代码。
  • C++ 的模板无法在运行时支持新类型,而 Java 动态性则更强,可以在运行时支持一些新的类型(如 ClassLoader 加载的类)
  • 相对与 C++ 的模板,Java 的泛型由于类型擦除机制,因此在运行时是类型不安全的,例如可以通过反射在 List<Integer> 中插入 String 对象。
  • C++ 支持模板类型数组,而 Java 则不支持(因此 ArrayList 采用了 Object[]

参考资料

Java 不能实现真正泛型的原因是什么?

聊一聊-JAVA 泛型中的通配符 T,E,K,V,?

Java获得泛型类型

点赞

发表评论

电子邮件地址不会被公开。必填项已用 * 标注

%d 博主赞过: