泛型


泛型

案例

如果现在要录入学生的成绩,内容包括课程名,课程编号和课程成绩,但课程成绩根据不同的课程会有不同的表示方法,一种为分数制(0-100),一种为评价制(优秀,良好…..)。那么如何进行编码才能满足两种成绩类型的存储?

一种是利用Object类存储成绩,这样在创建对象时,Integer和String可以自动装箱,但是要获得某一学生的成绩时,就需要利用强制类型转换获得,但我们事先并不知道这个成绩的表示方法,因此会造成很大的不便

public class Score{
	String name;
	String id;
	Object grade;
}

为了解决这种问题,JDK 5中引入了泛型

泛型

注意

  1. 泛型中不能写基本数据类型,但是基本类型的数组可以,因为数组本身是引用类型
  2. 指定泛型的具体类型后,传递数据时,可以传入该类类型或者其子类类型
  3. 如果不写泛型,类型默认是Object
  4. 泛型不具备继承性,但是数据具备继承性

例如,若 Zi类是 Fu类 的子类:

public static void method(ArrayList<Fu> arr){}
ArrayList<Fu> arr1 = new ArrayList<>();
ArrayList<Zi> arr2 = new ArrayList<>();
method(arr1);
method(arr2);//会报错

此时,若想使 method(arr2) 不报错,可以把 method 定义中的 <Fu> 换成 <?>

通配符?? 表示不确定的类型,可以进行类型的限定:

  • ? extends E:表示可以传递E / E所有的子类类型
  • ? super E:表示可以传递E / E所有的父类类型

好处

  • 统一数据类型
  • 把运行时期的问题提前到了编译时期,避免了强制类型转换可能出现的异常

泛型类

类似于C++中的模版,在定义泛型类时,要在类名后面加 <> ,尖括号里面写的就是你要使用的泛型的类型名(类型参数),可以随便取,但一般使用大写字母 T E ,如果类中有多个泛型,用逗号隔开即可

定义

public class Score<T>{
	String name;
	String id;
	T grade;
}

创建

仍然使用 new 创建,但是要注意,要在类名后加上 <> ,尖括号内写上使用泛型的数据类型,并且 new 后面的类名也要加上 <> ,此时尖括号内的数据类型可写可不写

Score s1<Integer> = new Score<>("数据结构与算法","CS03",95);
Score s2<String> = new Score<>("计算机组成原理","CS05","优秀");

注意

  1. 类中的静态方法不能使用泛型变量

泛型将数据类型的确定控制在了编译阶段,在编写代码的时候就能明确泛型的类型,如果类型不符合,将无法通过编译。

所以泛型是在具体使用一个对象时才能确定其数据类型,而类中的静态方法不依附于类,因此不能使用泛型变量

public class Score<T>{
	String name;
	String id;
	T grade;
	
	public static void test(){
		T g1;  //错误
	}
}
  1. 在类中的成员方法中使用泛型变量时,由于其类型还未确定,此时该变量类型为Object类。但也可以通过强转使其变成某一特定的类
  2. 通配符 “?” 表示“某种未知的类型”使用 ? 通配符可创建一个可接收任意类型的泛型对象
Test<?> test;

只要写成 Test<?>,编译器就会把所有跟该未知类型有关的操作“降级”成 Object,防止破坏类型安全:

Object o =test.getvalue();//用Object类的对象接收t的值
  1. 泛型内也可以写泛型
Test<Test<Integer>> test;

泛型与多态

不仅是类,泛型还可以用在抽象类,接口,记录类,密封类,继承中

//抽象类
public abstract class Person<T>{
	T grade;
}
//接口
public interface Study<T>{
	T study();
}
//继承
public class A<T>{T name;}
public class B extends A<String>{}

对于使用泛型的接口,当子类实现该接口时:

  1. 实现类直接给出具体类型
  2. 实现类延续泛型(此时类也要使用泛型):
//直接明确类型
public class Test implements Study<Integer>{
	@Override
	public Integer study(){
		return 1;
	}
}
//继续使用泛型
public class Test<T> implements Study<T>{
	@Override
	public T study(){
		return null;
	}
}

泛型方法

方法中也可以使用泛型,当某个方法(无论是是静态方法还是成员方法)需要接受的参数类型并不确定时,我们也可以使用泛型来表示

<> 的位置放在返回类型前面:

main(){
	test("Hello World"); //输出"Hello World"
	test(1); //输出1
	
	public static <T> void test(T obj){
		sout(T);
	}//返回类型也能写T
}

在很多工具类的方法中,都使用到了泛型方法

例如, Arrays 的排序方法:

Integer[] arr = {1,4,2,5,6};
Arrays.sort(arr,new Comparator<Integer>{
//Comparator为比较器,这里自定义了比较器
	@Override
	public int compare(Integer o1, Integer o2)
		return o1 - o2;
	}
}//在JDK21中,sort不是这样写的

