一、什么是泛型
泛型,顾名思义,"泛化的类型",还有一种比较有意思的解释叫"参数化类型"。
接下来先用一个例子来感受一下泛型的实际作用,这个例子我称作"魔术师的盒子"。
csharp
// 定义了一个泛型盒子类
public class Box<T> {
private T item;
public Box(T item) {
this.item = item;
}
public T getItem() {
return item;
}
public void setItem(T item) {
this.item = item;
}
public static void main(String[] args) {
// 创建一个盒子来存储字符串
Box<String> stringBox = new Box<>("Hello");
System.out.println(stringBox.getItem()); // Hello
// 创建一个盒子来存储整数
Box<Integer> integerBox = new Box<>(123);
System.out.println(integerBox.getItem()); // 123
// 创建一个盒子来存储浮点数
Box<Double> doubleBox = new Box<>(45.67);
System.out.println(doubleBox.getItem()); // 45.67
}
}
在上面的例子中我们用泛型定义了一个盒子类,在使用盒子的时候我们可以将Java类型作为参数传入并指定这个盒子的具体类型,这样做有几个最直接的好处:
- 代码重用:显然如果不用泛型我们就需要创建多个不同类型的盒子类来实现以上功能。
- 理解简单:在我们使用盒子的时候直接指定了类型,这对于开发者来说可以很直观确定盒子类的元素类型。
那么直观的好处有这些,还有些不直观却更有价值的好处在之后我们深入了解泛型之后再提出会更好理解。
首先我们作出以下规定:
字母 | 意义 |
---|---|
E - Element | 集合的元素类型 |
T - Type | 类的元素类型 |
K - Key | 键 |
V - Value | 值 |
N - Number | 软约束的数值类型 |
二、泛型的原理
在初步了解了泛型的使用之后我们深入了解泛型的原理。
我们先看接下来的两个简单例子:
java
public static void main(String[] args) {
List list = new ArrayList();
list.add("Hello"); // 添加一个String对象
list.add(123); // 添加一个Integer对象
// 在获取元素时假设所有元素都是String
for (Object obj : list) {
String s = (String) obj; // 尝试将每个元素转换为String
System.out.println(s);
}
}
在不使用泛型的情况下,Java程序会在运行时进行类型检查和转换,而转换失败时会报错如下:
而我们使用泛型后是以下效果:
在加入泛型后,编译器会直接检查出错误并无法通过编译,这样会大大减少了运行时出现类型转换错误的问题。
我们再看下一个例子:
java
public static void main(String[] args) {
List list = new ArrayList();
list.add("Hello"); // 添加一个String对象
list.add(123); // 添加一个Integer对象
Iterator iterator = list.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
当我们不进行类型转换,此时不会出现任何错误,控制台会输出一下结果:
总结一下,在我们不使用泛型的情况下,添加和访问任何类型元素的元素都是可以的,当我们进行类型转换时是有可能出现无法转换类型的运行时异常,而这个类型是在运行时进行检查和转换的。
在我们使用泛型后,只能添加和访问指定类型的元素,而指定的类型可以是任意类型,此时由于使用错误类型元素过不了编译所以也不存在类型转换失败的异常。
那么我们作出以下设问和猜想:
- 不使用泛型的时候是用什么类型在存储元素?显然只有
Object
类型可以做到存储任意类型。 - 使用泛型后我们使用错误类型元素过不了编译,是否意味着我们在编译阶段就进行了类型检查而不是运行时。
- 接着上一个结论,在编译阶段我们进行了类型检查,那么是否意味着我们不再需要在运行时进行类型检查。
- 如果说我们在运行时不再进行类型检查,这就和非泛型的普通类型是一样的处理结果,那么是否意味着在运行时泛型已经转变为特定类型了。
带着以上未经验证的结论我们继续深入底层证实猜想。
我们使用javac Box.java
命令将刚才的"魔术师的盒子"进行编译得到Box.class
字节码文件,接着我们再使用javap -s Box
命令反编译字节码文件并展示详细信息,我们可以得到如下结果:
java
// Compiled from "Box.java"
public class src.Box<T> {
public src.Box(T);
descriptor: (Ljava/lang/Object;)V
public T getItem();
descriptor: ()Ljava/lang/Object;
public void setItem(T);
descriptor: (Ljava/lang/Object;)V
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
}
此时我们观察以上结果可以得出结论------泛型的签名在编译之后被保留了,但是实际的元素类型确实被已经被替换为了java/lang/Object
类型,这样一个过程我们称之为类型擦除。实际上,类型擦出后会将参数类型替换为泛型的上界,也就是说如果泛型继承自某个其他类型那么泛型的上界会"缩小"。
那么我们如何确认编译器在什么时候将我们的类型"指定"的呢?我们用以下代码演示:
java
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("Hello");
String item = list.get(0);
}
同样我们通过javac
指令进行编译,再使用javap -c
指令查看字节码指令可以得到如下结果:
在第24行的checkcast
操作中,编译器将Object
类型转换为了String
类型,这就是插入类型转换。
从这里我们也能看出类型转换发生在编译时而非运行时,这在一定程度上优化了运行效率。
在以上过程结束后,泛型所做的事情也就差不多了,但是还有一种特殊情况存在着特殊步骤:
生成桥接方法:为了支持多态和保持兼容性,有时编译器需要生成额外的方法,这些方法被称为桥接方法。这主要出现在泛型和继承结合使用的情况下。例如:
java
public class Parent {
public void setValue(Object value) { ... }
}
public class Child extends Parent {
public void setValue(String value) { ... }
}
在子类中,setValue
方法与父类中的方法不是真正的重写,因为参数类型不同。为了确保多态的行为(例如,可以通过父类的引用调用子类的setValue
方法),编译器会为子类生成一个桥接方法,这个方法接受一个Object
参数并内部调用setValue(String value)
。
三、总结
泛型是Java提供的一种代码复用机制,使用泛型有以下优点和缺点:
优点:
- 类型安全:泛型在编译时检查类型,确保数据的类型安全。这减少了在运行时出现的类型转换错误。
- 代码重用:允许程序员编写一次,用于多种不同的数据类型。
- 提高代码质量:由于类型检查是在编译时完成的,所以错误可以在运行前被捕捉。
- 消除类型转换:不再需要手动进行类型转换,因为泛型会自动为你处理。
- 明确代码意图:使用泛型可以使代码更加清晰,因为可以看到类或方法操作的数据类型。
缺点:
- 类型擦除:Java中的泛型实现使用了类型擦除,这意味着运行时泛型类型信息是不可用的。
- 性能代价:虽然泛型在某些情况下优化了运行性能,但同时也会增加更多字节码从而拖垮编译性能。
- 有限的类型推断:在某些复杂的场景下,Java编译器可能无法推断出正确的类型,这时开发者需要显式地指定类型。(情况少见,不再展开)
总的来说,泛型提供了强大的类型检查和代码重用功能,但也带来了一些复杂性和限制。在实际应用中,评估其优缺点并根据实际情况决定是否使用泛型是很重要的。