只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

23、泛型

内容来自王争 Java 编程之美

泛型本质上就是对类型的参数化,在类的定义中,我们可以把类型当做参数,当使用类时,我们向类型参数传入具体类型

泛型在 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、课后思考题

把接口或类中的所有方法都设置为泛型方法,是不是就等价于泛型接口或泛型类了呢?

并不是
泛型类或泛型接口要求每个函数中的泛型都是相同的数据类型,而普通类中的两个泛型方法可以支持不同的数据类型
posted @   lidongdongdong~  阅读(55)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开