第八章《Java高级语法》第11节:泛型

时间:2022-12-31 12:59:13

​泛型也JDK1.5引入的一种技术,它改变了核心API中的许多类和方法。使用泛型,可以建立类型安全模式来处理各种数据的类、接口和方法。使用泛型,一旦定义了一个算法,就可以独立于任何特定的数据类型。泛型的强大功能从根本上改变了Java的编程方式,本小节将详细讲解泛型的相关知识。

8.11.1 泛型简介

在开发某个应用程序需要创建一个表示员工的Employee类,Employee类中需要定义一个表示员工编号的属性num。那么应该把num这个属性应该定义成哪种类型呢?如果定义成Integer类型,这样就能方便进行比较和排序的操作,因为Integer是一个表示整数的类。而如果把num定义为String类型,又能方便的进行截取和模糊搜索等操作。能否在定义Employee类的时候,先不去确定num的类型,而等到创建Employee对象时才去确定num属性的类型呢?这样的话,如果需要对编号进行排序,就把Employee对象的num属性定义为Integer类型,而如果需要对num属性进行截取操作的时候,就把Employee对象的num属性定义为String类型。JDK1.5引入的泛型技术就能很好的解决这个问题,因为泛型可以动态定义对象某个属性的类型。​

“泛型”这个词的意思就是暂时不确定的类型,这种不确定的类型最终会在创建对象的语句中由程序员指定,从而变成一种确定的类型。理论上程序员可以把泛型指定为任意类型。把属性定义为泛型的操作很简单,只需要在代码中用一个大写字母来代替属性的类型名称即可,例如下面的代码就把Employee类的num属性定义成了泛型。​

public class Employee<T>{
T num;
}

这段代码定义了一个Employee类,类中的num属性表示员工编号。可以看到,员工编号的类型被定义为T,这个T就表示泛型。但是通常情况下编译器会认为T是某个类的名称,为了告知编译器以及所有阅读代码的人T表示一种不确定的类型(也即泛型),需要在类的名称后面加上一对尖括号并在尖括号内写一个大写的T。也就是说,代码中的“Employee<T>”表示: Employee是一个类,这个类中T是一种暂时不确定的类型。T这种暂时不确定的类型要在创建对象时被指定为一种具体的类型,把T指定为具体类型的操作也很简单,只要在创建对象时把尖括号中的T替换为某个类的名称就可以,请看下面的【例08_26】。​

【例08_26 在类中使用泛型】

Employee.java​

public class Employee<T>{
T num;
}

Exam08_26.java​

public class Exam08_26 {
public static void main(String[] args) {
Employee<Integer> e1 = new Employee<Integer>();
Employee<String> e2 = new Employee<String>();
e1.num = new Integer(1);//①
e2.num = new String("1");//②
}
}

从上面的代码中可以看到:在创建Employee对象时,把一个具体的类型写到类名称以及构造方法名称后面的尖括号中就能指定T的具体类型。在【例08_26】中,创建e1对象时,T被指定为Integer类型,所以e1的num属性也就成了Integer类型。而创建e2对象时,T被指定为String类型,所以e2的num属性也就成了String类型。对e1和e2的num属性赋值都要使用类型正确的数据完成,否则就会出现语法错误,例如用String类型的数据为e1的num赋值就会报错。从这个例子各位读者就能学会如何定义泛型,并且可以体验到泛型具有非常强的灵活性。​

通过阅读代码不难发现:创建对象时如果还用引用指向了这个对象,语句中就会出现两对尖括号为泛型指定类型。这两对尖括号的作用其实并不相同,类名称后面的尖括号用来指定引用的泛型,而构造方法名称后面的尖括号用来指定对象的泛型。例如:​

Employee<Integer> e = new Employee<Integer>();

这条语句中,“Employee<Integer> e”表示创建泛型类引用e,并把引用e的泛型指定为Integer。而“new Employee<Integer>()”表示创建了一个对象,并把对象的泛型指定为Integer。​

为保证在后面的章节中能深入学习泛型相关知识,还必须先掌握以下几个相关概念和专业术语:​

  • 泛型类:在类中有不确定类型的数据,并且在名称后用尖括号加以声明的类,例如Employee<T>就是一个泛型类。​
  • 泛型接口:与泛型类的概念相似,只是不确定类型的数据被包含在接口中。​
  • 原始类:泛型类去掉其泛型声明部分就被称为原始类,如Employee就是原始类。​
  • 泛型属性:类型被定义为泛型的属性,例如Employee<T>类中的num属性。​
  • 类型参数:Employee<T>中,出现在尖括号中的T也是一个参数,只不过这个参数不是用来表示一个数据,而是用来表示数据的类型,因此把这种参数称为“类型参数”。一个泛型类允许定义多个类型参数。类型参数本质上就是一个占位符,在创建对象时会用某种真实的数据类型替换它。​
  • 使用了类型参数的方法:一个方法中如果出现了类型参数,这个方法就是“使用了类型参数的方法”​

8.11.2深入学习泛型

通过8.11.1小节的学习,各位读者已经对泛型有了初步认识。关于泛型还有很多语法方面的细节,本小节就来详细讲解一下这方面的知识。​

Java语言中,不仅仅能把类的属性定义为泛型,还可以把方法参数的类型、返回值的类型以及方法中创建的对象的类型都定义为泛型。如下所示。​

class Employee<T>{
T num;
public void setNum(T num){
this.num = num;
}
public T getNum(){
return num;
}
}

在这段代码中,setNum()方法的参数、getNum()方法的返回值的类型都被定义为泛型。当然,这些程序元素的类型都会在创建对象时得以确定。需要注意:把泛型指定为具体类型时,只能将其指定为引用数据类型,绝对不可以指定为基础数据类型。​

泛型类也可以被当作原始类来使用,也就是说:创建一个泛型类对象时可以忽略泛型的存在,例如Employee<T>是一个泛型类,但创建对象的语句却可以写成如下所示的语句。 ​

Employee e = new Employee();

这条语句出现在程序中是完全合法的,但编译器会发出警告来提示程序员应该把泛型指定为某个具体类型。如果没有把泛型指定为具体类型,编译器会把类中所包含的泛型默认设置为Object类型,因此上面的语句中,e对象的num属性的类型就会被设置为Object。既然num属性被设置为Object类型,那么用任何类的对象给num赋值都是合法的,因为任何类都是Object的子类。还需要注意:创建对象时如果根本不打算把泛型指定为某种具体类型,就不要写那一对尖括号,因为一旦写上尖括号就表示你打算把泛型确定为具体类型,而编译器发现尖括号里没有写具体类型,就会报错,因此以下语句就是不合法的。​

Employee<> e = new Employee<>();

通常情况下我们会为引用的泛型和对象的泛型都指定具体类型,但是从JDK1.7开始,编译器允许只指定引用中泛型的类型,不用指定对象中泛型的类型,如下所示。​

Employee<String> e = new Employee<>();

在这种情况下,编译器会让对象中的泛型与引用中的泛型在类型上保持一致,也就是说,上面这条语句等同于如下语句。​

Employee<String> e = new Employee<String>();

以上两条语法规则可以总结为:泛型类可以去掉尖括号当作原始类去使用,但如果带上尖括号,就一定要在类名后面的尖括号中明确指定类型。​

泛型技术的引入,其实是提供了一种定义数据的机制:以一个原始类为基础,对它进行二次定义,把类型参数指定为不同的类,就能衍生出这个原始类的多个小分类。例如,把Employee类的类型参数T指定为Integer,就衍生出了Employee<Integer>这个小分类,而把类型参数T指定为String,就衍生出了Employee<String>这个小分类。需要注意:此处提到的“小分类”的概念与之前学习过的“子类”的概念是完全不同的。在编译器看来,这两个小分类是两种不同的类型,因此这两种小分类的数据不能相互赋值,例如下面的语句是不合法的、​

Employee<Object> e = new Employee<String>();

之所以强调这条语法规则,是因为:这条语句中引用e的类型参数被指定为Object,对象的类型参数被String。Object是String的父类,而父类的引用又可以指向子类的对象,所以很多读者都会认为Employee<Object>类的引用可以指向Employee<String>的对象。这种想法是完全错误的,事实上,在完成赋值操作时,编译器只检查赋值符号左右两端的数据是否属于同一个小分类,完全不管尖括号中所指定的类型有没有继承关系。​

不同的小分类之间也不能进行强制类型转换,例如以下的语句也会导致出现语法错误、​

Employee<Object> e = (Employee<Object>)new Employee<String>();

以上语句试图把一个Employee<String>的对象强制转换成一个Employee<Object>类型的对象,这种操作是不被允许的。​

在完成赋值操作时,如果赋值符号的某一端出现了原始类的数据情况又会有所不同。具体来说就是:原始类的引用可以指向泛型类的对象,反过来,泛型类的引用也可以指向原始类的对象,如下所示。​

Employee e1 = new Employee<String>();//①​
Employee<String> e2 = new Employee();//②​

在这种情况下,如果通过引用操作对象,编译器会按照引用的类型标准来对待对象。例如语句①中,引用e1被定义为原始类型,因此它的类型参数被默认设置为Object,而对象中的类型参数被指定为String。如果用e1去操作对象,编译器都会认为e1.num是Ojbect类型,而不是创建对象时所指定的String类型。所以下面的语句是合法的。​

e1.num = new Object();

在语句②中,引用e2的类型参数被指定为String类型,而赋值符号右边是一个原始类对象,它的类型参数被默认设置为Object。如果用e2操作对象,编译器都会认为e2.num是String类型而不是Object类型。所以下面的语句是不合法的。​

e2.num = new Integer(1);

各位读者也可以自行尝试用各种类型的数据为e1.num和e2.num进行赋值来验证这个结论的正确性。​

在创建泛型类对象时,可以把类型参数指定为泛型类自身,因此下面的语句也是完全合法的。​

Employee<Employee<Object>> e = new Employee<Employee<Object>>();

一个泛型类允许定义多个类型参数,只要在这个类后面的尖括号中把这些类型参数用逗号隔开即可。如下所示。​

class Employee<T,E>{
T num;
E code;
}

在这段代码中,Employee类增又加了一个code属性,它也是泛型属性。可以看到:num属性与code属性的类型分别用两个类型参数来表示,这意味着虽然它们都是泛型属性,但在创建对象时这两个属性可以被指定为不同的类型,如下所示。​

Employee<Integer,String> e = new Employee<Integer,String>();

在这条语句中,类型参数T被指定为Integer,而E被指定为String,因此e对象中的num属性就被定为Integer类型,而code属性被指定为String类型。当然,如果把T和E指定为同一种类型也是可以的。但需要注意:创建对象时,类名称后面的尖括号中每一个类型参数都要被指定为具体的类型,不能只指定其中的一部分。而构造方法名称后面的尖括号中,类型参数要么全部被指定为具体类型,要么干脆在尖括号中什么都不写,同样不能只指定其中的一部分。所以,以下语句是合法的。​

Employee<Integer,String> e = new Employee<>();

而下面几条语句都是不合法的,都是因为在尖括号中没有把类型参数全部指定成具体的类型:​

Employee<Integer> e1 = new Employee<>();
Employee<Integer> e2 = new Employee<Integer>();
Employee<Integer,String> e3 = new Employee<Integer>();
Employee<Integer,E> e4 = new Employee<Integer,E>();

Java语言中,不能把类中的静态属性的类型定义为泛型,并且静态块中也都不能使用泛型来定义数据。原因很简单,就是因为泛型数据的类型只能在创建对象时才确定下来,而静态的属性和静态块都是在没有创建对象的情况下直接被调用的,调用静态属性和静态块时泛型根本没有被指定为具体的类型,数据没有确定的类型程序是无法运行的。但静态方法中能否使用泛型来定义数据,需要分为两种情况讨论。如果是普通的静态方法,那么这个静态方法中不能用泛型定义数据,而如果静态方法是泛型方法,则可以使用泛型定义数据。关于泛型方法的知识将在8.11.5小节中详细讲解。​

在程序中可以用泛型来定义指向对象或数组的引用,如下所示,​

T t;
T[] array;

但使用泛型直接创建对象或数组也却是不允许的。例如以下语句都是不合法的。​

T t = new T();
T[] array = new T[5];

以上代码中创建一个T类型对象的操作会出现语法错误,这是因为当T还未被指定真实类型时,编译器根本就不知道这种类型有没有无参数的构造方法、也不知道构造方法是不是私有的,因此不允许调用其构造方法创建对象。而代码中不允许创建泛型数组的原因也很简单:因为数组本身也是一个对象,在泛型没有被指定为具体类型之前,与这种类型相关的信息都是不明确的。​

事实上,不仅不可以用泛型创建数组,甚至连泛型类都不能创建数组,即使是为泛型类指定了具体类型也不可以,因此以下语句也都是不合法的。​

Employee<T>[] es1 = new Employee<T>[10];//试图创建泛型类数组​
Employee<String>[] es2 = new Employee<String>[10];//指定了类型参数也不行​

但用泛型类数组的引用指向原始类数组的对象却是可以的,如下所示。​

Employee<String>[] es3 = new Employee[10];

现在读者可以总结出:泛型可以存在于引用中,却不可以出现在对象中。就是因为创建对象时需要把所有与类型相关的信息都确定下来,而泛型是不确定的类型,存在很多不确定因素,因此不能用于创建对象。​

8.11.3有界类型

在前面的例子中,参数类型T可以被替换成除基础数据类型以外的任意类型。实际开发过程中,有时还需要对泛型所能指定的类型加以限制,使其只能被指定为某个类的子类。在这种情况下,就需要限定类型参数的可变范围,具体做法是:在类型参数的后面指定一个父类的名称,并且在类型参数和父类名称之间加上extends关键字,如下所示。​

class MyNum<T extends Number>{

    T num;

}

以上代码中,在类型参数T后面写上了extends Number,表示T只能被替换为Number或者是它的子类,否则编译器将会报错。需要指出的是:T也可以被替换为Number类的间接子类,只要它是Number类的后代即可。由于类型参数T被限定为必须是某个类或者是它的子类,所以它被称为“有界类型”,而把所指定的父类Number称之为参数类型的“上界”。​

