Java 之 泛型

79 阅读13分钟

一、泛型概述和好处

泛型概述

泛型是JDK5中引入的特性,它提供了编译时类型安全检测机制,该机制允许在编译时检测到非法的类型。

它的本质是参数化类型,也就是说操作的数据类型被指定为一个参数,一提到参数,最熟悉的就是定义方法时有形参,然后调用此方法时传递实参。而参数化类型就是将类型由原来的具体的类型参数化,然后在调用时传入具体的类型。这种参数类型可以用在类、方法和接口中,分别被称为泛型类、泛型方法、泛型接口。

在Java中通常使用E、T、K、V、N、?的含义。

E - Element (在集合中使用,因为集合中存放的是元素)。
T - Type(Java类) T代表在调用时的指定类型。
K - Key(键)。
V - Value(值)。
N - Number(数值类型)。
? - 表示不确定的java类型,一般用在通配。
S (Secondary Type):当需要第二个类型参数时,通常使用 S。 U (Third Type):当需要第三个类型参数时,通常使用 U。 V (Fourth Type):当需要第四个类型参数时,通常使用 V(注意这里的 V 与映射中的 V 不同)。

这些符号只是一些专业标识作用。

泛型的好处:

  1. 把运行时期的问题提前到了编译期间。

  2. 避免了强制类型转换。

二、泛型的使用方式

泛型有三种使用方式,分别为:泛型类、泛型接口和泛型方法。 image.png

泛型类

泛型类:把泛型定义在类上。

定义格式: image.png

泛型接口

泛型方法概述:把泛型定义在方法上。

定义格式: image.png 方法声明中定义的形参只能在该方法里使用,而接口、类声明中定义的类型形参则可以在整个接口、类中使用。

泛型方法

泛型方法,是在调用方法的时候指明泛型的具体类型 。

定义格式: image.png

例如:

       /**
         *
         * @param t 传入泛型的参数
         * @param <T> 泛型的类型
         * @return T 返回值为T类型
         * 说明:
         *   1)public 与 返回值中间<T>非常重要,可以理解为声明此方法为泛型方法。
         *   2)只有声明了<T>的方法才是泛型方法,泛型类中的使用了泛型的成员方法并不是泛型方法。
         *   3)<T>表明该方法将使用泛型类型T,此时才可以在方法中使用泛型类型T。
         *   4)与泛型类的定义一样,此处T可以随便写为任意标识,常见的如T、E等形式的参数常用于表示泛型。
         */
        public <T> T genercMethod(T t){
            System.out.println(t.getClass());
            System.out.println(t);
            return t;
        }

三、通配符

Java泛型的通配符是用于解决泛型之间引用传递问题的特殊语法, 主要有以下三类: image.png

无界通配符

语法格式:类型名称 <?> 对象名称
描述:

  1. ? 号是特殊的一类泛型符号,有专门的含义。
  2. List<Object>List<?>并不相同,在List<Object>里面你可以插入一切实例,但是在List<?>你就只能添加null值。
  3. 如果你正在编写的方法可以用Object类提供的方法进行实现。
  4. 类中的代码不依赖类型参数,例如List.sizeList.clear。则可以使用Class<?>,因为Class<T>的大部分方法都不依赖于类型参数T
/**
 * 这个方法的意图是打印任意List元素,但是这么写的话,你再调用的时候只能传递List<Object>类型的参数,不能
 * 传递List<Integer>类型的参数,原因也是在我们讨论过的,List<Integer> 并不是List<Object>的子类型。 
 * 这个时候我们就可以用到 ? 通配符。
 */
public static void printList(List<Object> list) {
    for (Object elem : list){
     System.out.println(elem + " ");
    }
}

/**
 * 因为任意类型A,List<A>都是List<?>的子类型。值得注意的是List<Object>和List<?> 并不相同,在
 * List<Object>里面你可以插入一切实例,但是在List<?>你就只能添加null值。
 */
public static void printList(List<?> list) {
    for (Object elem : list){
     System.out.println(elem + " ");
    }
}

上界通配符