可以自定义sort为从大到小,只需要把 return o1 - o2 改为 return o2 - o1 就行了

为什么?

在刚学的时候我也很迷惑,C中我知道自定义比较器时可以 return o1<o2 ,这样就从小到大了,但是这里返回的是int,要根据返回值 >0 <0 =0 做出排序的判断

我们首先要知道的是,如果返回值

  • 0 ——> o1在后面

  • <0 ——> o1在前面
  • =0 ——> o1,o2顺序不变

因此,根据 return 的不同,就能确定比较函数到底是从小到大还是从大到小

例如,

  • 当返回的是 o1-o2 时,

    如果 o1 = 5 ,o2 = 1,那么o1 - o2 >0,此时o1要放在后面,也就是从小到大了

  • 当返回的是 o2-o1 时,

    如果o1 = 5,o2 = 1,那么o2 - o1<0,此时o1要放在前面,也就是从大到小了

当然,也可以用Lambda表达式简化:

Arrays.sort(arr,(o1,o2) ->(o1 - o2);
//从小到大

注意

如果泛型方法中的返回值为泛型,但是无参数,调用时可以有以下几种方法(记得指明泛型的类型):

public static <T> T test(){
	return null;
}
main(){
	Main.<String>test();//使用Main类调用test方法
	
	String str = test();//可以推断类型
	
	Score score = new Score();
	score.<String>test();//通过类的对象调用
}

泛型的界限

上界

使用泛型时可以通过 extends 关键字规定泛型类只能为某个类或者其子类的类型

例如:

public class Score<T extends Number>{
	T value;
	
	public Score(T value){
		this.value=value;
	}
	public T getValue(){
		return value;
	}
}

此时 T 的类型只能为 Number 或者 Number的子类(因Number 为抽象类,实例化对象时不能用 Number),并且此时 T 还能使用 Number 类中的方法

泛型通配符 ?也支持泛型的界限:

Score<? extends Number> score=new Score(90);

此时使用这个对象的泛型成员:

main(){
	Score<? extends Number> score=new Score(90);
	Number n = score.getalue();
}

下界

使用 super 关键字规定泛型类只能为某个类或者其父类的类型

但是**下界仅适用于通配符,**对于类型变量来说不支持

使用的不多

类型擦除

这部分主要看泛型的底层实现到底是怎么样的

其实,正如我们在案例中所想的一样,泛型在编译之后,确实会使用 Object 类(有上界时就是处于最上方的类)和强制类型转换

例如,对于下面的代码:

public class Test<T>{
	T value;
	public Test(T value){
		this.value=value;
	}	
}
main(){
	Test<String> test = new Test("优秀");
	String str = test.value;
}

编译之后,通过反编译观察为:

public class Test{
	Object value;
	...
}
main(){
	Test test = new Test("优秀");
	String str = (String)test.value;
}

因此,泛型其实仅仅是在编译阶段进行类型检查,当程序在运行时,并不会真的去检查对应类型,所以说哪怕是我们不去指定类型也可以直接使用:

main(){
	Test test = new Test();//默认原始类型Object,不过此时编译器会给出警告
}

不过,我们思考一个问题,既然继承泛型类之后可以明确具体类型,那么为什么@Override不会出现错误呢?我们前面说了,重写的条件是需要和父类的返回值类型和形参一致,而泛型默认的原始类型是Object类型,子类明确后变为其他类型,这显然不满足重写的条件,但是为什么依然能编译通过呢?

public class Derived extends Test<String>{
    @Override
    String test(String s) {
        return null;
    }
}

编译之后通过反编译进行观察,实际上是编译器帮助我们生成了一个桥接方法用于支持重写:

public class Derived extends A {
    
    public Object test(Object obj) {   //这才是重写的桥接方法
        return this.test((String) obj);   //桥接方法调用我们自己写的方法
    }
    
    public String test(String str) {   //我们自己写的方法
        return null;
    }
}

由此带来的限制

类型擦除机制其实就是为了方便使用后面集合类(不然每次都要强制类型转换)同时为了向下兼容(老版本JDK)采取的方案。因此,泛型的使用会有一些限制:

  • 使用 instanceof 类型判断时,不允许使用泛型,只能使用原始类型
Test<String> test = new Test<>();
sout(test instanceof Test<String>);//错误,但不知道为什么IDEA不会报错,<>里面换成别的类型就报错了

原因:泛型使用类型擦除机制。这意味着在编译后,泛型信息会被移除,因此 Score<String> 和 Score<Object> 在运行时被视为相同。这导致在使用 instanceof 时不能使用泛型参数来区分不同的实例。

  • 泛型类型不支持创建参数化类型数组
Test<String>[] test = new Test<String>[10]; //错误

只不过只是把它当做泛型类型的数组还是可以用的:


Test<String>[] test = new Test[10]; //错误
test[0] = new Test<String>("1");

这里 new Test<>中的类型只能写 String 类型

但是由于类型擦除,实际上运行时这个Score数组就是被视为 Score[] 的,为什么只能存储 Score<String> 呢?这是因为:

  • 尽管 Score<String>[] 在运行时被视为 Score[],但这并不意味着可以在这个数组中存储任何类型的 Score 实例。Java 的数组是协变的,但泛型并不是,为了保证类型安全,Java 编译器会在编译期检查类型。(协变和逆变详见下面)
  • 当你尝试将 Score<Integer> 的实例赋值给 Score<String>[] 时,编译器会认为这是一种类型不匹配,因为 Score<Integer> 和 Score<String> 被视为两个不同的类。从类型上讲,Score<Integer> 并不是 Score<String> 的子类,虽然两者都来自于 Score 类。

原始类型可以创建:

Test[] test = new Test[10];

协变、逆变、抗变

引入

当我们有一个 Test 泛型类时:

public class Test<T>{
	T value;
	
	public Test<T>(T value){
		this.value = value;
	}
}

此时定义一个 StringObject类型的 Test 对象:

main(){
	Test<String> test1 = new Test<>("1");
	Test<Object> test2 = new Test<>("2");
}

如果我们想令 test2 = test1 ,是否可以呢?

在前面的数组部分,一个 String 类型的数组是可以赋值给一个 Object 类型的数组的(发生了隐式类型转换)

但是在泛型,这个操作是不成立

为什么?

泛型类型的形变

泛型类型 Test<T> 存在以下几种形变:

  • 协变 (Covariance):因为Integer是Number的子类,所以Test<Integer>同样是Test<Number>的子类,可以直接转换
  • 逆变(Contravariance):跟上面相反,Test<Number>可以直接转换为Test<Integer>,前者是后者的子类
  • 抗变 (Invariant):Test<Integer>Test<Number>没半毛钱关系,无法互相转换
  1. 抗变

而Java的泛型默认是抗变的,因此引入中的例子不成立,而数组是协变的

为什么泛型要设置为抗变?

其实是为了类型安全,这里给出具有协变性质的数组说明:

String[] str = {"AAA","BBB"};
Object[] obj = str;
Object[0] = 666;

这里将String类型的数组赋值给了Object类型的数组,随后又令Object的第一个下标对应内容赋值为 666

但是666为 Integer 类,它并不是 String 类,尽管没有编译错误,程序在运行时会出现ArrayStoreException异常

这就是协变带来的缺点,它可能会导致我们使用一个错误的类型进行存取。

  1. 协变

当我们在使用通配符时,如果规定上界( extends ),就会产生协变性质

Test<? extends Number> test = new Test<>(10);

但是这样存在风险,因为上界是 Number ,所以我们可以使任何其子类的数据存入 test 中:

test.value = 1.5;

导致我们为一个 Integer 类型的变量,赋值了一个 Double 类型的结果,这显然是错误的。

因此,泛型中的协变具有一些限制

    不允许修改或添加任何元素(除了`null`),读取可以正常得到T或其子类型(主要用于读取数据)
  1. 逆变

当我们在使用通配符时,如果规定下界( super ),就会产生逆变性质

Test<? super Number> test = new Test<>(null);

此时,只要是任何其父类都可以直接转换为? super Number

但是此时 Number 的子类其实也是可以赋值的:


Test<? super Number> test = new Test<>(10);

new Score<>(10)

  • 10 → 自动装箱 → 类型为 Integer
  • 菱形推断会推断出完整的泛型实参是 Integer,于是得到 Score<Integer>
  • 然而 Score<Integer> 并不是 Score<? super Number> 的子类型,这里之所以能直接赋值,是因为菱形推断会把实参直接推断成菱形左侧的类型,即编译器看见左侧是 Score<? super Number>,它会 就地推断成 Score<Number>,于是右侧实际上是 new Score<Number>(Integer.valueOf(10)),这样才兼容。

逆变的限制

     允许修改或添加T及其子类,但是读取被限制为只能读取Object类型的结果(主要用于写入数据)

总结

  • 协变: 不允许修改或添加任何元素(除了null),读取可以正常得到T或其子类型(主要用于读取数据)
  • 逆变: 允许修改或添加T及其子类,但是读取被限制为只能读取Object类型的结果(主要用于写入数据)
  • 抗变: 修改和读取均不受限制(读写通用)

(实际上到了后面的集合类中,如果类型为协变或是抗变,某些操作同样会受到限制。)


Author: havenochoice
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source havenochoice !
评论
  TOC