由于把泛型设定在一个特定范围内,所以泛型的一些信息就变得明确。例如在上一段代码中,T被限定为只能指定为Number或它的子类,而Number是位于java.lang包下用来表示数字的类,这个类定义了public的doubleValue()方法,所以它的子类也一定具有doubleValue()方法,由此可以进一步推导出:程序中所有T类型的对象都可以直接调用doubleValue()方法,如下所示。​

class MyNum<T extends Number>{
T num;
public double doubleNum(){
return num.doubleValue();//①​
}
}

这段代码定义了泛型类MyNum,类中定义了泛型属性num。由于T被限定为只能由Number及其子类来替换,所以在语句①中,T类型的num可以直接调用doubleValue(),就是因为编译器已经明确知道num一定具有这个方法。​

我们知道,Java语言不仅允许一个类继承一个父类,还允许一个类实现一个或多个接口。这条语法同样适用于类型参数,所以接口也可以做为类型参数的上界。需要注意的是:如果以接口作为类型参数的上界,并不使用implements关键字,而是依然使用extends关键字。既然一个类可以在同时继承一个父类的情况下实现多个接口,那么也可以同时用一个类以及多个接口去限定类型参数T,如果同时有类和接口的作为T的上界时,类名称必须放在最前面,这与Java的继承语法也是完全吻合的。用于定义上界的类以及接口都要用&隔开,下面的代码展示了如何用一个类以及多个接口共同设定类型参数的上界。​

class MyNum<T extends Number & Comparable & Serializable>{
T num;
public double doubleNum(){
return num.doubleValue();
}
}

这段代码中,出现了Number、Comparable和Serializable。Comparable和Serializable都是Java基础类库中定义的接口。可以看到:类型参数T被Number类以及Comparable和Serializable接口共同设定了上界,因此在创建MyNum类对象时,替换T的那个类必须是Number的子类,并且这个类还要同时实现Comparable和Serializable两个接口。​

假如有一个类A继承了Number,并且还同时实现Comparable和Serializable两个接口,但A并没有实现Comparable和Serializable中所有的抽象方法,那么A只能被定义成一个抽象类。在这种情况下,在创建MyNum类对象时,依然可以用A类替换类型参数T,这说明把类型参数指定为抽象类也是可以的,并且这条语法规则也能解释为什么不允许用泛型直接创建对象,就是因为泛型有可能被指定为一种抽象类,而抽象类是无法创建对象的。​

在实际开发中,有界类型技术还可以应用在异常处理中。但需要注意:当类型参数的上界被设定为Exception类时,它只能用于throws关键字的后面,而不能用于catch块中,如下所示。​

class MyNum<E extends Exception>{
void method() throws E{
}
}

在这段代码中,E被限定为Exception或它的子类,此时method()方法声明抛出异常的种类为E类型是完全没有问题的,但把E放在catch中来定义异常的类型却是不允许的,如下所示。​

class C<E extends Exception>{
void method(){
try {

}catch(E e) {//在catch块中以E定义异常的类型​

}
}
}

这段代码中,因为在catch块中用E来定义异常的类型会导致编译器报错。这是因为在编译阶段必须明确知道异常的类型,这样才能判断出try所搭配的多个catch块排列顺序是否合理。​

8.11.4范型通配符

一个程序中定义了以下泛型类:​

class MyObject<T>{
T att;
}

当创建这个类的对象时,泛型会被指定为某种特定类型。在8.11.2小节中曾经讲过,一个原始类的不同小分类是不能相互兼容的,因此以下语句会导致语法错误。​

MyObject<String> mo = new MyObject<Integer>();

如果想要定义出一种引用,它可以指向所有MyObject小分类的对象该怎么办呢?如果把引用的类型定义为MyObject的原始类就能解决这个问题,也就是把语句改为以下形式。​

MyObject mo = new MyObject<Integer>();

虽然这种办法能够解决问题,但这样做违背了MyObject类设计的原宗旨。MyObject原本一个是泛型类,但它却被当作原始类来使用,所以这种修改方式并不是最好的解决办法。​

实际上Java语言中有一种非常完美的解决这个问题的办法,就是使用泛型通配符。泛型通配符的写法是一个问号,它表示任意类型。程序员只需要把引用mo的类型定义为MyObject<?>,这样引用mo就可以指向每一种MyObject小分类的对象,因此,下面的语句全都不会出现语法错误。​

MyObject<?> mo;
mo = new MyObject<Integer>();
mo = new MyObject<String>();
mo = new MyObject<Object>();

从这个例子可以看出:泛型通配符可以定义引用的类型,它可以定义出某个泛型类的“万能引用”,所谓“万能引用”就是指这个引用所指向某个类所有小分类的对象。需要注意:引用可以体现为很多形式,例如它可以体现为方法的参数或者是类的属性,这样的话,把方法参数的类型或属性的类型定义为MyObject<?>也是完全可以的。此外,泛型通配符还可以定义方法返回值的类型。如果某方法的返回值类型被定义为MyObject<?>,这意味着方法返回MyObject<String>类型的对象或是MyObject<Integer>类型的对象都是可以的。​

需要注意,泛型通配符和类型参数的意义是完全不同的。类型参数实际上表示某一个类,只是这个类到底是什么暂时还不确定而已,但它最终一定会被指定为某种具体的类。而通配符表示所有的类,它在任何时候都不会被指定为某种具体的类。读者在使用泛型通配符的时候需要注意以下几条规则。​

1. 不能单独使用泛型通配符表示数据的类型​

泛型通配符的作用是把一个类下的所有小分类归为一种统一的类型,因此它只能跟泛型类配合使用,如果单独使用泛型通配符表示数据的类型会导致语法错误,如图8-37所示。​

第八章《Java高级语法》第11节:泛型

图8-37 单独使用泛型通配符表示数据类型​

可以看到:单独使用泛型通配符去表示属性、方法参数、返回值以及方法中某个对象的类型,这些操作都是违反语法规则的。​

2. 创建泛型类对象时不能使用泛型通配符​

创建对象时,所有不确定的数据类型都要被指定为一种具体的类型,而泛型通配符表示任意类型,它不能代表某一种具体的类型,因此以下语句是不符合语法的:​

new MyObject<?>();

3. 定义泛型类时不能用泛型通配符当作类型参数

泛型通配符是一种有特殊意义的符号,不能用这个符号来当作类型参数,因此下面的代码是不合法的:​

class MyObject<?>{
? att;
}

当创建一个泛型类对象时,泛型类中定义的所有类型参数都会被指定为具体的类型,但泛型通配符不受影响,它在对象被创建之后仍然表示所有的类型。它的这一特性能够帮助程序员解决很多问题,如图8-38所示。​

第八章《Java高级语法》第11节:泛型

图8-38 参数类型导致的语法错误​