语法格式:类型名称 <? extends 类 > 对象名称

  • 上界通配符限制的是传入的类型必须是限制类型或限制类型的子类型,
// 假如我们想制作一个处理`List<Number>`的方法,我们希望限制集合中的元素只能是`Number`的子类,比如如下:
public static <T extends Number> int processNumberList(List<T> anArray) {
     // 省略处理逻辑
     return 0;
}

// 但有了通配符 ? 之后,事实上我们可以这么声明:
public static int processNumberList(List<? extends  Number> numberList ) {
    return 0;
}

// 事实上编译器会认为这两个方法是一样的,IDEA上会给出提示是:
// 'processNumberList(List<? extends Number>)' clashes with 'processNumberList(List)'; 
// both methods have same erasure
// 两个方法拥有相同的泛型擦除.

上界通配符只需要读取数据的场景:
上界通配符 <? extends T> 表示类型必须是 TT 的子类。在这种情况下,你只能安全地从集合中读取数据,而不能往集合中写入数据。原因如下:

例如: 如果 AnimalCreature 的子类,而 Cat 又是 Animal 的子类,那么 List<? extends Creature> 可以是 List<Animal>List<Cat>

class Creature {}
class Animal extends Creature {}
class Cat extends Animal {}

public void method(List<? extends Creature> list) {
    // 可以读取
    for (Creature creature : list) {
        System.out.println(creature);
    }
    // 不可以写入
    list.add(new Animal()); // 编译错误
    list.add(new Cat()); // 编译错误
}

在这个例子中,List<? extends Creature> 可以是 List<Animal>List<Cat> 等。我们只能读取其中的 Creature 对象,而不能往里面添加任何元素(即使是 Creature 类型的元素),否则会引发编译错误。使用反证法,如果 method 的实参是 new ArrayList<Cat>(),而 method 的形参是可以接受泛型为 Animal 的,那么在方法体中给 List<Cat> 添加 Animal 对象显然是不合适的。因此,编译器禁止了这种写入操作。

在Java中,虽然不支持多继承,但是可以实现多个接口,但是如果多个上界中某个上界是类,那么这个类一定要出现在第一个位置,如下所示:

class A {}
interface B {}
interface C {}
// 如果A不在第一个位置,就会编译报错。
class D <T extends A & B & C>

下界通配符

语法格式:类型名称 <? super 类 > 对象名称

  • 下界通配符限制传入类型必须是限制类型或限制类型的父类型。
public static void addNumbers(List<? super Integer> list) {
    for (int i = 1; i <= 10; i++) {
        list.add(i);
    }
}

注意: 上界下界不能同时出现。

  • 用于只需要写入数据的场景 下界通配符 <? super T> 表示类型必须是 TT 的父类。在这种情况下,你只能安全地往集合中写入数据,而不能从集合中读取特定类型的数据。

反证法解释 假设 List<? super Animal> 可以读取为 Animal 类型:

public void method(List<? super Animal> list) {
    // 假设可以读取为 Animal 类型
    Animal animal = list.get(0); 
}

假如 method 方法接受一个 List<Creature> 作为参数:

List<Creature> creatures = new ArrayList<>();
method(creatures);

在方法内部,如果我们尝试将 list.get(0)读取为 Animal 类型,就会引发类型错误,因为 List<Creature> 可能包含非 AnimalCreature 对象。

这种假设会导致类型安全问题,因此编译器禁止从 List<? super Animal> 中读取为 Animal 类型。只能安全地向其中添加 Animal 或其子类对象。所以对读取数据进行限制,只能读取 Object 类型:

public void readObjects(List<? super Cat> list) {
    // 可以读取,但类型是 Object
    Object obj = list.get(0); 
}

下界通配符确保了类型安全,避免了类型不一致的问题。

通配符和子类型化

现在我们有两个类A和B,关系如下:

class A {}
class B extends A{}
class C extends A{}

BCA的子类,所以我们可以写出这样的代码:

A a1 = new B();
A a2 = new C();

这种写法我们一般称之为向上转型,但是下面的代码就不会编译通过:

List<B> bList = new ArrayList<>();
//编译失败
List<A> aList = bList;

BCA的子类,但是List<B>ListA<C>却不是List<A>的子类型,事实上,这两种类型并没有关系。它们的共同父类是List<?>, 为了让List<B>List<C>List<A>之间产生关系,我们可以借助上界通配符,例如:

public static void main(String[] args) {
    List<? extends A> bList = new ArrayList<>();
    bList = new ArrayList<B>();
    bList = new ArrayList<C>();
}

四、泛型擦除

  • 如果泛型的类型参数是有边界的,则用边界来替换,如果是无界的,就用Object来替换。所以最后的字节码,还是普通的类、方法、接口。
  • 必要时插入类型转换确保类型安全。
  • 生成桥接方法以保留扩展泛型类型中的多态性。

泛型类擦除

public class Node<T>{
  private T data;
  private Node<T> next;
  
  public Node(T data , Node<T> next) {
      this.data = data;
      this.next = next;
  }
  public T getData(){return data};
}

类型参数没有限界,编译器会将T替换为Object:

public class Node{
  private Object data;
  private Node next;
  
  public Node(Object data , Node next) {
      this.data = data;
      this.next = next;
  }
  public Object getData(){return data};
}

如果我们对类型参数进行了限制:

public class Node<T extends Comparable<T>> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }
	public T getData() { return data; }
}    

Java编译器会用类型参数的第一个限界来替换,实际擦除之后,变成了下面这样:

public class Node {

    private Comparable data;
    private Node next;

    public Node(Comparable data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Comparable getData() { return data; }
    // ...
}

泛型方法擦除

现在我们声明一个泛型方法,如下所示:

public static <T> int count(T[] anArray, T elem) {
    int cnt = 0;
    for (T e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}

泛型参数未被限制,经过Java编译器的处理,T会被替换为Object

public static  int count(Object[] anArray, Object elem) {
    int cnt = 0;
    for (T e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}

对泛型参数进行限制:

class Shape { /* ... */ }
class Circle extends Shape { /* ... */ }
class Rectangle extends Shape { /* ... */ }
public static <T extends Shape> void draw(T shape) { /* ... */ }

Java的编译器会用shape替换T:

public static void draw(Shape shape) { /* ... */ }

类型擦除的影响和桥接方法

有时,类型擦除会导致预料之外的事情发生,比如:

public class Node<T> {

    public T data;

    public Node(T data) { this.data = data; }

    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {
    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}
MyNode myNode = new MyNode(5);
Node node = mn; // 原始类型会给一个警告
node.setData("Hello"); // 这里会抛出一个类型转换异常
Integer x = myNode.data;

编译器在编译泛型类或泛型接口的时候,编译器可能会创建一种方法,我们称之为桥方法。通常不需要担心桥方法,但如果它出现在堆栈中,可能你会感到困惑。类型擦除之后,NodeMyNode会变成下面这样:

public class Node {

    public Object data;

    public Node(Object data) { this.data = data; }

    public void setData(Object data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node {
    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

在类型擦除之后,父类和子类的签名不一致,Node.setData(T)方法变成Node.setData(Object) 。因此,MyNode.setData(T)方法并没有覆盖Node.setData(Object)方法, 为了维护泛型的多态,Java编译器产生了桥接方法,以便让子类型也能继续工作。按照我们对泛型的理解,Node中的setData方法入参也应当是Integer, 如果没有桥接方法,那么MyNode中就会继承一个setData(Object data)方法。

泛型擦除注意事项

  • 不能是基本类型,例如:int
  • 不能获取带泛型类型的Class,例如:Pair<String>.class
  • 不能判断带泛型类型的类型,例如:x instanceof Pair<String>
  • 不能实例化T类型,例如:new T()
  • 泛型方法要防止重复定义方法,例如:public boolean equals(T obj)
  • 子类可以获取父类的泛型类型<T>

五、泛型与反射

把泛型变量当成方法的参数,利用Method类的getActualTypeArguments方法来获取泛型的实际类型参数。

public class GenericTest {

    public static void main(String[] args) throws Exception {
        getParamType();
    }
    
     /*利用反射获取方法参数的实际参数类型*/
    public static void getParamType() throws NoSuchMethodException{
        Method method = GenericTest.class.getMethod("applyMap",Map.class);
        //获取方法的泛型参数的类型
        Type[] types = method.getGenericParameterTypes();
        System.out.println(types[0]);
        //参数化的类型
        ParameterizedType pType  = (ParameterizedType)types[0];
        //原始类型
        System.out.println(pType.getRawType());
        //实际类型参数
        System.out.println(pType.getActualTypeArguments()[0]);
        System.out.println(pType.getActualTypeArguments()[1]);
    }

    /*供测试参数类型的方法*/
    public static void applyMap(Map<Integer,String> map){

    }
}

// 输出结果
java.util.Map<java.lang.Integer, java.lang.String>
interface java.util.Map
class java.lang.Integer
class java.lang.String

通过反射绕开编译器对泛型的类型限制

public static void main(String[] args) throws Exception {
		// 定义一个包含int的链表
		ArrayList<Integer> al = new ArrayList<Integer>();
		al.add(1);
		al.add(2);
		// 获取链表的add方法,注意这里是Object.class,如果写int.class会抛出NoSuchMethodException异常
		Method m = al.getClass().getMethod("add", Object.class);
		// 调用反射中的add方法加入一个string类型的元素,因为add方法的实际参数是Object
		m.invoke(al, "hello");
		System.out.println(al.get(2));
	}

六、泛型的限制

模糊性错误

对于泛型类User<K,V>而言,声明了两个泛型类参数。在类中根据不同的类型参数重载show方法。

public class User<K, V> {

    // 报错信息:'show(K)' clashes with 'show(V)'; both methods have same erasure
    public void show(K k) { 
        
    }
    public void show(V t) {

    }
}

由于泛型擦除,KV二者本质上都是Obejct类型。方法是一样的,所以编译器会报错。换一个方式即可正常的使用

public class User<K, V> {

    public void show(String k) {

    }
    public void show(V t) {

    }
}

不能实例化类型参数

编译器也不知道该创建那种类型的对象

public class User<K, V> {

    // 报错:Type parameter 'K' cannot be instantiated directly
    private K key = new K(); 

}

对静态成员的限制

静态方法无法访问类上定义的泛型;如果静态方法操作的类型不确定,必须要将泛型定义在方法上,使其成为泛型方法。

public class User<T> {

    //错误
    private static T t;

    //错误
    public static T getT() {
        return t;
    }

    //正确
    public static <K> void test(K k) {

    }
}

对泛型数组的限制

不能实例化元素类型为类型参数的数组,但是可以将数组指向类型兼容的数组的引用

public class User<T> {

    private T[] values;

    public User(T[] values) {

        //错误,不能实例化元素类型为类型参数的数组
        this.values = new T[5];

        //正确,可以将values指向类型兼容的数组的引用
        this.values = values;
    }
}

对泛型异常的限制

泛型类不能扩展Throwable

七、通配符捕获和辅助方法

在某些情况下,编译器会尝试推断通配符的类型。例如一个List被定为List<?>,编译器执行表达式的时候,编译器会从代码中推断出一个具体的类型。这种情况被称为通配符捕获。大部分情况下,你都不需要担心通配符捕获的问题,除非你看到包含"捕获" 这一短语的错误信息。通配符错误通常发生在编译器:

public class WildcardError {
    void foo(List<?> i) {
        // 编译错误
        i.set(0, i.get(0));
    }
}

八、通配符使用指南

  • 如果需要使用入参可以使用定义在Object类中的方法时,使用无界通配符。
  • 当代码需要将变量同时用作输入和输出时,不要使用无界通配符。
  • 上界通配符(<? extends T>):用于只需要读取数据的场景,因为它允许使用 T 的任何子类,但禁止写入数据以保证类型安全。
  • 下界通配符(<? super T>):用于只需要写入数据的场景,因为它允许向集合中添加 T 及其子类,但读取的数据只能是Object类型。
  • 上吐下泻:有上限只能读(吐),有下限只能写(泻)。