Java超详细分析泛型与通配符
作者:未见花闻
题外话: 泛型与通配符是Java语法中比较难懂的两个语法,学习泛型和通配符的主要目的是能够看懂源码,实际使用的不多。
1.泛型
1.1泛型的用法
1.1.1泛型的概念
《Java编程思想》上有这么一句话:一般的类和方法,只能使用具体的类型: 要么是基本类型,要么是自定义的类。如果要编写可以应用于多种类型的代码,这种刻板的限制对代码的束缚就会很大。 所以从Java5开始引入了泛型机制,这个泛型是什么意思呢?由于一般的类和方法只能使用一种具体得类型,这就使代码受到了很大的束缚,比如一个求三个数中最大值的方法,假设一开始方法中的参数列表的类型是Integer
,你从三个整型数据中找出一个最大值没有任何问题,这个程序能够完美地运行,但是你要找三个浮点数中的最大值时,这个程序编译都通不过,这时你可以选择另写一个重载方法将参数列表和实现功能都基于Double
再实现一遍,这样也可以解决问题,但是,你想过一个问题没有,万一有一万甚至一百万种类型需要求三个对象中最大那一个,那应该怎么办,写一百万个重载方法?这是不可能的,为了解决这种类型的问题,引入了泛型,泛型实现了参数化类型的概念,使代码可以应用多种类型,通俗说泛型就是“适用于许多许多类型”的意思。 使用泛型可以将类型作为“参数”传递至类,接口,方法中,这样类和方法就可以具有最广泛的表达能力,不需要因为一个参数不同而去另建类型。
注意:任意的基本类型都不能作为类型参数。
1.1.2泛型类
我们通过一段代码来认识泛型,首先看下面一段不使用代码的泛型的代码:
/** * 不使用泛型 */ class A { } class Print { private A a; public Print(A a) { setA(a); System.out.println(this.a); } public void setA(A a) { this.a = a; } public A getA() { return this.a; } } public class Generic { public static void main(String[] args) { Print print = new Print(new A()); } }
//output:A@1b6d3586
不使用泛型取创建一个类没有任何问题,但是这个类的重用性就不怎么样了,它只能持有类A
的对象,不能持有其他任何类的对象,我们不希望为碰到的每种类型都编写成一个新的类,这是不现实的。我们学习类的时候,知道Object
类是所有类的父类,所以Object
类可以接受所有的类型引用,我们可以让Print
类持有Object
类型的对象。
/** * 使用Object类 */ class B{ } class Print1 { private Object b; public Print1(Object b) { setB(b); System.out.println(this.b); } public void print(Object b) { setB(b); System.out.println(this.b); } public void setB(Object b) { this.b = b; } } public class Generic1 { public static void main(String[] args) { Print1 print1 = new Print1(new B());//打印B类型 int i = 2022; print1.print(i);//打印整型类型 print1.print("这是一个字符串对象!");//打印字符串类型 } }
//output:
//B@1b6d3586
//2022
//这是一个字符串对象!
Print1
可以接收并打印任何类型,但是这并不是我们想要的结果,你想想如果实现的是一个顺序表类,里面是通过一个数组来实现,如果这个数组什么类型都可以接收,那就非常混乱了,取出数据的时候不能确定取出的到底是什么类型的数据,而且取出的数据是Object
类,需要进行强制类型转换,那能不能实现指定类持有什么类型的对象并且编译器能够检查类型的正确性。 泛型就完美实现了这个目的,下面我们将上述代码改写成泛型类,那么首先得知道泛型的语法,泛型类创建语法如下:
class 类名<泛型参数列表> {
权限修饰 泛型参数 变量名;//泛型成员变量
权限修饰 返回值类型 方法名 (参数列表){}//参数列表和返回值类型可以是泛型
}
例如:
class Print2<T> { private T c; public void print(T c) { setC(c); System.out.println(this.c); } public void setC(T c) { this.c = c; } }
泛型类的使用语法如下:
泛型类<类型实参> 变量名; // 定义一个泛型类引用
new 泛型类<类型实参>(构造方法实参); // 实例化一个泛型类对象
例如:
Print2<Integer> print3 = new Print2<Integer>();
使用泛型实现一个类,并使用它:
/** * 使用泛型 */ class C{ } class Print2<T> { private T c; public void print(T c) { setC(c); System.out.println(this.c); } public void setC(T c) { this.c = c; } } public class Generic2{ public static void main(String[] args) { Print2<C> print2 = new Print2<>();//打印C类型 print2.print(new C()); Print2<Integer> print3 = new Print2<>();//打印整型类型 print3.print(2022); Print2<String> print4 = new Print2<>();//打印字符串类型 print4.print("这是一个字符串对象!"); } }
/**
* output:
*C@1b6d3586
* 2022
* 这是一个字符串对象!
*/
类名后的 <T>
代表占位符,表示当前类是一个泛型类。
【规范】类型形参一般使用一个大写字母表示,常用的名称有:
E 表示 Element
K 表示 Key
V 表示 Value
N 表示 Number
T 表示 Type
S, U, V 等等 - 第二、第三、第四个类型
//一个泛型类 class ClassName<T1, T2, ..., Tn> { }
使用泛型类时,指定了这个类的对象持有的类型,则该对象只能接收该类型的对象,传入其他类型对象,编译器会报错,并且接收泛型类中泛型方法的返回值时,不需要进行强制类型转换(向下转型),而使用Object
类需要强制类型转换。
1.1.3类型推导
使用泛型类时,可以通过泛型类型中传入的类型来推导实例化该泛型类时所需的类型参数,换个说法,定义泛型对象时,前面的尖括号内必须指定类型,后面实例化时可以不指定。如:
Print2<Integer> print3 = new Print2<>();//后面尖括号内可省略
1.2裸类型
裸类型其实很好理解,就是一个泛型类,你不去指定泛型对象持有的类型,这样的一个类型就是裸类型。 比如:
public static void main(String[] args) { Print2 print2 = new Print2(); print2.print(2022); print2.print("字符串"); }
//output:
//2022
//字符串
我们不要自己去使用裸类型,裸类型是为了兼容老版本的 API 保留的机制。
1.3擦除机制
1.3.1关于泛型数组
介绍泛型的擦除机制之前,我们先来了解泛型数组·,先说结论,在Java中不允许实例化泛型数组,如果一定要建立一个泛型数组,正确的做法只能通过反射来实现,当然有一个“捷径”可以不使用反射来创建泛型数组。创建的代码如下:
1.通过捷径创建,大部分情况下不会出错。
public class MyArrayList<T> { public T[] elem ; private int usedSize; public MyArrayList(int capacity) { this.elem = (T[])new Object[capacity]; } }
2.通过反射创建,现在只给代码,具体为什么要这么做后续介绍反射再说。
public class MyArrayList<T> { public T[] elem ; private int usedSize; public MyArrayList(Class<T> clazz, int capacity) { this.elem = (T[]) Array.newInstance(clazz, capacity); } }
1.3.2泛型的编译与擦除
我们先来实现一个简单的泛型顺序表,不考虑扩容问题,只实现简单的增删操作,来看看构造方法部分编译后的反汇编。
import java.lang.reflect.Array; public class MyArrayList<T> { public T[] elem ; private int usedSize; public MyArrayList(int capacity) { this.elem = (T[])new Object[capacity]; } public MyArrayList(Class<T> clazz, int capacity) { this.elem = (T[]) Array.newInstance(clazz, capacity); } }
我们发现所有的泛型占位符T
都被擦除替换成Object
了,这就说明Java的泛型机制是在编译期实现的,而泛型机制实现就是通过像这样的擦除机制实现的,并在编译期间完成类型的检查。
我们通过打印持有不同类型的MyArrayList
类来看看,泛型机制到底是不是不会出现在运行期间,如果是的话,打印出的类型都应该是MyArrayList
。
public static void main(String[] args) { MyArrayList<Integer> list1 = new MyArrayList<>(10); MyArrayList<String> list2 = new MyArrayList<>(10); System.out.println(list1); System.out.println(list2); }
/**
* output:
* MyArrayList@1b6d3586
* MyArrayList@4554617c
*/
我们发现打印的类型是一样的,都是MyArrayList
,所以可以得出一个结论,泛型是发生在编译期,泛型的类型检查是在编译期完成的,泛型的实现是通过擦除机制实现的,类后面的占位符都会被擦除,其他的占位符都会被替换成Object
。当然,这是在泛型参数没有指定上界的情况下,如果存在上界,那占位符会擦除成上界的类型或接口,其实没有指定上界,上界默认为Object
,什么是泛型上界,嘘,等一下再说。
根据擦除机制,也能解释为什么Java当中不能实例化泛型数组了,因为泛型数组前面的占位符会被擦除成Object
,实际上是创建一个Object
数组,而Object
数组中什么类型都能放,这就导致取数据时不安全,因为你不能确定数组里面存放的元素全部都是你预期的类型,所以为了安全,Java不允许实例化泛型数组。
1.4泛型的上界
1.4.1泛型的上界
在定义泛型类时,有时需要对传入的类型变量做一定的约束,可以通过类型边界来约束。
class 泛型类名称<类型形参 extends 类型边界> {
...
}
例如:Number
是Integer
,Float
,Double
等相关数字类型的父类。
public class MyArrayList<T extends Number> { }
那么这个MyArrayList
泛型类只能指定持有Number
类以及Number
的子类,像这样就给泛型的类型传参做了约束,这个约束就是泛型的上界,泛型类被类型边界约束时,只能指定泛型类持有类型边界这个类及其子类。
MyArrayList<Integer> list1 = new MyArrayList<>(10);//正确 MyArrayList<Double> list2 = new MyArrayList<>(10);//正确 MyArrayList<String> list3 = new MyArrayList<>(10);//错误,因为String不是Number的子类
1.4.2特殊的泛型上界
假设需要设计一个泛型类,能够找出数组中最大的元素。
class MaxVal<T extends Comparable<T>> { public T max(T[] data) { T max = data[0]; for (int i = 0; i < data.length; i++) { if (max.compareTo(data[i]) < 0) max = data[i]; } return max; } }
由于引用类型的比较需要使用Comparable
接口来判断大小,所以所传入的类需要实现Comparable
接口,上面这个泛型的类型参数的上界是一个特殊的上界,表示所传入的类型必须实现Comparable
接口,不过实现了Comparable
接口的类,那也就是Comparable
的子类了,综上,像这样类似需要通过实现某一个接口来达到预期功能的类型,使用泛型时需指定泛型的上界,并且该传入的类型必须实现该上界接口。
1.4.3泛型方法
有泛型类,那么就一定有泛型接口,泛型方法,其中泛型接口与泛型类的创建和使用是一样的,所以我们重点介绍泛型方法的创建与使用。 创建泛型方法的基本语法:
方法限定符 <类型形参列表> 返回值类型 方法名称(形参列表) { ... }
例如上面实现求数组中最大元素泛型版的方法如下:
class MaxVal<T extends Comparable<T>> { public <T extends Comparable<T>> T max(T[] data) { T max = data[0]; for (int i = 0; i < data.length; i++) { if (max.compareTo(data[i]) < 0) max = data[i]; } return max; } }
对于非static
修饰的静态方法, <类型形参列表>可以省略,上述代码可以变成:
class MaxVal<T extends Comparable<T>> { public T max(T[] data) { T max = data[0]; for (int i = 0; i < data.length; i++) { if (max.compareTo(data[i]) < 0) max = data[i]; } return max; } }
但是,如果是一个static
修饰的静态方法,<类型形参列表>不可以省略,因为静态方法不依赖与对象,它的使用不用实例化对象,所以必须有单独的类型参数列表来指定持有的对象类型。
class MaxVal<T extends Comparable<T>> { public static <T extends Comparable<T>> T max(T[] data) { T max = data[0]; for (int i = 0; i < data.length; i++) { if (max.compareTo(data[i]) < 0) max = data[i]; } return max; } }
1.4.4类型推导
和泛型类一样,泛型方法也有类型推导的机制,如果不使用类型推导,那么泛型方法是这么使用的:
使用类型推导图中画圆圈部分可以省略。
在泛型类中没有如下的父子类关系:
public class MyArrayList<E> { ... } // MyArrayList<Object> 不是 MyArrayList<Number> 的父类型 // MyArrayList<Number> 也不是 MyArrayList<Integer> 的父类型
但是使用通配符这两种类是有符子类关系的。
2.通配符
2.1通配符的概念
?
就是一个通配符,用与泛型的使用,与泛型不同的是,泛型T
是确定的类型,传入类型实参后,它就确定下来了,而通配符更像是一种规定,规定一个范围,表示你能够传哪些参数。 一个泛型类名尖括号之内仅含有一个?
,就会限制这个泛型类传入的类型为Object
,相当于没有限制,但是获取元素时由于不能确定具体类型,只能使用Object
引用接收,所以<?>
也被称为无界通配符。
//使用泛型打印顺序表 public static<T> void printList1(ArrayList<T> list) { for (T x:list) { System.out.println(x); } } //使用通配符打印顺序表 public static void printList2(ArrayList<?> list) { for (Object x:list) { System.out.println(x); } }
使用泛型T
能够确定传入的类型就是T
类型,所以使用T
类型的变量接收,而通配符?
没有设置边界的情况下,默认上界是Object
没有下界,为了保证安全,只能使用Object
类型的变量接收。
通配符是用来解决泛型无法协变的问题的,协变指的就是如果Student
是Person
的子类,那么List<Student>
也应该是List<Person>
的子类。但是泛型是不支持这样的父子类关系的。
2.2通配符的上界
通配符也有上界,可以限制传入的类型必须是上界这个类或者是这个类的子类。
基本语法:
<? extends 上界>
<? extends Number>//可以传入的实参类型是Number或者Number的子类
例如:
public static void printAll(ArrayList<? extends Number> list) { for (Number n: list) { System.out.println(n); } }
我们对printAll
方法的一个形参限制了类型的上界Number
,所以在遍历这个顺序表的时候,需要使用Number
来接收顺序表中的对象,并且使用该方法时,只能遍历输出Number
及其子类的对象。
public static void main(String[] args) { printAll(new ArrayList<Integer>());//ok printAll(new ArrayList<Double>());//ok printAll(new ArrayList<Float>());//ok printAll(new ArrayList<String>());//error }
假设有如下几个类:
class Animal{} class Cat extends Animal{} class Dog extends Animal{} class Bird extends Animal{}
Animal
是Cat
,Dog
,Bird
类的父类,我们来看一看使用泛型和使用通配符在打印对象结果上会有什么区别?我们对这两者都设置了上界,当打印不同的对象时,到底会调用谁的toString
方法。
//泛型 public static <T extends Animal> void printAnimal1(ArrayList<T> list) { for (T animal: list) { System.out.println(animal); } } //通配符 public static void printAnimal2(ArrayList<? extends Animal> list) { for (Animal animal: list) { System.out.println(animal); } }
我们先来看泛型,使用泛型指定类型后,那么指定什么类型,那它就会输出什么类型的对象,比如你指定顺序表中放的类型是Cat
,那么它调用的就是Cat
对象的toString
方法。
public static void main(String[] args) { Cat cat = new Cat(); Dog dog = new Dog(); Bird bird = new Bird(); //泛型 ArrayList<Cat> list1 = new ArrayList<>(); ArrayList<Dog> list2 = new ArrayList<>(); ArrayList<Bird> list3 = new ArrayList<>(); list1.add(cat); list2.add(dog); list3.add(bird); printAnimal1(list1);//Cat printAnimal1(list2);//Dog printAnimal1(list3);//Bird }
再来看一看通配符,使用通配符是规定能够使用Animal
及其子类,不伦你传入哪一个子类对象,都是父类的引用接收,但是具体哪一个子类,并不清楚。
public static void main(String[] args) { Cat cat = new Cat(); Dog dog = new Dog(); Bird bird = new Bird(); //通配符 ArrayList<Cat> list1 = new ArrayList<>(); ArrayList<Dog> list2 = new ArrayList<>(); ArrayList<Bird> list3 = new ArrayList<>(); list1.add(cat); list2.add(dog); list3.add(bird); printAnimal2(list1);//Cat printAnimal2(list2);//Dog printAnimal2(list3);//Bird }
父类引用接收子类对象发生了向上转型,当打印父类引用的子类对象时,会优先使用子类的toString
方法,在介绍多态的时候也讲过这个问题,所以输出结果与使用泛型是一样的,但是泛型和通配符的效果是不一样的,泛型是你传入什么类型,那这个类就会持有什么类型的对象,而通配符是规定一个范围,规定你能够传哪一些类型。
通配符的上界是支持如下的父子类关系的,而泛型的上界不支持:
MyArrayList<? extends Number> 是 MyArrayList <Integer>或者 MyArrayList<Double>的父类类型
MyArrayList<?> 是 MyArrayList<? extends Number> 的父类型
对于通配符的上界有个特点,先说结论,使用通配符上界可以读取数据,但是并不适合写入数据,因为不能确定类所持有的对象具体是什么。
public static void main(String[] args) { ArrayList<Integer> arrayList1 = new ArrayList<>(); ArrayList<Double> arrayList2 = new ArrayList<>(); arrayList1.add(10); List<? extends Number> list = arrayList1; System.out.println(list.get(0));//ok Integer = list.get(0);//error因为不能确定list所持有的对象具体是什么 list.add(2);//error因为不能确定list所持有的对象具体是什么,为了安全,这种情况Java不允许插入元素 }
因为从list
获取的对象类型一定Number
或者Number
的子类,所以可以使用Number
引用来获取元素,但是插入元素时你并不能确定它到底是哪一种类型,为了安全,使用通配符上界的list
不允许插入元素。
2.3通配符的下界
与泛型不同,通配符可以拥有下界,语法层面上与通配符的上界的区别是讲关键字extends
改为super
。
<? super 下界>
<? super Integer>//代表 可以传入的实参的类型是Integer或者Integer的父类类型
既然是下界那么通配符下界与上界对传入类的规定是相反的,即规定一个泛型类只能传入下界的这个类类型或者这个类的父类类型。比如<? super Integer>
代表 可以传入的实参的类型是Integer
或者Integer
的父类类型(如Number
,Object
)
public static void printAll(ArrayList<? super Number> list) { for (Object n: list) { //此处只能使用Object接收,因为传入的类是Number或者是Number的父类 System.out.println(n); } }
public static void main(String[] args) { printAll(new ArrayList<Number>());//ok printAll(new ArrayList<Object>());//ok printAll(new ArrayList<Double>());//error printAll(new ArrayList<String>());//error printAll(new ArrayList<Integer>());//error }
同理通配符的下界也是满足像下面这种父子类关系的。
MyArrayList<? super Integer> 是 MyArrayList<Integer>的父类类型
MyArrayList<?> 是 MyArrayList<? super Integer>的父类类型
总结: ?
是? extends ....
和? super ....
的父类,看通配符之间的父子类关系,最关键的是看通配符所“规定的”范围,判断父子类是根据这个范围来判断的。
通配符的下界也有一个特点,那就是它能够允许写入数据,当然能够写入的数据对象是下界以及下界的子类,但是并不擅长读数据,与通配符的上界相反。
public static void main(String[] args) { ArrayList<? super Animal> list = new ArrayList<Animal>(); ArrayList<? super Animal> list2 = new ArrayList<Cat>();//编译报错,list2只能引用Animal或者Animal父类类型的list list.add(new Animal());//添加元素时,只要添加的元素的类型是Animal或者Animal的子类就可以 list.add(new Cat()); Object s2 = list.get(0);//可以 ArrayList<? super Animal> list3 = new ArrayList<Object>(); Cat s1 = list3.get(0);//error因为构造对象时可以构造Animal父类类型的ArrayList,取出的对象不一定是Animal或者Animal的子类 }
对于这个栗子添加元素时,只要添加的元素的类型是Animal或者Animal的子类就可以,获取元素时,只能使用Object
引用接收,不能使用其他的引用接收,因为因为构造对象时可以构造Animal父类类型的ArrayList,虽然可以插入Animal以及其子类对象,但取出的对象不能保证是Animal或者Animal的子类。
关于泛型和通配符就介绍到这里了,这两个概念是非常抽象而且难懂的,而且用的也不多,因为基本上以后你也没机会用了,因为泛型通配符在编写类似Java或者其他语言源码的时候才用得到,学习泛型通配符的主要目的是能够读懂源码,看得懂其他人写的代码。
到此这篇关于Java超详细分析泛型与通配符的文章就介绍到这了,更多相关Java 泛型 内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!