图8-38中的MyObject类是一个泛型类,这个类中定义了一个print()方法。程序员原本希望这个方法能够打印出所有MyObject类对象的att属性。但如果把print()方法的参数类型定义为MyObject<T>就会出现图中所示的语法错误。出现语法错误的原因是:在创建obj1对象时把代表泛型的类型参数T替换成了Integer。这就要求obj1的print()方法的参数类型必须是MyObject<Integer>这个小分类。而作为参数的obj2是一个MyObject<String>类的对象,因为类型兼容性问题导致obj2不能作为obj1的print()方法的参数。如果把print()方法参数的类型定义为MyObject<?>就能轻松的解决这个问题,图图8-39所示。​

第八章《Java高级语法》第11节:泛型

图8-39使用泛型通配符消除语法错误​

从图8-39可以看到:即使obj2中的泛型与obj1中的泛型被指定为不同的类也不会报错,这是因为泛型通配符即使在创建了对象之后仍然能够表示任意类型。​

用泛型通配符所定义的引用可以指向一个类下所有小分类的对象。但如果想让这个引用只兼容部分小分类对象,就所以可以指定泛型通配符的表示范围。因此,程序员也可以为泛型通配符指定一个上界,这样的话这个泛型通配符只能表示某个类或者是它的子孙类,如下所示。​

public void print(MyObject<? extends Number> o){//指定了上界的泛型通配符​
System.out.println(o.att);
}

值得一提的是:通配符不仅仅能够被指定上界,还可以被指定“下界”。指定下界使用的是super关键字,这样泛型通配符只能表示某个类或者是它的祖先类,如下所示。​

public void print(MyObject<? super Integer> o){//指定了下界的泛型通配符​
System.out.println(o.att);
}

上面的代码为泛型通配符指定了下界,这样泛型通配符就只能表示Integer类或它的祖先类。需要注意:普通的类型参数只能定义上界,不能定义下界。​

有时候类型参数也会被设定上界,在这种情况下,通配符的上下界不能被指定为超出原有参数类型的限定范围,否则会出现如图8-40所示的语法错误。​

第八章《Java高级语法》第11节:泛型

图8-40泛型通配符超过类型参数的表示范围​

图8-40中,类型参数T的上界是Number,这个限定对所有的MyObject类对象都起作用,这当然也包括print()方法的参数,即使在定义这个参数类型时使用了泛型通配符也不例外。而图中给泛型通配符指定的上界是Exception,Exception类并不是Number类的子类,因此与类型T的限定范围相矛盾,所以会出现语法错误。​

8.11.5范型方法

一个类中,如果只有很少的几个方法用到了类型参数,就可以不用把这个类定义为泛型类,而只把这几个用到了类型参数的方法定义为“泛型方法”。泛型方法中使用了类型参数,但这些类型参数是在定义方法时独立声明的,它们被声明在方法的返回值的前面,并非像泛型类那样声明在类名称的后面。这些类型参数可以出现在方法的任何位置。其实泛型方法本质上也是“使用了类型参数的方法”,只不过为了与那些把类型参数声明在类名之后的方法加以区分,专业把这种把类型参数声明在方法返回值之前的方法称为“泛型方法”。也就是说,某个方法中出现了类型参数,如果类型参数是在类名称之后声明的,就把这种方法称为“使用了类型参数的方法”,而如果类型参数是在方法返回值之前声明的,就把这种方法称之为“泛型方法”。使用泛型方法的好处就是:当程序员没有用到泛型方法时,可以把这个类当作普通类来使用,免去了指定类型参数的麻烦,只有在需要调用泛型方法才需要去指定类型参数。下面的【例08_27】展示了如何定义并调用泛型方法。​

【例08_27 泛型方法的定义和使用】

GenericMethodTest.java​

public class GenericMethodTest {
public <T> void gMethod(T obj){//定义了一个泛型方法
System.out.println("泛型方法:类型参数被指定为:"+obj.getClass().getName());
}
}

Exam08_27.java​

public class Exam08_27 {
public static void main(String[] args) {
GenericMethodTest gmt = new GenericMethodTest();
gmt.<String>gMethod(new String(""));//在方法名称前面为类型参数T指定类型为String
gmt.gMethod(new String(""));//①根据参数确定T为String类型
gmt.gMethod(new Object());//②根据参数确定T为Object类型
}
}

GenericMethodTest类中的gMethod()方法就是一个泛型方法。方法所用到的类型参数T不是声明在类名称的后面,而是独立声明在方法的返回值之前。此处需要说明: getClass()方法能够取得一个对象的类型,在此基础上调用getName()方法就能获得这个类型的名称,这个名称不仅仅包含类的名称,还包含类所在包的名称。因此gMethod()方法的作用是打印参数的类型名称。【例08_27】的运行结果如图8-41所示。​

第八章《Java高级语法》第11节:泛型

图8-41【例08_27】运行结果​

在调用泛型方法时通常要在方法名称前面把类型参数列表中的每个类型参数都指定为具体类型。从图8-41可以看出:如果没有把类型参数指定为具体类型,编译器并不会报错,并且在运行时也不会出现异常,虚拟机会根据实际为方法传入的参数来判定类型参数应该被指定为哪一种类型。例如语句①和②都没有为类型参数指定具体类型,但虚拟机根据传入的参数对象就能判断出类型参数应该被指定为String和Object。当给泛型方法传入的参数为空对象null时,由于null没有任何类型信息,这时虚拟机会把这个参数的类型指定为Object。​

在同一个类中还可以定义一个与泛型方法名称和结构完全相同的普通方法。当调用方法时,虚拟机优先调用普通方法而不是泛型方法。下面的【例08_28】就能很好的展示这种特性。​

【例08_28 优先调用普通方法】

GenericMethodTest.java​

public class GenericMethodTest {
public <T> void gMethod(T obj){//定义了一个泛型方法
System.out.println("泛型方法:类型参数被指定为:"+obj.getClass().getName());
}

public void gMethod(String obj){ //一个与泛型方法名称及结构完全相同的普通方法
System.out.println("普通方法");
}
}

Exam08_28.java​

public class Exam08_28 {
public static void main(String[] args) {
GenericMethodTest gmt = new GenericMethodTest();
gmt.gMethod(new String());//①直接传递字符串
gmt.<String>gMethod(new String());//②指定参数类型为String,试图调用到泛型方法
gmt.<Object>gMethod(new String());//③指定参数类型为Object,试图调用到泛型方法
gmt.gMethod(new Object());//④不指定参数类型,并以Object类型的对象为参数
}
}

【例08_28】中的GenericMethodTest类修改自【例08_27】,在GenericMethodTest类中新增了一个与泛型方法gMethod()同名的普通方法。【例08_28】的运行结果如图8-42所示。​

第八章《Java高级语法》第11节:泛型

图8-42【例08_28】运行结果​

从图8-42可以看出:语句①直接给gMethod()方法传递了String类型的参数,这个参数对于泛型方法和普通方法都适用,此时虚拟机会选择调用普通方法。语句②和③在方法前面指定了类型参数,试图通过这种方式调用到泛型方法,但虚拟机还是根据参数的类型选择调用普通方法。这也说明虚拟机在选择调用哪一个方法时,是通过实际参数的类型去判断该调用哪一个方法,而不是通过是语句中否指定了类型参数进行判断。正因如此,编译器会在语句②和③发出警告,提示程序员为方法指定类型参数是没有意义的。语句④在执行时调用了泛型方法,这是因为类中的普通方法无法接收Object类型的参数,只能调用泛型方法。​

