java 泛型机制详解

本文主要讲解了 java 泛型机制

为什么 JAVA 需要支持泛型

泛型是是什么:在我的理解中泛型实际上就是将类当做一种参数。

我们先思考一下,JAVA 在没有泛型的情况下,我们使用 ArrayList 该如何使他支持所有的数据类型呢?我们总不可能一个方法重载所有的类吧,那面对开发者自己创建的类又该怎么办,且一个方法无限膨胀也是不现实的。因此,JDK1.4 之前是通过将所有的方法类型都定义为 Object,因为所有的类都是隐式继承的 Object 类,这样我们就能够为所有的类提供容器的支持。但是随之而来的就有另一个问题,因为集合中,所有的参数都是 Object,虽然我们知道我们一种容器只会放一种类型,但是你还是不得不在取出来的时候进行类型的强转。同时也为协同开发带来一定的问题,那就是如果别人并不知道你的容器存放的是什么,但是他却往里面插入了一些其他类型的数据,就会导致你的代码抛出类型转换异常。

因此,我们知道,JAVA 如果增加对泛型的支持,对开发效率来说是有一定的提升的,所以在 JDK1.5 的时候,JAVA 推出了对泛型的支持。

JAVA 的泛型与 C# 的泛型区别

C# 的泛型(真实的泛型):无论是在编译后的 IL 文件中(泛型是一个占位符),还是运行期的CLR中都是切实存在的。List<int> 与 List<String> 就是两个不同的类型。他们在系统运行期生成,有自己的虚方法表和类型数据。这种实现成为类型膨胀,是一种的真实泛型。

JAVA 的泛型(伪泛型):JAVA 的泛型是一种编译期检查,运行期强转的实现。在编译期的时候,任何不满足泛型使用的代码都会被阻止,编译后所有的泛型类型都会被擦除成最原始的 Object 类型。因此,List<Integer> 与 List<String> 实际上是同一个类,是一种伪泛型。

为什么 JAVA 使用类型擦除这种伪泛型的实现方式呢?可能是因为,JAVA 在市场的占有率比较高,而如果使用真泛型,则需要变更现有的 API 推出一套支持泛型的新 API,这样的话就会导致 JDK 的升级变得异常麻烦,需要开发者修改大量的代码,因此 JAVA 才采用泛型擦除来实现泛型的功能吧。

JAVA 的泛型解析

泛型的擦除

上面我们说了,JAVA 的泛型实际上是在编译期的时候做检查,内部的结构依然是采用 Object 来保存数据,下面我们来实践一下。

1
2
3
4
5
6
7
8
9
10
11
12
public class Parent<T> {

protected T value;

public void set(T t) {
value = t;
}

public T get() {
return value;
}
}

上面的代码我们经过字节码反编译之后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java> javap -c com\ovvow\gitlab\util\Parent.class
Compiled from "Parent.java"
public class com.ovvow.gitlab.util.Parent<T> {
protected T value;

public com.ovvow.gitlab.util.Parent();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public void set(T);
Code:
0: aload_0
1: aload_1
2: putfield #2 // Field value:Ljava/lang/Object;
5: return

public T get();
Code:
0: aload_0
1: getfield #2 // Field value:Ljava/lang/Object;
4: areturn
}

我们可以看到在 get 方法的第16行和 set 方法的第22行中,编译后的提示为“Field value:Ljava/lang/Object” 这表示,在我们调用 get 和 set 方法的时候,我们真实操作的对象都是 Object 对象。

那既然都是 Object,必然就会引发另一个问题,那就是我如果 set 的时候传入一个非泛型的类对象,岂不是也能调用成功,那泛型不就是个中看不中用的东西了吗?这的确是个问题,我们看看 JAVA 如何解决这个问题,我们写个 main 方法测试一下

1
2
3
4
5
public static void main(String[] args) {
Parent<String> parent = new Parent<>();
parent.set("1");
parent.set(1);
}

这个时候我们试图去编译它,结果如下

1
2
3
4
5
6
7
8
9
10
java> javac -Xdiags:verbose com\ovvow\gitlab\util\Parent.java
com\ovvow\gitlab\util\Parent.java:22: 错误: 无法将类 Parent<T>中的方法 set应用到给定类型;
parent.set(1);
^
需要: String
找到: int
原因: 参数不匹配; int无法转换为String
其中, T是类型变量:
T扩展已在类 Parent中声明的Object
1 个错误

