23、泛型
泛型本质上就是对类型的参数化,在类的定义中,我们可以把类型当做参数,当使用类时,我们向类型参数传入具体类型
泛型在 Java 中是一个非常重要的语法,我们经常用的容器都支持泛型,Java 泛型不支持 int 等基本类型,所以在 Java 容器中不能存储基本类型数据,比如 List<int> 是不合法的
而 C++ 泛型支持 int 等基本类型,所以在 C++ 容器中可以存储基本类型数据,比如 vector<int> 这样是合法的
那么为什么 C++ 泛型支持 int 等基本类型而 Java 泛型不支持呢?带着这个问题,我们来学习本节的内容
1、为什么使用泛型
假设我们要实现一个栈 Stack,并且希望这个栈可以支持不同类型数据的存储,比如 Integer、User 等,如果不使用泛型,我们有两种实现方式
1.1、第一种实现方式
我们为存储不同类型的数据定义不同的 Stack 类,示例如下所示,我们定义了存储 Integer 类型数据的 IntegerStack 和存储 Long 类型数据的 LongStack
支持多少种类型数据的存储,我们就需要定义多少个 Stack 类,这种实现方式的弊端显而易见,需要实现很多功能相似的类,并且编写大量的重复代码
// 存储 Integer 类型数据的 Stack 类 public class IntegerStack { private Integer[] arr; private int top; private int size; public IntegerStack(int size) { this.arr = new Integer[size]; this.size = size; this.top = 0; } public void push(Integer elem) { if (top == size) return; arr[top++] = elem; } public Integer pop() { if (top == 0) return null; return arr[--top]; } } // 存储 Long 类型数据的 Stack 类 public class LongStack { private Long[] arr; private int top; private int size; public LongStack(int size) { this.arr = new Long[size]; this.size = size; this.top = 0; } public void push(Long elem) { if (top == size) return; arr[top++] = elem; } public Long pop() { if (top == 0) return null; return arr[--top]; } }
1.2、第二种实现方式
我们为所有可能存储的类型,抽象出统一的接口或者父类,这样我们只需要针对接口或父类实现一个通用的 Stack 类,所有的子类数据都可以存储到这个通用的 Stack 类
比如栈中存储的是 Integer、Long、Float、Double 等数字类型的数据,这些数字类型的父类是 Number
那么我们只需要设计一个存储 Number 类型数据的 Stack 类,示例代码如下所示
// 支持 Number 的子类类型数据存储 public class NumericStack { private Number[] arr; private int top; private int size; public NumericStack(int size) { this.arr = new Number[size]; this.size = size; this.top = 0; } public void push(Number elem) { if (top == size) return; arr[top++] = elem; } public Number pop() { if (top == 0) return null; return arr[--top]; } }
如果栈中存储的数据的类型不确定,我们可以使用 Object 作为公共的父类来定义 Stack 栈,示例代码如下所示
// 支持任意类型数据存储 public class Stack { private Object[] arr; private int top; private int size; public Stack(int size) { this.arr = new Object[size]; this.size = size; this.top = 0; } public void push(Object elem) { if (top == size) return; arr[top++] = elem; } public Object pop() { if (top == 0) return null; return arr[--top]; } }
1.3、存在的问题
上述实现方式看起来很完美,但在使用时却存在一定的问题,示例代码如下所示,下列代码编译不会报错,但在运行时会抛出 ClassCastException 异常
Stack stack = new Stack(10); stack.push(12); stack.push("34"); // 编译时未作类型检查 Integer num = (Integer) stack.pop(); // 运行时报错
我们本希望在栈中存储整型数据,但在代码编写的过程中,因为疏忽,将字符串存储到了栈中
因为 Stack 支持 Object 类型的数据存储,所以存储字符串到栈中并不会引起编译或者运行时报错,换句话说,编译器没有帮我们做类型检查
父类类型向子类类型转换,需要显式的强制类型转换
当我们使用 pop() 函数将数据从栈中取出时,因为函数的返回值为 Object 类型,我们需要将其转化成 Integer 类型再使用
而执行 pop() 函数返回的是 String 类型的数据,强制类型转换为 Integer 类型失败,于是就抛出了 ClassCastException 运行时异常
总结一下,以上两种实现方式各有利弊,都不完美
- 使用第一种实现方式:需要定义大量相似的类,编写大量的重复代码
- 使用第二种实现方式:无法享受到编译器在编译时的类型检查服务,并且代码中充斥着显式的强制类型转换,也不美观
1.4、解决
于是,为了解决以上问题,JDK 5 引入了泛型语法,泛型继承了上述两种实现方式的优点,又摒弃了两种实现方式的缺点
使用泛型,我们既可以消除重复代码,又可以利用编译器的类型检查,保证类型的安全性
我们使用泛型实现了一个支持各种类型数据存储的栈,代码如下所示
public class Stack<E> { private Object[] arr; // 这里不是 E[] arr, 原因稍后解释 private int top; private int size; public Stack(int size) { this.arr = new Object[size]; this.size = size; this.top = 0; } public void push(E elem) { if (top == size) return; arr[top++] = elem; } public E pop() { if (top == 0) return null; return (E) arr[--top]; } }
当使用上述 Stack 泛型类(Stack<E>)时,我们需要指定类型,将其转化为具体类(比如 Stack<Integer>)
如果往栈中存储不同类型的数据,编译器会在编译时提示类型错误,除此之外,在代码中我们也不需要使用强制类型转换,示例代码如下所示
Stack<Integer> stack = new Stack<>(10); stack.push(12); // stack.push("34"); 去掉注释之后会报编译错误 Integer num = stack.pop(); // 不需要类型转换
根据上面的讲解,我们发现:泛型本质上就是对类型的参数化,在类的定义中,我们可以把类型当做参数,当使用类时,我们向类型参数传入具体类型
2、泛型的基本用法
2.1、泛型接口、泛型类、泛型方法
刚刚我们介绍了泛型的由来,接下来我们详细讲解一下泛型的用法,泛型一般有三种使用方式:泛型接口、泛型类、泛型方法,示例代码如下所示
/** * 泛型接口 */ public interface List<E> { void add(E element); E get(int index); // ... }
/** * 泛型类 */ public class ArrayList<E> implements List<E> { private Object[] arr; // 这里不是 E[] arr, 原因稍后解释 // ... public void add(E element) { // ... } public E get(int index) { // ... } }
/** * 泛型方法 */ public class Collections { public static <T> int binarySearch(List<T> list, T key); // ... }
以上代码中,尖括号内的 E、T 等表示类型参数,实际上它们也可以替换为任意大写字母,不过我们一般习惯性使用 E、T、K、V、N 这几个大写字母来表示类型参数
- E 是 element 的首字母,一般用来表示容器中的元素的类型参数
- T 是 type 的首字母,一般用来表示非容器元素的数据类型参数
- K、V 分别是 key 和 value 的首字母,一般用来表示键值对中键和值的类型参数
- N 是 number 的首字母,一般用来表示数字类型参数
2.2、多个类型参数
当然,一个泛型接口、泛型类、泛型方法中也可以支持多个类型参数,如下所示
public interface Map<K, V> { void put(K key, V value); V get(K key); // ... }
2.3、泛型受限
对于泛型,除了以上基本用法之外,我们还可以使用 extends 上界限定符,限定类型参数的具体取值范围
例如:<T extends Person> 表示限定传入类型参数的具体类型必须是 Person 或者 Person 的子类
需要注意的是,泛型中只有 extends,没有 implements,这里的 extends 既可以表示类型继承,也可以表示接口实现
例如:<T extends Closeable> 表示限定传入类型参数的具体类型必须实现了 Closeable 接口
对于 extends 上界限定符的用法,我们举例解释一下
假设我们希望设计一个泛型方法,用来比较两个对象的大小,这个方法只支持实现了 Comparable 接口的对象进行大小比较
为了实现这个需求,如下代码所示,我们可以使用 extends 关键字,限制 a、b 的类型必须实现 Comparable 接口
// Comparable 接口的定义(泛型接口) public interface Comparable<T> { int compareTo(T o); } // Student public class Student implements Comparable<Student> { public int id; public int age; public Student(int id, int age) { this.id = id; this.age = age; } @Override public int compareTo(Student o) { return this.age - o.age; } }
// Utils 类中定义了一个泛型方法 public class Utils { public <T extends Comparable<T>> T max(T a, T b) { if (a.compareTo(b) <= 0) return a; else return b; } } // 具体使用方法 Student s1 = new Student(2, 13); // Student 类实现了 Comparable 接口 Student s2 = new Student(4, 22); Student maxStu = Utils.max(s1, s2); // 不需要强制类型转换
3、泛型中的通配符
除了类型参数之外,泛型中还有另外一个常用语法:?通配符
通配符跟类型参数的应用场景并不相同
- 类型参数一般用来定义泛型类、泛型接口和泛型方法
- 通配符跟 Integer、Person、String 这些具体类型无异,用来具体化泛型类或泛型接口,可以看做一种特殊的具体类型
当我们在具体化某个泛型类或泛型接口,但又无法指明明确的具体类型时,我们就可以使用通配符这种特殊的具体类型
3.1、示例 1
通配符常用于 "方法参数" 中,当方法中的某个参数为 "泛型类或接口" 时
如果我们无法指定具体的类型,那么就可以使用通配符来表示可以匹配任意类型,示例代码如下所示
reverse() 方法中的 list 对应的类是一个泛型类,在使用时需要传入具体类型,但是这里我们并不知道具体的类型是什么,所以我们就用通配符来替代具体类型
public class Colletions { public static void reverse(List<?> list) { // ... } }
当然,在 reverse() 函数中,我们也可以使用类型参数替代通配符,如下所示,只不过此时的 reverse() 函数便是一个泛型方法
在泛型方法中,方法的前面需要添加 <T> 类型参数声明,而使用通配符定义的 reverse() 函数中,并没有类型参数声明,这是两者的主要区别
public class Colletions { public static <T> void reverse(List<T> list) { // ... } }
3.2、示例 2
关于通配符,我们再来看另一个稍微复杂点的例子,下面的代码是否可以成功执行呢?
public static class Member { } public static class Student extends Member { }
public class Demo { public static void test(List<Member> members) { } public static void main(String[] args) { List<Student> students = new ArrayList<Student>(); test(students); } }
上述代码编译失败,编译器提示 test(students) 这一语句类型不匹配
尽管 Student 是 Member 的子类,但是 List<Student> 跟 List<Member> 并没有任何继承关系,所以将 List<Student> 类型的数据传递给 List<Member> 会报错
解决 1
对于这个问题,我们只需要使用通配符配合 extends 上界限定符即可解决
public class Demo { public static void test(List<? extends Member> members) { } public static void main(String[] args) { List<Student> students = new ArrayList<Student>(); test(students); } }
解决 2
当然,我们也可以使用类型参数替代通配符来解决,代码如下所示,不过从类型参数与通配符的应用场景来看,我们更倾向于使用通配符来实现 test() 方法
public class Demo { public static <T extends Member> void test(List<T> members) { } public static void main(String[] args) { List<Student> students = new ArrayList<Student>(); test(students); } }
3.3、示例 3
如果说上面列举的一些场景,类型参数完全可以替代通配符,使用类型参数和使用通配符没有明显区别,都可以,那么以下两种情况下,我们就只能使用通配符,而不能使用类型参数
<? super Person>
前面我们只讲到了 extends 上界限定符,实际上,对应的还有 super 下界限定符
extends 上界限定符既可以用于类型参数(如 <T extends Student>),也可以用于通配符(如 <? extends Student>)
而 super 下界限定符只能用于通配符,比如 <? super Student>,表示传入通配符的具体类型为 Student 或者 Student 的父类
public void add(List<? super Student> list, Student stu) { // ... }
<? extends T> 或 <? super T>
通配符可以 extends 或 super 类型参数,但类型参数不可以 extends 或 super 类型参数,示例代码如下所示
// 合法 public <T> void copy(List<? super T> dest, List<? extends T> src) { // ... } // 报错 public <T, U, S> void copy(List<U super T> dest, List<S extends T> src) { // ... }
4、泛型的类型擦除
刚刚我们讲了泛型的用法,现在我们来看下泛型的底层实现原理
实际上,泛型只不过是一个语法糖,在编译时,编译器会使用泛型做类型检查
但是当代码编译为字节码之后,泛型中的类型参数和通配符统统替换成上界,比如 <T> 替换为 Object,<T extends String> 替换为 String,示例如下所示
Java 这种独特的泛型实现方式叫做类型擦除
public class Box<T> { private T var; public Box(T var) { this.var = var; } public T get() { return var; } }
上述代码对应的字节码如下所示,在字节码中,成员变量、参数、返回值都是 Object 类型的
public class Box<T extends java.lang.Object> extends java.lang.Object { public Box(T); descriptor: (Ljava/lang/Object;)V flags: (0x0001) ACC_PUBLIC Code: stack=2, locals=2, args_size=2 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: aload_1 6: putfield #2 // Field var:Ljava/lang/Object; 9: return LineNumberTable: line 3: 0 line 4: 4 line 5: 9 Signature: #13 // (TT;)V public T get(); descriptor: ()Ljava/lang/Object; flags: (0x0001) ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: getfield #2 // Field var:Ljava/lang/Object; 4: areturn LineNumberTable: line 6: 0 Signature: #16 // ()TT; } Signature: #17 // <T:Ljava/lang/Object;>Ljava/lang/Object;
因为 Java 泛型的类型擦除,我们不能使用 new T() 来创建类型参数对象
在代码编译成字节之后,类型信息已经擦除,所以在运行时,JVM 无法确定具体类型,也就无法知道 T 是否存在无参构造函数,所以也就无法使用 new 来创建 T 对象了
这也是为什么在本节的第一小节中实现的 Stack 泛型类中使用 Object 来定义 arr 数组的原因
除此之外,Java 泛型这种独特的实现方式,也导致了只有引用类型才可以传入类型参数,而基本类型并不继承自 Object,无法做类型擦除,因此无法传入类型参数
也就是说,Java 泛型并不支持基本类型,如下所示的代码会报错
List<int> list = new ArrayList<>(); // 编译出错 list.add(123);
当然 Java 也可以提供更进一步的语法糖
当程序员如上代码所示声明 List<int> 时,编译器将 int 替换为对应的包装类 Integer,也就是将 List<int> 替换为 List<Integer>
这样就可以在表面上实现支持基本类型参数了,但在 List 容器中存储的并非 int 类型数据,所以从本质上讲,这样做并没有真正支持基本类型
而如果要从本质上让 Java 泛型支持基本类型,需要从底层上改变 Java 泛型的实现方式,那么牵扯到的需要改动的 JDK 代码就非常多了,对应的开发量就非常大了
Java 泛型无法实现基本类型,也带来了一些开发上的困难,比如在 12 节中讲到的用于排序的 DualPivotQuickSort 类
为了支持不同的基本类型,分别定义了不同的排序函数,而每个函数都要重复实现一遍类似的排序逻辑,代码实现非常不美观
// DualPivotQuickSort 类中 sort() 函数定义 static void sort(int[] a, int left, int right, int[] work, int workBase, int workLen); static void sort(long[] a, int left, int right, long[] work, int workBase, int workLen); static void sort(float[] a, int left, int right, float[] work, int workBase, int workLen); static void sort(double[] a, int left, int right, double[] work, int workBase, int workLen); static void sort(short[] a, int left, int right, short[] work, int workBase, int workLen); static void sort(char[] a, int left, int right, char[] work, int workBase, int workLen); static void sort(byte[] a, int left, int right);
如果 Java 泛型支持基本类型,那么我们只需要如下所示,定义一个泛型方法即可,我觉得在后续版本中,Java 很有可能会优化泛型,让其支持基本类型
public static <T> void sort(T[] a, int left, int right, T[] work, int workBase, int workLen);
熟悉 C++ 语言的同学应该知道,C++ 语言也支持泛型,只不过它有另一个叫法,叫做模板(Templates)
跟 Java 泛型不同的是,C++ 泛型支持基本类型,如下所示,我们可以定义 int 类型的 vector 容器,那么 C++ 泛型为什么能支持基本类型呢?
vector<int> nums;
之所以 C++ 泛型支持基本类型,是因为其底层实现方式跟 Java 泛型完全不同
C++ 中的泛型有点类似宏定义,当某个 cpp 文件用到泛型类时,编译器会将泛型类中的类型参数,替换为具体类型,也就是将泛型类转换为具体类,然后内联到这个 cpp 文件中
如果有多个 cpp 文件使用同一个泛型类,那么就要生成多个具体类,可想而知,这种实现方式并不高效
对于一个泛型类,JVM 中只需要保存一个类型擦除之后的类即可,但是 C++ 需要生成多个不同的具体类
5、课后思考题
把接口或类中的所有方法都设置为泛型方法,是不是就等价于泛型接口或泛型类了呢?
并不是 泛型类或泛型接口要求每个函数中的泛型都是相同的数据类型,而普通类中的两个泛型方法可以支持不同的数据类型
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17471396.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步