泛型方法也可以被子类重写,只要子类定义一个与父类方法名称和参数和以及返回值都相同的方法,就能够实现对父类泛型方法的重写。当然,父类泛型方法也可以通过添加final关键字阻止子类对其进行重写。需要注意:final关键字要写到类型参数定义的前面,如下所示。​

public final <T> void gMethod(T obj){

}

泛型方法还可以被定义成静态方法,调用静态泛型方法时,也是在方法名称的前面为其指定类型参数。需要注意:使用了类型参数的方法不能被定义为静态方法,这是因为这种方法的类型参数是在创建对象时被指定为具体类型的。而调用静态方法不需要创建对象,因此不能保证方法在运行时类型参数已经被指定为具体类型。而泛型方法则不同,它是在被调用时指定类型参数的,因此,在方法运行时类型参数肯定已经被指定为具体类型。如果在调用静态泛型方法时没有为其指定类型参数,虚拟机会根据传入的参数对象判断出类型参数应该被指定为哪一种类型。​

需要注意:如果一个方法中的类型参数并不是全部都定义在方法之前,那么这个方法不能算作泛型方法,而只能算使用了类型参数的方法,如下所示。​

class GenericMethodTest<T>{
public <E> void gMethod(T obj, E e){
}
}

上面代码中,gMethod()方法用到了两个类型参数T和E,但类型参数T不是定义在方法名称前面,而是定义在类名称的后面,所以上面的gMethod()方法不算泛型方法,只能算使用了类型参数的方法,因此这个方法也不能被定义为静态方法。​

8.11.6范型接口

Java语言中有泛型类,自然也会有泛型接口,下面的代码就定义了一个泛型接口。​

interface GInterface<T>{
public String getClassName(T obj);
}

接口不能创建对象,因此不能通过创建对象的方式把泛型接口中的类型参数指定为具体类型。那么如何把泛型接口中的类型参数指定为具体类型呢?有三种方式可以把泛型接口中的类型参数指定为具体类型。​

第一种方式是为泛型接口定义一个实现类,在这个类实现泛型接口时把类型参数确定为具体类型。​

class GInterfaceImp1 implements GInterface<String>{//把T指定为String​
public String getClassName(String obj) {//实现类中类型参数T被替换为String​
return obj.getClass().getName();
}
}

以上代码中定义了一个叫GInterfaceImp1的类,它实现了泛型接口。在实现泛型接口时,把泛型接口所声明的类型参数T确定为String类型。可以看到:在接口名称后面的尖括号中写了一个“String”,这表示泛型接口所声明的类型参数在它的实现类中被确定为String类型。既然实现类中把类型参数确定为String类型,那么实现类中就不再有类型参数,所以实现类就没有必要被定义为一个泛型类。当然,如果程序员把实现类定义为一个泛型类,那么这个泛型类的类型参数是实现类自己新增的,与所实现的接口并没有关系。定义出实现类以后,就可以创建这个实现类的对象,并且调用被实现了的抽象方法。​

接口的引用可以指向实现类的对象,如果这个接口是泛型接口,并且实现类已经把类型参数指定为具体类型,在这种情况下,用泛型接口的引用指向实现类对象时,引用的类型也必须和实现类对象的类型保持一致。例如在定义GInterfaceImp1这个实现类时把接口的类型参数指定为String,那么泛型接口的引用指向GInterfaceImp1对象时,引用的类型必须被定义为GInterface<String>,下面的语句会因引用没有把类型参数指定为String而报错:​

GInterface<Integer> imp= new GInterfaceImp1();

第二种方式是定义一个实现类,让实现类把泛型接口中的类型参数继承过来,在创建实现类对象的时候把定义在泛型接口中的类型参数指定为具体类型。例如:​

class GInterfaceImp2<T> implements GInterface<T>{ 
public String getClassName(T obj) {
return obj.getClass().getName();
}
}

以上代码中,GInterfaceImp2类实现了泛型接口,但并没有把泛型接口声明的类型参数T指定为具体类型,而是把类型参数T继承了下来。这样,在创建GInterfaceImp2类的对象时就可以把那些继承自泛型接口的类型参数指定为具体类型。需要注意:如果实现泛型接口时没有为泛型接口的类型参数指定具体的类型,那么类实现类必须被定义成泛型类,这样才能把泛型接口中的类型参数继承过来。这个道理与子类继承了父类的抽象方法但没有实现抽象方法的情况下,必须把子类定义成抽象类是一样的。另外,实现类在声明类型参数的时候,每个类型参数的名称以及出现的顺序都要与implements关键字之后的泛型接口所标出的类型参数的名称和顺序完全一致,这样表示一脉相承的继承关系。当然,实现类在继承了泛型接口的类型参数的基础上,它还可以扩展出属于自己的类型参数。为了不引起歧义,通常都把实现类自己扩展的那些类型参数写到类型参数列表的最后面。​

为泛型接口指定类型参数的第三种方式比较特殊,就是实现类把泛型接口中的部分类型参数指定为具体类型,而另一部分依然保留。假如把泛型接口定义为如下形式。​

interface GInterface<A,B,T,C,D>{
public String getClassName(T obj);
}

在这个泛型接口中定义了5个类型参数,假如实现类在实现这个泛型接口时,只把其中的T和D两个类型参数指定为具体类型,更具体来说,要把T确定为String类型,而把D确定为Integer类型,其他类型参数依然保留,那么在定义实现类时要把剩余的类型参数声明出来,以此来表示这些类型参数没有被指定具体类型,因此实现类要被定义成如下形式。​

class GInterfaceImp3<A,B,C> implements GInterface<A,B,String,C,Integer>{
public String getClassName(String obj) {//实现类中T已经被替换为String​
return obj.getClass().getName();
}
}

从以上代码可以看出:定义泛型接口的实现类时有三个类型参数A、B、C没有被指定为具体的类型,因此实现类要声明这3个类型参数。在创建实现类对象的时候,就要把没有确定下来的这三个类型参数A、B、C都指定为具体类型,如下所示。​

GInterfaceImp3<Object,String,Integer> imp = new GInterfaceImp3<Object,String,Integer>();

以上讲解了如何把泛型接口中的类型参数指定为具体类型。如果泛型接口中声明的类型参数是有界类型,而实现类在实现泛型接口时把类型参数指定为一个具体的类,那么这个类必须符合泛型接口对类型参数所规定的范围,如下所示。​

interface GInterface<T extends Number>//泛型接口的类型参数被声明为一种有界类型​
{
public String getClassName(T obj);
}

以上代码中,泛型接口所声明的类型参数T必须是Number类或它的子类,因此实现类可以定义为如下所示。​

class GInterfaceImp implements GInterface<Integer>{
public String getClassName(Integer obj) {
return obj.getClass().getName();
}
}

