初识泛型:从包装类到类型参数化
1 包装类:为什么需要它
Java 里基本类型(int、double 这些)不是继承自 Object 的,而泛型代码要求操作对象。为了在泛型里能支持基本类型,Java 给每个基本类型都配了一个包装类。
| 基本数据类型 | 包装类 |
|---|---|
| byte | Byte |
| short | Short |
| int | Integer |
| long | Long |
| float | Float |
| double | Double |
| char | Character |
| boolean | Boolean |
记法:除了 Integer 和 Character 这两个,其余包装类都是基本类型首字母大写。
1.1 手动装箱和拆箱
java
int i = 10;
// 装箱:把基本类型的值"装"进对象里
Integer ii = Integer.valueOf(i); // 推荐
Integer ij = new Integer(i); // 会额外创建对象,不推荐
// 拆箱:把对象里的值"取"出来
int j = ii.intValue();
1.2 自动装箱和自动拆箱
手动写 valueOf 和 intValue 太啰嗦,Java 干脆自动了:
java
int i = 10;
Integer ii = i; // 自动装箱
Integer ij = (Integer) i; // 自动装箱(强转写法)
int j = ii; // 自动拆箱
int k = (int) ii; // 自动拆箱(强转写法)
1.3 一道常考的面试题
java
Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;
System.out.println(a == b); // true
System.out.println(c == d); // false
答案是第一个 true、第二个 false。原因是 Integer 内部有缓存:-128 到 127 之间的整数直接复用同一个对象,所以 127 那组 a 和 b 指向同一个对象,比较引用,相等;128 超出缓存范围,每次赋值都会新建对象,c 和 d 不是同一个对象 返回 false。
所以比较包装类相等,老老实实用 equals,别用 ==。这个坑写代码和面试都容易踩。
2 什么是泛型
《Java 编程思想》里有一段话:一般的类和方法,只能使用具体的类型,要么是基本类型,要么是自定义的类。如果要编写可以应用于多种类型的代码,这种刻板的限制对代码的束缚就会很大。
泛型就是干这个的:JDK 1.5 引入的新语法,通俗讲就是适用于许多类型,从代码上讲,就是对类型实现了参数化。
3 引出泛型:Object 数组的坑
先看一个需求:实现一个类,里面有一个数组成员,希望它能存放任何类型的数据,还能按下标返回。
直觉做法:所有类的父类都是 Object,那把数组类型声明成 Object\[\] 不就行了?
java
class MyArray {
public Object[] array = new Object[10];
public Object getPos(int pos) {
return this.array[pos];
}
public void setVal(int pos, Object val) {
this.array[pos] = val;
}
}
public class TestDemo {
public static void main(String[] args) {
MyArray myArray = new MyArray();
myArray.setVal(0, 10);
myArray.setVal(1, "hello"); // 字符串也能存进去
String ret = myArray.getPos(1); // 编译报错
}
}
问题很明显:
- 任何类型都能往里放,但也意味着什么类型都可能取出来;
- 取出来是 Object,想用回原来的类型必须强转;
- 编译期完全不检查类型,写错了要等运行期才炸。
现实里我们更希望:这个容器只持有一种类型,放错了编译器直接报错。于是泛型登场,把类型也当成参数传进去,需要什么类型就传什么类型。
3.1 泛型类的基本语法
java
class 泛型类名称<类型形参列表> {
// 类体里可以直接用类型参数
}
class ClassName<T1, T2, ..., Tn> {
// 多个类型参数也行
}
类型形参的命名规范(了解即可,约定俗成):
- E 表示 Element(元素)
- K 表示 Key(键)
- V 表示 Value(值)
- N 表示 Number(数字)
- T 表示 Type(类型)
- S、U、V 等表示第二、第三、第四个类型
3.2 用泛型改写 MyArray
java
class MyArray<T> {
public T[] array = (T[]) new Object[10]; // 1
public T getPos(int pos) {
return this.array[pos];
}
public void setVal(int pos, T val) {
this.array[pos] = val;
}
}
public class TestDemo {
public static void main(String[] args) {
MyArray<Integer> myArray = new MyArray<>(); // 2
myArray.setVal(0, 10);
myArray.setVal(1, 12);
int ret = myArray.getPos(1); // 3
System.out.println(ret);
myArray.setVal(2, "bit"); // 4 编译报错
}
}
逐行解释:
- 类名后的 是占位符,声明这个类是泛型类;
- 注释 1:不能直接 new T\[\] 泛型数组,这里先 new Object\[\] 再强转成 T\[\],属于过渡写法,不算好,后面擦除机制一节会解释原因;
- 注释 2:使用时用 指定当前类型,new MyArray<>() 的尖括号里留空,因为编译器能推导出来;
- 注释 3:取出来直接用 int 接,不需要强转;
- 注释 4:存字符串直接编译报错,类被指定成 Integer 之后,编译器帮忙检查类型。
4 泛型类的使用
java
MyArray<Integer> list = new MyArray<Integer>(); // 完整写法
MyArray<Integer> list = new MyArray<>(); // 类型推导,推荐
注意:泛型只能接受类类型,所有基本类型都必须用包装类,比如 MyArray 而不是 MyArray。
还有一个概念叫裸类型(Raw Type):泛型类不带类型实参,比如 MyArray list = new MyArray()。裸类型是为了兼容老版本 API 才保留的机制,自己写代码别用。
5 泛型怎么编译:擦除机制
泛型本质是编译期的东西。用 javap -c 反编译字节码看看,源码里的 T 全部变成了 Object:
text
class MyArray<T> {
public T[] array;
MyArray();
Code:
0: aload_0
5: bipush 10
7: anewarray #2 // class java/lang/Object
10: checkcast #3 // class "[Ljava/lang/Object;"
}
这种编译时把 T 替换成 Object 的机制,叫类型擦除。也就是说:Java 的泛型只在编译期起作用,编译器生成的字节码里不包含泛型类型信息,运行期一切都按 Object 处理。
5.1 那为什么不能直接 new T\[\]
现在可以回答前面的疑问了。T\[\] ts = new T5 不行,是因为擦除之后它相当于 Object\[\] ts = new Object5,而运行期把 Object\[\] 直接赋给 Integer\[\] 引用是不安全的:
java
MyArray<Integer> myArray1 = new MyArray<>();
Integer[] arr = myArray1.getArray(); // 运行期抛 ClassCastException
擦除后 getArray 返回的是 Object\[\],里面可能装着任何类型,直接转成 Integer\[\] 编译器认为不安全,所以报错。
想真正创建指定类型的泛型数组,得借助反射(了解即可):
java
class MyArray<T> {
public T[] array;
public MyArray(Class<T> clazz, int capacity) {
array = (T[]) Array.newInstance(clazz, capacity);
}
}
MyArray<Integer> myArray1 = new MyArray<>(Integer.class, 10);
Integer[] integers = myArray1.getArray();
6 泛型的上界
有时候不想让类型参数太自由,可以用 extends 给它加上界:
java
class MyArray<E extends Number> {
// E 只能是 Number 及其子类型
}
MyArray<Integer> l1; // 正常,Integer 是 Number 的子类
MyArray<String> l2; // 编译错误,String 不是 Number 的子类
如果没有指定边界,默认等价于 extends Object。常见的复杂写法还有:
java
// E 必须实现 Comparable 接口,说明 E 是可以比较大小的
class MyArray<E extends Comparable<E>> {
}
这种写法在排序、比较的场景里很常见,后面学 PriorityQueue、TreeMap 之类的源码时都会见到。
7 泛型方法
泛型不仅能用在类上,还能用在方法上。比如写一个交换数组两个位置元素的工具方法:
java
public class Util {
// 静态泛型方法:static 后面用 <E> 声明类型参数
public static <E> void swap(E[] array, int i, int j) {
E t = array[i];
array[i] = array[j];
array[j] = t;
}
}
调用时可以让编译器推导类型:
java
Integer[] a = {1, 2, 3};
swap(a, 0, 2); // 自动推导 E 是 Integer
String[] b = {"a", "b"};
swap(b, 0, 1); // 自动推导 E 是 String
也可以显式指定:
java
Util.<Integer>swap(a, 0, 2);
Util.<String>swap(b, 0, 1);
小结
把这篇的内容串一遍:泛型就是把数据类型参数化,让代码能复用于多种类型;编译期它帮你做类型检查和自动转换,运行期又通过擦除机制把 T 还原成 Object,兼顾类型安全和性能。包装类是泛型能用于基本类型的前提,上界用来约束类型参数的范围,泛型方法让静态工具方法也能用上这套机制。
地基打完了,下一步就可以带着这些概念去拆 ArrayList、HashMap 的源码了。