13. 泛型

空~2022年8月11日
  • java
大约 33 分钟

13. 泛型

在没有泛型之前,一旦把一个对象“丢进”Java 集合中,集合就会忘记对象的类型,把所有的对象当成 Object 类型处理。当程序从集合中取出对象后,就需要进行强制类型转换,这种强制类型转换不仅使代码臃肿,而且容易引起 ClassCastExeception 异常。

泛型入门

Java 集合有个缺点——当我们把一个对象“丢进”集合里后,集合就会“忘记”这个对象的数据类型,当再次取出该对象时,该对象的编译类型就变成了 Object 类型(其运行时类型没变)。

Java 集合之所以被设计成这样,是因为设计集合的程序员不会知道我们用它来保存什么类型的对象,所以他们把集合设计成能保存任何类型的对象,只要求具有很好的通用性。但这样做带来如下两个问题:

  1. 集合对元素类型没有任何限制,这样可能引发一些问题。例如,想创建一个只能保存 Dog 对象的集合,但程序也可以轻易地将 Cat 对象“丢”进去,所以可能引发异常。
  2. 由于把对象“丢进”集合时,集合丢失了对象的状态信息,集合只知道它盛装的是 Object,因此取出集合元素后通常还需要进行强制类型转换。这种强制类型转换既增加了编程的复杂度,也可能引发 ClassCastException 异常。

编译时不检查类型的异常

public class ListErr {
    public static void main(String[] args) {
        // 创建一个只想保存字符串的List集合
        List strList = new ArrayList();
        strList.add("疯狂Java讲义");
        strList.add("疯狂Ajax讲义");
        strList.add("轻量级Java EE企业应用实战");
        // ①“不小心”把一个Integer对象“丢进”了集合
        strList.add(5);
        for (int i = 0; i < strList.size(); i++) {
            // 因为List里取出的全部是Object,所以必须进行强制类型转换
            // 最后一个元素将出现ClassCastException异常
            String str = (String)strList.get(i);
        }
    }
}

手动实现编译时检查类型

如果希望创建一个 List 对象,且该 List 对象中只能保存字符串类型,那么我们可以扩展 ArrayList 类。

// 自定义一个StrList集合类,使用组合的方式来复用ArrayList类
class StrList {
    private List strList = new ArrayList();

    // 定义StrList的add方法
    public boolean add(String ele) {
        return strList.add(ele);
    }

    // 重写get()方法,将get()方法的返回值类型改为String类型
    public String get(int index) {
        return (String)strList.get(index);
    }

    public int size() {
        return strList.size();
    }
}

public class CheckType {
    public static void main(String[] args) {
        // 创建一个只想保存字符串的List集合
        StrList strList = new StrList();
        strList.add("疯狂Java讲义");
        strList.add("疯狂Android讲义");
        strList.add("轻量级Java EE企业应用实战");
        // 下面语句不能把Integer对象“丢进”集合中,否则将引起编译错误
        strList.add(5);
        System.out.println(strList);
        for (int i = 0; i < strList.size(); i++) {
            // 因为StrList里元素的类型就是String类型
            // 所以无须进行强制类型转换
            String str = strList.get(i);
        }
    }
}

从代码的健壮性角度来看,该方法极其有用,而且使用 get() 方法返回集合元素时,无须进行类型转换。

这种做法虽然有效,但局限性非常明显——程序员需要定义大量的 List 子类,这是一件让人沮丧的事情。

从 Java 5 以后,Java 引入了“参数化类型(parameterized type)”的概念,允许我们在创建集合时指定集合元素的类型。Java 的参数化类型被称为泛型(Generic)。

使用泛型

public class GenericList {
    public static void main(String[] args) {
        // ①创建一个只想保存字符串的List集合
        List<String> strList = new ArrayList<String>();
        strList.add("疯狂Java讲义");
        strList.add("疯狂Android讲义");
        strList.add("轻量级Java EE企业应用实战");
        // ②下面代码将引起编译错误
        // strList.add(5);
        for (int i = 0; i < strList.size(); i++) {
            // ③下面代码无须进行强制类型转换
            String str = strList.get(i);
        }
    }
}

在集合接口、类后增加尖括号,尖括号里放一个数据类型,即表明这个集合接口、集合类只能保存特定类型的对象。

① 处的类型声明,它指定 strList 不是一个任意的 List,而是一个 String 类型的 List,写作:List<String>。我们说 List 是带一个类型参数的泛型接口,在本例中,类型参数是 String。在创建这个 ArrayList 对象时也指定了一个类型参数。

② 处引发编译异常,因为 strList 集合只能添加 String 对象(及其子类对象,如果有),所以不能将 Integer 对象“丢进”该集合(没有继承关系)。

③ 处不需要进行强制类型转换,因为 strList 对象可以“记住”它的所有集合元素都是 String 类型。

尖括号内只能放引用数据类型,不能放基本数据类型。

不指定泛型标记符的时候默认是 Object 类型。

指定数据类型后只能存放相应的数据类型及其子类,不需要强制类型转换。

声明一个 Animal 类型的 List,这个 List 不仅能存入 Animal 本身,还能存入他的子类。

class Animal {
}

class Pig extends Animal {
}

public class Test {
    public static void main(String[] args) {
        List<Animal> animals = new ArrayList<Animal>();
        animals.add(new Animal());
        animals.add(new Pig());
    }
}

“菱形”语法

从 Java 7 开始,Java 允许在构造器后不需要带完整的泛型信息,只要给出一对尖括号<>即可,Java 可以推断尖括号里应该是什么泛型信息。

List<String> strList = new ArrayList<String>();
Map<String, Integer> scores = new HashMap<String, Integer>();

上面两条语句可以改写为如下形式:

List<String> strList = new ArrayList<>();
Map<String, Integer> scores = new HashMap<>();

把两个尖括号并排放在一起非常像一个菱形,这种语法也就被称为“菱形”语法(钻石表达式)。

public class DiamondTest {
    public static void main(String[] args) {
        // Java自动推断出ArrayList的<>里应该是String
        List<String> books = new ArrayList<>();
        books.add("疯狂Java讲义");
        books.add("疯狂Android讲义");
        books.add("轻量级Java EE企业应用实战");
        // 遍历时集合元素就是String
        for (String book : books) {
            System.out.println(book);
        }
        // Java自动推断出HashMap的<>里应该是String, List
        Map<String, List<String>> schoolsInfo = new HashMap<>();
        // Java自动推断出ArrayList的<>里应该是String
        List<String> schools = new ArrayList<>();
        schools.add("斜月三星洞");
        schools.add("西天取经路");
        schoolsInfo.put("孙悟空", schools);
        // 遍历Map时,Map的key是String类型
        for (String key : schoolsInfo.keySet()) {
            // value是List类型
            List<String> list = schoolsInfo.get(key);
            System.out.println(key + "-->" + list);
        }
    }
}

深入泛型

Java 5 改写了集合框架中的全部接口和类,为这些接口、类增加了泛型支持,从而可以在声明集合变量、创建集合对象时传入类型实参,这就是在前面程序中看到的 List<String>ArrayList<String> 两种类型。

定义泛型接口、类

java 中泛型标记符:

  • E - Element (在集合中使用,因为集合中存放的是元素)
  • T - Type(Java 类)
  • K - Key(键)
  • V - Value(值)
  • N - Number(数值类型)
  • - 表示不确定的 java 类型

标记符除 ? 外都属于规范书写,也可以写任意字母,但是不推荐。

泛型标记符同样只能是引用数据类型,不能是基本数据类型。

Java 5 改写后 List 接口、Iterator 接口、Map 的代码片段。

// 定义接口时指定了一个类型形参,该形参名为E
public interface List<E> {
    // 在该接口里,E可作为类型使用
    // ①下面方法可以使用E作为参数类型
    void add(E x);
    Iterator<E> iterator();
    ...
}
// 定义接口时指定了一个类型形参,该形参名为E
public interface Iterator<E> {
    // 在该接口里E完全可以作为类型使用
    E next();
    boolean hasNext();
    ...
}
// 定义该接口时指定了两个类型形参,其形参名为K、V
public interface Map<K , V> {
    // ②在该接口里K、V完全可以作为类型使用
    Set<K> keySet()
    V put(K key, V value)
    ...
}

上面三个接口声明是比较简单的,除了尖括号中的内容——这就是泛型的实质:允许在定义接口、类时声明类型形参,类型形参在整个接口、类体内可当成类型使用,几乎所有可使用普通类型的地方都可以使用这种类型形参。

①② 处方法声明返回值类型是 Iterator<E>Set<K>,这表明 Set<K> 形式是一种特殊的数据类型,是一种与 Set 不同的数据类型。

我们使用 List 类型时,为 E 形参传入 String 类型实参,则就相当于把所有的 E 都替换成了 String

public interface List<String> {
    // 在该接口里,E可作为类型使用
    // ①下面方法可以使用E作为参数类型
    void add(String x);
    Iterator<String> iterator();
    ...
}

通过这种方式,虽然程序只定义了一个 List<E> 接口,但实际使用时可以产生无数多个 List 接口,只要为 E 传入不同的类型实参,系统就会多出一个新的 List 接口。

提示

泛型接口和泛型类就相当于一个模板,具体什么类型由使用者决定。

我们可以为任何类、接口增加泛型声明(并不是只有集合类才可以使用泛型声明,虽然集合类是泛型的重要使用场所)。

// 定义Apple类时使用了泛型声明
public class Apple<T> {
    // 使用T类型形参定义实例变量
    private T info;

    public Apple() {
    }

    // 下面方法中使用T类型形参来定义构造器
    public Apple(T info) {
        this.info = info;
    }
    
    
    public T getInfo() {
        return this.info;
    }

    public void setInfo(T info) {
        this.info = info;
    }

    public static void main(String[] args) {
        // 因为传给T形参的是String实际类型
        // 所以构造器的参数只能是String
        Apple<String> a1 = new Apple<>("苹果");
        System.out.println(a1.getInfo());
        // 因为传给T形参的是Double实际类型
        // 所以构造器的参数只能是Double或者double
        Apple<Double> a2 = new Apple<>(5.67);
        System.out.println(a2.getInfo());
    }
}

泛型是在程序运行时才会确定具体类型的,所以会有一定的限制。

  1. 可以指定多个泛型标识符

  2. 普通成员可以使用泛型(属性、方法)

  3. 泛型数组不能初始化

  4. 静态方法无法使用泛型

class Gen<A, K, T> {
    // 属性使用泛型
    A a;
    K k;
    // 泛型数组不能初始化
    T[] t;

    public A getA() {
        return a;
    }

    public void setA(A a) {
        this.a = a;
    }

    public K getK() {
        return k;
    }

    public void setK(K k) {
        this.k = k;
    }

    // 方法使用泛型
  	public T[] getT() {
        return t;
    }    

    public void setT(T[] t) {
        this.t = t;
    }
    
     /*
    静态方法无法使用泛型
    public static void t(A a){}
     */
}

提示

当创建带泛型声明的自定义类,为该类定义构造器时,构造器名还是原来的类名,不要增加泛型声明。

例如,为 Apple<T> 类定义构造器,其构造器名依然是 Apple,而不是 Apple<T>!调用该构造器时却可以使用 Apple<T> 的形式,当然应该为 T 形参传入实际的类型参数。

从泛型类派生子类

当创建了带泛型声明的接口、父类之后,可以为该接口创建实现类,或从该父类派生子类,但需要指出的是,当使用这些接口、父类时不能再包含类型形参。

下面代码就是错误的。

// 定义类A继承Apple类,Apple类不能跟类型形参
public class A extends Apple<T> {}

定义类、接口、方法时可以声明类型形参,使用类、接口、方法时应为类型形参传入实际的类型。

// 使用Apple类时为T形参传入String类型
public class A extends Apple<String>

调用方法时必须为所有的数据形参传入参数值,与调用方法不同的是,使用类、接口时可以不为类型形参传入实际的类型参数。

// 使用Apple类时,没有为T形参传入实际的类型参数
public class A extends Apple

如果从 Apple<String> 类派生子类,则在 Apple 类中所有使用 T 类型形参的地方都将被替换成 String 类型,即它的子类将会继承到 String getInfo()void setInfo(String info) 两个方法,如果子类需要重写父类的方法,就必须注意这一点。

public class A1 extends Apple<String> {
    // 正确重写了父类的方法,返回值
    // 与父类Apple<String>的返回值完全相同
    public String getInfo() {
        return "子类" + super.getInfo();
    }
   /*
    // 下面方法是错误的,重写父类方法时返回值类型不一致
    public Object getInfo()
    {
          return "子类";
    }
    */
}

如果使用 Apple 类时没有传入实际的类型参数,Java 编译器可能发出警告:使用了未经检查或不安全的操作——这就是泛型检查的警告。

如果希望看到该警告提示的更详细信息,则可以通过为javac命令增加 -Xlint:unchecked 选项来实现。

此时,系统会把 Apple<T> 类里的 T 形参当成 Object 类型处理,意味着可以传任意引用类型进去。

public class A2 extends Apple {
    // 重写父类的方法
    public String getInfo() {
        // super.getInfo()方法返回值是Object类型
        // 所以加toString()才返回String类型
        return super.getInfo().toString();
    }
}

不存在泛型类

提示

不要把 ArrayList<String> 类当成 ArrayList 的子类,虽然 ArrayList<String> 类也确实像一种特殊的 ArrayList 类,这个 ArrayList<String> 对象只能添加 String 对象作为集合元素。

List<String> l1 = new ArrayList<>();
List<Integer> l2 = new ArrayList<>();
// 调用getClass()方法来比较l1和l2的类是否相等
System.out.println(l1.getClass() == l2.getClass());

运行上面的代码片段,实际输出 true。因为不管泛型的实际类型参数是什么,它们在运行时总有同样的类(class)。

不管为泛型的类型形参传入哪一种类型实参,对于 Java 来说,它们依然被当成同一个类处理,在内存中也只占用一块内存空间,因此在静态方法、静态初始化块或者静态变量的声明和初始化中不允许使用类型形参。

public class R<T> {
    // 下面代码错误,不能在静态Field声明中使用类型形参
    static T info;
    T age;

    public void foo(T msg) {
    }

    // 下面代码错误,不能在静态方法声明中使用类型形参
    public static void bar(T msg) {
    }
}

instanceof 运算符后不能使用泛型类。

Collection cs=new ArrayList<String>();
// 下面代码编译时引起错误:instanceof 运算符后不能使用泛型类
if(cs instanceof List<String>){...}

类型通配符

假设现在需要定义一个方法,该方法里有一个集合形参,集合形参的元素类型是不确定的,那应该怎样定义呢?

public void test(List c) {
    for (int i = 0; i < c.size(); i++) {
        System.out.println(c.get(i));
    }
}

程序中 List 是一个有泛型声明的接口,此处使用 List 接口时没有传入实际类型参数,这将引起泛型警告。为此,我们考虑为 List 接口传入实际的类型参数——因为 List 集合里的元素类型是不确定的,将上面方法改为如下形式:

public void test(List<Object> c) {
    for (int i = 0; i < c.size(); i++) {
        System.out.println(c.get(i));
    }
}

下面代码试图调用该方法。

// 创建一个List<String>对象
List<String> strList = new ArrayList<>();
// ①将strList作为参数来调用前面的test方法
test(strList);

编译上面程序,将在 ① 处发生如下编译错误:

无法将 Test 中的test(java.util.List<java.lang.Object>)
应用于 (java.util.List<java.lang.String>)

这表明 List<String> 对象不能被当成 List<Object> 对象使用,也就是说, List<String> 类并不是 List<Object> 类的子类。

与数组进行对比,先看一下数组是如何工作的。

在数组中,程序可以直接把一个 Integer[] 数组赋给一个 Number[] 变量。如果试图把一个 Double 对象保存到该 Number[] 数组中,编译可以通过,但在运行时抛出 ArrayStoreException 异常。

public class ArrayErr {
    public static void main(String[] args) {
        // 定义一个Integer数组
        Integer[] ia = new Integer[5];
        // 可以把一个Integer[]数组赋给Number[]变量
        Number[] na = ia;
        // 下面代码编译正常,但运行时会引发ArrayStoreException异常
        // ①因为0.5并不是Integer
        na[0] = 0.5;
    }
}

在 Java 的早期设计中,允许 Integer[] 数组赋值给 Number[] 变量存在缺陷,因此 Java 在泛型设计时进行了改进,它不再允许把 List<Integer> 对象赋值给 List<Number> 变量。

Java 泛型的设计原则是,只要代码在编译时没有出现警告,就不会遇到运行时 ClassCastException 异常。

使用类型通配符

类型通配符是一个问号?,将一个问号作为类型实参传给 List 集合,写作:List<?>(意思是未知类型元素的 List)。这个问号 ? 被称为通配符,它的元素类型可以匹配任何类型。

public void test(List<?> c) {
    for (int i = 0; i < c.size(); i++) {
        System.out.println(c.get(i));
    }
}

现在使用任何类型的 List 来调用它,程序依然可以访问集合 c 中的元素,其类型是 Object,这永远是安全的,因为不管 List 的真实类型是什么,它包含的都是 Object

但这种带通配符的 List 仅表示它是各种泛型 List 的父类,并不能把元素加入到其中。

List<?> c = new ArrayList<String>();
// 下面程序引起编译错误
c.add(new Object());

我们不知道上面程序中 c 集合里元素的类型,所以不能向其中添加对象。

设定类型通配符的上限

我们不想使这个 List<?> 是任何泛型 List 的父类,只想表示它是某一类泛型 List 的父类。考虑一个简单的绘图程序,下面先定义三个形状类:

// 定义一个抽象类Shape
public abstract class Shape {
    public abstract void draw(Canvas c);
}
// 定义Shape的子类Circle
public class Circle extends Shape {
    // 实现画图方法,以打印字符串来模拟画图方法实现
    public void draw(Canvas c) {
        System.out.println("在画布" + c + "上画一个圆");
    }
}
// 定义Shape的子类Rectangle
public class Rectangle extends Shape {
    // 实现画图方法,以打印字符串来模拟画图方法实现
    public void draw(Canvas c) {
        System.out.println("把一个矩形画在画布" + c + "上");
    }
}

接下来定义一个 Canvas 类,该画布类可以画数量不等的形状(Shape 子类的对象),我们该如何定义这个 Canvas 类呢?

public class Canvas {
    // 同时在画布上绘制多个形状,使用被限制的泛型通配符
    public void drawAll(List<? extends Shape> shapes) {
        for (Shape s : shapes) {
            s.draw(this);
        }
    }
}

Canvas 改为如上形式,就可以把 List<Circle> 对象当成 List<? extends Shape> 使用。

List<? extends Shape> 可以表示 List<Circle>List<Rectangle> 的父类——只要 List 后尖括号里的类型是 Shape 的子类型即可。

List<? extends Shape> 是受限制通配符的例子,此处的问号 ? 代表一个未知的类型,就像前面看到的通配符一样。

但是此处的这个未知类型一定是 Shape 的子类型(也可以是 Shape 本身),因此我们把 Shape 称为这个通配符的上限(upper bound)。

因为我们不知道这个受限制的通配符的具体类型,所以不能把 Shape 对象或其子类的对象加入这个泛型集合中。

public void addRectangle(List<? extends Shape> shapes) {
    // 下面代码引起编译错误
    shapes.add(0, new Rectangle());
}

设定类型形参的上限

Java 泛型不仅允许在使用通配符形参时设定上限,而且可以在定义类型形参时设定上限,用于表示传给该类型形参的实际类型要么是该上限类型,要么是该上限类型的子类。

public class Apple<T extends Number> {
    T col;

    public static void main(String[] args) {
        Apple<Integer> ai = new Apple<>();
        Apple<Double> ad = new Apple<>();
        // 下面代码将引发编译异常,下面代码试图把String类型传给T形参
        // 但String不是Number的子类型,所以引起编译错误
        Apple<String> as = new Apple<>();
    }
}

Apple 类的类型形参的上限是 Number 类,这表明使用 Apple 类时为 T 形参传入的实际类型参数只能是 NumberNumber 类的子类。

在一种更极端的情况下,程序需要为类型形参设定多个上限(至多有一个父类上限,可以有多个接口上限),表明该类型形参必须是其父类的子类(是父类本身也行),并且实现多个上限接口。

// 表明T类型必须是Number类或其子类,并必须实现java.io.Serializable接口
public class Apple<T extends Number & java.io.Serializable> {
            ...
}

如果需要为类型形参指定类上限,类上限必须位于第一位。

泛型方法

在定义类、接口时可以使用类型形参,在该类的方法定义和 Field 定义、接口的方法定义中,这些类型形参可被当成普通类型来用。在另外一些情况下,我们定义类、接口时没有使用类型形参,但定义方法时想自己定义类型形参,这也是可以的,Java 5 还提供了对泛型方法的支持。

定义泛型方法

Java 5 提供的泛型方法(Generic Method)。所谓泛型方法,就是在声明方法时定义一个或多个类型形参。

泛型方法的用法格式如下:

修饰符 <T , S> 返回值类型 方法名(形参列表) {
    // 方法体...
}

泛型方法的方法签名比普通方法的方法签名多了类型形参声明,类型形参声明以尖括号括起来,多个类型形参之间以逗号(,)隔开,所有的类型形参声明放在方法修饰符和方法返回值类型之间。

假设需要实现这样一个方法——该方法负责将一个 Object 数组的所有元素添加到一个 Collection 集合中。

static <T> void fromArrayToCollection(T[] a, Collection<T> c) {
    Collections.addAll(c, a);
}

完整的用法。

public class GenericMethodTest {
    public static void main(String[] args) {
        Object[] oa = new Object[100];
        Collection<Object> co = new ArrayList<>();
        // 下面代码中T代表Object类型
        fromArrayToCollection(oa, co);
        String[] sa = new String[100];
        Collection<String> cs = new ArrayList<>();
        // 下面代码中T代表String类型
        fromArrayToCollection(sa, cs);
        // 下面代码中T代表Object类型
        fromArrayToCollection(sa, co);
        Integer[] ia = new Integer[100];
        Float[] fa = new Float[100];
        Number[] na = new Number[100];
        Collection<Number> cn = new ArrayList<>();
        // 下面代码中T代表Number类型
        fromArrayToCollection(ia, cn);
        // 下面代码中T代表Number类型
        fromArrayToCollection(fa, cn);
        // 下面代码中T代表Number类型
        fromArrayToCollection(na, cn);
        // 下面代码中T代表Object类型
        fromArrayToCollection(na, co);
        // 下面代码中T代表String类型,但na是一个Number数组
        // 因为Number既不是String类型,也不是它的子类
        // 所以出现编译错误
        // fromArrayToCollection(na, cs);
    }

    // 声明一个泛型方法,该泛型方法中带一个T类型形参
    static <T> void fromArrayToCollection(T[] a, Collection<T> c) {
        Collections.addAll(c, a);
    }
}

方法中的泛型参数无须显式传入实际类型参数,如上面程序所示,当程序调用fromArrayToCollection()方法时,无须在调用该方法前传入 String、Object 等类型,但系统依然可以知道类型形参的数据类型,因为编译器根据实参推断类型形参的值,它通常推断出最直接的类型参数。

为了让编译器能准确地推断出泛型方法中类型形参的类型,不要制造迷惑!系统一旦迷惑了,就是你错了!看如下程序。

public class ErrorTest {
    // 声明一个泛型方法,该泛型方法中带一个T类型形参
    static <T> void test(Collection<T> from, Collection<T> to) {
        for (T ele : from) {
            to.add(ele);
        }
    }

    public static void main(String[] args) {
        List<Object> as = new ArrayList<>();
        List<String> ao = new ArrayList<>();
        // 下面代码将产生编译错误
        test(as, ao);
    }
}

方法中的两个形参 from、to 的类型都是Collection<T>,这要求调用该方法时的两个集合实参中的泛型类型相同,否则编译器无法准确地推断出泛型方法中类型形参的类型。

为了避免这种错误,可以将该方法改为如下形式:

public class RightTest {
    // 声明一个泛型方法,该泛型方法中带一个T形参
    static <T> void test(Collection<? extends T> from, Collection<T> to) {
        for (T ele : from) {
            to.add(ele);
        }
    }

    public static void main(String[] args) {
        List<Object> ao = new ArrayList<>();
        List<String> as = new ArrayList<>();
        // 下面代码完全正常
        test(as, ao);
    }
}

将该方法的前一个形参类型改为Collection<?extends T>,这种采用类型通配符的表示方式,只要 test 方法的前一个 Collection 集合里的元素类型是后一个 Collection 集合里元素类型的子类即可。

泛型方法和类型通配符的区别

大多数时候都可以使用泛型方法来代替类型通配符。例如,对于 Java 的 Collection 接口中两个方法定义:

public interface Collection<E> {
    boolean containsAll(Collection<?> c);

    boolean addAll(Collection<? extends E> c);
            ...
}

也可以采用泛型方法的形式,如下所示。

public interface Collection<E> {
    <T> boolean containsAll(Collection<T> c);

    <T extends E> boolean addAll(Collection<T> c);
}

泛型方法允许类型形参被用来表示方法的一个或多个参数之间的类型依赖关系,或者方法返回值与参数之间的类型依赖关系。如果没有这样的类型依赖关系,就不应该使用泛型方法。

提示

如果某个方法中一个形参(a)的类型或返回值的类型依赖于另一个形参(b)的类型,则形参(b)的类型声明不应该使用通配符——因为形参(a)或返回值的类型依赖于该形参(b)的类型,如果形参(b)的类型无法确定,程序就无法定义形参(a)的类型。在这种情况下,只能考虑使用在方法签名中声明类型形参——也就是泛型方法。

如果有需要,我们可以同时使用泛型方法和通配符,如 Java 的Collections.copy()方法。

public class Collections {
    public static <T> void copy(List<T> dest, List<? extends T> src) {...}
            ...
}

上面 copy 方法中的destsrc存在明显的依赖关系,从源 List 中复制出来的元素,必须可以“丢进”目标 List 中,所以源 List 集合元素的类型只能是目标集合元素的类型的子类型或者它本身。但 JDK 定义src形参类型时使用的是类型通配符,而不是泛型方法。这是因为:该方法无须向src集合中添加元素,也无须修改src集合里的元素,所以可以使用类型通配符,无须使用泛型方法。

也可以将上面的方法签名改为使用泛型方法,不使用类型通配符。

class Collections {
    public static <T, S extends T> void copy(List<T> dest, List<S> src) {...}
            ...
}

这个方法签名可以代替前面的方法签名。但注意上面的类型形参 S,它仅使用了一次,没有其他参数的类型、方法返回值的类型依赖于它,那类型形参 S 就没有存在的必要,即可以用通配符来代替 S。使用通配符比使用泛型方法(在方法签名中显式声明类型形参)更加清晰和准确,因此 Java 设计该方法时采用了通配符,而不是泛型方法。

类型通配符与泛型方法(在方法签名中显式声明类型形参)还有一个显著的区别:类型通配符既可以在方法签名中定义形参的类型,也可以用于定义变量的类型;但泛型方法中的类型形参必须在对应方法中显式声明。

Java 7 的“菱形”语法与泛型构造器

Java 允许在构造器签名中声明类型形参,这样就产生了所谓的泛型构造器。

一旦定义了泛型构造器,接下来在调用构造器时,就不仅可以让 Java 根据数据参数的类型来“推断”类型形参的类型,而且程序员也可以显式地为构造器中的类型形参指定实际的类型。

class Foo {
    public <T> Foo(T t) {
        System.out.println(t);
    }
}

public class GenericConstructor {
    public static void main(String[] args) {
        // 泛型构造器中的T参数为String
        new Foo("疯狂Java讲义");
        // 泛型构造器中的T参数为Integer
        new Foo(200);
        // 显式指定泛型构造器中的T参数为String
        // ①传给Foo构造器的实参也是String对象,完全正确
        new <String>Foo("疯狂Android讲义");
        // 显式指定泛型构造器中的T参数为String
        // ②传给Foo构造器的实参也是Double对象,下面代码出错
        new <String>Foo(12.3);
    }
}

如果程序显式指定了泛型构造器中声明的类型形参的实际类型,则不可以使用“菱形”语法。

class MyClass<E> {
    public <T> MyClass(T t) {
        System.out.println("t参数的值为:" + t);
    }
}

public class GenericDiamondTest {
    public static void main(String[] args) {
        // MyClass类声明中的E形参是String类型
        // 泛型构造器中声明的T形参是Integer类型
        MyClass<String> mc1 = new MyClass<>(5);
        // 显式指定泛型构造器中声明的T形参是Integer类型
        MyClass<String> mc2 = new <Integer>MyClass<String>(5);
        // MyClass类声明中的E形参是String类型
        // 如果显式指定泛型构造器中声明的T形参是Integer类型
        // 此时就不能使用“菱形”语法,下面代码是错的
        // MyClass<String> mc3=new <Integer> MyClass<>(5);
    }
}

设定通配符下限

假设自己实现一个工具方法:实现将src集合里的元素复制到dest集合里的功能,因为dest集合可以保存src集合里的所有元素,所以dest集合元素的类型应该是src集合元素类型的父类。为了表示两个参数之间的类型依赖,考虑同时使用通配符、泛型参数来实现该方法。

public static <T> void copy(Collection<T> dest, Collection<? extends T> src) {
    for (T ele : src) {
        dest.add(ele);
    }
}

现在假设该方法需要一个返回值,返回最后一个被复制的元素,则可以把上面方法改为如下形式:

public static <T> T copy(Collection<T> dest, Collection<? extends T> src) {
    T last = null;
    for (T ele : src) {
        last = ele;
        dest.add(ele);
    }
    return last;
}

上面方法实现了这个功能,实际上有一个问题:当遍历src集合的元素时,src元素的类型是不确定的(但可以肯定它是 T 的子类),程序只能用 T 来笼统地表示各种src集合的元素类型。例如如下代码:

List<Number> ln = new ArrayList<>();
List<Integer> li = new ArrayList<>();
// 下面代码引起编译错误
Integer last = copy(ln, li);

上面代码中 ln 的类型是List<Number>,与 copy 方法签名的形参类型进行对比即得到 T 的实际类型是 Number,而不是 Integer 类型——即 copy 方法的返回值也是 Number 类型,而不是 Integer 类型,但实际上最后一个复制元素的元素类型一定是 Integer。也就是说,程序在复制集合元素的过程中,丢失了src集合元素的类型。

对于上面的 copy 方法,不管src集合元素的类型是什么,只要dest集合元素的类型与前者相同或是前者的父类即可。为了表达这种约束关系,Java 允许设定通配符的下限:<? super Type>,这个通配符表示它必须是 Type 本身,或是 Type 的父类。

public class MyUtils {
    // 下面dest集合元素的类型必须与src集合元素的类型相同,或是其父类
    public static <T> T copy(Collection<? super T> dest, Collection<T> src) {
        T last = null;
        for (T ele : src) {
            last = ele;
            dest.add(ele);
        }
        return last;
    }

    public static void main(String[] args) {
        List<Number> ln = new ArrayList<>();
        List<Integer> li = new ArrayList<>();
        li.add(5);
        // 此处可准确地知道最后一个被复制的元素是Integer类型
        // 与src集合元素的类型相同
        // ①
        Integer last = copy(ln, li);
        System.out.println(ln);
    }
}

Java 集合框架中的TreeSet<E>有一个构造器也用到了这种设定通配符下限的语法,如下所示。

// 下面的E是定义TreeSet类时的类型形参
TreeSet(Comparator c)

TreeSet会对集合中的元素按自然顺序或定制顺序进行排序。如果需要TreeSet对集合中的所有元素进行定制排序,则要求TreeSet对象有一个与之关联的 Comparator 对象。上面构造器中的参数 c 就是进行定制排序的 Comparator 对象。

Comparator 接口也是一个带泛型声明的接口:

public interface Comparator<T> {
    int compare(T fst, T snd);
}

通过这种带下限的通配符的语法,可以在创建TreeSet对象时灵活地选择合适的 Comparator。假定需要创建一个TreeSet<String>集合,并传入一个可以比较 String 大小的 Comparator,这个 Comparator 既可以是Comparator<String>,也可以是Comparator<Object>——只要尖括号里传入的类型是 String 的父类型(或它本身)即可。

public class TreeSetTest {
    public static void main(String[] args) {
        // Comparator的实际类型是TreeSet里实际类型的父类,满足要求
        TreeSet<String> ts1 = new TreeSet<>(new Comparator<Object>() {
            public int compare(Object fst, Object snd) {
                return hashCode() > snd.hashCode() ? 1 : hashCode() < snd.hashCode() ? -1 : 0;
            }
        });
        ts1.add("hello");
        ts1.add("wa");
        TreeSet<String> ts2 = new TreeSet<>(new Comparator<String>() {
            public int compare(String first, String second) {
                return first.length() > second.length() ? -1 : first.length() < second.length() ? 1 : 0;
            }
        });
        ts2.add("hello");
        ts2.add("wa");
        System.out.println(ts1);
        System.out.println(ts2);
    }
}

泛型方法与方法重载

因为泛型既允许设定通配符的上限,也允许设定通配符的下限,从而允许在一个类里包含如下两个方法定义。

// ①
public static <T> void copy(Collection<T> dest, Collection<? extends T> src) {...}
// ②
public static <T> T copy(Collection<? super T> dest, Collection<T> src) {...}

这两个方法的参数列表存在一定的区别,但这种区别不是很明确:这两个方法的两个参数都是 Collection 对象,前一个集合里的集合元素类型是后一个集合里集合元素类型的父类。如果这个类仅包含这两个方法不会有任何错误,但只要调用这个方法就会引起编译错误。

List<Number> ln = new ArrayList<>();
List<Integer> li = new ArrayList<>();
copy(ln , li);

这个 copy 方法既可以匹配 ① 号 copy 方法,此时 T 类型参数的类型是 Number;也可以匹配 ② 号 copy 方法,此时 T 参数的类型是 Integer。编译器无法确定这行代码想调用哪个 copy 方法,所以这行代码将引起编译错误。

擦除和转换

在严格的泛型代码里,带泛型声明的类总应该带着类型参数。但为了与老的 Java 代码保持一致,也允许在使用带泛型声明的类时不指定实际的类型参数。如果没有为这个泛型类指定实际的类型参数,则该类型参数被称作 raw type(原始类型),默认是声明该参数时指定的第一个上限类型。

当把一个具有泛型信息的对象赋给另一个没有泛型信息的变量时,所有在尖括号之间的类型信息都将被扔掉。比如一个List<String>类型被转换为 List,则该 List 对集合元素的类型检查变成了类型变量的上限(即 Object)。

class Apple<T extends Number> {
    T size;

    public Apple() {
    }

    public Apple(T size) {
        this.size = size;
    }

    public void setSize(T size) {
        this.size = size;
    }

    public T getSize() {
        return this.size;
    }
}

public class ErasureTest {
    public static void main(String[] args) {
        // ①
        Apple<Integer> a = new Apple<>(6);
        // a的getSize()方法返回Integer对象
        Integer as = a.getSize();
        // ②把a对象赋给Apple变量,丢失尖括号里的类型信息
        Apple b = a;
        // b只知道size的类型是Number
        Number size1 = b.getSize();
        // ③下面代码引起编译错误
        // Integer size2=b.getSize();
    }
}

当把 a 赋给一个不带泛型信息的 b 变量时,编译器就会丢失 a 对象的泛型信息,即所有尖括号里的信息都会丢失——因为 Apple 的类型形参的上限是 Number 类,所以编译器依然知道 b 的getSize()方法返回 Number 类型,但具体是 Number 的哪个子类就不清楚了。

从逻辑上来看,List<String>是 List 的子类,如果直接把一个 List 对象赋给一个List<String>对象应该引起编译错误,但实际上不会。对泛型而言,可以直接把一个 List 对象赋给一个List<String>对象,编译器仅仅提示“未经检查的转换”,看下面程序。

public class ErasureTest2 {
    public static void main(String[] args) {
        List<Integer> li = new ArrayList<>();
        li.add(6);
        li.add(9);
        List list = li;
        // ①下面代码引起“未经检查的转换”警告,编译、运行时完全正常
        List<String> ls = list;
        // 只要访问ls里的元素,如下代码将引发运行时异常
        System.out.println(ls.get(0));
    }
}

程序中定义了一个List<Integer>对象,这个 List 对象保留了集合元素的类型信息。当把这个 List 对象赋给一个 List 类型的 list 后,编译器就会丢失前者的泛型信息,即丢失 list 集合里元素的类型信息,这是典型的擦除。

Java 又允许直接把 List 对象赋给一个List<Type>(Type 可以是任何类型)类型的变量,所以程序在 ① 处可以编译通过,只是发出“未经检查的转换”警告。但对 list 变量实际上引用的是List<Integer>集合,所以当试图把该集合里的元素当成 String 类型的对象取出时,将引发运行时异常。

泛型与数组

Java 5 的泛型有一个很重要的设计原则——如果一段代码在编译时没有提出[unchecked] 未经检查的转换警告,则程序在运行时不会引发ClassCastException异常。

正是基于这个原因,所以数组元素的类型不能包含类型变量或类型形参,除非是无上限的类型通配符。但可以声明元素类型包含类型变量或类型形参的数组。

也就是说,只能声明List<String>[]形式的数组,但不能创建ArrayList<String>[10]这样的数组对象。

Peel<Banana>[] peels = new Peel<Banana>[10]; // Illegal

类型擦除需要删除参数类型信息,而且数组必须知道它们所保存的确切类型,以强制保证类型安全。

编译器不会让你 实例化 一个泛型的数组。但是,它将允许您创建对此类数组的引用。还可以创建一个非泛型的数组并对其进行强制类型转换:

// 下面代码编译时有“[unchecked] 未经检查的转换”警告
List<String>[] lsa = new ArrayList[10];
Object[] oa = (Object[])lsa;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li;
// ①下面代码引起ClassCastException异常
String s = lsa[1].get(0);

Java 允许创建无上限的通配符泛型数组,例如new ArrayList<?>[10],因此也可以将代码改为使用无上限的通配符泛型数组,在这种情况下,程序不得不进行强制类型转换。

List<?>[] lsa = new ArrayList<?>[10];
Object[] oa = (Object[])lsa;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li;
// 下面代码引发ClassCastException异常
String s = (String)lsa[1].get(0);

通过instanceof运算符来保证数据类型一致。

List<?>[] lsa = new ArrayList<?>[10];
Object[] oa = (Object[])lsa;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li;
Object target = lsa[1].get(0);
if (target instanceof String) {
    // 下面代码安全了
    String s = (String)target;
}

与此类似的是,创建元素类型是类型变量的数组对象也将导致编译错误。

<T> T[] makeArray(Collection<T> coll) {
    // 下面代码导致编译错误
    return new T[coll.size()];
}

因为类型变量在运行时并不存在,所以编译器无法确定实际类型是什么。