我们可以看到,JAVA 在调用泛型方法的时候会去检查你的参数类型与声明类型是否一致,如果不一致,则拒绝编译并报错。在编译的时候我们还可以清晰的看到,“T扩展已在类 Parent中声明的Object”,这也能佐证在泛型声明的类中,并不没有保存真正的泛型信息,而是擦除成了 Object。

泛型类的继承问题

由上一节我们知道,Parent<String> 在编译后产生的类文件应该为:

1
2
3
4
5
6
7
8
9
10
11
12
public class Parent {

protected Object value;

public void set(Object t) {
value = t;
}

public Object get() {
return value;
}
}

那我们创建一个 Children 类,去继承一个带泛型的 Parent<String>,同时重写他的 get 和 set 方法,且将泛型修改为我们确切的类型 String,是否可行呢?代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Children extends Parent<String> {

@Override
public void set(String s) {
value = s;
}

@Override
public String get() {
return value;
}

}

答案是可行的。但是我们知道,子类重写父类的方法,需要满足以下几点:

  1. 子类方法的权限修饰符要大于父类的方法权限修饰符且父类方法权限修饰符不能为private。
  2. 子类和父类必须要有相同的方法签名(即:方法名和方法参数类型相同)
  3. 子类的返回值必须是父类返回值本身或者其子类。
  4. 子类抛出的异常必须是父类抛出的异常本身或者其子类。

而我们明显可以看到,我们子类重写的 set 方法是不满足第二点的(参数类型不满足)。那为什么能正常编译通过,并且我们代码在运行的时候也没有任何问题呢?

这时候我们先看一看反编译后生成的字节码

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
33
34
35
36
37
java> javap -c com\ovvow\gitlab\util\Children.class
Compiled from "Children.java"
public class com.ovvow.gitlab.util.Children extends com.ovvow.gitlab.util.Parent<java.lang.String> {
public com.ovvow.gitlab.util.Children();
Code:
0: aload_0
1: invokespecial #1 // Method com/ovvow/gitlab/util/Parent."<init>":()V
4: return

public void set(java.lang.String);
Code:
0: aload_0
1: aload_1
2: putfield #2 // Field value:Ljava/lang/Object;
5: return

public java.lang.String get();
Code:
0: aload_0
1: getfield #2 // Field value:Ljava/lang/Object;
4: checkcast #3 // class java/lang/String
7: areturn

public java.lang.Object get();
Code:
0: aload_0
1: invokevirtual #4 // Method get:()Ljava/lang/String;
4: areturn

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

我们把字节码转换成 JAVA 代码后,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void set(String s) {
value = s;
}

public void set(Object s) {
set((String) s);
}

public String get() {
return (String)value;
}

public Object get() {
return get(); // 调用的为上一个 get 方法,字节码 27 行代表调用方法 get 且返回类型为 String 的方法。
}

我们可以看到,在 Children 的字节码中,并非我们所想像的生成了重写后的两个方法,而是生成四个方法,分别是父类的两个泛型擦除后类型为 Object 的get 和 set 方法,以及子类自己定义的 get 和 set 方法。从父类继承过来的方法只是作为一个桥接方法,用来调用我们自己的重写的两个方法。

同时我们发现,在生成的字节码中,存在方法签名一样,只有返回类型不一样的 get 方法。这很显然是满足方法的重写规则的,但是 JAVA 并没有用重写实现它(如果是重写的话,只会存在子类的方法,而不会存在父类的方法)。并且,这种写法在我们的规范中是不支持的,他并不属于重载和重写任何一种。如果我们自己定义两个方法签名一样但返回类型不一样的方法,编译是不能通过的。这么看来,JVM 实际上是支持方法签名相同的方法存在,只是规定编译器只有在泛型的继承中才能正常编译通过。可能是考虑到 JAVA 本身就支持多态,因此方法的签名相同的重载并没有什么意义而且还容易引起混乱,毕竟不知道你调用的具体是哪一种返回的方法。

总结

所以,JAVA 的泛型是一种伪泛型,利用编译器和语法糖的效果来实现泛型的功能。同时,为了支持正常的泛型继承,在编译的时候使用了语法糖来实现代码的正常运行。