6

浅谈泛型擦除

 3 years ago
source link: https://blog.ixk.me/post/talking-about-type-erasure
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

浅谈泛型擦除

2021-07-01 • Otstar Lin •

泛型是 Java 5 引入的一个新特性,现在的高级语言基本也都支持泛型了。泛型本质上是将类型作为参数,以提供类型检查和避免不必要的类型转换。具体关于类型的本篇文章就不再说明了,具体可以自行查找。

什么是泛型擦除?

在 Java 中的泛型,常常被称为伪泛型。之所以这么说是因为 Java 的泛型在经过编译时会将实际的类型信息擦除掉。在字节码中就不存在泛型的信息了,JVM 运行的时候自然而然的就没有泛型信息了。

真泛型与伪泛型:

  • 真泛型:即泛型在运行期间是真实存在的,运行时可以取得泛型对应的实际类型,如 this.getComponent<ExampleComponent>() 方法可以取得 ExampleComponent 类型的实例。
  • 伪泛型:即泛型在运行期间不可见,仅在编译时进行类型检查,如 this.getComponent<ExampleComponent>(ExampleComponent.class) 方法,必须传入 ExampleComponent.class 才能取得对应类型的实例,因为泛型定义 <ExampleComponent> 在运行期间无法取得,只能通过另外传入类型的方式来实现相同的功能。

为什么 Java 要进行泛型擦除?

在说明原因的时候我们需要了解真泛型和伪泛型的实现机制。

编程语言引入真泛型的方式一般是使用代码膨胀的方式,举个例子来说当我们有一个 Component<T> 的类,那么代码经过编译后会生成如 Component@T 的类,其中多出来的 @T 分别为标记和占位符。当我们使用的时候如 new Component<String>(),此时编译器则会将原始类中的 T 占位符更改为 String 类,同时生成一个新的类,如 Component@String 类,此时实际类型就被保留下来了。当我们使用的时候就可以同普通类一样使用,如:

1if (obj instanceof T) {}
2new T();
3new T[1];

伪泛型的处理方式则很简单,还是举个例子来说吧,当 Component<T> 编译后会将其中的 T 占位符去除,变成 Component 类,当我们使用的时候如 new Component<String>() 那么经过编译后,会将 String 类型标注去除,变成 Component 相当于 Component<Object>

使用真泛型带来的问题

Java 是在 Java 5 后才引入的泛型支持,为了支持真泛型需要修改 JVM 源代码,加入泛型支持,同时为了兼容 Java 5 以前的程序,不能改动以前的旧版本类,而是另外新增一套支持泛型的新版本类,如 java.util.ArrayListjava.util.generic.ArrayList<T>

这样的实现看似很简单,只需要引入一套泛型,当需要使用泛型的时候就使用泛型包下的类,似乎还更灵活一点?但是我们忽略了一个问题,当项目中某个依赖库使用了泛型重新生成了字节码文件,此时这个项目为了兼容就必须也同时升级到 Java 5,否则会抛出 UnsupportedClassVersionError 错误。而 Java 5 之前的生态已经非常完善,此时如果为了引入泛型,就不得不让许多库都修改代码,显然这是不合理的。

为什么 Spring 还能进行泛型注入?

我们知道 Spring 可以通过集合元素的泛型来注入对应的依赖,如下面的代码,在容器启动后,Spring 会将容器内对应类型的依赖注入到响应的变量中。

1class Demo1ApplicationTests {
2    @Autowired
3    List<ApplicationContext> contexts;
4
5    @Autowired
6    Map<String, ApplicationContext> contextMap;
7
8    @Test
9    void contextLoads() {
10        log.info("ApplicationContext: {}", contexts);
11        log.info("ApplicationContext: {}", contextMap);
12    }
13}

既然 Java 会进行泛型擦除,那么这两个字段在 JVM 中实际上是 List<Object>Map<Object, Object>,理应是无法注入的,那么 Spring 又是通过何种手段取得这本不存在的信息呢?

实际上 Java 虽然将泛型信息擦除了,但是为了能够让虚拟机解析、反射等各种场景正确获取到参数类型,JCP组织修改了虚拟机规范,引入了 SignatureLocalVariableTypeTable。这样即使泛型被擦除了,在一些特定的场景下还是能取得泛型信息。我们就以上面的代码为例,通过 jclasslib 查看字节码信息(偷懒不用 javap):

3020210630220950.png

可以看到特有信息里出现了 ApplicationContext 的身影,这就是保留下来的泛型信息。在 Java 中,以下场景都会提供签名:

  • 具有通用或者具有参数化类型的超类或者超接口的类。
  • 方法中的通用或者参数化类型的返回值或者入参,以及方法的throw子句中的类型变量。
  • 任何类型、类型变量、或者参数化类型的字段、形式参数或者局部变量。

取得泛型信息

既然知道 Java 会将泛型信息保留到 Signature,那么我们就可以取得对应的泛型信息了,如下:

