《Java 编程的逻辑》笔记——第8章 泛型

声明:

本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

开头语

之前章节中我们多次提到过泛型这个概念,从本节开始,我们就来详细讨论 Java 中的泛型,虽然泛型的基本思维和概念是比较简单的,但它有一些非常令人费解的语法、细节、以及局限性,内容比较多。

后续章节我们会介绍各种容器类,容器类可以说是日常程序开发中天天用到的,没有容器类,难以想象能开发什么真正有用的程序。而容器类是基于泛型的,不理解泛型,我们就难以深刻理解容器类。那泛型到底是什么呢?

8.1 基本概念和原理

8.1.1 一个简单泛型类

我们通过一个简单的例子来说明泛型类的基本概念、实现原理和好处。

8.1.1.1 基本概念

我们直接来看代码:

public class Pair<T> {
    
    

    T first;
    T second;
    
    public Pair(T first, T second){
    
    
        this.first = first;
        this.second = second;
    }
    
    public T getFirst() {
    
    
        return first;
    }
    
    public T getSecond() {
    
    
        return second;
    }
}

Pair 就是一个泛型类,与普通类的区别,体现在:

  1. 类名后面多了一个 <T>
  2. first 和 second 的类型都是 T

T 是什么呢?T 表示类型参数,泛型就是类型参数化,处理的数据类型不是固定的,而是可以作为参数传入

扫描二维码关注公众号,回复: 11938868 查看本文章

怎么用这个泛型类,并传递类型参数呢?看代码:

Pair<Integer> minmax = new Pair<Integer>(1,100);
Integer min = minmax.getFirst();
Integer max = minmax.getSecond();

Pair<Integer>,这里 Integer 就是传递的实际类型参数。

Pair 类的代码和它处理的数据类型不是绑定的,具体类型可以变化。上面是 Integer,也可以是 String,比如:

Pair<String> kv = new Pair<String>("name","老马");

类型参数可以有多个,Pair 类中的 first 和 second 可以是不同的类型,多个类型之间以逗号分隔,来看改进后的 Pair 类定义:

public class Pair<U, V> {
    
    

    U first;
    V second;
    
    public Pair(U first, V second){
    
    
        this.first = first;
        this.second = second;
    }
    
    public U getFirst() {
    
    
        return first;
    }

    public V getSecond() {
    
    
        return second;
    }
}

可以这样使用:

Pair<String,Integer> pair = new Pair<String,Integer>("老马",100);

<String,Integer> 既出现在了声明变量时,也出现在了 new 后面,比较啰嗦,Java 支持省略后面的类型参数,可以这样:

Pair<String,Integer> pair = new Pair<>("老马",100);

8.1.1.2 基本原理

泛型类型参数到底是什么呢?为什么一定要定义类型参数呢?定义普通类,直接使用 Object 不就行了吗?比如,Pair 类可以写为:

public class Pair {
    
    

    Object first;
    Object second;
    
    public Pair(Object first, Object second){
    
    
        this.first = first;
        this.second = second;
    }
    
    public Object getFirst() {
    
    
        return first;
    }
    
    public Object getSecond() {
    
    
        return second;
    }
}    

使用 Pair 的代码可以为:

Pair minmax = new Pair(1,100);
Integer min = (Integer)minmax.getFirst();
Integer max = (Integer)minmax.getSecond();

Pair kv = new Pair("name","老马");
String key = (String)kv.getFirst();
String value = (String)kv.getSecond();

这样是可以的。实际上,Java 泛型的内部原理就是这样的。

我们知道,Java 有 Java 编译器和 Java 虚拟机,编译器将 Java 源代码转换为 .class 文件,虚拟机加载并运行 .class 文件。对于泛型类,Java 编译器会将泛型代码转换为普通的非泛型代码,就像上面的普通 Pair 类代码及其使用代码一样,将类型参数 T 擦除,替换为 Object,插入必要的强制类型转换。Java 虚拟机实际执行的时候,它是不知道泛型这回事的,它只知道普通的类及代码

再强调一下,Java 泛型是通过擦除实现的,类定义中的类型参数如 T 会被替换为 Object,在程序运行过程中,不知道泛型的实际类型参数,比如 Pair<Integer>,运行中只知道 Pair,而不知道 Integer,认识到这一点是非常重要的,它有助于我们理解 Java 泛型的很多限制。

Java 为什么要这么设计呢?泛型是 Java 1.5 以后才支持的,这么设计是为了兼容性而不得已的一个选择。

8.1.1.3 泛型的好处

既然只使用普通类和 Object 就是可以的,而且泛型最后也转换为了普通类,那为什么还要用泛型呢?或者说,泛型到底有什么好处呢?

主要有两个好处:

  • 更好的安全性
  • 更好的可读性

语言和程序设计的一个重要目标是将 bug 尽量消灭在摇篮里,能消灭在写代码的时候,就不要等到代码写完,程序运行的时候。

只使用 Object,代码写错的时候,开发环境和编译器不能帮我们发现问题,看代码:

Pair pair = new Pair("老马",1);
Integer id = (Integer)pair.getFirst();
String name = (String)pair.getSecond();

看出问题了吗?写代码时,不小心,类型弄错了,不过,代码编译时是没有任何问题的,但运行时程序抛出了类型转换异常 ClassCastException

如果使用泛型,则不可能犯这个错误,如果这么写代码:

