Featured image of post 泛型中的桥接方法引起的 bug

泛型中的桥接方法引起的 bug

因为意外的 bug,想着再去回顾一下 java 的泛型

最近开发中遇到一个奇怪的现象,在对某一类bean下面的方法进行扫描,获取当前方法上面的注解对应的值的时候,意外发现某一个继承自父类的方法竟然被扫描了两次,由于我在代码中设置了每一个方法允许且只允许被扫描一次,所以程序运行过程中也自然报错了。通过调试以及综合之前所了解的知识,发现是泛型导致的问题,在此记录,以便学习。

泛型与java

泛型

泛型对于大家来说,应该不是一个没有接触过的概念,我们在开发过程中,或多或少都会接触到,常见的ListMap等在使用过程中都会用到泛型,但是,我们可能没有去认真考虑,为什么需要用到泛型,以及泛型带来的好处是什么。

泛型主要是将类型参数话,使得可以将类型像参数传递到方法内部那样传递到数据结构内部,这样编译器在编译期间就可以针对类型进行筛选和检查,以提高类型安全,减少在运行时由于类型不匹配而引发的异常。

为什么要引入泛型

在面向对象的语言中,程序中会有很多类型的对象,对象多了,就需要一些用来存储某些对象的容器,常见的比如ArrayListMap等,由于这些容器不仅仅用来存储对象,还提供了一些方法用来操作容器内部的对象,这时,问题就来了…容器需要知道容器中存放的是什么类型才能进行setreturn

进行java开发的哥们可能会想到,我们使用Object类型啊,这个类是所有的类的根类。那么一个容器类的内部就变成了下面这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/**
 * @author yinan
 * @date 2020/1/22
 */

public class Container {
  private Object obj;
  public Object getObj() {
    return obj;
  }
  public void setObj(Object obj) {
    this.obj = obj;
  }
}

如果真的是这样的话,那么问题就来了,这货是一个能够装任何类型的对象,然后取出的时候,如果是自己需要的,就要去手动转成实际的类型,这样会不会显得很麻烦呢?除此之外,也会引起类型不安全,由于编译器无法检查的缘故,如果实际返回的对象无法强转成我们需要的类型,就会抛出异常。所以只能由开发人员来把关,记住对象属于什么类型避免类型转换错误。

这时候,泛型就有必要出场了…

java的泛型

首先,我们将上面的容器类改写一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/**
 * @author yinan
 * @date 2020/1/22
 */

public class Container<T> {
  private T obj;
  public T getObj() {
    return obj;
  }
  public void setObj(T obj) {
    this.obj = obj;
  }
}

上面便是java的泛型写法,当我们想要向容器中添加指定类型的对象时,便可以使用Container<T> container = new Container<>()进行指定,然后再向其中添加对应的类型对象即可,这样如果添加的对象不是指定类型的,编译器就会直接报错,相对于非范型来说,可谓是从农业时代到工业时代的一大跨越。

真假泛型

我们都知道,java泛型实际上和C#C++等还是不太一样的,在编译之后的javaclass文件中,对应的泛型类都会被擦出,也就是说,我们无法从类文件中去了解当前容器中所真正包含的对象类型,只有在运行时才会去实时解析出当前存入或返回的对象类型。那么java中的泛型是怎样进行擦出的呢?我们还是来举一个例子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public static void main(String[] args) {
  try {
    ArrayList<Integer> arr=new ArrayList<Integer>();
    //存储int型
    arr.add(1);
    //利用反射存储String
    arr.getClass().getMethod("add", Object.class).invoke(arr, "asd");
    for (int i=0;i<arr.size();i++) {
      System.out.println(String.format("泛型擦除 %s", arr.get(i)));
    }
  } catch (Exception e) {
    e.printStackTrace();
  }
}

运行结果

运行结果

我们可以看到,在定义的Integer类型的容器中,竟然存入了字符串类型的对象,仿佛泛型消失了一样,没错,的确是消失了,程序在编译之后,将Integer类型擦除了,而反射避开了程序的编译过程,因此编译之后的程序可以存储任何Object类型的子类。这也就是类型擦除

那么为什么java不像其它语言那样,考虑使用真的泛型,即编译期也不擦除类型呢?这其实是一个历史原因,也是java不得已而为之,泛型是在java5才被引入的,因此为了兼容之前的List list = new ArrayList()这种写法的代码,java不得已而为之。

泛型与注解

上面说明那么多,其实还仅仅是介绍一下泛型的历史,下面我们来进入正题,考虑一下如下的代码能否运行,以及运行结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package para.test;
/**
 * @author yinan
 * @date 2020/1/20
 */

public class ParaFather<T> {
  public void say(T mes) {
    System.out.println("para father says: " + mes);
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package para.test;
/**
 * @author yinan
 * @date 2020/1/20
 */

public class ParaSon extends ParaFather<String> {
  @Override
  public void say(String mes) {
    System.out.println("para son says " + mes);
  }
}
1
2
3
4
public static void main(String[] args) {
  ParaFather<String> obj = new ParaSon();
  obj.say("111");
}

我想大部分人都会能够正确的说出答案,这里也不卖关子,运行结果是para son says 111;但是我们需要去看看原因,为什么是这样的结果,因为上面我们也说到了,泛型最终会被擦除,所以父类中的方法会变成如下所示:

而在其子类ParaSon中的实现方法其参数类型却是一个String类型,这样好像和面向对象中的继承性有点不太一样,我们明明是重载了say()方法,而非重写该方法。这其中一定有一些猫腻。

桥接方法

反编译ParaSon类文件之后,我们可以看到,类文件中多了一个方法:public void say(java.lang.Object);,反编译结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
yinans-MacBook-Pro:test yinan$ javap -c ParaSon.class 
Compiled from "ParaSon.java"
public class para.test.ParaSon extends para.test.ParaFather<java.lang.String> {
  public para.test.ParaSon();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method para/test/ParaFather."<init>":()V
       4: return

  public void say(java.lang.String);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: new           #3                  // class java/lang/StringBuilder
       6: dup
       7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      10: ldc           #5                  // String para son says
      12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      15: aload_1
      16: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      22: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      25: return

  public void say(java.lang.Object);
    Code:
       0: aload_0
       1: aload_1
       2: checkcast     #9                  // class java/lang/String
       5: invokevirtual #10                 // Method say:(Ljava/lang/String;)V
       8: return
}

这说明,在编译阶段,子类中的确有生成一个父类的方法,而这个方法就是一个桥接方法(Bridge,可以使用isBridge()方法来判断),其实桥接方法的内部是调用了另外一个参数类型为Stringsay()方法,因此在子类中其实是有两个方法名为say的方法。这也解释了为什么我刚开始说到的,有扫描到两次注解。

但是其实这里我有不明白的地方,明明桥接方法会调用真正的执行方法,那么为什么还需要在桥接方法上生成相关注解呢?

这里仅仅作为一个问题来记录,目前没有看到相关文档有说明,想着有时间再看一看桥接方法的实现原理,或许可以得到回答……

总结

本文主要从最近遇到的问题入手,解释泛型以及桥接方法相关知识,最后得出桥接方法上的注解导致的扫描出现问题,另外针对桥接方法上添加注解还存在着一些不理解的地方,期望在此记录后续翻看时可以有机会再弄懂。

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus