
泛型程序设计
在前面我们学习了最重要的类和对象,了解了面向对象编程的思想,注意,非常重要,面向对象是必须要深入理解和掌握的内容,不能草草结束。在本章节,我们还会继续深入了解,从泛型开始,再到数据结构,最后再开始我们的集合类学习,循序渐进。
泛型
为了统计学生成绩,要求设计一个Score对象,包括课程名称、课程号、课程成绩,但是成绩分为两种,一种是以优秀、良好、合格
来作为结果,还有一种就是 60.0、75.5、92.5
这样的数字分数,可能高等数学这门课是以数字成绩进行结算,而计算机网络实验这门课是以等级进行结算,这两种分数类型都有可能出现,那么现在该如何去设计这样的一个Score类呢?
现在的问题就是,成绩可能是String
类型,也可能是Integer
类型,如何才能很好的去存可能出现的两种类型呢?
public class Score {
String name;
String id;
Object value; //因为Object是所有类型的父类,因此既可以存放Integer也能存放String
public Score(String name, String id, Object value) {
this.name = name;
this.id = id;
this.score = value;
}
}
以上的方法虽然很好地解决了多种类型存储问题,但是Object类型在编译阶段并不具有良好的类型判断能力,很容易出现以下的情况:
public static void main(String[] args) {
Score score = new Score("数据结构与算法基础", "EP074512", "优秀"); //是String类型的
...
Integer number = (Integer) score.score; //获取成绩需要进行强制类型转换,虽然并不是一开始的类型,但是编译不会报错
}
使用Object类型作为引用,对于使用者来说,由于是Object类型,所以说并不能直接判断存储的类型到底是String还是Integer,取值只能进行强制类型转换,显然无法在编译期确定类型是否安全,项目中代码量非常之大,进行类型比较又会导致额外的开销和增加代码量,如果不经比较就很容易出现类型转换异常,代码的健壮性有所欠缺
所以说这种解决办法虽然可行,但并不是最好的方案。
为了解决以上问题,JDK 5新增了泛型,它能够在编译阶段就检查类型安全,大大提升开发效率。
泛型类
泛型在Java非常重要,我们可以使用一个特殊的名字表示待定类型,在泛型类中,我们可以将这个待定类型添加到类名后,它通常也被称为类型参数(Type Parameter)泛型在定义时并不明确是什么类型,而是需要到使用时才会确定具体的类型。
我们可以将一个类定义为一个泛型类:
public class Score<T> { //泛型类需要使用<>,我们需要在里面添加1 - N个类型参数
String name;
String id;
T value; //T会根据使用时提供的类型自动变成对应类型
public Score(String name, String id, T value) { //这里T可以是任何类型,但是一旦确定,那么就不能修改了
this.name = name;
this.id = id;
this.value = value;
}
}
我们来看看这是如何使用的:
public static void main(String[] args) {
Score<String> score = new Score<String>("计算机网络", "EP074512", "优秀");
//因为现在有了类型变量,在使用时同样需要跟上<>并在其中填写明确要使用的类型
//这样我们就可以根据不同的类型进行选择了
String value = score.value; //一旦类型明确,那么泛型就变成对应的类型了
System.out.println(value);
}
泛型将数据类型的确定控制在了编译阶段,在编写代码的时候就能明确泛型的类型,如果类型不符合,将无法通过编译!因为是具体使用对象时才会明确具体类型,所以说静态方法中是不能用的:

只不过这里需要注意一下,我们在方法中使用待确定类型的变量时,因为此时并不明确具体是什么类型,那么默认会认为这个变量是一个Object类型的变量,因为无论具体类型是什么,一定是Object类的子类:

我们可以对其进行强制类型转换,但是实际上没多大必要:
public void test(T t){
String str = (String) t; //都明确要用String了,那这里定义泛型不是多此一举吗
}
因为泛型本身就是对某些待定类型的简单处理,如果都明确要使用什么类型了,那大可不必使用泛型。还有,不能通过这个不确定的类型变量就去直接创建对象和对应的数组:

注意,具体类型不同的泛型类变量,不能使用不同的变量进行接收:

如果要让某个变量支持引用确定了任意类型的泛型,那么可以使用?
通配符:
public static void main(String[] args) {
Test<?> test = new Test<Integer>();
test = new Test<String>();
Object o = test.value; //但是注意,如果使用通配符,那么由于类型不确定,所以说具体类型同样会变成Object
}
当然,泛型变量不止可以只有一个,如果需要使用多个的话,我们也可以定义多个:
public class Test<A, B, C> { //多个类型变量使用逗号隔开
public A a;
public B b;
public C c;
}
那么在使用时,就需要将这三种类型都进行明确指定:
public static void main(String[] args) {
Test<String, Integer, Character> test = new Test<>(); //使用钻石运算符可以省略其中的类型
test.a = "lbwnb";
test.b = 10;
test.c = '淦';
}
是不是感觉好像还是挺简单的?只要是在类中,都可以使用类型变量:
public class Test<T>{
private T value;
public void setValue(T value) {
this.value = value;
}
public T getValue() {
return value;
}
}
只不过,泛型只能确定为一个引用类型,基本类型是不支持的:
public class Test<T>{
public T value;
}

如果要存放基本数据类型的值,我们只能使用对应的包装类:
public static void main(String[] args) {
Test<Integer> test = new Test<>();
}
当然,如果是基本类型的数组,因为数组本身是引用类型,所以说是可以的:
public static void main(String[] args) {
Test<int[]> test = new Test<>();
}
泛型也可以支持套娃使用:
Test<Test<Test<Integer>>> test = new Test<>();
//你没看错,泛型的类型参数也可以是另一个泛型类型,套娃形式存在
通过使用泛型,我们就可以将某些不明确的类型在具体使用时再明确。
泛型与多态
不只是类,包括接口、抽象类,都是可以支持泛型的:
public interface Study<T> {
T test();
}
当子类实现此接口时,我们可以选择在实现类明确泛型类型,或是继续使用此泛型让具体创建的对象来确定类型:
public class Main {
public static void main(String[] args) {
A a = new A();
Integer i = a.test();
}
static class A implements Study<Integer> {
//在实现接口或是继承父类时,如果子类是一个普通类,那么可以直接明确对应类型
@Override
public Integer test() {
return null;
}
}
}
或者是继续摆烂,依然使用泛型:
public class Main {
public static void main(String[] args) {
A<String> a = new A<>();
String i = a.test();
}
static class A<T> implements Study<T> {
//让子类继续为一个泛型类,那么可以不用明确
@Override
public T test() {
return null;
}
}
}
继承也是同样的:
static class A<T> {
}
static class B extends A<String> {
}
泛型方法
当然,类型参数并不是只能在泛型类中才可以使用,我们也可以定义泛型方法。
当某个方法(无论是是静态方法还是成员方法)需要接受的参数类型并不确定时,我们也可以使用泛型来表示:
public class Main {
public static void main(String[] args) {
String str = test("Hello World!");
}
private static <T> T test(T t){ //在返回值类型前添加<>并填写泛型变量表示这个是一个泛型方法
return t;
}
}
泛型方法会在使用时自动确定泛型类型,比如上我们定义的是类型T作为参数,同样的类型T作为返回值,实际传入的参数是一个字符串类型的值,那么T就会自动变成String类型,因此返回值也是String类型。
public static void main(String[] args) {
String[] strings = new String[1];
Main main = new Main();
main.add(strings, "Hello");
System.out.println(Arrays.toString(strings));
}
private <T> void add(T[] arr, T t){
arr[0] = t;
}
实际上泛型方法在很多工具类中也有,比如说Arrays的排序方法:
Integer[] arr = {1, 4, 5, 2, 6, 3, 0, 7, 9, 8};
Arrays.sort(arr, new Comparator<Integer>() {
//通过创建泛型接口的匿名内部类,来自定义排序规则,因为匿名内部类就是接口的实现类,所以说这里就明确了类型
@Override
public int compare(Integer o1, Integer o2) { //这个方法会在执行排序时被调用(别人来调用我们的实现)
return 0;
}
});
比如现在我们想要让数据从大到小排列,我们就可以自定义:
public static void main(String[] args) {
Integer[] arr = {1, 4, 5, 2, 6, 3, 0, 7, 9, 8};
Arrays.sort(arr, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) { //两个需要比较的数会在这里给出
return o2 - o1;
//compare方法要求返回一个int来表示两个数的大小关系,大于0表示大于,小于0表示小于
//这里直接o2-o1就行,如果o2比o1大,那么肯定应该排在前面,所以说返回正数表示大于
}
});
System.out.println(Arrays.toString(arr));
}
因为我们前面学习了Lambda表达式,像这种只有一个方法需要实现的接口,直接安排了:
public static void main(String[] args) {
Integer[] arr = {1, 4, 5, 2, 6, 3, 0, 7, 9, 8};
Arrays.sort(arr, (o1, o2) -> o2 - o1); //瞬间变一行,效果跟上面是一样的
System.out.println(Arrays.toString(arr));
}
包括数组复制方法:
public static void main(String[] args) {
String[] arr = {"AAA", "BBB", "CCC"};
String[] newArr = Arrays.copyOf(arr, 3); //这里传入的类型是什么,返回的类型就是什么,也是用到了泛型
System.out.println(Arrays.toString(newArr));
}
因此,泛型实际上在很多情况下都能够极大地方便我们对于程序的代码设计。
泛型的界限
现在有一个新的需求,现在没有String类型的成绩了,但是成绩依然可能是整数,也可能是小数,这时我们不希望用户将泛型指定为除数字类型外的其他类型,我们就需要使用到泛型的上界定义:
public class Score<T extends Number> { //设定类型参数上界,必须是Number或是Number的子类
private final String name;
private final String id;
private final T value;
public Score(String name, String id, T value) {
this.name = name;
this.id = id;
this.value = value;
}
public T getValue() {
return value;
}
}
只需要在泛型变量的后面添加extends
关键字即可指定上界,使用时,具体类型只能是我们指定的上界类型或是上界类型的子类,不得是其他类型。否则一律报错:

