作为一个 Java 程序员,用到泛型最多的,我估计应该就是这一行代码:
List<String> list = new ArrayList<>();
这也是所有 Java 程序员的泛型之路开始的地方啊。
不过本文讲泛型,先不从这里开始讲,而是再往前看一下,看一看没有泛型的时候,Java 代码是怎么写的,然后我们才会知道为什么要加入泛型,泛型代码该怎么写。
这里插播一下我的微信公众号,希望大家能够多多关注,我会不定期更新优秀的技术文章:
接下来,开始我们的正文。
为什么要设计泛型
提高代码重用性
没有泛型之前,我们写一个两数相加的函数:
public static int add(int a, int b) {
return a + b;
}
看似没问题,对吧。不过这个时候我们想计算 float 类型的加法,那这个函数就不行了,因为他只能计算 int 值。此时就只能再加入一个相同的函数了:
public static float add(float a, float b) {
return a + b;
}
现在我们有两个方法能够计算 int 和 float 类型的加法。那现在如果要计算 String 类型的加法呢,这两个方法就又不够用了。面对这样的需求,在没有泛型的支持下,我们只能不断地增加逻辑基本相同的方法,代码重用性极低。
这就是泛型要解决的第一个问题:提高代码重用性。
那在泛型的加持下,我们如何编写这个函数呢?
public static <T extends Number> double add(T a, T b) {
return a.doubleValue() + b.doubleValue();
}
这个方法使用了泛型,它能够处理任何类型的数字相加,不需要针对每个类型编写各自的加法方法。这就大大提高了代码的重用性,有了这个方法,那些固定类型的方法就都可以删了。
特别是一些逻辑相同的代码,使用泛型不仅能够提高代码重用性,还能够提高可读性。比如说下面这段代码,真是的是非常好用:
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}
泛型的这个特性虽然很牛了,但是这还不是 Java 要设计泛型的全部原因。因为泛型还有一个作用,那就是保证类型安全。
保证类型安全
在说泛型的这个作用之前,先问大家一个问题,咱们常用的集合 ArrayList
是 Java 哪个版本加入的呢?泛型又是 Java 哪个版本加入的呢?
答案:ArrayList
是 Java 1.2 版本加入的,而泛型是 Java 1.5 加入的。
也就是说,有一段时时间,ArrayList
不是大家普遍认识的带泛型的 ArrayList<T>
这种形式,而是一个只能存放 Object
的列表。
在那一段泛型之光没有照耀到 Java 的日子里,保证类型安全成为了 Java 程序员在使用集合时不得不考虑的事情,考虑下面这一段代码:
ArrayList list = new ArrayList();
list.add("123");
// do some work......
Integer num = (Integer) list.get(0);
这段代码没有使用泛型来使用 ArrayList
,我们加入了字符串 "123"
,但是在使用时,我们假定程序员忘记了加入的类型,他只记得好像应该是数字,于是在获取时就直接使用了 Integer
类强转。
这样的代码是能通过编译的,但是在运行的时候,会崩溃:
Caused by: java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
这是一个典型的未使用泛型,而导致的类型安全无法保证,引发的崩溃。让程序员去保证类型安全,本身是不靠谱的做法,特别是在这种都是 Object 对象的列表中,鬼都不会知道存着的是个什么鬼。
这个时候就需要泛型出场了,泛型能够在编译时保证类型安全。例如上面的代码,我们加入泛型:
ArrayList<String> list = new ArrayList<>();
list.add("123");
Integer num = (Integer) list.get(0);
首先,ArrayList
加入泛型后,我们就知道这个列表是只能存入 String
类型的,也就不会将其转换为 Integer
。那如果我非要转换呢,javac 编译器就会报错:
错误: 不兼容的类型: String无法转换为Integer
Integer num = (Integer) list.get(0);
这样类型安全就可以在编译时得到保证,不会出现在运行时的崩溃。
例子的代码很简单,大家可能看不到这一点对于软件开发有多重要,在大型复杂的项目中,这种类型安全的保证,是能减少很多运行时的崩溃的。特别是,一般像这种类型不一致的崩溃很多都是偶现的,偶现的 BUG 是最恶心的,因此使用泛型保证类型安全是十分必要的。
消除强制类型转换
泛型的这个作用其实就是上面保证类型安全这一点带来的。没有用泛型时,需要我们使用强制类型转化,但是加入泛型后,编译器已经能够知道我们存入的是什么类型,因此也就不需要我们进行强制类型转换了。
既然泛型有那么大的作用,那我们就赶紧把泛型用起来吧。
使用泛型
这一节,我们来看看如何使用系统提供的泛型类,以及其中需要注意的事项。
最常用到泛型的地方便是集合了,使用这些泛型集合类时,只需要把具体泛型参数 <T>
替换为需要的类型即可,例如 ArrayList<String>
、ArrayList<Number>
、 Map<String, Integer>
等。
如果在使用泛型类时不指定类型参数,编译器会给出警告,且只能将 <T>
视为 Object
类型。这个时候就需要程序员自己去保证类型安全了,因此强烈不建议这么做,因为这样容易将类型转换异常带到运行时中去。
使用泛型基本就需要注意以上两点,下面介绍一下在使用泛型时的注意事项,这也是大家很少关注到的向上转型的问题。
在 Java 中,ArrayList<T>
是实现了 List<T>
接口,也就是说它可以向上转型为 List<T>
:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
那么问题就来了,当泛型参数不同时,还能向上转型么,说具体一点,ArrayList<String>
能转型为 List<Number>
么?
答案是不行的:
ArrayList List = new ArrayList<String>(); //Raw use of parameterized class 'ArrayList'
List<Integer> list = new ArrayList<String>(); //直接报错
为什么 Java 不允许这么转型呢?因为运行转型的话,那么对于一个 ArrayList<String>
的容器,我将其转型为 ArrayList<Integer>
就可以往里面加入 Integer
对象了,这明显会造成 ClassCastException
。泛型的存在用于限定类型的,这么一搞,泛型就失去了其作用。
这里,大家可以简单理解为,当泛型参数不一样时,两个类就没有太大关系了。例如 ArrayList<Integer>
和 List<Number>
两者完全没有继承关系。
编写泛型
知道怎么使用系统的泛型之后,我们现在就来看看如何编写自己的泛型类。
泛型作为对类型进行限制的一种方式,我们编写泛型代码,也就是对使用我们代码的人进行一种限制。在这种情况下,我们是作为其他程序员的底层,向上提供某种框架代码,让其他程序员能够在我们设定的框架中更容易地编写代码实现功能。这有点类似于库的开发者,或是框架开发者,作为这种角色,写好泛型代码就更显得尤为重要了。毕竟,你也不想让别人说,这代码写得就跟一坨屎一样吧。
编写泛型类
编写泛型类,是比普通类要复杂的。这里我们就用 Pair<F, S>
这个类作为目标,一步一步编写出一个合格的泛型类。Pair
类是 Android 开发中一个简单的使用工具类,用于存储一对相关联的对象。
我们的第一版 Pair
只能使用没有使用泛型:
public class Pair {
public final String first;
public final String second;
}
那这肯定是不行的,因为这个 Pair 只能存放 String 类型的 first 和 second,那了能够存放所有类型,我们就使用泛型 <T>
:
public class Pair<T> {
public final T first;
public final T second;
}
我们把 first
和 second
用 T
来修饰,表示其这两个成员变量是 T
类型的。而这个 T
类型,Java 是不知道的,我们必须声明告诉 Java 这是一个类型,因此类名从 Pair
变成了 Pair<T>
,后面的 <T>
就是我们的泛型类型声明。
上面的代码看上去没问题,但是这个 Pair<T>
只能存放的 first
和 second
必须是相同的类型 T
,那不同类型的怎么办呢?这时候我们再加一个泛型不就行了:
public class Pair<F, S> {
public final F first;
public final S second;
}
在加入两个泛型之后,first
和 second
的类型对应不同的泛型,这样就可以表示不同的类型了,注意 F
、S
这两个不同的泛型都需要在类上进行声明。
我们在为 Pair<F, S>
添加个构造方法:
public class Pair<F, S> {
public final F first;
public final S second;
public Pair(F first, S second) {
this.first = first;
this.second = second;
}
}
这算是一个简单的泛型类,那接下来,我们再为它编写一个泛型方法。
编写泛型方法
此处的泛型方法是指静态方法,而不是成员方法。这两种方法在使用泛型时是有一些区别的,其中最重要的一点就是,静态方法是不能使用类上声明的泛型类型,必须得自己声明泛型类型。例如,下面的代码将编译错误:
public static class Pair<F, S> {
public final F first;
public final S second;
//编译错误,F、S 类型不能在 static 方法上使用
public static Pair<F, S> create(F a, S b) {
return new Pair<F, S>(a, b);
}
}
可以想一想,为什么静态方法不能使用类上已经声明的泛型类型呢?
在回答这个问题之前,我们可以先想一下,类上的泛型类型,是在什么时候确定下来的呢?是在类创建的时候,我们在 new
的时候是需要提供具体类型的,这个时候泛型就被具体化为某个特定类型。不同的对象可能被创建为不同的类型,而静态方法只跟类相关,跟具体对象无关,而这些泛型又是跟具体对象相关的。所以静态对象不能使用类上声明的泛型也就变得合理了。
那要想使静态方法使用泛型,那就必须这个静态方法自己声明泛型:
public static <F, S> Pair <F, S> create(F a, S b) {
return new Pair<F, S>(a, b);
}
这个静态方法在函数名前使用 <F, S>
来声明了两个泛型,那么后续这两个泛型就可以在这个函数中使用了。此时注意,这里的 F
、S
虽然与 Pair
上的 F
、S
泛型看似相同,实际上是没有任何关系的。所以为了避免产生误会,一般都会使用不同的泛型名,例如将这个方法的 <F, S>
变成 <A, B>
:
public static class Pair<F, S> {
public final F first;
public final S second;
public static <A, B> Pair <A, B> create(A a, B b) {
return new Pair<A, B>(a, b);
}
}
这样才能够清楚地将静态方法的泛型类型和实例类型的泛型类型区分开。
在使用时,我们可以使用如下代码创建一个 Pair<F, S>
实例:
Pair<String, Integer> pair = Pair.create("123", 123);
这里总结一下编写泛型需要注意的几点:
- 编写泛型时,需要定义泛型类型
<T>
; - 静态方法不能引用类上的泛型类型
<T>
,必须定义自己方法特有的泛型类型; - 泛型可以同时定义多个,例如
<F, S>
、<F、S、T>
;
在这里我们需要注意泛型的一个限制,那就是不能使用泛型类型直接创建对象。这一点也好理解,T
是什么类型只有在使用时,指定了泛型的具体类型才能确定。T
类型是一个抽象的类型,它是无法直接 new
出来的,就像你无法直接 new
一个 interface
一样。例如下面的代码是错误的:
public static class Pair<F, S> {
public final F first;
public final S second;
public Pair(F first, S second) {
this.first = new F(); //错误
this.second = new S(); //错误
}
}
这里使用 F
类型的默认构造,设想一下假如这个类型被确定为一个没有默认构造方法的类型呢。所以使用泛型类型创建对象是不行的。
Java 的泛型实现方式:类型擦除
上面的几节介绍了泛型的好处,泛型的使用,那这一节我们就来看看 Java 是如何实现泛型技术的。
首先,泛型编程并非 Java 特有的,在其他语言 C++、C# 上都有类似的技术,只不过名称不同而已,例如 C++ 上叫模版。在这些技术的加持下,程序员可以编写与具体类型无关的代码,只需要在使用时指定具体类型,从而提高代码的复用性;并且在编译时进行类型检查,减少运行时错误。
Java 的泛型是通过类型擦除(Type Erasure)来实现的。也就是说在编译时将泛型类型擦除,替换为其上限类型(通常为 Object
),并在必要时插入类型转换。这种机制在编译时处理泛型类型,而在运行时移除了所有的泛型信息,因此叫做类型擦除。
这也就意味着,Java 的泛型是由编译器实现的,在编译成 class 文件时类型信息已经被擦除了,因此运行时,Java 虚拟机是没有任何泛型信息的。
例如上面我们编写的 Pair
的这个类,在我们看来,它是这样的,在源代码阶段,里面是包含泛型信息的:
public static class Pair<F, S> {
public final F first;
public final S second;
public Pair(F first, S second) {
this.first = first;
this.second = second;
}
public static <A, B> Pair<A, B> create(A a, B b) {
return new Pair<A, B>(a, b);
}
}
那么在虚拟机的视角,它是这样的:
public class Pair {
private Object first;
private Object last;
public Pair(Object first, Object last) {
this.first = first;
this.last = last;
}
}
从这里就能看到,这个 Pair
在运行时已经没有泛型信息了,所有的泛型类型都被替换为了 Object
。
那么既然我们定义的泛型类型最终都变成了 Object
,那我们就知道了 Java 泛型的一个局限:泛型类型 <T>
不能是基本类型。
因为像 int
、float
这些基本类型不是 Object
的子类,所以我们必须使用包装类:
Pair<float, int> pair = Pair.create(3.15, 123); //编译错误
Pair<Float, Integer> pair = Pair.create(3.15F, 123); //编译通过
尽管 Java 的泛型在编译时通过类型擦除机制移除了泛型类型信息,但 Java 编译器会在 class
文件中保留一些泛型信息,以便工具和开发人员能够利用这些信息进行反射和调试。所以如果大家把这个类编译为 class
文件之后,再查看它的反编译的内容,会发现它是有一些泛型信息的。但这并不意味着 JVM 在运行时会携带这些类型信息,既然是类型擦除,也就是说泛型类型参数被擦除并替换为其边界类型,如果没有指定边界,则默认为 Object
。
这里又引入了边界类型这个概念,在下一篇文章中,我们就来详细聊聊这个边界类型,这也是泛型中比较重要和难的点。