4 泛型(一)
基本概念¶
引言¶
- 泛型(Generic)指可以把类型参数化,这个能力使得我们可以定义带类型参数的泛型类、泛型接口、泛型方法,随后编译器会用唯一的具体类型替换它。
- 主要优点是在编译时而不是运行时检测出错误。泛型类或方法允许用户指定可以和这些类或方法一起工作的对象类型。如果试图使用一个不相容的对象,编译器就会检测出这个错误。
- Java 的泛型通过擦除法实现,和 C++ 模板生成多个实例类不同。编译时会用类型实参代替类型形参进行严格的语法检查,然后擦除类型参数、生成所有实例类型共享的唯一原始类型。这样使得泛型代码能兼容老的使用原始类型的遗留代码。
引言中的这三条隐含着很多重要的信息,初看时可能无法深刻理解,后面我们将结合具体实例阐述。
类型参数¶
我们先引入一个例子。比如,我们需要实现一个类,这个类中提供了对于各种类型的数据求和的方法(实际上这个类的意义不大,只是为了说明问题而编写)。我们可以这样写:
NormalAdder
中,我们写了 $3$ 个重载的add
函数实现了 $3$ 种不同数据类型的加法。在实际使用的时候,编译器会根据我们提供的参数类型选择具体调用哪个add
函数。但是现在有一个问题:这个类中没有
short
类型、byte
类型、char
类型数据的相加方法。按照传统的函数重载子路,我们还需要再编写 $3$ 个重载的 add
方法。这就显得代码比较冗长。而这时我们就可以用上泛型函数:
add
函数。其中,泛型函数的类型参数放在<>
里。
这个例子仅仅是用于说明。实际上它不是很恰当,因为 Java 中没有运算符重载,上面
GenericAdder
中的add
方法在编译时会报错。一旦T
不是基本数据类型的包装类,虚拟机就不知道怎么解释+
这个运算。
泛型类¶
当一个类后面带上形式化参数,这个类就成为泛型类。泛型接口也是这样定义的。形式化类型参数是一个逗号分隔的变量名列表,位于类声明中类名后面的尖括号<>
中。下面的代码声明一个泛型类Wrapper
,它接受一个形式化类型参数T
:
T
是一个类型变量,它可以是 Java 中的任何引用类型。当把一个具体的类型实参传递给类型形参T
时,就得到了一系列的参数化类型(Parameterized Types),如Wrapper<String>
,Wrapper<Integer>
,这些参数化类型是泛型类Wrapper<T>
的实例类型:
强调:类型变量只能是引用类型,不能是
int
,double
,char
等值类型。不过可以用这些值类型的包装类。
动机和优点¶
泛型的概念是在 JDK 1.5 提出的,它的提出肯定有一定的动机。下面的例子能够说明这个动机。
当我们想要对两个对象进行比较时,通常会让这个类实现Comparable
接口,并重写Comparable
中的compareTo
函数。在 JDK 1.5 之前,Comparable
接口如下:
A
如果实现了Comparable
接口,其中的函数compareTo
的参数总是Object
类型,这意味着我们可以让A
类的对象与非A
类的对象比较:
上面的语句能够通过编译,但是运行时会产生错误,抛出ClassCastException
异常。显然,程序在编译的时候看不出有什么问题,但是我们一眼就能够发现
Date
和String
两个不同类的对象不应该进行比较。泛型的引入解决了这个问题,JDK 1.5 之后的Comparable
接口成为了泛型接口:
引用Comparable
对象时,需要传入实际类型参数,完成“泛型实例化”。比如下面就将Comparable<T>
泛型接口实例化为了Comparable<Date>
的实例接口:
此时程序会在编译时报错,这就是引言第 $2$ 条所说的,我们使用的"red"
是一个String
对象,与c
(Date
对象)不相容。泛型引入后,编译时根据传入的泛型参数
Date
,将Comparable<Date>
实例类型中的T
全部替换成Date
,并检查所有实例方法的调用是否正确,防止编译通过的地方运行时出错问题的发生。一旦编译检查通过,编译器会擦除类型参数,并按照非泛型年代的标准编译程序,这时不会再产生编译通过的地方运行时出错的问题了。(关于擦除类型参数,这个在文末会着重介绍。)因此,泛型引入的最大优点就是在编译时找出类型不相容的问题,早发现、早解决。
泛型类、泛型接口、泛型方法的定义¶
泛型类的定义¶
我们可以利用泛型定义一个栈:
E
就是类型参数。其它具体类名在什么地方,它就也几乎可以出现在什么地方;但是有一些例外,将在文章末尾介绍。具体使用这个泛型类时,只需要将实际参数赋给类型参数,即可确定栈的数据类型。例如:
泛型接口的定义¶
上文中我们已经看到了Comparable<T>
,这就是一个泛型接口。非泛型类如果要实现泛型接口,需要给泛型接口传递实际参数类型。上面例子中的Date
的函数头就是:
Comparable<T>
传递了实际参数类型Date
。如果我们写了一个类Circle
,要实现两个Circle
的比较,也可以将Circle
的头写成:
Circle
内重写compareTo
方法。
泛型方法的定义¶
前文已经介绍了泛型方法,这里再对泛型方法做一个简单的描述。声明泛型方法,将类型参数<E>
置于返回类型之前。方法的类型参数可以作为形参类型,方法返回类型,也可以用在方法体内其他类型可以用的地方;和泛型类一样,类型参数E
也存在一些限制。
而在实际调用泛型方法时,将实际类型放于<>
之中方法名之前;也可以不显式指定实际类型,而直接给实参调用,由编译器自动发现实际类型。
受限的泛型¶
可以给形式化参数限定一个范围。考虑下面的一个要求:找到两个对象中较大的那个。
首先我们不难写出下面的代码:
Comparable
接口。也就是说不是所有的对象实例都能够调用compareTo
方法。因此,我们需要限定T
必须要是实现了Comparable
接口的类型。改进后的代码如下:
<T extends Comparable<E>>
规定了传进来的T
必须实现了Comparable<E>
接口,否则编译器报错。还可以用<T extends SomeClass>
来限定T
必须是SomeClass
的子类。需要注意的是,无论是限定
T
需要继承某些类,还是限定T
要实现某些接口,一律使用关键字extends
。
泛型擦除和对泛型的限制¶
泛型擦除¶
在引言中的第 $3$ 条提到过,Java 的泛型通过擦除法实现。泛型的作用就是使得编译器在编译时通过类型参数来检测代码的类型匹配性。当编译通过,意味着代码里的类型都是匹配的。因此,所有的类型参数使命完成而全部被擦除。
一个泛型类的所有实例类型,在擦除类型变量后,共享同一个原始类型。
比如,下面有一个泛型类:
编译器拿到上面的代码,看见了泛型,那么首先根据泛型检查代码。发现没有问题后,会擦除泛型类型,变成下面的代码: 在擦除后的代码中,由于擦除前已经检查了list.get(0)
的类型,所以此时强制类型转换为String
是安全的,就不会在运行时出错。不难发现,擦除后的代码中,所有的泛型类都变成了它们的原始类型。这之后,将按照 java 正常的编译流程,将擦除后的代码编译成字节码。
非受限类型参数的擦除¶
看完了上面的泛型擦除,你可能会有疑惑。因为有的类型参数T
是被用来当做返回类型之类的,你把它擦除了,那究竟返回的是什么类型呢?
比如上面的GenericStack<E>
中,就有一段这样的代码:
E
之后,这段代码返回的o
是什么?实际上,上面的代码擦除泛型后,会用Object
代替E
。擦除后的代码如下所示:
受限类型参数的擦除¶
上面非受限的类型参数E
,用Object
来代替。如果一个泛型的参数类型是受限的,编译器会用该受限类型来替换它。比如下面的代码:
对泛型的限制¶
不能创建泛型对象¶
如果有一个类型参数E
,那么不能new E()
,也不能new E[N]
。如果实在想要创建一个对象实例,只能想办法获取E
的类型实参的class
对象,再通过反射机制,如newInstance()
创建一个对象实例。
不能创建泛型数组¶
如果有一个类A
,以及一个类型参数E
,那么不能使用new A<E>[]
的方式创建数组。但是要注意,new A<E>()
创建对象是可以的。
因此,下面的语句都是错误的:
静态上下文中不允许使用泛型类的类型参数¶
下面代码段存在 $3$ 个非法的地方:
Test
内的m
方法不是泛型方法/泛型函数。静态方法m
由于引用了类Test
的类型参数E
而非法;但是如果静态方法m
引用自己的类型参数,就是合法的。如果将上面的m
改成下面的:
m
已经成为一个泛型方法了。
异常类不能是泛型的¶
泛型类不能继承java.lang.Throwable
。如果你尝试让一个泛型类继承java.lang.Throwable
,编译器会报错。比如 IDEA 就会直接告诉你:Generic class may not extend 'java.lang.Throwable'.
如果定义了一个:
try...catch
块处理它:
在我的理解里,这是没有必要的。因为在运行时,T
会被擦除,就和普通的非泛型异常是一样的。所以 java 就直接不让定义泛型类异常了。