1class Demo1ApplicationTests {
2    @Autowired
3    List<ApplicationContext> contexts;
4
5    @Test
6    void contextLoads() throws NoSuchMethodException, NoSuchFieldException {
7        // 具有泛型的超类
8        log.info(
9            "Type: {}",
10            Arrays.toString(
11                (
12                    (ParameterizedType) TestClass.class.getGenericSuperclass()
13                ).getActualTypeArguments()
14            )
15        );
16        // 返回值
17        final Method method =
18            this.getClass().getDeclaredMethod("testMethod", List.class);
19        log.info(
20            "Type: {}",
21            Arrays.toString(
22                (
23                    (ParameterizedType) method.getGenericReturnType()
24                ).getActualTypeArguments()
25            )
26        );
27        // 参数
28        log.info(
29            "Type: {}",
30            Arrays
31                .stream(method.getGenericParameterTypes())
32                .map(type -> (ParameterizedType) type)
33                .map(ParameterizedType::getActualTypeArguments)
34                .collect(Collectors.toList())
35        );
36        // 字段
37        log.info(
38            "Type: {}",
39            Arrays.toString(
40                (
41                    (ParameterizedType) this.getClass()
42                        .getDeclaredField("contexts")
43                        .getGenericType()
44                ).getActualTypeArguments()
45            )
46        );
47    }
48
49    List<ApplicationContext> testMethod(List<ApplicationContext> contexts) {
50        return contexts;
51    }
52
53    public static class TestClass extends ArrayList<ApplicationContext> {}
54}

文中就只列举了几种常见的场景,不过需要注意有些泛型信息虽然有保留但是使用 Java 反射无法获取,如局部变量,Java 没有对应局部变量的反射机制,自然也无法取得对应的泛型信息,需要额外借助 ASM 等字节码工具类来进行读取。

在 Java 中,我们为了限制泛型的使用会使用一些修饰符来定义泛型的上下限,如以下几种定义:

1<?>           // 无限制通配符
2<T>           // 无限制通配符
3<? extends E> // extends 关键字声明了类型的上界,表示参数化的类型可能是所指定的类型,或者是此类型的子类
4<? super E>   // super 关键字声明了类型的下界,表示参数化的类型可能是指定的类型,或者是此类型的父类
5<T extends Staff & Passenger> // 合并限制

从上面的几种修饰方式我们可以看出,泛型擦除被分成了有限制泛型擦除和无限制泛型擦除两种。

无限制泛型擦除

无限制泛型擦除,当类或方法定义中的类型参数没有任何限制时,即形如 <T><?> 的类型参数都属于无限制泛型擦除。在进行无限制泛型擦除时,Java 编译器会将这些泛型信息都替换为 Object

0120210701154903.png

如以下的测试代码,其编译后的签名为 <<T:Ljava/lang/Object;>(TT;)V>

1public static <T> void object(final T object) {} // <<T:Ljava/lang/Object;>(TT;)V>

有限制泛型擦除

有限制泛型擦除,当类或方法参数等类型参数存在限制(上下界)时,即形如 <T extends Number><? extends Number><? super Number> 的类型参数都属于有限制泛型擦除。在进行有限制泛型擦除的时候 Java 编译器会将这些泛型信息进行变更。其中 extends 修饰的会被替换成具体的类型,如 <T extends Number> 会替换为 Numbersuper 修饰的会被替换为 Object

0120210701160046.png

如以下的测试代码,其编译后的签名为 <<T:Ljava/lang/Number;>(TT;)V>

1public static <T extends Number> void number(final T number) {} // <<T:Ljava/lang/Number;>(TT;)V>

合并泛型擦除

合并泛型擦除也属于有限制泛型擦除,不过由于其泛型定义具有多个类型,其签名也存在多个类型信息,如以下的测试代码,其编译后包含了两个类型:<<T::Lorg/springframework/context/ApplicationContext;:Lorg/springframework/context/annotation/AnnotationConfigRegistry;>(TT;)V>

1public static <T extends ApplicationContext & AnnotationConfigRegistry> void merge(final T merge) {} // <<T::Lorg/springframework/context/ApplicationContext;:Lorg/springframework/context/annotation/AnnotationConfigRegistry;>(TT;)V>

需要注意一点,虽然签名中保留了多个泛型信息,但是在进行反射取得方法的时候需要使用第一个类型来获取:

1this.getClass().getDeclaredMethod("merge", ApplicationContext.class);

由于泛型是在语法层面上支持的也就是说编译器上支持的,所以自然而然就要有泛型的检查,否则泛型就没有存在的意义了。

比如以下的代码:

1public static void main(String[] args) {
2  List<String> list = new ArrayList<String>();
3  list.add("123");
4  list.add(123); //编译错误
5}

由于我们定义 List 的元素类型是 String,所以当添加的类型不为 String 的时候,Java 编译器就会检测出来,并抛出编译错误。即使在运行时会进行 泛型擦除,List 可以存储任何 继承 自 Object 的类型,不过运行时是处于编译后,在编译期间就会对泛型的使用进行检查。

泛型检查是针对泛型的,如果原始使用的话就不会报编译错误,这是因为如果不写泛型,那么 Java 会默认泛型为 Object 。如下:

1public static void main(String[] args) {
2  List list = new ArrayList();
3  list.add("123");
4  list.add(123); //编译通过
5}

还有一种常见的泛型检查是泛型间类型的转换,如下:

1List<String> list1 = new ArrayList<Object>(); //编译错误
2List<Object> list2 = new ArrayList<String>(); //编译错误

第一种转换报错的原因是因为 Object 类型无法转换为 String,如果 List 中存储了非 String 类型的对象,当我们获取的时候,Java 会默认认为 List 中所有对象都是 String,而此时如果获取它,就会抛出 ClassCastException 异常。因此 Java 编译器不允许此种情况的发生。

第二种虽然可以正常工作,但是如果这样转换泛型也就失去了意义,所以 Java 编译器也不允许这种情况的发生。

这篇文章从 5 月份就开始计划写了,一直咕到了今天 🤣。

最近应该都会写一些深入一点的文章(类似这篇),框架系列就一直没写了,等过几天看看吧(逃。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK