💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
# 序言 一般的类和方法,只能使用具体的类型:要么是基本数据类型,要么是自定义的类。 如果要编写可以应用于多种类型的代码,这种刻板的限制对代码的束缚就会很大。                                                                                                    —— 《Think in Java》 # 泛型程序设计带来的好处是什么 不难想象,“可以应用于多种类型的代码”这种需求当然是最初就存在的,因为代码的复用一直是编码的一个重点。 然而值得注意的是:泛型技术是是在Java 1.5版本之后才出现的。那么在此之前,对于上述的需求,Java是怎么样来满足的呢? 其实答案不难想象,当然是通过继承的多态特性来实现的:超类类型声明的对象引用能够指向任何其子类对象。 而同时被众所周知的还有:Java当中定义的任何类,都有一个默认的共同的超类:Object类。 那么自然的,如果想要一段代码能够接收任一类型的数据,那么该数据的类型自然就应当被定义为“Object”类型。 Java集合框架当中的容器类就基于泛型技术而实现,所以它们能够保证你所定义的容器对象能够接受任何指定类型的数据进行存储。 而你已经知道集合框架于JDK 1.2之后就诞生了。那么在Java 1.2 - 1.5版本期间,它们是如何保证“能够存储任一对象类型”这一特征的呢? 答案并不让人惊讶,当然同样是基于继承来实现的。正如当时的ArrayList容器类,内部只是维护一个Object类型引用的可变数组。 由此已经不难想象:正如Java中,原始数组已经可以完成对同一数据类型的多个数据进行存储的工作。而在之后的升级中,却衍生了集合框架的道理一样。 既然通过继承的多态特性,能够完成同一段代码应用于多种类型的目的。Java还是在升级中制定出了泛型技术,那么自然是因为原本的设计方式存在缺陷。 我们可以这样考虑:假设想要编写一个方法,方法能够接受操作任一对象类型的参数。那么,在Java 1.5之前,其代码自然是这样的: ~~~ public class Demo { void anyObject(Object o){ //some code.. } } ~~~ 我们不难看出使用这样的方式,可能会造成的困扰。 首先,因为Java中的“动态绑定机制”。所以通过这样的方式传递参数,在方法中只能调用到Object类自身的方法。 所以如果你想在方法内,调用你传入的特定对象类型其自身额外的方法时,就必须涉及到强制类型转换 - 完成“向下转型”。 从而导致了蝴蝶效应:因为一旦涉及到类型的强制转换,就可能会导致类型转换异常:"ClassCastException"。 而泛型程序设计技术的出现,正是为了弥补上述实现方式的缺陷。所以说泛型的最大好处正是: - 减少了代码的复杂性:避免了在代码中使用强制类型转换的麻烦。 - 增强了代码的安全性:将运行时异常“ClassCastException”转到了编译时检测异常。 # 泛型的实际使用 泛型的使用规范,并不复杂,可以简单归纳为: - Java中用符号"<>"用以声明使用泛型。括号内用以包含一个或多个泛型参数,多个参数之间用逗号隔开。 - 通常推荐使用简练的名字来作为泛型参数的命名。最好避免小写字母,这能很好的用以其和其他普通形式参数的区分。 - 如果一个泛型类里还包含有泛型方法,那么最好避免对方法的泛型类型参数与类的泛型参数使用同样的标示符,避免混淆。 ### 泛型类的定义 一个泛型类就是具有一个或多个类型变量的类。其定义格式通常为:class ClassName <T>。举例来说: ~~~ package com.tsr.j2seoverstudy.generic; public class GenericClassDemo<T> { private T t; GenericClassDemo(T t) { this.t = t; } public T getT() { return t; } public void setT(T t) { this.t = t; } /* * 输出结果为: * generic_class * new value */ public static void main(String[] args) { String genericVar = new String("generic_class"); GenericClassDemo<String> g = new GenericClassDemo<String>(genericVar); System.out.println(g.getT()); g.setT("new value"); System.out.println(g.getT()); } } ~~~ 对于泛型类的使用,可以看到其特点就是: 在泛型类的内部,可以使用类声明当中的类型参数,作为该类的成员变量的数据类型。 正如我们在上面的代码中所定义的:“private T t;”。 而该类型参数,可以在该类进行构造工作时,被具体指定为具体的任意对象数据类型。 上面的例子中,我们为其制定的具体类型,是字符串类型“String”。这个过程就是“泛型的实例化”。 同时,泛型类当中定义的方法,可以接受和操作泛型类上声明的类型参数。 但同时有一点特别需要注意的是:泛型的实例化工作是在对泛型类进行构造,生成对象的时候完成的。 这也解释了为什么类中的静态方法如果要使用泛型,泛型就必须被定义在该方法上,而不允许被定义在类上的原因。 正是因为一个类上声明的泛型,本身就是依赖于该类的对象来明确的,但静态方法却不依赖于对象。 于是,接下来就让我们来看一看泛型方法的定义和使用。 ### 泛型方法的定义 泛型方法的定义格式为:public <T> 返回类型 方法名(T t) 关于泛型方法的定义,需要注意的就是:方法上的泛型应该放在方法修饰符和方法返回类型之间。 ~~~ package com.tsr.j2seoverstudy.generic; public class GenericMethodDemo { /* * 输出结果为: * 位于数组中间的数是:5 */ public static void main(String[] args) { Integer[] nums = { 1, 3, 5, 7, 9 }; System.out.println("位于数组中间的数是:" + getMiddle(nums)); } static <T> T getMiddle(T[] t) { return t[t.length / 2]; } } ~~~ Java中的泛型载体有:泛型方法,泛型类,泛型接口。也就是说泛型除类与方法之外,还可以应用在接口上。 不过对于泛型接口的使用,在了解了泛型在类和方法的使用方式之后,你也应该差不多了解了。 ### 泛型变量范围的限定 有的时候,根据需求需要对类型变量加以约束。这时就涉及到了泛型变量的范围限定的使用。 我们都知道实现compareable下的compareTo方法,可以用于对两个对象的比较和排序。 那么假设我们定义了如下的泛型方法,想要举出一个数组中compareTo方法比较下最大的对象结果: ~~~ package com.tsr.j2seoverstudy.generic; public class GenericMethodDemo { /* * 输出结果为: * 最大的字符串元素是:zsda */ public static void main(String[] args) { String [] strs = {"asdas","xzvzh","zsda","qwe"}; System.out.println("最大的字符串元素是:"+getMaxElement(strs)); } static <T extends Comparable<T>> T getMaxElement(T[] t) { if (t == null || t.length == 0) return null; T max = t[0]; for (int i = 1; i < t.length; i++) { if (t[i].compareTo(max) > 0) { max = t[i]; } } return max; } } ~~~ 我们知道:最基本的泛型参数,可以用于接受任一对象数据类型。 但在上面的例子中,我们希望通过compareTo方法用于对象的比较。 什么样的对象才具备compareTo方法,就是实现了Comparable接口的类的对象。 所以这个时候我们还必须对声明的泛型做一个约束:指定的数据类型必须实现了Comparable接口。 这就是所谓的:泛型参数的范围限定。 我们在上面的代码中:“<T extends Comparable<T>>”就是一种用于限定泛型范围的使用方式。 你可能注意到在这里我们使用了Java中原本用于表明类的继承关系的关键字extends,用于指定泛型必须实现Comparable接口。 没错,在泛型当中,用于表示声明的类型参数继承自一个类或者实现自一个接口,都用extends表示。 这种限定的方式也常常被称为:泛型的上限。其表现形式也就是:<泛型类型参数 extends 上限>。 注:如果声明的泛型类型实现了多个接口,则其上限中的多个接口以符号”&“隔开。    如果声明的类型参数继承自某个类,并同时实现了多个接口,那么其继承的类必须被声明在上限中的第一个。 既然有泛型的上限,就不难想象肯定存在与之对应的:泛型的下限。其限定方式为:<泛型类型参数 super  下限>。 下限的限定代表,该泛型参数除开至少可以被声明的下限类型实例化之外,还可以被该下限类型的所有超类类型所实例化。 举例来说,有自定义的三个类:动物类、老虎类、东北虎类。它们之间的继承关系是:东北虎继承自老虎。老虎继承自动物。 如果使用"<T super 老虎>"代表的含义就是:泛型参数T即可以被实例化为老虎,还可以被实例化为动物,但不能被实例化为东北虎。 # 泛型擦除 所谓的泛型擦除是指:当程序度过编译器,进入到运行期后,在JVM中会将泛型去掉。这个过程就被称为泛型的擦除。 之所以要进行泛型的擦除工作,实际上是因为:泛型实际上仅仅只是被Java编译器所支持的的一项技术。而虚拟机是并不认识泛型的。 所以当一个Java程序使用到泛型技术的时候,首先会在编译期经过编译器的检查:确定是否存在类型转换的问题。 而当编译通过,程序转向运行期之后。在负责Java程序运行的虚拟机中,则会将泛型类型擦除。 所谓的泛型擦除就是指:将定义的泛型类型还原为其对应的原始类型(raw type)。这个还原的过程是根据泛型的限定类型确定的: - 如果没有声明限定类型的类型参数则将被还原为:Object。 以上面我们说泛型类的定义时,所用到的例子来说,其还原形式就如同: ~~~ //泛型擦除前 public class GenericClassDemo<T> { private T t; GenericClassDemo(T t) { this.t = t; } } //泛型擦除后 public class GenericClassDemo { private Object o; GenericClassDemo(Object o) { this.o = o; } } ~~~ - 如果泛型本身存在限定,则原始类型用限定的第一个类型变量代替。 例如说: ~~~ //泛型擦除之前 public class GerericDemo <T extends Comparable<T>&Serializable>{ private T t; GerericDemo(T t){ this.t = t; } } //泛型擦除之后 public class GerericDemo implements Serializable{ private Comparable t; GerericDemo(Comparable t){ this.t = t; } } ~~~ 所以实际上可以看到:所谓的泛型技术在使用时,最终还是会涉及到类型的强制转换。但不同的是:使用泛型后的强制转换是安全的! 之所以这么说,是因为我们在编译时期已经确保了数据类型的一致性。如果你使用了指定泛型类型之外的数据类型,程序是不能编译通过的。 那么既然话至于此,也正好通过一个例子,来看一看我们上面所谈到的:“泛型将运行时异常“ClassCastException”转到了编译时期到底是指什么? 以容器类ArrayList为例,如果不通过泛型技术,那么假设使用以下这样的方式: ~~~ package com.tsr.j2seoverstudy.generic; import java.util.ArrayList; public class GerericDemo{ public static void main(String[] args) { ArrayList list = new ArrayList(); list.add(new String()); list.add(new Integer(5)); for (int i = 0; i < list.size(); i++) { String value = (String)list.get(0); } } } ~~~ 上面的代码编译不会出现任何问题,但一旦运行就会报告运行时异常:java.lang.Integer cannot be cast to java.lang.String 这正是因为,如果没有泛型的限定。ArrayList自身接受任何继承自Object类的对象类型数据。 所以因为继承的特性,我们要将定义的两个分别为String与Integer类型的对象存放进该list容器是没有问题的,因为它们自身都会完成一次“向上转型”。 但也正是因为当完成“向上转型”的工作后,实际上存放进list容器之后,它们的类型已经是Object类型了。 所以当我们再想要以特定类型将其取出时,就必须进行“向下转型”,也就是强制类型转换工作。 但Integer类型自身是不能够强制转换为Strring类型的,从而也就导致了类型转换异常的出现。 反之,当容器类加入泛型技术之后,代码则变为了: ~~~ public class GerericDemo{ public static void main(String[] args) { ArrayList<String> list = new ArrayList<String>(); list.add(new String()); list.add(new Integer(5));//因为泛型的出现,编译出错! for (int i = 0; i < list.size(); i++) { //不再需要强转 String value = list.get(i); } } } ~~~ 因为泛型类在构造时,必须为类型参数指定具体的类型,也就是完成泛型参数的实例化工作。 所以当我们将类型指定为<String>之后,再想要向容器内加入Integer类型的对象就会编译出错。 而同时因为编译器已经知道该容器内存放的数据类型时String,所以在取出数据时,也就不再需要进行强转了。 # 泛型通配符 Java当中的泛型通配符的符号是“?”。顾名思义,也就是指任何对象数据类型都可以通通匹配。 其主要的使用方式我们可以通过一个例子来看一下,假设我们定义了如下几个类: ~~~ package com.tsr.j2seoverstudy.generic; public class Animal<T> { private T t; Animal(T t) { this.t = t; } public T getT() { return t; } } class Tiger { @Override public String toString() { return "老虎"; } } class Bird { @Override public String toString() { return "鸟"; } } ~~~ 假设我们想要定义一个工具类,用于输出动物信息,如果没有通配符的时候,实际上用起来是很不爽的: ~~~ package com.tsr.j2seoverstudy.generic; public class GerericDemo { public static void main(String[] args) { Animal<Tiger> tiger = new Animal<Tiger>(new Tiger()); Animal<Bird> bird = new Animal<Bird>(new Bird()); printTiger(tiger); printBird(bird); } static void printTiger(Animal<Tiger> animal){ System.out.println(animal.getT()); } static void printBird(Animal<Bird> animal){ System.out.println(animal.getT()); } } ~~~ 因为我们已经说过了,泛型类在构造时必须指定类型参数的具体类型。所以你只能通过这种笨重的方式来实现需求。 但通过通配符,编码的工作就变得轻松多了: ~~~ public class GerericDemo { public static void main(String[] args) { Animal<Tiger> tiger = new Animal<Tiger>(new Tiger()); Animal<Bird> bird = new Animal<Bird>(new Bird()); printAnimal(tiger); printAnimal(bird); } static void printAnimal(Animal<?> animal){ System.out.println(animal.getT()); } } ~~~ 另外,泛型的通配符同样可以用于泛型上限,下限的限定。 最后,顺带一提吧。注意下面一种错误的使用方式: ~~~ static void printAnimal(Animal<Tiger> animal){ System.out.println(animal.getT()); } static void printAnimal(Animal<Bird> animal){ System.out.println(animal.getT()); } ~~~ 一定要知道这样的书写方式是会导致编译失败的,而不要认为这是通过参数类型的不同对方法实现了重载。 泛型擦除!!!一定不要忘了这个工作。上面的代码经泛型擦除之后,实际就变成了两个一模一样的方法声明,自然是不能编译通过的。 # 小结 到此,关于Java中泛型程序设计的应用,基本上已经是做了一个比较全面和详细的总结了。 如果要继续深入,可能就要自己再通过一些书籍和资料去研究泛型的原理以及java虚拟机方面的知识了。 个人感觉关于泛型擦除方面的底层原理还是十分复杂的,要深入掌握还是需要花一定精力的。