Pair<String,Integer> pair = new Pair<>("老马",1);
Integer id = pair.getFirst();
String name = pair.getSecond();

开发环境如 Eclipse 会提示你类型错误,即使没有好的开发环境,编译时,Java 编译器也会提示你。这称之为类型安全,也就是说,通过使用泛型,开发环境和编译器能确保你不会用错类型,为你的程序多设置一道安全防护网

使用泛型,还可以省去繁琐的强制类型转换,再加上明确的类型信息,代码可读性也会更好。

8.1.2 容器类

泛型类最常见的用途是作为容器类,所谓容器类,简单的说,就是容纳并管理多项数据的类。数组就是用来管理多项数据的,但数组有很多限制,比如说,长度固定,插入、删除操作效率比较低。计算机技术有一门课程叫数据结构,专门讨论管理数据的各种方式。

这些数据结构在 Java 中的实现主要就是 Java 中的各种容器类,甚至,Java 泛型的引入主要也是为了更好的支持 Java 容器。后续章节我们会详细讨论主要的 Java 容器,本节我们先自己实现一个非常简单的 Java 容器,来解释泛型的一些概念。

我们来实现一个简单的动态数组容器,所谓动态数组,就是长度可变的数组,底层数组的长度当然是不可变的,但我们提供一个类,对这个类的使用者而言,好像就是一个长度可变的数组,Java 容器中有一个对应的类 ArrayList,本节我们来实现一个简化版。

来看代码:

public class DynamicArray<E> {
    
    
    private static final int DEFAULT_CAPACITY = 10;

    private int size;
    private Object[] elementData;

    public DynamicArray() {
    
    
        this.elementData = new Object[DEFAULT_CAPACITY];
    }

    private void ensureCapacity(int minCapacity) {
    
    
        int oldCapacity = elementData.length;
        if(oldCapacity>=minCapacity){
    
    
            return;
        }
        int newCapacity = oldCapacity * 2;
        if (newCapacity < minCapacity)
            newCapacity = minCapacity;
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

    public void add(E e) {
    
    
        ensureCapacity(size + 1);
        elementData[size++] = e;
    }

    public E get(int index) {
    
    
        return (E)elementData[index];
    }
    
    public int size() {
    
    
        return size;
    }

    public E set(int index, E element) {
    
    
        E oldValue = get(index);
        elementData[index] = element;
        return oldValue;
    }

}    

DynamicArray 就是一个动态数组,内部代码与我们之前分析过的 StringBuilder 类似,通过 ensureCapacity 方法来根据需要扩展数组。作为一个容器类,它容纳的数据类型是作为参数传递过来的,比如说,存放 Double 类型:

DynamicArray<Double> arr = new DynamicArray<Double>();
Random rnd = new Random();
int size = 1+rnd.nextInt(100);
for(int i=0; i<size; i++){
    
    
    arr.add(Math.random());
}

Double d = arr.get(rnd.nextInt(size));

这就是一个简单的容器类,适用于各种数据类型,且类型安全。本节后面和后面两节还会以 DynamicArray 为例进行扩展,以解释泛型概念。

具体的类型还可以是一个泛型类,比如,可以这样写:

DynamicArray<Pair<Integer,String>> arr = new DynamicArray<>()

arr 表示一个动态数组,每个元素是 Pair<Integer,String> 类型。

8.1.3 泛型方法

除了泛型类,方法也可以是泛型的,而且,一个方法是不是泛型的,与它所在的类是不是泛型没有什么关系

我们看个例子:

public static <T> int indexOf(T[] arr, T elm){
    
    
    for(int i=0; i<arr.length; i++){
    
    
        if(arr[i].equals(elm)){
    
    
            return i;
        }
    }
    return -1;
}

这个方法就是一个泛型方法,类型参数为 T,放在返回值前面,它可以这么调用:

indexOf(new Integer[]{
    
    1,3,5}, 10)

也可以这么调用:

indexOf(new String[]{
    
    "hello","老马","编程"}, "老马")

indexOf 表示一个算法,在给定数组中寻找某一个元素,这个算法的基本过程与具体数据类型没有什么关系,通过泛型,它就可以方便的应用于各种数据类型,且编译器保证类型安全

与泛型类一样,类型参数可以有多个,多个以逗号分隔,比如:

public static <U,V> Pair<U,V> makePair(U first, V second){
    
    
    Pair<U,V> pair = new Pair<>(first, second);
    return pair;
}

与泛型类不同,调用方法时一般并不需要特意指定类型参数的实际类型是什么,比如调用 makePair:

makePair(1,"老马");

并不需要告诉编译器 U 的类型是 Integer,V 的类型是 String,Java 编译器可以自动推断出来。

8.1.4 泛型接口

接口也可以是泛型的,我们之前介绍过的 Comparable 和 Comparator 接口都是泛型的,它们的代码如下:

public interface Comparable<T> {
    
    
    public int compareTo(T o);
}
public interface Comparator<T> {
    
    
    int compare(T o1, T o2);
    boolean equals(Object obj);
}

与前面一样,T 是类型参数。实现接口时,应该指定具体的类型,比如,对 Integer 类,实现代码是:

public final class Integer extends Number implements Comparable<Integer>{
    
    
    public int compareTo(Integer anotherInteger) {
    
    
        return compare(this.value, anotherInteger.value);
    }
    //...
}

通过 implements Comparable<Integer>,Integer 实现了 Comparable 接口,指定了实际类型参数为 Integer,表示 Integer 只能与 Integer 对象进行比较。

再看 Comparator 的一个例子,String 类内部一个 Comparator 的接口实现为:

private static class CaseInsensitiveComparator
        implements Comparator<String> {
    
    
    public int compare(String s1, String s2) {
    
    
        //....
    }
}

这里,指定了实际类型参数为 String。

8.1.5 类型参数的限定

在之前的介绍中,无论是泛型类、泛型方法还是泛型接口,关于类型参数,我们都知之甚少,只能把它当做 Object,但 Java 支持限定这个参数的一个上界,也就是说,参数必须为给定的上界类型或其子类型,这个限定是通过 extends 这个关键字来表示的

这个上界可以是某个具体的类,或者某个具体的接口,也可以是其他的类型参数,我们逐个来看下其应用。

8.1.5.1 上界为某个具体类

比如说,上面的 Pair 类,可以定义一个子类 NumberPair,限定两个类型参数必须为 Number,代码如下:

public class NumberPair<U extends Number, V extends Number> extends Pair<U, V> {
    
    

