文章目录
- 前言
- 一、泛型概述
- 1. 什么是泛型?为什么要使用泛型?
- 2. 泛型使用场景
- 3. 泛型概述小结
- 二、泛型类
- 1. 泛型类的定义
- 2. 泛型类的使用
- 三、泛型接口
- 四、泛型方法
- 1. 泛型方法的定义
- 2. 泛型方法的使用
- 3. 泛型方法中的类型推断
- 五、类型擦除
- 1. 什么是类型擦除
- 2. 类型擦除的原理
- 3. 类型擦除小结
- 六、泛型通配符
- 1. 泛型的继承
- 2. 泛型通配符的引入
- 3. 什么是泛型通配符
- 4. 上界通配符
- 4.1 的定义
- 4.2 的用法
- 4.3 小结
- 5. 下界通配符
- 5.1 的定义
- 5.2 的用法
- 5.3 小结
- 6. 无限定通配符
- 7. 与 对比
- 8. PECS 原则
- 七、面试题
- 总结
前言
博主将用 CSDN 记录 Java 后端开发学习之路上的经验,并将自己整理的编程经验和知识分享出来,希望能帮助到有需要的小伙伴。
博主也希望和一直在坚持努力学习的小伙伴们共勉!唯有努力钻研,多思考勤动手,方能在编程道路上行至所向。
由于博主技术知识有限,博文中难免会有出错的地方,还望各位大佬包涵并批评指正,博主会及时改正;如果本文对小伙伴你有帮助的话,求求给博主一个赞支持一下,可以一起交流,一起加油!!
参考文章:Java泛型详解,史上最全图文详解 、java 泛型全解 – 绝对最详细 、Java 泛型,你了解类型擦除吗? 、java 泛型详解-绝对是对泛型方法讲解最详细的,没有之一 、Java泛型深入理解
一、泛型概述
1. 什么是泛型?为什么要使用泛型?
泛型,即“参数化类型”。一提到参数,最熟悉的就是定义方法时有形参列表,普通方法的形参列表中,每个形参的数据类型是确定的,而变量是一个参数。在调用普通方法时需要传入对应形参数据类型的变量(实参),若传入的实参与形参定义的数据类型不匹配,则会报错。
那
参数化类型
是什么?以方法的定义为例,在方法定义时,将方法签名中的形参的数据类型
也设置为参数(也可称之为类型参数),在调用该方法时再从外部传入一个具体的数据类型和变量。
泛型的本质是为了将类型参数化, 也就是说在泛型使用过程中,数据类型被设置为一个参数,在使用时再从外部传入一个数据类型;而一旦传入了具体的数据类型后,传入变量(实参)的数据类型如果不匹配,编译器就会直接报错。这种
参数化类型
可以用在类、接口和方法中,分别被称为泛型类、泛型接口、泛型方法。
2. 泛型使用场景
在 ArrayList 集合中,可以放入所有类型的对象,假设现在需要一个只存储了 String 类型对象的 ArrayList 集合。
代码如下:
@ Testpublic void test() {ArrayList list = new ArrayList();list.add("aaa");list.add("bbb");list.add("ccc");for (int i = 0; i < list.size(); i++) {System.out.println((String)list.get(i));}}
- 上面代码没有任何问题,在遍历 ArrayList 集合时,只需将 Object 对象进行向下转型成 String 类型即可得到 String 类型对象。
但如果在添加 String 对象时,不小心添加了一个 Integer 对象,会发生什么?看下面代码:
@ Testpublic void test() {ArrayList list = new ArrayList();list.add("aaa");list.add("bbb");list.add("ccc");list.add(111);for (int i = 0; i < list.size(); i++) {System.out.println((String)list.get(i));}}
输出结果:
- 上述代码在编译时没有报错,但在运行时却抛出了一个
ClassCastException 异常
,其原因是 Integer 对象不能强转为 String 类型。
那如何可以避免上述异常的出现?即我们希望当我们向集合中添加了不符合类型要求的对象时,编译器能直接给我们报错,而不是在程序运行后才产生异常。这个时候便可以使用
泛型
了。
使用泛型代码如下:
@ Testpublic void test() {ArrayList<String> list = new ArrayList<>();list.add("aaa");list.add("bbb");list.add("ccc");list.add(111);// 在编译阶段,编译器会报错for (int i = 0; i < list.size(); i++) {System.out.println((String)list.get(i));}}
- 是一个泛型,其限制了 ArrayList 集合中存放对象的数据类型只能是 String,当添加一个非 String 对象时,编译器会直接报错。这样,我们便解决了上面产生的 ClassCastException 异常的问题(这样体现了泛型的类型安全检测机制)。
3. 泛型概述小结
- 与使用 Object 对象代替一切引用数据类型对象这样简单粗暴方式相比,泛型使得数据类型的类别可以像参数一样由外部传递进来。它提供了一种扩展能力,更符合面向对象开发的软件编程宗旨。
- 当具体的数据类型确定后,泛型又提供了一种
类型安全检测机制
,只有数据类型相匹配的变量才能正常的赋值,否则编译器就不通过。所以说,泛型一定程度上提高了软件的安全性,防止出现低级的失误。 - 泛型提高了程序代码的可读性。在定义泛型阶段(类、接口、方法)或者对象实例化阶段,由于
需要在代码中显式地编写,所以程序员能够快速猜测出代码所要操作的数据类型,提高了代码可读性。
泛型有三种使用方式,分别为:泛型类、泛型接口、泛型方法,下面将正式介绍泛型的相关知识。
二、泛型类
1. 泛型类的定义
(1)类型参数用于类的定义中,则该类被称为泛型类。通过泛型可以完成对一组类的操作对外开放相同的接口。最典型的就是各种容器类,如:List、Set、Map等。
泛型类的基本语法如下:
class 类名称 <泛型标识> {private 泛型标识 /*(成员变量类型)*/ 变量名; .....}}
尖括号 中的 泛型标识被称作是
类型参数
,用于指代任何数据类型。泛型标识是任意设置的(如果你想可以设置为 Hello都行),Java 常见的泛型标识以及其代表含义如下:
T :代表一般的任何类。E :代表 Element 元素的意思,或者 Exception 异常的意思。K :代表 Key 的意思。V :代表 Value 的意思,通常与 K 一起配合使用。S :代表 Subtype 的意思,文章后面部分会讲解示意。
举例如下:
public class Generic<T> { // key 这个成员变量的数据类型为 T, T 的类型由外部传入private T key;// 泛型构造方法形参 key 的类型也为 T,T 的类型由外部传入public Generic(T key) { this.key = key;}// 泛型方法 getKey 的返回值类型为 T,T 的类型由外部指定public T getKey(){ return key;}}
在泛型类中,类型参数定义的位置有三处,分别为:
1.非静态的成员属性类型2.非静态方法的形参类型(包括非静态成员方法和构造器)3.非静态的成员方法的返回值类型
(2)泛型类中的静态方法和静态变量不可以使用泛型类所声明的类型参数
代码如下:
public class Test<T> {public static T one; // 编译错误public static T show(T one){ // 编译错误return null;}}
泛型类中的类型参数的确定是在创建泛型类对象的时候(例如 ArrayList)。
而静态变量和静态方法在类加载时已经初始化,直接使用类名调用;在泛型类的类型参数未确定时,静态成员有可能被调用,因此泛型类的类型参数是不能在静态成员中使用的。
(3)静态泛型方法中可以使用自身的方法签名中新定义的类型参数(即泛型方法,后面会说到),而不能使用泛型类中定义的类型参数。
代码如下:
public class Test2<T> { // 泛型类定义的类型参数 T 不能在静态方法中使用public static <E> E show(E one){ // 这是正确的,因为 E 是在静态方法签名中新定义的类型参数return null;}}
(4)泛型类不只接受一个类型参数,它还可以接受多个类型参数。
代码如下:
public class MultiType <E,T> {E value1;T value2;public E getValue1(){return value1;}public T getValue2(){return value2;}}
2. 泛型类的使用
在创建泛型类的对象时,必须指定类型参数 T 的具体数据类型,即尖括号 中传入的什么数据类型,T 便会被替换成对应的类型。如果 中什么都不传入,则默认是
假设有个泛型类如下:
public class Generic<T> {private T key;public Generic(T key) { this.key = key;}public T getKey(){ return key;}}
当创建一个 Generic 类对象时,会向尖括号 中传入具体的数据类型。
代码如下:
@ Testpublic void test() {Generic<String> generic = new Generic<>();// 传入 String 类型// 中什么都不传入,等价于 GenericGeneric generic = new Generic();}
传入 String 类型时,原泛型类可以想象它会自动扩展,其类型参数会被替换。
扩展如下:
public class Generic {private String key;public Generic(String key) { this.key = key;}public String getKey() { return key;}}
- 可以发现,泛型类中的
类型参数 T
被 中的 String 类型全部替换了。 - 使用泛型的上述特性便可以在集合中限制添加对象的数据类型,若集合中添加的对象与指定的泛型数据类型不一致,则编译器会直接报错,这也是泛型的类型安全检测机制的实现原理。
三、泛型接口
泛型接口和泛型类的定义差不多,基本语法如下:
public interface 接口名<类型参数> {...}
举例如下:
public interface Inter<T> {public abstract void show(T t) ;}
重要!泛型接口中的类型参数,在该接口被继承或者被实现时确定。解释如下:
(1)定义一个泛型接口如下:
- 注意:在泛型接口中,静态成员也不能使用泛型接口定义的类型参数。
interface IUsb<U, R> {int n = 10;U name;// 报错! 接口中的属性默认是静态的,因此不能使用类型参数声明R get(U u);// 普通方法中,可以使用类型参数void hi(R r);// 抽象方法中,可以使用类型参数// 在jdk8 中,可以在接口中使用默认方法, 默认方法可以使用泛型接口的类型参数default R method(U u) {return null;}}
(2)定义一个接口 IA 继承了 泛型接口 IUsb,在 接口 IA 定义时必须确定泛型接口 IUsb 中的类型参数。
代码如下:
// 在继承泛型接口时,必须确定泛型接口的类型参数interface IA extends IUsb<String, Double> {...}// 当去实现 IA 接口时,因为 IA 在继承 IUsu 接口时,指定了类型参数 U 为 String,R 为 Double// 所以在实现 IUsb 接口的方法时,使用 String 替换 U,用 Double 替换 Rclass AA implements IA {@Overridepublic Double get(String s) {return null;}@Overridepublic void hi(Double d) {...}}
(3)定义一个类 BB 实现了 泛型接口 IUsb,在 类 BB 定义时需要确定泛型接口 IUsb 中的类型参数。
代码如下:
// 实现接口时,需要指定泛型接口的类型参数// 给 U 指定 Integer, 给 R 指定了 Float// 所以,当我们实现 IUsb 方法时,会使用 Integer 替换 U, 使用 Float 替换 Rclass BB implements IUsb<Integer, Float> {@Overridepublic Float get(Integer integer) {return null;}@Overridepublic void hi(Float afloat) {...}}
(4)定义一个类 CC 实现了 泛型接口 IUsb 时,若是没有确定泛型接口 IUsb 中的类型参数,则默认为 Object。
代码如下:
// 实现泛型接口时没有确定类型参数,则默认为 Object// 建议直接写成 IUsbclass CC implements IUsb {//等价 class CC implements IUsb@Overridepublic Object get(Object o) {return null;}@Overridepublic void hi(Object o) {...}}
(5)定义一个类 DD 实现了 泛型接口 IUsb 时,若是没有确定泛型接口 IUsb 中的类型参数,也可以将 DD 类也定义为泛型类,其声明的类型参数必须要和接口 IUsb 中的类型参数相同。
代码如下:
// DD 类定义为 泛型类,则不需要确定 接口的类型参数// 但 DD 类定义的类型参数要和接口中类型参数的一致class DD<U, R> implements IUsb<U, R> { ...}
四、泛型方法
1. 泛型方法的定义
当在一个方法签名中的返回值前面声明了一个 时,该方法就被声明为一个泛型方法
。表明该方法声明了一个类型参数 T,并且这个类型参数 T 只能在该方法中使用。当然,泛型方法中也可以使用泛型类中定义的泛型参数
。
基本语法如下:
public <类型参数> 返回类型 方法名(类型参数 变量名) {...}
(1)只有在方法签名中声明了的方法才是泛型方法,仅使用了泛型类定义的类型参数的方法并不是泛型方法。
举例如下:
public class Test<U> {// 该方法只是使用了泛型类定义的类型参数,不是泛型方法public void testMethod(U u){System.out.println(u);}// 真正声明了下面的方法是一个泛型方法public <T> T testMethod1(T t){return t;}}
(2)泛型方法中可以同时声明多个类型参数。
举例如下:
public class TestMethod<U> {public <T, S> T testMethod(T t, S s) {return null;}}
(3)泛型方法中也可以使用泛型类中定义的泛型参数。
举例如下:
public class TestMethod<U> {public <T> U testMethod(T t, U u) {return u;}}
(4)特别注意的是:泛型类中定义的类型参数和泛型方法中定义的类型参数是相互独立的,它们一点关系都没有。
举例如下:
public class Test<T> {public void testMethod(T t) {System.out.println(t);}public <T> T testMethod1(T t) {return t;}}
上面代码中,Test 是泛型类,
testMethod()
是泛型类中的普通方法,其使用的类型参数是与泛型类中定义的类型参数。
而testMethod1()
是一个泛型方法,他使用的类型参数是与方法签名中声明的类型参数。
虽然泛型类中定义的类型参数标识和泛型方法中定义的类型参数标识都为,但它们彼此之间是相互独立的。也就是说,泛型方法始终以自己声明的类型参数为准。
注意事项:
1. 表明该方法声明了一个类型参数 T,并且这个类型参数 T 只能在该方法中使用。2. 为了避免混淆,如果在一个泛型类中存在泛型方法,那么两者的类型参数最好不要同名。3. 与泛型类的类型参数定义一样,此处泛型方法中的 T 可以写为`任意标识`,常见的如 T、E、K、V 等形式的参数常用于表示泛型。
补充一点:将静态方法声明为泛型方法
前面在泛型类的定义中提到,在静态成员中不能使用泛型类定义的类型参数,但我们可以将静态成员方法定义为一个泛型方法。
代码如下:
public class Test2<T> { // 泛型类定义的类型参数 T 不能在静态方法中使用// 但可以将静态方法声明为泛型方法,方法中便可以使用其声明的类型参数了public static <E> E show(E one) { return null;}}
2. 泛型方法的使用
泛型类,在创建类的对象的时候确定类型参数的具体类型;
泛型方法,在调用方法的时候再确定类型参数的具体类型。
泛型方法签名中声明的类型参数只能在该方法里使用,而泛型接口、泛型类中声明的类型参数则可以在整个接口、类中使用。
当调用泛型方法时,根据外部传入的实际对象的数据类型,编译器
就可以判断出类型参数 T
所代表的具体数据类型。
举例如下:
public class Demo {public static void main(String args[]) {GenericMethod d = new GenericMethod(); // 创建 GenericMethod 对象String str = d.fun("汤姆"); // 给GenericMethod中的泛型方法传递字符串int i = d.fun(30);// 给GenericMethod中的泛型方法传递数字,自动装箱System.out.println(str); // 输出 汤姆System.out.println(i);// 输出 30GenericMethod.show("Lin");// 输出: 静态泛型方法 Lin}}class GenericMethod {// 普通的泛型方法public <T> T fun(T t) { // 可以接收任意类型的数据return t;} // 静态的泛型方法public static <E> void show(E one){System.out.println("静态泛型方法 " + one);}}
- 不难发现,当调用泛型方法时,根据传入的实际对象,
编译器
会判断出类型形参 T 所代表的具体数据类型。
3. 泛型方法中的类型推断
在调用泛型方法的时候,可以显式地指定类型参数,也可以不指定。
- 当泛型方法的形参列表中有多个类型参数时,在不指定类型参数的情况下,方法中声明的的类型参数为泛型方法中的几种类型参数的共同父类的最小级,直到 Object。
- 在指定了类型参数的时候,传入泛型方法中的实参的数据类型必须为指定数据类型或者其子类。
举例如下:
public class Test {// 这是一个简单的泛型方法public static <T> T add(T x, T y) {return y;}public static void main(String[] args) {// 一、不显式地指定类型参数//(1)传入的两个实参都是 Integer,所以泛型方法中的 == int i = Test.add(1, 2);//(2)传入的两个实参一个是 Integer,另一个是 Float,// 所以取共同父类的最小级, == Number f = Test.add(1, 1.2);// 传入的两个实参一个是 Integer,另一个是 String,// 所以取共同父类的最小级, == Object o = Test.add(1, "asd");// 二、显式地指定类型参数//(1)指定了 = ,所以传入的实参只能为 Integer 对象int a = Test.<Integer>add(1, 2);//(2)指定了 = ,所以不能传入 Float 对象int b = Test.<Integer>add(1, 2.2);// 编译错误//(3)指定 = ,所以可以传入 Number 对象// Integer 和 Float 都是 Number 的子类,因此可以传入两者的对象Number c = Test.<Number>add(1, 2.2); }}
五、类型擦除
1. 什么是类型擦除
泛型的本质是将数据类型参数化
,它通过擦除的方式来实现,即编译器会在编译期间擦除
代码中的所有泛型语法并相应的做出一些类型转换动作。
换而言之,泛型信息只存在于代码编译阶段,在代码编译结束后,与泛型相关的信息会被擦除掉,专业术语叫做类型擦除
。也就是说,成功编译过后的 class 文件中不包含任何泛型信息,泛型信息不会进入到运行时阶段
。
看一个例子,假如我们给 ArrayList 集合传入两种不同的数据类型,并比较它们的类信息。
代码如下:
public class GenericType {public static void main(String[] args) {ArrayList<String> arrayString = new ArrayList<String>(); ArrayList<Integer> arrayInteger = new ArrayList<Integer>(); System.out.println(arrayString.getClass() == arrayInteger.getClass());// true}}
在这个例子中,我们定义了两个 ArrayList 集合,不过一个是 ArrayList,只能存储字符串。一个是 ArrayList,只能存储整型对象。我们通过 arrayString 对象和 arrayInteger 对象的
getClass()
方法获取它们的类信息并比较,发现结果为true
。明明我们在 中传入了两种不同的数据类型,按照上文所说的,它们的类型参数 T 不是应该被替换成我们传入的数据类型了吗,那为什么它们的类信息还是相同呢? 这是因为,在编译期间,所有的
泛型信息
都会被擦除, ArrayList 和 ArrayList类型,在编译后都会变成ArrayList
类型。
再看一个例子,假设定义一个泛型类如下:
public class Caculate<T> {private T num;}
在该泛型类中定义了一个属性 num,该属性的数据类型是泛型类声明的类型参数 T ,这个 T 具体是什么类型,我们也不知道,它只与外部传入的数据类型有关。将这个泛型类反编译。
代码如下:
public class Caculate {public Caculate() {}// 默认构造器,不用管private Object num;// T 被替换为 Object 类型}
- 可以发现编译器
擦除
了 Caculate 类后面的泛型标识 ,并且将 num 的数据类型替换为 Object 类型,而替换了 T 的数据类型我们称之为原始数据类型
。
那么是不是所有的类型参数被擦除后都以 Object 类进行替换呢?
- 答案是否定的,大部分情况下,类型参数 T 被擦除后都会以 Object 类进行替换;而有一种情况则不是,那就是使用到了 extends 和 super 语法的
有界类型参数
(即泛型通配符
,后面我们会详细解释)。
再看一个例子,假设定义一个泛型类如下:
public class Caculate<T extends Number> {private T num;}
将其反编译:
public class Caculate {public Caculate() {}// 默认构造器,不用管private Number num;}
可以发现,使用到了 extends 语法的类型参数 T 被擦除后会替换为 Number 而不再是 Object。
extends 和 super 是一个限定类型参数边界的语法,extends 限定 T 只能是 Number 或者是 Number 的子类。 也就是说,在创建 Caculate 类对象的时候,
尖括号 中只能传入 Number 类或者 Number 的子类的数据类型
,所以在创建 Caculate 类对象时无论传入什么数据类型,Number 都是其父类,于是可以使用 Number 类作为 T 的原始数据类型
,进行类型擦除
并替换。(这一部分涉及到了泛型通配符,在下面还会具体介绍)
2. 类型擦除的原理
假如我们定义了一个 ArrayList 泛型集合,若向该集合中插入 String 类型的对象,不需要运行程序,编译器就会直接报错。这里可能有小伙伴就产生了疑问:
不是说泛型信息在编译的时候就会被擦除掉吗?那既然泛型信息被擦除了,如何保证我们在集合中只添加指定的数据类型的对象呢?
换而言之,我们虽然定义了 ArrayList 泛型集合,但其泛型信息最终被擦除后就变成了
ArrayList
,那为什么不允许向其中插入 String 对象呢?
Java 是如何解决这个问题的?
- 其实在创建一个泛型类的对象时, Java 编译器是先检查代码中传入 的数据类型,并记录下来,然后再对代码进行编译,
编译的同时进行类型擦除
;如果需要对被擦除了泛型信息的对象进行操作,编译器会自动将对象进行类型转换。
可以把泛型的
类型安全检查机制
和类型擦除
想象成演唱会的验票机制:以 ArrayList 泛型集合为例。
- 当我们在创建一个 ArrayList 泛型集合的时候,ArrayList 可以看作是演唱会场馆,而就是场馆的验票系统,Integer 是验票系统设置的门票类型;
- 当验票系统设置好为后,只有持有 Integer 门票的人才可以通过验票系统,进入演唱会场馆(集合)中;若是未持有 Integer 门票的人想进场,则验票系统会发出警告(编译器报错)。
- 在通过验票系统时,门票会被收掉(类型擦除),但场馆后台(JVM)会记录下观众信息(泛型信息)。
- 进场后的观众变成了没有门票的普通人(原始数据类型)。但是,在需要查看观众的信息时(操作对象),场馆后台可以找到记录的观众信息(编译器会自动将对象进行类型转换)。
举例如下:
public class GenericType {public static void main(String[] args) {ArrayList<Integer> arrayInteger = new ArrayList<Integer>();// 设置验票系统 arrayInteger.add(111);// 观众进场,验票系统验票,门票会被收走(类型擦除)Integer n = arrayInteger.get(0);// 获取观众信息,编译器会进行强制类型转换System.out.println(n);}}
擦除 ArrayList 的泛型信息后,get() 方法的返回值将返回 Object 类型,但编译器会自动插入 Integer 的强制类型转换。也就是说,编译器把 get() 方法调用翻译为两条字节码指令:
- 对原始方法 get() 的调用,返回的是 Object 类型;
- 将返回的 Object 类型强制转换为 Integer 类型;
代码如下:
Integer n = arrayInteger.get(0);// 这条代码底层如下://(1)get() 方法的返回值返回的是 Object 类型Object object = arrayInteger.get(0);//(2)编译器自动插入 Integer 的强制类型转换Integer n = (Integer) object;
3. 类型擦除小结
泛型信息(包括泛型类、接口、方法)只在代码编译阶段存在
,在代码成功编译后,其内的所有泛型信息都会被擦除,并且类型参数 T 会被统一替换为其原始类型
(默认是 Object 类,若有 extends 或者 super 则另外分析);在泛型信息被擦除后,若还需要使用到对象相关的泛型信息,编译器底层会
自动进行类型转换
(从原始类型转换为未擦除前的数据类型)。
六、泛型通配符
1. 泛型的继承
在介绍泛型通配符之前,先提出一个问题,在 Java 的多态中,我们知道可以将一个子类对象赋值给其父类的引用,这也叫向上转型
。
举例如下:
public class GenericType {public static void main(String[] args) { List list = new ArrayList();}}
- 上面的代码很好得体现了 Java 的多态特性。
在 Java 标准库中的集合 ArrayList 类实现了 List接口,其源码大致如下:
public class ArrayList implements List {...}
那现在我们思考一个问题,在 ArrayList 泛型集合中,当传入 中的数据类型相同时,是否还能将一个 ArrayList 对象赋值给其父类的引用 List。
代码如下:
public class GenericType {public static void main(String[] args) { List<Integer> list = new ArrayList<Integer>();}}
- 上面的代码没有问题, 即 ArrayList 对象可以向上转型为 List,但两者传入 中的数据类型必须相同。
继续思考一个问题,已知 Integer 类是 Number 类的子类,那如果 ArrayList 泛型集合中,在 之间使用
向上转型
,也就是将 ArrayList 对象赋值给 List 的引用,是否被允许呢?
举例如下:
public class GenericType {public static void main(String[] args) { List<Number> list01 = new ArrayList<Integer>();// 编译错误 ArrayList<Number> list02 = new ArrayList<Integer>();// 编译错误}}
- 上面代码会报错,我们发现并不能把 ArrayList 对象赋值给 List的引用,甚至不能把 ArrayList 对象赋值给 ArrayList的引用。
这也说明了在一般泛型中,不能向上转型
。
这是为什么?如果我们假设 ArrayList可以向上转型为 ArrayList。
观察下面代码:
public class GenericType {public static void main(String[] args) { // 创建一个 ArrayList 集合ArrayList<Integer> integerList = new ArrayList<>();// 添加一个 Integer 对象integerList.add(new Integer(123));// “向上转型”为 ArrayListArrayList<Number> numberList = integerList;// 添加一个 Float 对象,Float 也是 Number 的子类,编译器不报错numberList.add(new Float(12.34));// 从 ArrayList 集合中获取索引为 1 的元素(即添加的 Float 对象):Integer n = integerList.get(1); // ClassCastException,运行出错}}
- 当我们把一个 ArrayList 向上转型为 ArrayList 类型后,这个 ArrayList 集合就可以接收 Float 对象了,因为 Float 类是 Number 类的子类。
- 但是,ArrayList 实际上和 ArrayList 是同一个集合,而在泛型的定义中, ArrayList 集合是不可以接收 Float 对象的。这是因为,在使用 get() 方法获取集合元素的时候,编译器会自动将 Float 对象强转成 Integer 对象,而这会产生
ClassCastException 异常
。
正因如此,编译器为了避免发生这种错误,根本就不允许把 ArrayList对象向上转型为 ArrayList;换而言之, ArrayList 和 ArrayList 两者之间没有继承关系。
2. 泛型通配符的引入
我们上面讲到了泛型的继承关系,ArrayList 不是 ArrayList 的子类。
(1)先看一个问题:假设我们定义了一个 Pair类,如下:
public class Pair<T> {private T first;private T last;public Pair(T first, T last) {this.first = first;this.last = last;}public T getFirst() {return first;}public T getLast() {return last;}public void setFirst(T first) {this.first = first;}public void setLast(T last) {this.last = last;}}
(2)然后,我们针对 Pair类型写了一个静态方法,它接收的参数类型是 Pair。
代码如下:
public class PairHelper {static int addPair(Pair<Number> p) {Number first = p.getFirst();Number last = p.getLast();return first.intValue() + last.intValue();}}
(3)在测试类中创建一个 Pair 对象,并调用 addPair() 方法。
代码如下:
public class Main {public static void main(String[] args) {Pair<Number> pair = new Pair<>(1, 2); int sum = PairHelper.addPair(pair);}}
(4)上面的代码正常编译运行。但我们发现,在实际创建 Pair 对象的时候,我们传入的实参 (1, 2) 实际上是 Integer 类型;那我们是否可以直接创建一个 Pair 对象,并将其传给 add() 方法呢?
代码如下:
public class Main {public static void main(String[] args) {Pair<Integer> pairInteger = new Pair<>(123, 456); int sum = PairHelper.addPair(pairInteger);}}
- 编译器会直接报错,
原因是 Pair 并不是 Pair 的子类
,而 addPair() 方法的形参数据类型为 Pair。因此, Pair 对象不能传给 addPair() 方法。**
那有没有办法使得 addPair() 方法可以接收 Pair 对象?总不能重新定义一个新的 addPair() 方法来处理 Pair 对象吧,这显然与 Java 中的多态理念相违背。
- 因此我们需要一个在逻辑上可以表示为 Pair 和 Pair 这两者的父类引用类型,由此,泛型通配符便应运而生。
3. 什么是泛型通配符
在现实编码中,确实有这样的需求,希望泛型能够处理某一类型范围内
的类型参数,比如某个泛型类和它的子类,为此 Java 引入了泛型通配符
这个概念。
泛型通配符有 3 种形式:
- 4. 上界通配符
4.1 的定义
上界通配符
:T 代表了类型参数的上界,
表示类型参数的范围是 T 和 T 的子类。需要注意的是:
也是一个数据类型实参,它和 Number、String、Integer 一样都是一种实际的数据类型。
(1)在
泛型的继承
中我们说到,ArrayList 和 ArrayList 之间不存在继承关系。而引入上界通配符
的概念后,我们便可以在逻辑上将 ArrayList 看做是 ArrayList 的父类,但实质上它们之间没有继承关系。举例如下:
public class GenericType {public static void main(String[] args) {ArrayList<Number> list01 = new ArrayList<Integer>();// 编译错误ArrayList<? extends Number> list02 = new ArrayList<Integer>();// 编译正确}}
- 逻辑上可以将 ArrayList 看做是 ArrayList 的父类,因此,在使用了上界通配符 后,便可以将 ArrayList 对象
向上转型
了。(2)ArrayList 可以代表 ArrayList、ArrayList、… 、ArrayList中的
某一个集合
,但我们不能指定 ArrayList 的数据类型。(这里有点难理解)举个例子:
public class GenericType {public static void main(String[] args) {ArrayList<? extends Number> list = new ArrayList<>();list.add(new Integer(1));// 编译错误list.add(new Float(1.0));// 编译错误}}
可以这样理解,ArrayList 集合表示了:我这个集合可能是 ArrayList 集合,也可能是 ArrayList 集合,… ,还可能是 ArrayList 集合;但到底是哪一个集合,不能确定;程序员也不能指定。
所以,在上面代码中,创建了一个 ArrayList 集合 list,但我们并不能往 list 中添加 Integer、Float 等对象,这也说明了 list 集合并不是某个确定了数据类型的集合。
思考:那既然 ArrayList 可以代表 ArrayList 或 ArrayList,为什么不能向其中加入 Integer、Float 等对象呢?
- 其原因是 ArrayList 表示的是一个
未知类型的 ArrayList 集合
,它可以代表 ArrayList或 ArrayList… 等集合,但却不能确定它到底是 ArrayList 还是 ArrayList 集合。- 因此,
泛型的特性
决定了不能往 ArrayList 集合中加入 Integer 、 Float 等对象,以防止在获取 ArrayList 集合中元素的时候,产生ClassCastException 异常
。那为什么还需要引入
上界统配符
的概念?—- 答:是为了拓展方法形参中类型参数的范围。(1)在
泛型通配符的引入
部分,我们提出了一个问题,有没有办法使得 addPair(Pair p) 方法接收 Pair 对象?而在有了上界通配符的概念后,这个问题便有了解决办法,就是将 addPair() 方法改写。代码如下:
// 改写前public class PairHelper {static int addPair(Pair<Number> p) {Number first = p.getFirst();Number last = p.getLast();return first.intValue() + last.intValue();}}// 改写后public class PairHelper {static int addPair(Pair<? extends Number> p) {Number first = p.getFirst();Number last = p.getLast();return first.intValue() + last.intValue();}}
改写 addPair() 方法,用
替换了 ,由于 Pair 可以
向上转型
为 Pair ,所以调用 addPair() 方法时,我们便可以传入 Pair 对象了。除了可以传入 Pair 对象,我们还可以传入 Pair 对象,Pair 对象等等,因为 Double 类和 BigDecimal 类也都是 Number 的子类。
4.2 的用法
上面说到,我们无法确定 ArrayList 具体是什么数据类型的集合,因此其 add() 方法会受限(即不能往集合中添加任何数据类型的对象);但是可以往集合中添加 null,因为 null 表示任何类型。
我们可以调用 get() 方法从集合中获取元素,并赋值给集合中的最高父类 Number (
即 的上界
)。(1)上界通配符 的正确用法:
public class Test {public static void main(String[] args) {// 创建一个 ArrayList 集合ArrayList<Integer> integerList = new ArrayList<>();integerList.add(1);integerList.add(2);// 将 ArrayList 传入 printIntVal() 方法printIntVal(integerList);// 创建一个 ArrayList 集合ArrayList<Float> floatList = new ArrayList<>();floatList.add((float) 1.0);floatList.add((float) 2.0);// 将 ArrayList 传入 printIntVal() 方法printIntVal(floatList);}public static void printIntVal(ArrayList<? extends Number> list) { // 遍历传入的集合,并输出集合中的元素 for (Number number : list) {System.out.print(number.intValue() + " ");}System.out.println();}}
输出如下:
在 printIntVal() 方法中,其形参为 ArrayListpublic class Test {public static void main(String[] args) {ArrayList<? extends Number> list = new ArrayList();list.add(null);// 编译正确list.add(new Integer(1));// 编译错误list.add(new Float(1.0));// 编译错误}public static void fillNumList(ArrayList<? extends Number> list) {list.add(new Integer(0));//编译错误list.add(new Float(1.0));//编译错误list.set(0, new Integer(2));// 编译错误list.set(0, null);// 编译成功,但不建议这样使用}}
- 在 ArrayList 集合中,不能添加任何数据类型的对象,只能添加空值 null,因为 null 可以表示任何数据类型。
4.3 小结
一句话总结:使用 extends 通配符表示可以读,不能写。
5. 下界通配符
5.1 的定义
下界通配符
:T 代表了类型参数的下界,
表示类型参数的范围是 T 和 T 的超类,直至 Object。需要注意的是:
也是一个数据类型实参,它和 Number、String、Integer 一样都是一种实际的数据类型。
(1)ArrayList 在逻辑上表示为 Integer 类以及 Integer 类的所有父类,它可以代表 ArrayList、ArrayList、 ArrayList
举个例子:
public class GenericType {public static void main(String[] args) {ArrayList<Integer> list01 = new ArrayList<Number>();// 编译错误ArrayList<? super Integer> list02 = new ArrayList<Number>();// 编译正确}}
- 逻辑上可以将 ArrayList 看做是 ArrayList 的父类,因此,在使用了下界通配符 后,便可以将 ArrayList 对象
向上转型
了。(2)ArrayList 只能表示指定类型参数范围中的
某一个集合
,但我们不能指定 ArrayList 的数据类型。(这里有点难理解)看一个例子:
public class GenericType {public static void main(String[] args) {ArrayList<? super Number> list = new ArrayList<>();list.add(new Integer(1));// 编译正确list.add(new Float(1.0));// 编译正确// Object 是 Number 的父类 list.add(new Object());// 编译错误}}
这里奇怪的地方出现了,为什么和ArrayList 集合不同, ArrayList 集合中可以添加 Number 类及其子类的对象呢?
其原因是, ArrayList 的下界是 ArrayList 。因此,我们可以确定 Number 类及其子类的对象自然可以加入 ArrayList 集合中; 而
Number 类的父类对象
就不能加入 ArrayList 集合中了,因为不能确定 ArrayList 集合的数据类型。5.2 的用法
(1)下界通配符 的正确用法:
public class Test {public static void main(String[] args) {// 创建一个 ArrayList 集合ArrayList<Number> list = new ArrayList(); // 往集合中添加 Number 类及其子类对象list.add(new Integer(1));list.add(new Float(1.1));// 调用 fillNumList() 方法,传入 ArrayList 集合fillNumList(list);System.out.println(list);}public static void fillNumList(ArrayList<? super Number> list) {list.add(new Integer(0));list.add(new Float(1.0));}}
输出如下:
- 与带有上界通配符的集合
ArrayListpublic class Test {public static void main(String[] args) {// 创建一个 ArrayList 集合ArrayList<Integer> list = new ArrayList<>();list.add(new Integer(1));// 调用 fillNumList() 方法,传入 ArrayList 集合fillNumList(list);// 编译错误}public static void fillNumList(ArrayList<? super Number> list) {list.add(new Integer(0));// 编译正确list.add(new Float(1.0));// 编译正确// 遍历传入集合中的元素,并赋值给 Number 对象;会编译错误for (Number number : list) {System.out.print(number.intValue() + " ");System.out.println();}// 遍历传入集合中的元素,并赋值给 Object 对象;可以正确编译// 但只能调用 Object 类的方法,不建议这样使用for (Object obj : list) {System.out.println(obj);使用}}}
注意,
ArrayList
代表了 ArrayList、 ArrayList并且,不能将传入集合的元素赋值给 Number 对象,因为传入的可能是 ArrayList
不过,可以将传入集合的元素赋值给 Object 对象,因为 Object 是所有类的父类,不会产生
ClassCastException 异常
,但这样的话便只能调用 Object 类的方法了,不建议这样使用。5.3 小结
一句话总结:使用 super 通配符表示可以写,不能读。
6. 无限定通配符
我们已经讨论了
和
作为方法参数的作用。实际上,Java 的泛型还允许使用无限定通配符,即只定义一个
?
符号。
无界通配符
:
?
代表了任何一种数据类型,能代表任何一种数据类型的只有 null。需要注意的是:也是一个数据类型实参,它和 Number、String、Integer 一样都是一种实际的数据类型。
注意:Object 本身也算是一种数据类型,但却不能代表任何一种数据类型,所以 ArrayList
(1)ArrayList 在逻辑上表示为所有数据类型的父类,它可以代表 ArrayList、ArrayList、ArrayList
中的某一个集合,但实质上它们之间没有继承关系。 举例如下:
public class GenericType {public static void main(String[] args) {ArrayList<Integer> list01 = new ArrayList<>(123, 456);ArrayList<?> list02 = list01; // 安全地向上转型}}
- 上述代码是可以正常编译运行的,因为 ArrayList 在逻辑上是 ArrayList 的父类,可以安全地
向上转型
。(2)ArrayList 既没有上界也没有下界,因此,它可以代表所有数据类型的某一个集合,但我们不能指定 ArrayList 的数据类型。
举例如下:
public class GenericType {public static void main(String[] args) {ArrayList<?> list = new ArrayList<>();list.add(null);// 编译正确Object obj = list.get(0);// 编译正确list.add(new Integer(1));// 编译错误Integer num = list.get(0);// 编译错误}}
- ArrayList 集合的数据类型是不确定的,因此我们只能往集合中添加 null;而我们从 ArrayList 集合中取出的元素,也只能赋值给 Object 对象,不然会产生
ClassCastException 异常
(原因可以结合上界和下界通配符理解)。(3)大多数情况下,可以用类型参数 代替 通配符。
举例如下:
static <?> void isNull(ArrayList<?> list) {...}// 替换如下:static <T> void isNull(ArrayList<T> list) {...}
7. 与 对比
(1)对于,编译器将只允许读操作,不允许写操作。即只可以取值,不可以设值。
(2)对于,编译器将只允许写操作,不允许读操作。即只可以设值(比如 set 操作),不可以取值(比如 get 操作)。
以上两点都是针对于源码里涉及到了类型参数的方法而言的。比如对于 List 而言,不允许的写操作有 add 方法,因为它的方法签名是
boolean add(E e);
,此时这个形参 E 就变成了一个涉及了通配符的类型参数;而不允许的读操作有 get 方法,因为它的方法签名是
E get(int index);
,此时这个返回值 E 就变成了一个涉及了通配符的类型参数。作为方法形参, 类型和 类型的区别在于:
- 允许调用读方法
T get()
获取 T 的引用,但不允许调用写方法set(T)
传入 T 的引用(传入 null 除外)。- 允许调用写方法
set(T)
传入 T 的引用,但不允许调用读方法 Tget()
获取 T 的引用(获取 Object 除外)。先记住上面的结论,我们来看 Java 标准库的 Collections 类定义的 copy() 方法。
(1)copy() 方法的作用是把一个 List 中的每个元素依次添加到另一个 List 中。它的第一个形参是 List,表示
目标 List
,第二个形参是 List,表示源 List
。代码如下:
public class Collections {// 把 src 的每个元素复制到 dest 中:public static <T> void copy(List<? super T> dest, List<? extends T> src) {for (int i = 0; i < src.size(); i++) {// 获取 src 集合中的元素,并赋值给变量 t,其数据类型为 TT t = src.get(i);// 将变量 t 添加进 dest 集合中 dest.add(t);// 添加元素进入 dest 集合中}}}
- 我们可以简单地用 for 循环实现复制。在 for 循环中,我们可以看到,对于 集合 src,我们可以安全地获取
类型参数 T
的引用(即变量 t),而对于 的集合 dest,我们可以安全地传入类型参数 T
的引用。(2)copy() 方法的定义完美地展示了通配符 extends 和 super 的意图:
- copy() 方法内部不会读取 dest,因为不能调用 dest.get() 方法来获取 T 的引用(如果调用则编译器会直接报错)。
- copy() 方法内部也不会修改 src,因为不能调用 src.add(T) 方法(如果调用则编译器会直接报错)。
这是由
编译器检查
来实现的。如果在方法代码中意外修改了 src 集合,或者意外读取了 dest ,就会导致一个编译错误。代码如下:
public class Collections {// 把 src 的每个元素复制到 dest 中:public static <T> void copy(List<? super T> dest, List<? extends T> src) {...// 获取 集合的元素只能赋值给 Object 对象T t = dest.get(0); // 编译错误// 不能向 集合中添加任何类型的对象,除了 nullsrc.add(t); // 编译错误}}
- 根据上面介绍的,获取 集合 dest 的元素后只能赋值给 Object 对象,而不能赋值给其下界类型 T;我们不能向 集合 src 中添加任何类型的对象,除了 null。
(3)copy() 方法的另一个好处是可以安全地把一个 List添加到 List,但是
无法反过来添加
。代码如下:
// 将 List 复制到 ListList<Number> numList = ...;List<Integer> intList = ...;Collections.copy(numList, intList);// 编译正确// 不能将 List 复制到 ListCollections.copy(intList, numList);// 编译错误
- 这个很好理解,List 集合中可能有 Integer、Float 等对象,所以肯定不能复制到List 集合中;而 List 集合中只有 Integer 对象,因此肯定可以复制到 List 集合中。
8. PECS 原则
我们何时使用 extends,何时使用 super 通配符呢?为了便于记忆,我们可以用 PECS 原则:Producer Extends Consumer Super。
即:如果需要
返回 T
,则它是生产者(Producer),要使用 extends 通配符;如果需要写入 T
,则它是消费者(Consumer),要使用 super 通配符。还是以 Collections 的 copy() 方法为例:
public class Collections {public static <T> void copy(List<? super T> dest, List<? extends T> src) {for (int i = 0; i < src.size(); i++) {T t = src.get(i); // src 是 producerdest.add(t); // dest 是 consumer}}}
- 需要返回 T 的 src 是生产者,因此声明为
List
,需要写入 T 的 dest 是消费者,因此声明为List
。七、面试题
1、Java中的泛型是什么 ? 使用泛型的好处是什么?
- 泛型是一种参数化类型的机制。它可以使得代码适用于各种数据类型,从而编写更加通用的代码,例如集合框架。
- 泛型是一种编译时类型确认机制。它提供了代码编译期的类型安全,确保在泛型类型(通常为泛型集合)上只能使用正确类型的对象,避免了在运行时产生
ClassCastException 异常
。2、Java的泛型是如何工作的 ? 什么是类型擦除 ?
- 泛型的正常工作是依赖编译器在编译源码的时候,先进行类型检查,然后进行类型擦除并且在类型参数出现的地方插入强制转换的相关指令实现的。
类型擦除
:编译器在编译时擦除了代码中所有与泛型相关的信息,所以在运行时不存在任何泛型信息。例如 List 类在运行时仅用一个 List 类型来表示。而为什么要进行擦除呢?这是为了避免类型膨胀
。3、什么是泛型中的限定通配符和非限定通配符 ?
- 限定通配符对类型参数的范围进行了限制。有两种限定通配符,一种是
,它通过确保泛型类型必须是
T 的子类
来设定类型参数的上界;另一种是,它通过确保泛型类型必须是
T 的父类
来设定类型参数的下界。- 泛型类型必须使用
限定范围内
的类型来进行初始化,否则会导致编译错误。另一方面表示了非限定通配符,因为 可以用任意数据类型来替代。
4、List 和 List 之间有什么区别 ?
- 这和上一题有联系,有时面试官会用这个问题来评估你对泛型的理解,而不是直接问你什么是限定通配符和非限定通配符。
- 这两个 List 的声明都是限定通配符的例子,List 可以接受任何继承自
T 的类型
的 List,而 List 可以接受任何T 的父类
构成的 List。- 例如:
List
可以接受 List 或 List;List
可以接受 List但不能接受 List。 5、如何编写一个泛型方法,让它能接受泛型参数并返回泛型类型?
- 编写泛型方法并不困难,你需要用泛型类型来替代原始类型,比如使用 T,E,K,V 等被广泛认可的
类型占位符
。泛型方法的例子请参阅 Java 集合类框架,最简单的情况下,一个泛型方法可能会像这样:public class TestMethod<U> {public <T, S> T testMethod(T t, S s) {return null;}}
6、Java 中如何使用泛型编写带有类型参数的类?
- 这是上一道题的延伸,面试官可能会要求你用泛型编写一个类型安全的类,而不是编写一个泛型方法。关键仍然是使用泛型类型来代替
原始类型
,而且要使用 JDK 中采用的类型占位符
。举例如下:public class Generic<T> { // key 这个成员变量的数据类型为 T, T 的类型由外部传入private T key;// 泛型构造方法形参 key 的类型也为 T,T 的类型由外部传入public Generic(T key) { this.key = key;}// 泛型方法 getKey 的返回值类型为 T,T 的类型由外部指定public T getKey(){ return key;}}
7、编写一段泛型程序来实现 LRU 缓存?
- 对于喜欢 Java 编程的人来说这相当于是一次练习。提示,LinkedHashMap 可以用来实现固定大小的 LRU 缓存,当 LRU 缓存已经满了的时候,它会把最老的键值对移出缓存。LinkedHashMap 提供了一个称为 removeEldestEntry() 的方法,该方法会被 put() 和 putAll() 调用来删除最老的键值对。
8、你可以把 List 传递给一个接受 List
参数的方法吗?
- 对任何一个不太熟悉泛型的人来说,这个Java泛型题目看起来令人疑惑,因为乍看起来 String 是 Object 的子类,所以 List 应当可以
向上转型
为 List。但是事实并非如此, List 与 List 之间没有继承关系,真这样做的话会导致编译错误。 List<Object> objectList;List<String> stringList;objectList = stringList;// 编译错误
9、Array 中可以用泛型吗?
- 这可能是 Java 泛型面试题中最简单的一个了,当然前提是你要知道 Array 事实上并不支持泛型,这也是为什么《 Effective Java》 一书中建议使用 List 来代替 Array,因为 List 可以提供编译期的
类型安全保证
,而 Array 却不能。10、Java 中 List
和原始类型 List 之间的区别?
原始类型和
之间的主要区别是,在编译时编译器不会对原始类型进行类型安全检查,却会对泛型类型 进行检查。 通过使用 Object 作为类型参数,可以告知编译器可以接收任何数据类型的对象,比如 String 或 Integer。 这道题的考察点在于对泛型中原始类型
的正确理解。它们之间的第二点区别是,你可以把
任何泛型类型
传递给接收原始类型 List 的方法,但却不能把 List 传递给 List的方法,因为会产生编译错误。举例如下: public class Test {public static void main(String[] args) {// 创建一个 ArrayList 集合List<String> list = new ArrayList(); fillNumList(list);// 编译正确fillObjList(list);// 编译错误}public static void fillList(List list) {...}public static void fillObjList(List<Object> list) {...}}
11、Java 中 List 和 List
之间的区别是什么?
- 这道题跟上一道题看起来很像,实质上却完全不同。List 是一个不确定的未知类型的 List,而 List
是一个确定的 Object 类型的 List。 - List 在逻辑上是所有 List 的父类,你可以把 List、 List 等集合赋值给 List 的引用;而 List
只代表了自己这个泛型集合类,只能把 List 赋值给 List 的引用,但是 List 集合中可以加入任意类型的数据,因为 Object 类是 最高父类
。 举例如下:List<?> listOfAnyType;List<Object> listOfObject = new ArrayList<Object>();List<String> listOfString = new ArrayList<String>();List<Integer> listOfInteger = new ArrayList<Integer>();listOfAnyType = listOfString;// 编译正确listOfAnyType = listOfInteger;// 编译正确listOfObjectType =listOfString;// 编译错误
12、Java 中 List 和原始类型 List 之间的区别。
该题类似于“List
和原始类型 List 之间的区别”。泛型数据类型是 类型安全
的,而且其类型安全是由编译器
保证的,但原始类型 List 却不是类型安全
的。你不能把 String 之外的任何其它类型的对象存入 List 中,而你可以把任何类型的对象存入原始 List 中。使用泛型数据类型你不需要进行
类型转换
,但是对于原始类型,你则需要进行显式的类型转换
。举例如下:List listOfRawTypes = new ArrayList();listOfRawTypes.add("abc");listOfRawTypes.add(123);String item = (String) listOfRawTypes.get(0);// 获取元素时需要显式的类型转换// 编译器不报错,但运行时会产生 ClassCastException异常,因为 Integer不能被转换为 Stringitem = (String) listOfRawTypes.get(1);List<String> listOfString = new ArrayList();listOfString.add("abcd");listOfString.add(1234);// 编译器直接报错item = listOfString.get(0); // 不需要显式的类型转换,编译器会自动转换
总结
博主花了一周多的时间,对 Java 泛型的知识进行详细地整理总结。但是受限于个人的知识水平,导致篇幅可能过长,再加上排版的问题,不知道小伙伴们的最后的观感如何。
但是终于终于把 Java 泛型的知识整理完了(可能还有些遗漏,后面会慢慢补充的)。其中需要完善的地方,等过段时间再回来完善,如果文章中有什么错误和需要修改的地方,还望小伙伴们提出和批评指正,博主一定会尽快更改。谢谢大家!
最后,大家看后有收获的话,就给博主点个赞吧!下篇文章再见~~