实际上就像这样:

同样的,当我们在使用变量时,泛型通配符也支持泛型的界限:
public static void main(String[] args) {
Score<? extends Integer> score = new Score<>("数据结构与算法", "EP074512", 60);
}
那么既然泛型有上界,那么有没有下界呢?肯定的啊:

只不过下界仅适用于通配符,对于类型变量来说是不支持的。下界限定就像这样:

那么限定了上界后,我们再来使用这个对象的泛型成员,会变成什么类型呢?
public static void main(String[] args) {
Score<? extends Number> score = new Score<>("数据结构与算法基础", "EP074512", 10);
Number o = score.getValue(); //可以看到,此时虽然使用的是通配符,但是不再是Object类型,而是对应的上界
}
但是我们限定下界的话,因为还是有可能是Object,所以说依然是跟之前一样:
public static void main(String[] args) {
Score<? super Number> score = new Score<>("数据结构与算法基础", "EP074512", 10);
Object o = score.getValue();
}
通过给设定泛型上限,我们就可以更加灵活地控制泛型的具体类型范围。
类型擦除
前面我们已经了解如何使用泛型,那么泛型到底是如何实现的呢,程序编译之后的样子是什么样的?
public abstract class A <T>{
abstract T test(T t);
}
实际上在Java中并不是真的有泛型类型(为了兼容之前的Java版本)因为所有的对象都是属于一个普通的类型,一个泛型类型编译之后,实际上会直接使用默认的类型:
public abstract class A {
abstract Object test(Object t); //默认就是Object
}
当然,如果我们给类型变量设定了上界,那么会从默认类型变成上界定义的类型:
public abstract class A <T extends Number>{ //设定上界为Number
abstract T test(T t);
}
那么编译之后:
public abstract class A {
abstract Number test(Number t); //上界Number,因为现在只可能出现Number的子类
}
因此,泛型其实仅仅是在编译阶段进行类型检查,当程序在运行时,并不会真的去检查对应类型,所以说哪怕是我们不去指定类型也可以直接使用:
public static void main(String[] args) {
Test test = new Test(); //对于泛型类Test,不指定具体类型也是可以的,默认就是原始类型
}
只不过此时编译器会给出警告:

同样的,由于类型擦除,实际上我们在使用时,编译后的代码是进行了强制类型转换的:
public static void main(String[] args) {
A<String> a = new B();
String i = a.test("10"); //因为类型A只有返回值为原始类型Object的方法
}
实际上编译之后:
public static void main(String[] args) {
A a = new B();
String i = (String) a.test("10"); //依靠强制类型转换完成的
}
不过,我们思考一个问题,既然继承泛型类之后可以明确具体类型,那么为什么@Override
不会出现错误呢?我们前面说了,重写的条件是需要和父类的返回值类型和形参一致,而泛型默认的原始类型是Object类型,子类明确后变为其他类型,这显然不满足重写的条件,但是为什么依然能编译通过呢?
public class B extends A<String>{
@Override
String test(String s) {
return null;
}
}
我们来看看编译之后长啥样:
// Compiled from "B.java"
public class com.test.entity.B extends com.test.entity.A<java.lang.String> {
public com.test.entity.B();
java.lang.String test(java.lang.String);
java.lang.Object test(java.lang.Object); //桥接方法,这才是真正重写的方法,但是使用时会调用上面的方法
}
通过反编译进行观察,实际上是编译器帮助我们生成了一个桥接方法用于支持重写:
public class B extends A {
public Object test(Object obj) { //这才是重写的桥接方法
return this.test((Integer) obj); //桥接方法调用我们自己写的方法
}
public String test(String str) { //我们自己写的方法
return null;
}
}
类型擦除机制其实就是为了方便使用后面集合类(不然每次都要强制类型转换)同时为了向下兼容采取的方案。因此,泛型的使用会有一些限制:
首先,在进行类型判断时,不允许使用泛型,只能使用原始类型:

只能判断是不是原始类型,里面的具体类型是不支持的:
Test<String> test = new Test<>();
System.out.println(test instanceof Test); //在进行类型判断时,不允许使用泛型,只能使用原始类型
还有,泛型类型是不支持创建参数化类型数组的:

要用只能用原始类型:
public static void main(String[] args) {
Test[] test = new Test[10]; //同样是因为类型擦除导致的,运行时可不会去检查具体类型是什么
}
只不过只是把它当做泛型类型的数组还是可以用的:

协变和逆变
注意: 本小节作为选学内容,不强制要求掌握。
我们在前面介绍了泛型的基本使用,实际上就是一个待定的类型,我们在使用时可以指定具体的类型,并在编译时检查类型是否匹配,保证运行时类型的安全性,就像下面这样:
public static void main(String[] args) {
Test<Integer> test = new Test<>(10);
}
static class Test<T> {
T value;
public Test(T value) {
this.value = value;
}
}
一旦泛型变量类型确定,后续将一直固定使用此类型,并且当我们将其赋值给其他类型时,也会提示不兼容:

但是现在存在这样一个问题,我们如果使用某个类型的父类呢,会不会出现类型不匹配的情况?

可以看到,即使是Integer类型的父类Number,也无法接收其子类类型的结果,这就很奇怪了,我们前面说过一个类可以被当做其父类使用(因为父类具有属性什么子类一定也有)会自动完成隐式类型转换,但是为什么到了泛型这里就不行了呢?
为了探究这个问题,我们先从几个概念开始说起,现在假设Integer类型是Number类型的子类,正常情况下只能子类转换为父类,泛型类型Test<T>
存在以下几种形变:
- 协变 (Covariance):因为Integer是Number的子类,所以
Test<Integer>
同样是Test<Number>
的子类,可以直接转换 - 逆变(Contravariance):跟上面相反,
Test<Number>
可以直接转换为Test<Integer>
,前者是后者的子类 - 抗变 (Invariant):
Test<Integer>
跟Test<Number>
没半毛钱关系,无法互相转换
而Java的泛型,默认就是抗变的,即使两个类型存在父子关系,到编译器这里也不认账。而我们前面认识的数组,它的性质就是协变的:
String[] strings = { "AAA", "BBB" };
Object[] objects = strings; //正常编译通过
因为Integer是Number的子类,因此,数组的Integer[]
同样也是Object[]
的子类。
那为什么泛型要设计成抗变性质的呢?这其实是为了类型安全,我们可以通过协变的数组来进行测试:
public static void main(String[] args) {
String[] strings = { "AAA", "BBB" };
Object[] objects = strings; //通过其父类数组接收对于String[]的引用
objects[1] = 666; //此时由于类型是Object[],那么任何Object的子类对象都可以塞
}
此时就会出现问题了,我们这里原本的对象是一个String类型的数组,但是现在我们却在往里面存一个Integer类型的数据,并且程序没有出现任何的编译错误,这就出现BUG了,我们居然可以存入一个不符合数组类型的元素。
因此,在程序实际运行时,会直接抛出ArrayStoreException异常:

实际上这就是协变带来的缺点,它可能会导致我们使用一个错误的类型进行存取。因此现在很多其他的编程语言都会设计泛型,但是很少会有支持协变的泛型,几乎都是抗变。
除此之外,在我们使用通配符时,也会存在逆变和协变,比如:
Test<? extends Number> test = new Test<>(10); //接收到一个Test<Integer>类型的对象
我们可以将?
添加extends
来限制其上界,我们前面说过它将可以同时匹配所有继承自Number的类型(包括其自身)也就是我们这里谈到的协变性质,但是这样是存在风险的:
Test<? extends Number> test = new Test<>(10); //Test<Integer>
test.value = 1.5; //此时由于类型为Number,那么所有子类都可以,我们可以直接给一个Double
如果这段代码可以正常执行,那么这将导致我们为一个Integer类型的变量,赋值了一个Double类型的结果,这显然是错误的。因此,我们实际上会发现,这里会限制我们对于协变的泛型属性赋值,当然,只是获取则不会受到影响。
同样的,如果我们使用super
关键字:
Test<? super Number> test = new Test<>(10);
那么此时它会具有逆变性质,只要是任何其父类都可以直接转换为? super Number
,只不过由于Java中所有类型都是Object的子类,所以这里直接给一个Integer
类型的value也不会出现错误,但是这并不影响它的逆变性质。
Test<? super Number> test = new Test<>(10);
Object object = test.value; //这里只能使用Object来接收其结果
由于此时限制的是上界,这里给到value的对象有可能是Number类型但是也有可能Object类型的,因此我们只能拿到一个Object类型的结果。因此,针对于以上三种类型,虽然协变和逆变允许更灵活地进行使用,但是同时也存在一定代价:
- 协变: 不允许修改或添加任何元素(除了
null
),读取可以正常得到T或其子类型(主要用于读取数据) - 逆变: 允许修改或添加T及其子类,但是读取被限制为只能读取Object类型的结果(主要用于写入数据)
- 抗变: 修改和读取均不受限制(读写通用)
实际上到了后面的集合类中,如果类型为协变或是抗变,某些操作同样会受到限制。