    public NumberPair(U first, V second) {
    
    
        super(first, second);
    }
}    

限定类型后,就可以使用该类型的方法了,比如说,对于 NumberPair 类,first 和 second 变量就可以当做 Number 进行处理了,比如可以定义一个求和方法,如下所示:

public double sum(){
    
    
    return getFirst().doubleValue()
            +getSecond().doubleValue();
}

可以这么用:

NumberPair<Integer, Double> pair = new NumberPair<>(10, 12.34);
double sum = pair.sum();

限定类型后,如果类型使用错误,编译器会提示。

指定边界后,类型擦除时就不会转换为 Object 了,而是会转换为它的边界类型,这也是容易理解的。

8.1.5.2 上界为某个接口

在泛型方法中,一种常见的场景是限定类型必须实现 Comparable 接口,我们来看代码:

public static <T extends Comparable> T max(T[] arr){
    
    
    T max = arr[0];
    for(int i=1; i<arr.length; i++){
    
    
        if(arr[i].compareTo(max)>0){
    
    
            max = arr[i];
        }
    }
    return max;
}

max 方法计算一个泛型数组中的最大值,计算最大值需要进行元素之间的比较,要求元素实现 Comparable 接口,所以给类型参数设置了一个上边界 Comparable,T 必须实现 Comparable 接口。

不过,直接这么写代码,Java 中会给一个警告信息,因为 Comparable 是一个泛型接口,它也需要一个类型参数,所以完整的方法声明应该是:

public static <T extends Comparable<T>> T max(T[] arr){
    
    

//...

}

<T extends Comparable<T>> 是一种令人费解的语法形式,这种形式称之为递归类型限制,可以这么解读,T 表示一种数据类型,必须实现 Comparable 接口,且必须可以与相同类型的元素进行比较。

8.1.5.3 上界为其他类型参数

上面的限定都是指定了一个明确的类或接口,Java 支持一个类型参数以另一个类型参数作为上界。为什么需要这个呢?

我们看个例子,给上面的 DynamicArray 类增加一个实例方法 addAll,这个方法将参数容器中的所有元素都添加到当前容器里来,直觉上,代码可以这么写:

public void addAll(DynamicArray<E> c) {
    
    
    for(int i=0; i<c.size; i++){
    
    
        add(c.get(i));
    }
}

但这么写有一些局限性,我们看使用它的代码:

DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);

numbers 是一个 Number 类型的容器,ints 是一个 Integer 类型的容器,我们希望将 ints 添加到 numbers 中,因为 Integer 是 Number 的子类,应该说,这是一个合理的需求和操作。

但,Java 会在 number.addAll(ints) 这行代码上提示编译错误,提示,addAll 需要的参数类型为 DynamicArray<Number>,而传递过来的参数类型为 DynamicArray<Integer>,不适用,Integer 是 Number 的子类,怎么会不适用呢?

事实就是这样,确实不适用,而且是很有道理的,假设适用,我们看下会发生什么。

DynamicArray<Integer> ints = new DynamicArray<>();
//假设下面这行是合法的
DynamicArray<Number> numbers = ints;

numbers.add(new Double(12.34));

那最后一行就是合法的,这时,DynamicArray<Integer> 中就会出现 Double 类型的值,而这,显然就破坏了 Java 泛型关于类型安全的保证。

我们强调一下,虽然 Integer 是 Number 的子类,但 DynamicArray<Integer> 并不是 DynamicArray<Number> 的子类,DynamicArray<Integer> 的对象也不能赋值给 DynamicArray<Number> 的变量,这一点初看上去是违反直觉的,但这是事实,必须要理解这一点。

不过,我们的需求是合理的啊,将 Integer 添加到 Number 容器中,这没有问题啊。这个问题,可以通过类型限定,这样来解决

public <T extends E> void addAll(DynamicArray<T> c) {
    
    
    for(int i=0; i<c.size; i++){
    
    
        add(c.get(i));
    }
}

E 是 DynamicArray 的类型参数,T 是 addAll 的类型参数,T 的上界限定为 E,这样,下面的代码就没有问题了:

DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);

对于这个例子,这个写法有点啰嗦,8.2 节我们会看到一种简化的方式。

8.1.6 小结

泛型是计算机程序中一种重要的思维方式,它将数据结构和算法与数据类型相分离,使得同一套数据结构和算法,能够应用于各种数据类型,而且还可以保证类型安全,提高可读性。在 Java 中,泛型广泛应用于各种容器类中,理解泛型是深刻理解容器的基础。

本节介绍了泛型的基本概念,包括泛型类、泛型方法和泛型接口,关于类型参数,我们介绍了多种上界限定,限定为某具体类、某具体接口、或其他类型参数。泛型类最常见的用途是容器类,我们实现了一个简单的容器类 DynamicArray,以解释泛型概念。

在 Java 中,泛型是通过类型擦除来实现的,它是 Java 编译器的概念,Java 虚拟机运行时对泛型基本一无所知,理解这一点是很重要的,它有助于我们理解 Java 泛型的很多局限性。

关于泛型,Java 中有一个通配符的概念,语法非常令人费解,而且容易混淆,8.2 节,我们力图对它进行清晰的剖析。

8.2 解析通配符

本节主要讨论泛型中的通配符概念。通配符有着令人费解和混淆的语法,但通配符大量应用于 Java 容器类中,它到底是什么?本节,让我们逐步来解析。

8.2.1 更简洁的参数类型限定

在 8.2 节最后,我们提到一个例子,为了将 Integer 对象添加到 Number 容器中,我们的类型参数使用了其他类型参数作为上界,代码是:

public <T extends E> void addAll(DynamicArray<T> c) {
    
    
    for(int i=0; i<c.size; i++){
    
    
        add(c.get(i));
    }
}

我们提到,这个写法有点啰嗦,它可以替换为更为简洁的通配符形式

public void addAll(DynamicArray<? extends E> c) {
    
    
    for(int i=0; i<c.size; i++){
    
    
        add(c.get(i));
    }
}

这个方法没有定义类型参数,c 的类型是 DynamicArray<? extends E>,? 表示通配符<? extends E> 表示有限定通配符,匹配 E 或 E 的某个子类型,具体什么子类型,我们不知道。

使用这个方法的代码不需要做任何改动,还可以是:

DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);

这里,E 是 Number 类型,DynamicArray<? extends E> 可以匹配 DynamicArray<Integer>

那么问题来了,同样是 extends 关键字,同样应用于泛型,<T extends E><? extends E> 到底有什么关系?

它们用的地方不一样,我们解释一下

  • <T extends E> 用于定义类型参数,它声明了一个类型参数 T,可放在泛型类定义中类名后面、泛型方法返回值前面。
  • <? extends E> 用于实例化类型参数,它用于实例化泛型变量中的类型参数,只是这个具体类型是未知的,只知道它是 E 或 E 的某个子类型。

虽然它们不一样,但两种写法经常可以达成相同目标,比如,前面例子中,下面两种写法都可以:

public void addAll(DynamicArray<? extends E> c)
public <T extends E> void addAll(DynamicArray<T> c) 

那,到底应该用哪种形式呢?我们先进一步理解通配符,然后再解释。

8.2.2 理解通配符

8.2.2.1 无限定通配符

除了有限定通配符,还有一种通配符,形如 DynamicArray<?>,称之为无限定通配符。我们来看个使用的例子,在 DynamicArray 中查找指定元素,代码如下:

public static int indexOf(DynamicArray<?> arr, Object elm){
    
    
    for(int i=0; i<arr.size(); i++){
    
    
        if(arr.get(i).equals(elm)){
    
    
            return i;
        }
    }
    return -1;
}

其实,这种无限定通配符形式,也可以改为使用类型参数。也就是说,下面写法:

public static int indexOf(DynamicArray<?> arr, Object elm)

可以改为:

public static <T> int indexOf(DynamicArray<T> arr, Object elm)

不过,通配符形式更为简洁。

8.2.2.2 通配符的只读性

通配符形式更为简洁,但上面两种通配符都有一个重要的限制,只能读,不能写

怎么理解呢?看下面例子:

DynamicArray<Integer> ints = new DynamicArray<>();
DynamicArray<? extends Number> numbers = ints;
Integer a = 200;
numbers.add(a);
numbers.add((Number)a);
numbers.add((Object)a);

三种 add 方法都是非法的,无论是 Integer,还是 Number 或 Object,编译器都会报错。为什么呢?

? 就是表示类型安全无知,? extends Number 表示是 Number 的某个子类型,但不知道具体子类型,如果允许写入,Java 就无法确保类型安全性,所以干脆禁止。我们来看个例子,看看如果允许写入会发生什么:

DynamicArray<Integer> ints = new DynamicArray<>();
DynamicArray<? extends Number> numbers = ints;
Number n = new Double(23.0);
Object o = new String("hello world");
numbers.add(n);
numbers.add(o);

如果允许写入 Object 或 Number 类型,则最后两行编译就是正确的,也就是说,Java 将允许把 Double 或 String 对象放入 Integer 容器,这显然就违背了 Java 关于类型安全的承诺。

大部分情况下,这种限制是好的,但这使得一些理应正确的基本操作都无法完成,比如交换两个元素的位置,看代码:

public static void swap(DynamicArray<?> arr, int i, int j){
    
    
    Object tmp = arr.get(i);
    arr.set(i, arr.get(j));
    arr.set(j, tmp);
}

这个代码看上去应该是正确的,但 Java 会提示编译错误,两行 set 语句都是非法的。不过,借助带类型参数的泛型方法,这个问题可以这样解决:

private static <T> void swapInternal(DynamicArray<T> arr, int i, int j){
    
    
    T tmp = arr.get(i);
    arr.set(i, arr.get(j));
    arr.set(j, tmp);
}

public static void swap(DynamicArray<?> arr, int i, int j){
    
    
    swapInternal(arr, i, j);
}

swap 可以调用 swapInternal,而带类型参数的 swapInternal 可以写入。Java 容器类中就有类似这样的用法,公共的 API 是通配符形式,形式更简单,但内部调用带类型参数的方法。

8.2.2.3 参数类型间的依赖关系

除了这种需要写的场合,如果参数类型之间有依赖关系,也只能用类型参数。比如说,看下面代码,将 src 容器中的内容拷贝到 dest 中:

public static <D,S extends D> void copy(DynamicArray<D> dest,
        DynamicArray<S> src){
    
    
    for(int i=0; i<src.size(); i++){
    
    
        dest.add(src.get(i));
    }
}

S 和 D 有依赖关系,要么相同,要么 S 是 D 的子类,否则类型不兼容,有编译错误。不过,上面的声明可以使用通配符简化一下,两个参数可以简化为一个,如下所示:

public static <D> void copy(DynamicArray<D> dest,
        DynamicArray<? extends D> src){
    
    
    for(int i=0; i<src.size(); i++){
    
    
        dest.add(src.get(i));
    }
}

8.2.2.4 通配符与返回值

还有,如果返回值依赖于类型参数,也不能用通配符。比如,计算动态数组中的最大值,如下所示:

public static <T extends Comparable<T>> T max(DynamicArray<T> arr){
    
    
    T max = arr.get(0);
    for(int i=1; i<arr.size(); i++){
    
    
        if(arr.get(i).compareTo(max)>0){
    
    
            max = arr.get(i);
        }
    }
    return max;
}

上面的代码就难以用通配符代替。

8.2.2.5 通配符和类型参数比较

现在我们再来看,泛型方法,到底应该用通配符的形式,还是加类型参数?两者到底有什么关系?我们总结下:

  • 通配符形式都可以用类型参数的形式来替代,通配符能做的,用类型参数都能做。
  • 通配符形式可以减少类型参数,形式上往往更为简单,可读性也更好,所以,能用通配符的就用通配符。
  • 如果类型参数之间有依赖关系,或者返回值依赖类型参数,或者需要写操作,则只能用类型参数。
  • 通配符形式和类型参数往往配合使用,比如,上面的 copy 方法,定义必要的类型参数,使用通配符表达依赖,并接受更广泛的数据类型。

8.2.3 超类型通配符

8.2.3.1 灵活写入

还有一种通配符,与形式 <? extends E> 正好相反,它的形式为 <? super E>,称之为超类型通配符,表示 E 的某个父类型,它有什么用呢?有了它,我们就可以更灵活的写入了。

如果没有这种语法,写入会有一些限制,来看个例子,我们给 DynamicArray 添加一个方法:

public void copyTo(DynamicArray<E> dest){
    
    
    for(int i=0; i<size; i++){
    
    
        dest.add(get(i));
    }
}

这个方法也很简单,将当前容器中的元素添加到传入的目标容器中。我们可能希望这么使用:

DynamicArray<Integer> ints = new DynamicArray<Integer>();
ints.add(100);
ints.add(34);
DynamicArray<Number> numbers = new DynamicArray<Number>();
ints.copyTo(numbers);

Integer 是 Number 的子类,将 Integer 对象拷贝入 Number 容器,这种用法应该是合情合理的,但 Java 会提示编译错误,理由我们之前也说过了,期望的参数类型是 DynamicArray<Integer>DynamicArray<Number> 并不适用。

如之前所说,一般而言,不能将 DynamicArray<Integer> 看做 DynamicArray<Number>,但我们这里的用法是没有问题的,Java 解决这个问题的方法就是超类型通配符,可以将 copyTo 代码改为:

public void copyTo(DynamicArray<? super E> dest){
    
    
    for(int i=0; i<size; i++){
    
    
        dest.add(get(i));
    }
}

这样,就没有问题了。

8.2.3.2 灵活比较

超类型通配符另一个常用的场合是 Comparable/Comparator 接口。同样,我们先来看下,如果不使用,会有什么限制。以前面计算最大值的方法为例,它的方法声明是:

public static <T extends Comparable<T>> T max(DynamicArray<T> arr)

这个声明有什么限制呢?我们举个简单的例子,有两个类 Base 和 Child,Base 的代码是:

class Base implements Comparable<Base>{
    
    
    private int sortOrder;
    
    public Base(int sortOrder) {
    
    
        this.sortOrder = sortOrder;
    }
    
    @Override
    public int compareTo(Base o) {
    
    
        if(sortOrder < o.sortOrder){
    
    
            return -1;
        }else if(sortOrder > o.sortOrder){
    
    
            return 1;
        }else{
    
    
            return 0;
        }
    }
}

Base 代码很简单,实现了 Comparable 接口,根据实例变量 sortOrder 进行比较。Child 代码是:

class Child extends Base {
    
    
    public Child(int sortOrder) {
    
    
        super(sortOrder);
    }
}

这里,Child 非常简单,只是继承了 Base。注意,Child 没有重新实现 Comparable 接口,因为 Child 的比较规则和 Base 是一样的。我们可能希望使用前面的 max 方法操作 Child 容器,如下所示:

DynamicArray<Child> childs = new DynamicArray<Child>();
childs.add(new Child(20));
childs.add(new Child(80));
Child maxChild = max(childs);

遗憾的是,Java 会提示编译错误,类型不匹配。为什么不匹配呢?我们可能会认为,Java 会将 max 方法的类型参数 T 推断为 Child 类型,但类型 T 的要求是 extends Comparable<T>,而 Child 并没有实现 Comparable<Child>,它实现的是 Comparable<Base>

但我们的需求是合理的,Base 类的代码已经有了关于比较所需要的全部数据,它应该可以用于比较 Child 对象。解决这个问题的方法,就是修改 max 的方法声明,使用超类型通配符,如下所示

public static <T extends Comparable<? super T>> T max(DynamicArray<T> arr)

就这么修改一下,就可以了,这种写法比较抽象,将 T 替换为 Child,就是:

Child extends Comparable<? super Child>

<? super Child> 可以匹配 Base,所以整体就是匹配的。

8.2.3.3 注意

我们比较一下类型参数限定与超类型通配符,类型参数限定只有 extends 形式,没有 super 形式。比如说,前面的 copyTo 方法,它的通配符形式的声明为:

public void copyTo(DynamicArray<? super E> dest)

如果类型参数限定支持 super 形式,则应该是:

public <T super E> void copyTo(DynamicArray<T> dest)

事实是,Java 并不支持这种语法。

前面我们说过,对于有限定的通配符形式 <? extends E>,可以用类型参数限定替代,但是对于类似上面的超类型通配符,则无法用类型参数替代

8.2.4 通配符比较

两种通配符形式 <? super E><? extends E> 也比较容易混淆,我们再来比较下。

  • 它们的目的都是为了使方法接口更为灵活,可以接受更为广泛的类型。
  • <? super E> 用于灵活写入或比较,使得对象可以写入父类型的容器,使得父类型的比较方法可以应用于子类对象。
  • <? extends E> 用于灵活读取,使得方法可以读取 E 或 E 的任意子类型的容器对象。

Java 容器类的实现中,有很多这种用法,比如说,Collections 中就有如下一些方法:

public static <T extends Comparable<? super T>> void sort(List<T> list)
public static <T> void sort(List<T> list, Comparator<? super T> c)
public static <T> void copy(List<? super T> dest, List<? extends T> src)
public static <T> T max(Collection<? extends T> coll, Comparator<? super T> comp)

通过上节和本节,我们应该可以理解这些方法声明的含义了。

8.3 细节和局限性

本节来介绍泛型中的一些细节和局限性。

这些局限性主要与 Java 的实现机制有关,Java 中,泛型是通过类型擦除来实现的,类型参数在编译时会被替换为 Object,运行时 Java 虚拟机不知道泛型这回事,这带来了很多局限性,其中有的部分是比较容易理解的,有的则是非常违反直觉的。

一项技术,往往只有理解了其局限性,我们才算是真正理解了它,才能更好的应用它。

下面,我们将从以下几个方面来介绍这些细节和局限性:

  • 使用泛型类、方法和接口
  • 定义泛型类、方法和接口
  • 泛型与数组

8.3.1 使用泛型类、方法和接口

在使用泛型类、方法和接口时,有一些值得注意的地方,比如:

  • 基本类型不能用于实例化类型参数
  • 运行时类型信息不适用于泛型
  • 类型擦除可能会引发一些冲突

我们逐个来看下。

基本类型不能用于实例化类型参数

Java 中,因为类型参数会被替换为 Object,所以 Java 泛型中不能使用基本数据类型,也就是说,类似下面写法是不合法的:

Pair<int> minmax = new Pair<int>(1,100);

解决方法就是使用基本类型对应的包装类。

运行时类型信息不适用于泛型

在介绍继承的实现原理时,我们提到,在内存中,每个类都有一份类型信息,而每个对象也都保存着其对应类型信息的引用。关于运行时信息,后续文章我们会进一步详细介绍,这里简要说明一下。

在 Java 中,这个类型信息也是一个对象,它的类型为 Class,Class 本身也是一个泛型类,每个类的类型对象可以通过 <类名>.class 的方式引用,比如 String.class,Integer.class

这个类型对象也可以通过对象的 getClass() 方法获得,比如:

Class<?> cls = "hello".getClass();

这个类型对象只有一份,与泛型无关,所以 Java 不支持类似如下写法:

Pair<Integer>.class

一个泛型对象的 getClass 方法的返回值与原始类型对象也是相同的,比如说,下面代码的输出都是 true:

Pair<Integer> p1 = new Pair<Integer>(1,100);
Pair<String> p2 = new Pair<String>("hello","world");
System.out.println(Pair.class==p1.getClass());
System.out.println(Pair.class==p2.getClass());

之前,我们介绍过 instanceof 关键字,instanceof 后面是接口或类名,instanceof 是运行时判断,也与泛型无关,所以,Java 也不支持类似如下写法:

if(p1 instanceof Pair<Integer>)

不过,Java 支持这么写:

if(p1 instanceof Pair<?>)

类型擦除可能会引发一些冲突

由于类型擦除,可能会引发一些编译冲突,这些冲突初看上去并不容易理解,我们通过一些例子看一下。

上节我们介绍过一个例子,有两个类 Base 和 Child,Base 的声明为:

class Base implements Comparable<Base>

Child 的声明为:

class Child extends Base

Child 没有专门实现 Comparable 接口,上节我们说 Base 类已经有了比较所需的全部信息,所以 Child 没有必要实现,可是如果 Child 希望自定义这个比较方法呢?直觉上,可以这样修改 Child 类:

class Child extends Base implements Comparable<Child>{
    
    
    @Override
    public int compareTo(Child o) {
    
    
        
    }
    //...
}

遗憾的是,Java 编译器会提示错误,Comparable 接口不能被实现两次,且两次实现的类型参数还不同,一次是 Comparable<Base>,一次是 Comparable<Child>。为什么不允许呢?因为类型擦除后,实际上只能有一个

那 Child 有什么办法修改比较方法呢?只能是重写 Base 类的实现,如下所示:

class Child extends Base {
    
    
    @Override
    public int compareTo(Base o) {
    
    
        if(!(o instanceof Child)){
    
    
            throw new IllegalArgumentException();
        }
        Child c = (Child)o;
        //...
        return 0;
    }
    //...
}

还有,你可能认为可以这么定义重载方法:

public static void test(DynamicArray<Integer> intArr)
public static void test(DynamicArray<String> strArr)

虽然参数都是 DynamicArray,但实例化类型不同,一个是 DynamicArray<Integer>,另一个是 DynamicArray<String>,同样,遗憾的是,Java 不允许这种写法,理由同样是,类型擦除后,它们的声明是一样的

8.3.2 定义泛型类、方法和接口

在定义泛型类、方法和接口时,也有一些需要注意的地方,比如:

  • 不能通过类型参数创建对象
  • 泛型类类型参数不能用于静态变量和方法
  • 了解多个类型限定的语法

我们逐个来看下。

不能通过类型参数创建对象

不能通过类型参数创建对象,比如,T 是类型参数,下面写法都是非法的:

T elm = new T();
T[] arr = new T[10];

为什么非法呢?因为如果允许,那你以为创建的就是对应类型的对象,但由于类型擦除,Java 只能创建 Object 类型的对象,而无法创建 T 类型的对象,容易引起误解,所以 Java 干脆禁止这么做。

那如果确实希望根据类型创建对象呢?需要设计 API 接受类型对象,即 Class 对象,并使用 Java 中的反射机制,后续文章我们再详细介绍反射,这里简要说明一下,如果类型有默认构造方法,可以调用 Class 的 newInstance 方法构建对象,类似这样:

public static <T> T create(Class<T> type){
    
    
    try {
    
    
        return type.newInstance();
    } catch (Exception e) {
    
    
        return null;
    }
}

比如:

Date date = create(Date.class);
StringBuilder sb = create(StringBuilder.class);

泛型类类型参数不能用于静态变量和方法

对于泛型类声明的类型参数,可以在实例变量和方法中使用,但在静态变量和静态方法中是不能使用的。类似下面这种写法是非法的:

public class Singleton<T> {
    
    

    private static T instance;
    
    public synchronized static T getInstance(){
    
    
        if(instance==null){
    
    
             // 创建实例
        }
        return instance;
    }
}    

如果合法的话,那么对于每种实例化类型,都需要有一个对应的静态变量和方法。但由于类型擦除,Singleton 类型只有一份,静态变量和方法都是类型的属性,且与类型参数无关,所以不能使用泛型类类型参数。

不过,对于静态方法,它可以是泛型方法,可以声明自己的类型参数,这个参数与泛型类的类型参数是没有关系的

了解多个类型限定的语法

之前介绍类型参数限定的时候,我们介绍,上界可以为某个类、某个接口或者其他类型参数,但上界都是只有一个,Java 中还支持多个上界,多个上界之间以 & 分隔,类似这样:

T extends Base & Comparable & Serializable

Base 为上界类,Comparable 和 Serializable为上界接口,如果有上界类,类应该放在第一个,类型擦除时,会用第一个上界替换。

8.3.3 泛型与数组

泛型与数组的关系稍微复杂一些,我们单独讨论一下。

为什么不能创建泛型数组?

引入泛型后,一个令人惊讶的事实是,你不能创建泛型数组。比如说,我们可能想这样创建一个 Pair 的泛型数组,以表示 7.6 节中介绍的奖励面额和权重。

Pair<Object,Integer>[] options = new Pair<Object,Integer>[]{
    
    
        new Pair("1元",7),
        new Pair("2元", 2),
        new Pair("10元", 1)
};

Java 会提示编译错误,不能创建泛型数组。这是为什么呢?我们先来进一步理解一下数组。

前面我们解释过,类型参数之间有继承关系的容器之间是没有关系的,比如,一个 DynamicArray<Integer> 对象不能赋值给一个 DynamicArray<Number> 变量。不过,数组是可以的,看代码:

Integer[] ints = new Integer[10];
Number[] numbers = ints;
Object[] objs = ints;

后面两种赋值都是允许的。数组为什么可以呢?数组是 Java 直接支持的概念,它知道数组元素的实际类型,它知道 Object 和 Number 都是 Integer 的父类型,所以这个操作是允许的。

虽然 Java 允许这种转换,但如果使用不当,可能会引起运行时异常,比如:

Integer[] ints = new Integer[10];
Object[] objs = ints;
objs[0] = "hello";

编译是没有问题的,运行时会抛出 ArrayStoreException,因为 Java 知道实际的类型是 Integer,所以写入 String 会抛出异常。

理解了数组的这个行为,我们再来看泛型数组。如果 Java 允许创建泛型数组,则会发生非常严重的问题,我们看看具体会发生什么:

Pair<Object,Integer>[] options = new Pair<Object,Integer>[3];
Object[] objs = options;
objs[0] = new Pair<Double,String>(12.34,"hello");

如果可以创建泛型数组 options,那它就可以赋值给其他类型的数组 objs,而最后一行明显错误的赋值操作,则既不会引起编译错误,也不会触发运行时异常,因为 Pair<Double,String> 的运行时类型是 Pair,和 objs 的运行时类型 Pair[] 是匹配的。但我们知道,它的实际类型是不匹配的,在程序的其他地方,当把 objs[0] 当做 Pair<Object,Integer> 进行处理的时候,一定会触发异常。

也就是说,如果允许创建泛型数组,那就可能会有上面这种错误操作,它既不会引起编译错误,也不会立即触发运行时异常,却相当于埋下了一颗炸弹,不定什么时候爆发,为避免这种情况,Java 干脆就禁止创建泛型数组

如何存放泛型对象?

但,现实需要能够存放泛型对象的容器啊,怎么办呢?可以使用原始类型的数组,比如:

Pair[] options = new Pair[]{
    
    
      new Pair<String,Integer>("1元",7),
      new Pair<String,Integer>("2元", 2),
      new Pair<String,Integer>("10元", 1)};

更好的选择是,使用后续章节介绍的泛型容器。目前,可以使用我们自己实现的 DynamicArray,比如:

DynamicArray<Pair<String,Integer>> options = new DynamicArray<>();
options.add(new Pair<String,Integer>("1元",7));
options.add(new Pair<String,Integer>("2元",2));
options.add(new Pair<String,Integer>("10元",1));

DynamicArray 内部的数组为 Object 类型,一些操作插入了强制类型转换,外部接口是类型安全的,对数组的访问都是内部代码,可以避免误用和类型异常。

如何转换容器为数组?

有时,我们希望转换泛型容器为一个数组,比如说,对于 DynamicArray,我们可能希望它有这么一个方法:

public E[] toArray()

而我们希望可以这么用:

DynamicArray<Integer> ints = new DynamicArray<Integer>();
ints.add(100);
ints.add(34);
Integer[] arr = ints.toArray();

先使用动态容器收集一些数据,然后转换为一个固定数组,这也是一个常见合理的需求,怎么来实现这个 toArray 方法呢?

可能想先这样:

E[] arr = new E[size];

遗憾的是,如之前所述,这是不合法的。Java 运行时根本不知道 E 是什么,也就无法做到创建 E 类型的数组。

另一种想法是这样:

public E[] toArray(){
    
    
    Object[] copy = new Object[size];
    System.arraycopy(elementData, 0, copy, 0, size);
    return (E[])copy;
}

或者使用之前介绍的 Arrays 方法:

public E[] toArray(){
    
    
    return (E[])Arrays.copyOf(elementData, size);
}

结果都是一样的,没有编译错误了,但运行时,会抛出 ClassCastException 异常,原因是,Object 类型的数组不能转换为 Integer 类型的数组。

那怎么办呢?可以利用 Java 中的运行时类型信息和反射机制,这些概念我们后续章节再介绍。这里,我们简要介绍下。

Java 必须在运行时知道你要转换成的数组类型,类型可以作为参数传递给 toArray 方法,比如:

public E[] toArray(Class<E> type){
    
    
    Object copy = Array.newInstance(type, size);
    System.arraycopy(elementData, 0, copy, 0, size);
    return (E[])copy;
}

Class<E> 表示要转换成的数组类型信息,有了这个类型信息,Array 类的 newInstance 方法就可以创建出真正类型的数组对象。

调用 toArray 方法时,需要传递需要的类型,比如,可以这样:

Integer[] arr = ints.toArray(Integer.class);

泛型与数组小结

我们来稍微总结下泛型与数组的关系:

  • Java 不支持创建泛型数组。
  • 如果要存放泛型对象,可以使用原始类型的数组,或者使用泛型容器。
  • 泛型容器内部使用 Object 数组,如果要转换泛型容器为对应类型的数组,需要使用反射。

8.3.4 小结

本节介绍了泛型的一些细节和局限性,这些局限性主要是由于 Java 泛型的实现机制引起的,这些局限性包括,不能使用基本类型,没有运行时类型信息,类型擦除会引发一些冲突,不能通过类型参数创建对象,不能用于静态变量等,我们还单独讨论了泛型与数组的关系。

我们需要理解这些局限性,幸运的是,一般并不需要特别去记忆,因为用错的时候,Java 开发环境和编译器会提示你,当被提示时,你需要能够理解,并可以从容应对

至此,关于泛型的介绍就结束了,泛型是 Java 容器类的基础,理解了泛型,接下来,就让我们开始探索 Java 中的容器类。

猜你喜欢

转载自blog.csdn.net/bm1998/article/details/108015471