以上代码中定义的实现类把泛型接口中的类型参数指定为Integer,Integer是Number的子类,所以这样的声明符合规定,但如果把泛型接口中的类型参数指定为String将会导致语法错误,因为String并非Number的子类。​

如果实现类在实现泛型接口的时候,没有指定类型参数T的具体类型,那么在定义实现类时就必须要声明类型参数,并且要把这个类型参数也声明为有界类型,但implements关键字之后泛型接口的尖括号中只写类型参数的名称就可以,不需要再次声明它的边界,因为在定义泛型接口时已经声明过T的边界了。例如下面定义的实现类就属于这种情况。​

class GInterfaceImp<T extends Number> implements GInterface<T>{  public String getClassName(T obj) {
return obj.getClass().getName();
}
}

以上代码中,实现类所声明的类型参数T也是有界类型,并且其边界与泛型接口所声明的类型参数的边界完全相同。其实实现类可以重新定义类型参数的边界,但这个新的边界不能超出最初泛型接口所定义的类型参数边界。​

一个类如果希望实现多个泛型接口,而泛型接口中类型参数的命名有可能会重复,这时需要在实现类中为泛型接口的类型参数重新命名,如下所示。​

interface GInterface1<T>//泛型接口的类型参数命名为T​
{
public String getClassName(T obj);
}
interface GInterface2<T>//泛型接口的类型参数命名为T​
{
public void anOtherMethod(T obj);
}

以上代码定义了两个泛型接口,这两个泛型接口所声明的类型参数都叫T,实现类如果要同时实现这两个泛型接口,就必须对类型参数进行重命名,否则无法区分来自两个泛型接口的类型参数,因此实现类可以定义为如下形式。​

class GInterfaceImp<T1,T2> implements GInterface1<T1>,GInterface2<T2>{
public String getClassName(T1 obj) {
return obj.getClass().getName();
}
public void anOtherMethod(T2 obj) {
System.out.println("anOtherMethod");
}
}

以上代码所定义的实现类GInterfaceImp在声明类型参数时,把两个继承自泛型接口的类型参数T重新命名为T1和T2以示区分,这样就解决了类型参数名称冲突的问题。需要说明:并不是只在同时实现多个泛型接口并且泛型接口的类型参数有重名的情况下才能对类型参数进行重命名,程序员定义任何一个实现类时都可以对继承自泛型接口的类型参数进行重命名,只要实现类中的类型参数名称与泛型接口中的类型参数名称对应相同就可以,例如泛型接口GInterface原本声明的类型参数是T,实现类如果把类型参数重新命名为E,那么实现类就必须按如下形式来定义,如下所示。​

class GInterfaceImp<E> implements GInterface<E>{

    ......

}

以上代码中,实现类把类型参数重命名为E,那么implements关键字后面必须写GInterface<E>,而不能写GInterface<T>。​

8.11.7范型与类的继承

自从Java语言引入泛型技术后,在类的继承语法上就产生了一些新规则。子类如果继承一个泛型类,可以把父类的中的类型参数指定为一种具体类型,也可以把类型参数继承过来,当然还可以把部分类型参数指定为具体类型,而把剩余的类型参数继承过来,这与实现类去实现一个泛型接口的原理是完全一样的。当使用父类的引用指向子类对象时,由于子类对父类的类型参数可能采取不同的继承方式,就会导致父类引用指向子类对象的语句有多种变化形式。请看下面的例子:有一个泛型类Father,它的定义如下。​

class Father<T>{
T a;
}

子类Child在继承这个泛型类时,如果把类型参数T指定为Integer,那么子类要按如下方式定义。​

class Child extends F<Integer>{ 
}

如果用父类的引用指向子类的对象,要按如下方式编写代码。​

Father<Integer> f = new Child();

可以看到:如果要把父类引用的类型参数指定为具体类型,就必须把它指定为Integer,也就是说要把引用的类型定义为“Father<Integer>”,这是因为子类在继承父类时已经明确的把类型参数T指定为Integer。​

如果子类没有为父类的类型参数指定具体类型,而是原封不动的继承了父类的类型参数,那么就要按如下形式定义子类。​

class Child<T> extends F<T>{ 
}

在这种情况下,如果用父类的引用指向子类的对象,必须把引用和对象的类型参数都指定为相同的类型,如下所示。​

Father<Integer> f = new Child<Integer>();

子类在继承父类时,如果把父类的类型参数指定为具体类型Integer,并且子类又扩展出属于自己的类型参数,在这种情况下,要按如下方式定义子类。​

class Child<T> extends F<Integer>{ 
}

必须强调:子类中的类型参数T并非继承自父类,而是自己独立定义的,虽然这个类型参数与父类中定义的类型参数名称都叫T,但它与父类毫无关系。在这种情况下,创建子类对象时可以把类型参数指定为任意具体类型,例如把它指定为String,那么创建对象的语句如下。​

Father<Integer> f = new Child<String>();

需要注意,语句中出现的<String>是把子类自身的类型参数指定为String,与父类无关。综上所述,可以看到:父类引用指向子类对象总共出现了3种不同样式的语句。​

Father<Integer> f = new Child();
Father<Integer> f = new Child<Integer>();
Father<Integer> f = new Child<String>();

这3条语句都是正确的,但它们的正确性都依赖于特定的继承方式。因此,不能抛开继承方式孤立的讨论父类引用指向子类对象的语句应该如何书写。​

子类对父类的继承方式也会影响到方法的覆盖。在没有引入泛型技术之前,方法的覆盖其实是一个并不难理解的思想和技术。但引入了泛型技术后,因为类型参数的不确定性,再加上子类在继承父类时可以修改类型参数的上界,这些因素参杂起来,使得方法的覆盖的况变得非常复杂。下面就来由浅入深的分析一下子类覆盖父类方法可能出现的各种情况。​

我们知道:子类覆盖父类的方法,子类方法的参数必须与父类方法的参数相同。如果方法参数类型被定义为泛型,只要泛型的表示范围相同也能实现覆盖,下面的【例08_29】就展示了这种特性。​

【例08_29 对泛型类方法的覆盖1】

Father.java​

public class Father<T> {
public void method(T t){
System.out.println("父类method方法");
}
}

Child.java​

public class Child<T> extends Father<T>{
public void method(T t){
System.out.println("子类method方法");
}
}

Exam08_29.java​

public class Exam08_29 {
public static void main(String[] args) {
Father<String> f = new Child<String>();
f.method("abc");
}
}

【例08_29】总共涉及3个类,Father是一个泛型类,这个类定义了一个方法method(),方法的参数类型被定义为类型参数T。Child类继承了Father类,并且继承了父类的类型参数。Child类中定义了一个与父类完全相同的method()方法。【例08_29】的运行结果如图8-43所示。​

第八章《Java高级语法》第11节:泛型

图8-43 【例08_29】运行结果​

从图8-43可以看出:子类中的method()方法形成了对父类方法的覆盖,这说明方法参数的类型是泛型的情况下可以实现覆盖。但用指定类型参数的方式不能实现方法的覆盖,下面的【例08_30】很好的展示了这一特性。​

【例08_30 对泛型类方法的覆盖2】

Child.java​

public class Child<T> extends Father<T>{
public void method(String t){
System.out.println("子类method方法");
}
}

Exam08_30.java​

public class Exam08_30 {
public static void main(String[] args) {
Father<String> f = new Child<String>();
f.method("abc");
}
}

【例08_30】也涉及3个类,与【例08_29】相比,仅有Child类发生了一点改变,就是method()方法的参数变成了String类型。【例08_30】的运行结果如图8-44所示。​

第八章《Java高级语法》第11节:泛型

图8-44 【例08_30】运行结果​

从图8-44可以看出:这一次子类方法并没有覆盖父类方法。很多读者认为:父类Father中method()方法的参数类型是T,而创建对象时把T指定为String,这样子类Child继承过来的method()方法参数类型就是String,而子类自身又定义了一个参数为String的method()方法,这个方法与父类继承来的method()方法名称、参数以及返回值类型都完全相同,所以应该能够覆盖父类的方法。但事实证明并非如此,这是因为:当创建Child类对象时虽然把类型参数T“指定”为String,但编译器并不会把父类中的method()方法的类型参数“编译”为String类,因此子类中的method()方法与父类中的method()方法参数类型并不相同,所以无法覆盖父类中的method()方法。​

那么在创建对象时,到底把类型参数T编译成了什么类型呢?这个问题将在8.11.8小节详细讲解。还有读者会问:既然没有把类型参数T编译为String,那么为什么在调用method()方法时,不能给方法传递String类以外的对象呢?这是因为虽然没有把类型参数T编译为String,但在完成赋值操作时,编译器会根据指定的类型做匹配性检查,也就是说:编译器看到方法的参数被指定为String,就不允许用其他类型的对象为这个参数赋值。​

虽然在创建对象时指定了类型参数并不能实现方法的覆盖,但子类在继承父类时却可以通过指定类型参数的上界来实现方法的覆盖,请看下面的【例08_31】。​

【例08_31 对泛型类方法的覆盖3】

Child.java​

public class Child<T extends String> extends Father<T>{
public void method(String t){//方法参数为String类型
System.out.println("子类method方法");
}
}

Exam08_31.java​

public class Exam08_31 {
public static void main(String[] args) {
Father<String> f = new Child<String>();
f.method("abc");
}
}

【例08_31】的Child类继承了父类的类型参数,但缩小了类型参数T范围,把类型参数T的上界指定为String。String是一个被final关键字修饰的类,它没有子类,因此“T extends String”实际上把类型参数T精确的指定为String。【例08_31】的运行结果如图8-45所示。​

第八章《Java高级语法》第11节:泛型

图8-45 【例08_31】运行结果​

从图8-45可以看出,在这种情况下子类method()方法覆盖了父类的method()方法。为什么把类型参数的上界设定为String就能实现方法的覆盖呢?这是因为对普通类的继承都采用了“原封不动”的继承方式,也就是子类继承父类的属性或方法时并不会改变属性、方法参数以及方法返回值的类型,但如果需要的话可以再对父类的属性进行屏蔽或者是对父类的方法进行覆盖。而子类对泛型类的继承则是采用了“个性化”的继承方式,也就是说子类会根据自身的特点重新定义属性、方法参数以及方法返回值的类型。以本例的Child类为例:父类的类型参数T本来可以表示所有的类,但子类在继承父类时把T的范围限定在String及其子类的范围内,那么子类继承过来的类型参数T的范围也被限定在String及其子类的范围内,而String根本没有子类,所以子类继承过来的T只能表示String。由于父类中定义的method()方法参数类型是T,这样的话,子类所继承的method()方法的参数实际上是String型的。而子类自身又重新定义了一个参数为String型的method()方法,这个方法与父类的方法名称、参数以及返回值类型都相同,所以就能形成对父类方法的覆盖。​

根据对泛型类的“个性化”继承原理,可以得出:如果把子类定义成如下形式也能实现对父类方法的覆盖。​

public class Child<T extends String> extends Father<T>
public void method(T t){//子类的方法参数类型被定义为T​
System.out.println("子类method方法");
}
}

这种方式能够实现覆盖的原因是:子类继承父类的类型参数T时把T的范围限定在String及其子类,String本身没有子类,所以在子类中T实际上就是String。这样的话,子类继承而来的method()方法参数类型是String,而子类自身定义的method()方法参数类型也是String,完全能够达到覆盖的效果。​

同样,如果把子类定义成如下形式也可以覆盖父类的method()方法:​

public class Child extends Father<String>{//继承父类时把类型参数T指定为String​
public void method(String t){
System.out.println("子类method方法");
}
}

以这种方式定义的子类之所以能覆盖父类的method()方法,是因为子类在继承父类时把它的类型参数T指定为String,而子类自身定义的method()方法参数类型也是String,父子两个类的method()方法参数类型都是String,并且方法名称以及返回值类型也都完全相同,所以能够实现覆盖。​

由此可见,子类能否覆盖父类的方法并不取决于方法定义的表面形式,例如【例08_31】中父类method()方法的参数类型为T,而子类method()方法的参数类型为String,但仍然能实现覆盖。真正决定能否实现方法覆盖的是:经过个性化继承后,父子两个类方法的参数类型是否完全相同。​

需要注意:子类覆盖了泛型类的某个方法后,如果没有以正确的形式进行调用该方法还有可能导致类型转换异常。如果把【例08_31】中创建对象以及调用方法的语句改为如下形式将会导致程序运行出现异常:​

Father f = new Child<String>();
f.method(new Integer(1));

这段代码中,把引用f的类型定义为原始类。由于原始类忽略了泛型,这样的话,通过原始类的引用调用method()方法时就不做类型检查,所以把Integer对象传递给method()方法不会出现语法错误。但程序在实际运行时,由于方法实现了覆盖,所以调用到的是子类的method()方法,这个方法参数的类型是String,所以会因参数类型不匹配导致出现异常,因此,尽量不要把泛型类当作原始类使用。读者可以自行修改【例08_31】的代码并实际运行,仔细体会这个异常抛出的原因。​

以上列举的例子中,子类都没有扩展出属于自己的类型参数。如果子类扩展出属于自己的类型参数,在实现覆盖时会有可能出现一种新的语法错误,如图8-46所示。​

第八章《Java高级语法》第11节:泛型

图8-46 覆盖泛型类方法导致语法错误​

从图8-46可以看出,子类在扩展出属于自己的类型参数后,对父类方法进行覆盖却出现了语法错误。编译器给出的错误提示的最后几个单词是是“does not override it”,翻译过来就是“没有覆盖它”。之所以产生这个语法错误,是因为泛型技术的引入让方法的覆盖变得非常复杂,于是编译器加强了对方法覆盖的检查力度。编译器引入了一种新的检查机制,这种机制被称为“检查试图覆盖机制”。这个机制的原理是:当编译器看到父类和子类中都出现了一模一样的方法时,编译器会认为程序员试图用子类的方法覆盖父类的方法。如果根本不能达到覆盖的效果,编译器就会报出编译错误。图8-46中,子类在继承父类时把类型参数指定为Integer,所以子类继承过来的method()方法参数的类型也是Integer类型,而子类定义的method()方法参数是T类型的,T属于子类自身扩展出的类型参数,它可以在创建子类对象的时候被指定成任意的类型,所以,虽然父子两个类的method()方法形式完全相同,但由于完成继承时把父类的类型参数T指定为Integer,这导致两个方法的参数类型实际上并不相同,根本不能达到覆盖效果,因此编译器就给出了错误提示。在这种情况下,即使把子类的类型参数改为其他名称,例如把T改为E,编译器仍然认为父子两个类中的method()方法形式是相同的,所以语法错误并不会消失。既然方法定义的形式完全相同却达不到覆盖的效果,那么就干脆把子类中的method()方法改成另一个名称,这样就能消除语法错误。​

到此为止,本章所有的内容都已讲述完毕。读者肯定已经体会到了继承泛型类的复杂性。实际上,无论以何种方式继承泛型类,只要把握住两个原则就能解开继承泛型类所带来的所有问题:​

原则一:创建对象时把类型参数指定为具体类型,这个操作并不会使得类型参数被编译为指定类型。例如在本小节的很多例子中,创建对象时都把Father类的类型参数T指定为String,但编译时并不会把T编译为String类。​

原则二:子类在继承泛型类时,采用的是“个性化”的继承方式,这种继承方式使得子类继承了父类的类型参数后会根据子类自身的实际情况把类型参数编译为某种特定类型。例如Father类中定义的类型参数T原本可以表示所有类型,但子类继承T时把T的范围限制在String及其子类,那么子类中所有继承而来的T都会被编译为String。​

8.11.8擦拭及相关问题

在8.11.7小节中曾经提到:创建对象时,虽然把Father类中定义的类型参数T指定为String,但编译器并不会把T编译为String类,那么T究竟被编译成了哪一种类型呢?实际上,如果没有为类型参数指定上界,那么类型参数会被编译为Object,而如果为类型参数指定了上界,那么就把类型参数编译为上界类型。例如:在8.11.3小节中,把MyNum类的类型参数T的上界指定为Number类,那么MyNum类的类型参数T会被编译为Number。专业上,编译源代码时把类型参数化转换为某种具体类型的操作被称为“擦拭”。之所以叫这个名称,就是因为编译之后的代码中再也看不到类型参数,它们已经被编译器从代码中全部擦拭掉了。​

从以上讲解中可以看出:擦拭操作是在编译阶段完成的,而不是在创建对象阶段完成的。在没有规定类型参数的上界时把类型参数编译为Object,这样就能做到创建对象时把它指定为任意类型,因为所有类都是Object的子类。而在规定了类型参数的上界时,会把类型参数编译为上界类型,这是因为只有这样才能保证泛型对象调用上界类型属性和方法的操作不违反语法规则。请看下面的这段代码。​

class MyNum<T extends Number>{
T num;
public double doubleNum(){
return num.doubleValue();//①​
}
}

在这段代码中,由于设定了上界,类型参数T会被编译为Number类。只有这样,语句①中T类型的num对象在调用doubleValue()方法才是合法的。如果把T编译为Object,由于Object类根本没有定义doubleValue()方法,因此num对象调用doubleValue()方法就变成了不合法操作。​

读者了解了擦拭原理后,就能轻松的解开前面所遗留的一些问题。例如在【例08_28】中有这样一段代码。​

GenericMethodTest gmt = new GenericMethodTest();
gmt.gMethod(new String());//①直接传递字符串​
gmt.<String>gMethod(new String());//②指定参数类型为String试图调用到泛型方法​
gmt.<Object>gMethod(new String());//③指定参数类型为Object试图调用到泛型方法​
gmt.gMethod(new Object());//④不指定参数类型,并以Object类型的对象为参数​

以上代码中,语句②和③虽然都符合调用泛型方法的语法格式,但都没有调用到泛型方法。原因就在于类型被擦拭掉后,泛型方法与普通方法没有了任何区别,因此编译之后GenericMethodTest类中的两个gMethod()方法都成了普通方法。而语句②和③中给gMethod()传递的参数是String,根据参数类型匹配原则,虚拟机会选择调用那个参数为String类的gMethod()方法,而不会调用泛型方法。​

同样,根据擦拭原理还能解释【例08_30】的运行结果。在【例08_30】中,父类method()方法参数的类型会被编译为Object,而子类的method()方法参数的类型是String,两个方法参数类型不同,所以无法形成覆盖。​

此外,通过擦拭原理也能很轻松的解释为什么泛型方法和普通方法也会产生定义冲突,如图8-47所示。​

第八章《Java高级语法》第11节:泛型

图8-47 泛型方法与普通方法产生定义冲突​

图8-47中泛型方法的定义出现了语法错误,就是因为类型参数T被编译为Object之后,两个方法无论从名称、参数以及返回值类型都完全相同,并且它们定义在同一个类中,不会形成覆盖,这样就导致了方法的重复定义。​

很多读者都会问:类型参数在编译后最终都会被擦拭掉,那么Java语言引入泛型还有什么意义呢?引入泛型的意义是能够标记引用和对象的实际类型,如图8-48所示。​

第八章《Java高级语法》第11节:泛型

图8-48 没有使用泛型技术定义Data类​

图8-48中定义了一个Data类,这个类没有用到泛型技术,它的属性att被定义为Object。虽然图中用一个String类的对象初始化其att属性,但因为att的类型被定义为Object,所以通过getAtt()方法获得的att被编译器认为是一个Object类的对象而不是一个String类的对象,所以att对象不能直接调用String类的concat()方法,因此concat()方法在代码编辑器中会变成红色以提示开发者不能调用它。att对象只能调用那些定义在Object类中的方法,如果att对象想要调用String类中所定义的方法,必须通过强制转换的方式来还原对象的真实类型,但强制类型转换时有可能把类型弄错而导致出现类型转换异常从而降低了代码的安全性。​

如果在定义Data类时使用了泛型技术就不会有这样的问题,图图8-49所示。​

第八章《Java高级语法》第11节:泛型

图8-49使用泛型技术定义Data类​

图8-49中定义Data类时采用了泛型技术,att属性被定义为T类型。虽然T最终也会被擦拭而变成Object,但当执行语句“Data<String> data = new Data<String>();”时把类型参数T指定为String,这样就相当于把所有用T定义的对象和引用的类型都标记为String。在操作att时,编译器就会把att当作一个String类的对象,这样通过getAtt()方法得到att对象后就可以直接调用String类所定义的那些方法。可以看出:使用了泛型技术能够免去类型转换的操作,因此不会出现类型转换异常,从而保证了代码的安全性。由于把T类型的引用和对象都标记为String,这样在赋值操作时也会进行类型检查,不允许用其他类型的对象来初始化att。对比这两个Data类的定义,可以看出:使用泛型技术会使得编码更加简单,同时也提高了程序的安全性。​

泛型技术的知识点很多,也具有一定的难度,但它在实际开发过程中有很广泛的应用,尤其在定义和使用集合类时都会大量用到泛型技术,因此各位读者一定要克服困难认真学好泛型。

除此文字版教程外还有更详细的视频讲解,小伙伴们可以点击这里观看。