Java 干货之深入理解Java泛型

时间:2022-08-12 19:25:44

一般的类和方法,只能使用具体的类型,要么是基本类型,要么是自定义的类。如果要编写可以应用多中类型的代码,这种刻板的限制对代码得束缚会就会很大。
---《Thinking in Java》

泛型大家都接触的不少,但是由于Java 历史的原因,Java 中的泛型一直被称为伪泛型,因此对Java中的泛型,有很多不注意就会遇到的“坑”,在这里详细讨论一下。对于基础而又常见的语法,这里就直接略过了。

什么是泛型

自JDK 1.5 之后,Java 通过泛型解决了容器类型安全这一问题,而几乎所有人接触泛型也是通过Java的容器。那么泛型究竟是什么?
泛型的本质是参数化类型
也就是说,泛型就是将所操作的数据类型作为参数的一种语法。

public class Paly<T>{
    T play(){}
}

其中T就是作为一个类型参数在Play被实例化的时候所传递来的参数,比如:

Play<Integer> playInteger=new Play<>();

这里T就会被实例化为Integer

泛型的作用

- 使用泛型能写出更加灵活通用的代码

泛型的设计主要参照了C++的模板,旨在能让人写出更加通用化,更加灵活的代码。模板/泛型代码,就好像做雕塑时的模板,有了模板,需要生产的时候就只管向里面注入具体的材料就行,不同的材料可以产生不同的效果,这便是泛型最初的设计宗旨。

- 泛型将代码安全性检查提前到编译期

泛型被加入Java语法中,还有一个最大的原因:解决容器的类型安全,使用泛型后,能让编译器在编译的时候借助传入的类型参数检查对容器的插入,获取操作是否合法,从而将运行时ClassCastException转移到编译时比如:

List dogs =new ArrayList();
dogs.add(new Cat());

在没有泛型之前,这种代码除非运行,否则你永远找不到它的错误。但是加入泛型后

List<Dog> dogs=new ArrayList<>();
dogs.add(new Cat());//Error Compile

会在编译的时候就检查出来。

- 泛型能够省去类型强制转换

在JDK1.5之前,Java容器都是通过将类型向上转型为Object类型来实现的,因此在从容器中取出来的时候需要手动的强制转换。

Dog dog=(Dog)dogs.get(1);

加入泛型后,由于编译器知道了具体的类型,因此编译期会自动进行强制转换,使得代码更加优雅。

泛型的具体实现

我们可以定义泛型类,泛型方法,泛型接口等,那泛型的底层是怎么实现的呢?

从历史上看泛型

由于泛型是JDK1.5之后才出现的,在此之前需要使用泛型(模板代码)的地方都是通过Object向上转型以及强制类型转换实现的,这样虽然能满足大多数需求,但是有个最大的问题就在于类型安全。在获取“真正”的数据的时候,如果不小心强制转换成了错误类型,这种错误只能在真正运行的时候才能发现。

因此Java 1.5推出了“泛型”,也就是在原本的基础上加上了编译时类型检查的语法糖。Java 的泛型推出来后,引起来很多人的吐槽,因为相对于C++等其他语言的泛型,Java的泛型代码的灵活性依然会受到很多限制。这是因为Java被规定必须保持二进制向后兼容性,也就是一个在Java 1.4版本中可以正常运行的Class文件,放在Java 1.5中必须是能够正常运行的:

在1.5之前,这种类型的代码是没有问题的。

public static void addRawList(List list){
   list.add("123");
   list.add(2);
}

1.5之后泛型大量应用后:

public static void addGenericList(List<String> list){
    list.add("1");//Only String
    list.add("2");
}

虽然我们认为addRawList()方法中的代码不是类型安全的,但是某些时候这种代码是有用的,在设计JDK1.5的时候,想要实现泛型有两种选择:

  • 需要泛型化的类型(主要是容器(Collections)类型),以前有的就保持不变,然后平行地加一套泛型化版本的新类型;
  • 直接把已有的类型泛型化,让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于已有类型的泛型版。

什么意思呢?也就是第一种办法是在原有的Java库的基础上,再添加一些库,这些库的功能和原本的一模一样,只是这些库是使用Java新语法泛型实现的,而第二种办法是保持和原本的库的高度一致性,不添加任何新的库。

在出现了泛型之后,原本没有使用泛型的代码就被称为raw type(原始类型)
Java 的二进制向后兼容性使得Java 需要实现前后兼容的泛型,也就是说以前使用原始类型的代码可以继续被泛型使用,现在的泛型也可以作为参数传递给原始类型的代码。
比如

 List<String> list=new ArrayList<>();
 List rawList=new ArrayList();
 addRawList(list);
 addGenericList(list);
 
 addRawList(rawList);
 addGenericList(rawList);

上面的代码能够正确的运行。

Java 设计者选择了第二种方案

C# 在1.1过渡到2.0中增加泛型时,使用了第一种方案。


为了实现以上功能,Java 设计者将泛型完全作为了语法糖加入了新的语法中,什么意思呢?也就是说泛型对于JVM来说是透明的,有泛型的和没有泛型的代码,通过编译器编译后所生成的二进制代码是完全相同的。

这个语法糖的实现被称为擦除

擦除的过程

泛型是为了将具体的类型作为参数传递给方法,类,接口。
擦除是在代码运行过程中将具体的类型都抹除。

前面说过,Java 1.5 之前需要编写模板代码的地方都是通过Object来保存具体的值。比如:

public class Node{
   private Object obj;

   public Object get(){
       return obj;
   }
   
   public void set(Object obj){
       this.obj=obj;
   }
   
   public static void main(String[] argv){
    
    Student stu=new Student();
    Node  node=new Node();
    node.set(stu);
    Student stu2=(Student)node.get();
   }
}

这样的实现能满足绝大多数需求,但是泛型还是有更多方便的地方,最大的一点就是编译期类型检查,于是Java 1.5之后加入了泛型,但是这个泛型仅仅是在编译的时候帮你做了编译时类型检查,成功编译后所生成的.class文件还是一模一样的,这便是擦除

1.5 以后实现

public class Node<T>{

    private T obj;
    
    public T get(){
        
        return obj;
    }
    
    public void set(T obj){
        this.obj=obj;
    }
    
    public static void main(String[] argv){
    
    Student stu=new Student();
    Node<Student>  node=new Node<>();
    node.set(stu);
    Student stu2=node.get();
  }
}

两个版本生成的.class文件:
Node:

  public Node();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
  public java.lang.Object get();
    Code:
       0: aload_0
       1: getfield      #2                  // Field obj:Ljava/lang/Object;
       4: areturn
  public void set(java.lang.Object);
    Code:
       0: aload_0
       1: aload_1
       2: putfield      #2                  // Field obj:Ljava/lang/Object;
       5: return
}

Node

public class Node<T> {
  public Node();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
  public T get();
    Code:
       0: aload_0
       1: getfield      #2                  // Field obj:Ljava/lang/Object;
       4: areturn

  public void set(T);
    Code:
       0: aload_0
       1: aload_1
       2: putfield      #2                  // Field obj:Ljava/lang/Object;
       5: return
}

可以看到泛型就是在使用泛型代码的时候,将类型信息传递给具体的泛型代码。而经过编译后,生成的.class文件和原始的代码一模一样,就好像传递过来的类型信息又被擦除了一样。

泛型语法

Java 的泛型就是一个语法糖,而语法糖最大的好处就是让人方便使用,但是它的缺点也在于如果不剥开这颗语法糖,有很多奇怪的语法就很难理解。

  • 类型边界
    前面说过,泛型在最终会擦除为Object类型。这样导致的是在编写泛型代码的时候,对泛型元素的操作只能使用Object自带的一些方法,但是有时候我们想使用其他类型的方法呢?
    比如:
public class Node{
    private People obj;
    public People get(){
        
        return obj;
    }
    
    public void set(People obj){
        this.obj=obj;
    }
    
    public void playName(){
        System.out.println(obj.getName());
    }
}

如上,代码中需要使用obj.getName()方法,因此比如规定传入的元素必须是People及其子类,那么这样的方法怎么通过泛型体现出来呢?
答案是extend,泛型重载了extend关键字,可以通过extend关键字指定最终擦除所替代的类型。

public class Node<T extend People>{

    private T obj;
    
    public T get(){
        
        return obj;
    }
    
    public void set(T obj){
        this.obj=obj;
    }
    
    public void playName(){
        System.out.println(obj.getName());
    }
}

通过extend关键字,编译器会将最后类型都擦除为People类型,就好像最开始我们看见的原始代码一样。

泛型与向上转型的概念

先讲一讲几个概念:

  • 协变:子类能向父类转换 Animal a1=new Cat();
  • 逆变: 父类能向子类转换 Cat a2=(Cat)a1;
  • 不变: 两者均不能转变

对于协变,我们见得最多的就是多态,而逆变常见于强制类型转换。
这好像没什么奇怪的。但是看以下代码:

public static void error(){
   Object[] nums=new Integer[3];
   nums[0]=3.2;
   nums[1]="string"; //运行时报错,nums运行时类型是Integer[]
   nums[2]='2';
 }

因为数组是协变的,因此Integer[]可以转换为Object[],在编译阶段编译器只知道numsObject[]类型,而运行时nums则为Integer[]类型,因此上述代码能够编译,但是运行会报错。

这就是常见的人们所说的数组是协变的。这里带来一个问题,为什么数组要设计为协变的呢?既然不让运行,那么通过编译有什么用?

答案是在泛型还没出现之前,数组协变能够解决一些通用的问题:

public static void sort(Object[] a) {
        if (LegacyMergeSort.userRequested)
            legacyMergeSort(a);
        else
            ComparableTimSort.sort(a, 0, a.length, null, 0, 0);
    }
/**
 * 摘自JDK 1.8 Arrays.equals()
 */
  public static boolean equals(Object[] a, Object[] a2) {
        //...
        for (int i=0; i<length; i++) {
            Object o1 = a[i];
            Object o2 = a2[i];
            if (!(o1==null ? o2==null : o1.equals(o2)))
                return false;
        }
        //..
        return true;
    }

可以看到,只操作数组本身,而关心数组中具体保存的原始,或则是不管什么元素,取出来就作为一个Object存储的时候,只用编写一个Object[]就能写出通用的数组参数方法。比如:

Arrays.sort(new Student[]{...})
Arrays.sort(new Apple[]{...})

等,但是这样的设计留下来的诟病就是偶尔会出现对数组元素有具体的操作的代码,比如上面的error()方法。

泛型的出现,是为了保证类型安全的问题,如果将泛型也设计为协变的话,那也就违背了泛型最初设计的初衷,因此在Java中,泛型是不变的,什么意思呢?

List<Number>List<Integer> 是没有任何关系的,即使IntegerNumber的子类

也就是对于

public static void test(List<Number> nums){...}

方法,是无法传递一个List<Integer>参数的

逆变一般常见于强制类型转换。

Object obj="test";
String str=(String)obj;

原理便是Java 反射机制能够记住变量obj的实际类型,在强制类型转换的时候发现obj实际上是一个String类型,于是就正常的通过了运行。

泛型与向上转型的实现

前面说了这么多,应该关心的问题在于,如何解决既能使用数组协变带来的方便性,又能得到泛型不变带来的类型安全?

答案依然是extend,super关键字与通配符?

泛型重载了extendsuper关键字来解决通用泛型的表示。

注意:这句话可能比较熟悉,没错,前面说过extend还被用来指定擦除到的具体类型,比如<E extend Fruit>,表示在运行时将E替换为Fruit,注意E表示的是一个具体的类型,但是这里的extend和通配符连续使用<? extend Fruit>这里通配符?表示一个通用类型,它所表示的泛型在编译的时候,被指定的具体的类型必须是Fruit的子类。比如List<? extend Fruit> list= new ArrayList<Apple>ArrayList<>中指定的类型必须是Apple,Orange等。不要混淆。

概念麻烦,直接看代码:

协变泛型


public static  void playFruit(List < ? extends Fruit> list){
    //do somthing
}

public static void main(String[] args) {
    List<Apple> apples=new ArrayList<>();
    List<Orange> oranges=new ArrayList<>();
    List<Food> foods =new ArrayList<>();
    playFruit(apples);
    playFruit(oranges);
    //playFruit(foods); 编译错误
}

可以看到,参数List < ? extend Fruit>所表示是需要一个List<>,其中尖括号所指定的具体类型必须是继承自Fruit的。

这样便解决了泛型无法向上转型的问题,前面说过,数组也能向上转型,但是存取元素有问题啊,这里继续深入,看看泛型是怎么解决这一问题的。

  public static  void playFruit(List < ? extends  Fruit> list){
         list.add(new Apple());
    }

向传入的list添加元素,你会发现编译器直接会报错

逆变泛型

public  static  void playFruitBase(List < ? super  Fruit> list){
     //..
}

public static void main(String[] args) {
    List<Apple> apples=new ArrayList<>();
    List<Food> foods =new ArrayList<>();
    List<Object> objects=new ArrayList<>();
    playFruitBase(foods);
    playFruitBase(objects);
    //playFruitBase(apples); 编译错误
}
    

同理,参数List < ? super Fruit>所表示是需要一个List<>,其中尖括号所指定的具体类型必须是Fruit的父类类型。

public  static  void playFruitBase(List < ? super  Fruit> list){
    Object obj=list.get(0);
}

取出list的元素,你会发现编译器直接会报错

思考: 为什么要这么麻烦要区分开到底是xxx的父类还是子类,不能直接使用一个关键字表示么?

前面说过,数组的协变之所以会有问题是因为在对数组中的元素进行存取的时候出现的问题,只要不对数组元素进行操作,就不会有什么问题,因此可以使用通配符?达到此效果:

public static void playEveryList(List < ?> list){
    //..
}

对于playEveryList方法,传递任何类型的List都没有问题,但是你会发现对于list参数,你无法对里面的元素存和取。这样便达到了上面所说的安全类型的协变数组的效果。

但是觉得多数时候,我们还是希望对元素进行操作的,这就是extendsuper的功能。

<? extend Fruit>表示传入的泛型具体类型必须是继承自Fruit,那么我们可以里面的元素一定能向上转型为Fruit。但是也仅仅能确定里面的元素一定能向上转型为Fruit

public static  void playFruit(List < ? extends  Fruit> list){
     Fruit fruit=list.get(0);
     //list.add(new Apple());
}

比如上面这段代码,可以正确的取出元素,因为我们知道所传入的参数一定是继承自Fruit的,比如

List<Apple> apples=new ArrayList<>();
List<Orange> oranges=new ArrayList<>();

都能正确的转换为Fruit
但是我们并不知道里面的元素具体是什么,有可能是Orange,也有可能是Apple,因此,在list.add()的时候,就会出现问题,有可能将Apple放入了Orange里面,因此,为了不出错,编译器会禁止向里面加入任何元素。这也就解释了协变中使用add会出错的原因。


同理:

<? super Fruit>表示传入的泛型具体类型必须是Fruit父类,那么我们可以确定只要元素是Fruit以及能转型为Fruit的,一定能向上转型为对应的此类型,比如:

    public  static  void playFruitBase(List < ? super  Fruit> list){
        list.add(new Apple());
    }

因为Apple继承自Fruit,而参数list最终被指定的类型一定是Fruit的父类,那么Apple一定能向上转型为对应的父类,因此可以向里面存元素。

但是我们只能确定他是Furit的父类,并不知道具体的“上限”。因此无法将取出来的元素统一的类型(当然可以用Object)。比如

List<Eatables> eatables=new ArrayList<>();
List<Food> foods=new ArrayList<>();

除了

Object obj;

obj=eatables.get(0);
obj=foods.get(0);

之外,没有确定类型可以修饰obj以达到类似的效果。

针对上述情况。我们可以总结为:PECS原则,Producer-Extend,Customer-Super,也就是泛型代码是生产者,使用Extend,泛型代码作为消费者Super

泛型的阴暗角落

通过擦除而实现的泛型,有些时候会有很多让人难以理解的规则,但是了解了泛型的真正实现又会觉得这样做还是比较合情合理。下面分析一下关于泛型在应用中有哪些奇怪的现象:

擦除的地点---边界

    static <T> T[] toArray(T... args) {

        return args;
    }

    static <T> T[] pickTwo(T a, T b, T c) {
        switch(ThreadLocalRandom.current().nextInt(3)) {
            case 0: return toArray(a, b);
            case 1: return toArray(a, c);
            case 2: return toArray(b, c);
        }
        throw new AssertionError(); // Can't get here
    }

    public static void main(String[] args) {

        String[] attributes = pickTwo("Good", "Fast", "Cheap");
    }

这是在《Effective Java》中看到的例子,编译此代码没有问题,但是运行的时候却会类型转换错误:Exception in thread "main" java.lang.ClassCastException: [Ljava.lang.Object; cannot be cast to [Ljava.lang.String;

当时对泛型并没有一个很好的认识,一直不明白为什么会有Object[]转换到String[]的错误。现在我们来分析一下:

  • 首先看toArray方法,由本章最开始所说泛型使用擦除实现的原因是为了保持有泛型和没有泛型所产生的代码一致,那么:
    static <T> T[] toArray(T... args) {
        return args;
    }

static Object[] toArray(Object... args){
    return args;
}

生成的二进制文件是一致的。

进而剥开可变数组的语法糖:

static Object[] toArray(Object[] args){
    return args;
}
    static <T> T[] pickTwo(T a, T b, T c) {

        switch(ThreadLocalRandom.current().nextInt(3)) {
            case 0: return toArray(a, b);
            case 1: return toArray(a, c);
            case 2: return toArray(b, c);
        }

        throw new AssertionError(); // Can't get here
    }

    static  Object[] pickTwo(Object a, Object b, Object c) {
        switch(ThreadLocalRandom.current().nextInt(3)) {
            case 0: return toArray(new Object[]{a,b});//可变参数会根据调用类型转换为对应的数组,这里a,b,c都是Object
            case 1: return toArray(new Object[]{a,b});
            case 2: return toArray(new Object[]{a,b});
        }

        throw new AssertionError(); // Can't get here
    }

是一致的。
那么调用pickTwo方法实际编译器会帮我进行类型转换

    public static void main(String[] args) {
        String[] attributes =(String[])pickTwo("Good", "Fast", "Cheap");
    }

可以看到,问题就在于可变参数那里,使用可变参数编译器会自动把我们的参数包装为一个数组传递给对应的方法,而这个数组的包装在泛型中,会最终翻译为new Object,那么toArray接受的实际类型是一个Object[],当然不能强制转换为String[]

上面代码出错的关键点就在于泛型经过擦除后,类型变为了Object导致可变参数直接包装出了一个Object数组产生的类型转换失败。

基类劫持

public interface Playable<T>  {
    T play();
}

public class Base implements  Playable<Integer> {
    @Override
    public Integer play() {
        return 4;
    }
}

public class Derived extend Base implements Playable<String>{
    ...
}

可以发现在定义Derived类的时候编译器会报错。
观察Derived的定义可以看到,它继承自Base
那么它就拥有一个Integer play()和方法,继而实现了Playable<String>接口,也就是它必须实现一个String play()方法。对于Integer play()String play()两个方法的函数签名相同,但是返回类型不同,这样的方法在Java 中是不允许共存的:

public static void main(String[] args){
    new Derived().play();
}

编译器并不知道应该调用哪一个play()方法。

自限定类型

自限定类型简单点说就是将泛型的类型限制为自己以及自己的子类。最常见的在于实现Compareable接口的时候:

public class Student implements Comparable<Student>{
    
}

这样就成功的限制了能与Student相比较的类型只能是Student,这很好理解。

但是正如Java 中返回类型是协变的:

public class father{
    public Number test(){
        return nll;
    }
}


public class Son extend father{
    @Override
    public Interger test(){
        return null;
    }
}

有些时候对于一些专门用来被继承的类需要参数也是协变的。比如实现一个Enum:

public abstract class Enum implements Comparable<Enum>,Serializable{
    @Override
    public int compareTo(Enum o) {
        return 0;
    }
}

这样是没有问题的,但是正如常规所说,假如PenCup都继承于Enum,但是按道理来说笔和杯子之间相互比较是没有意义的,也就是说在EnumcompareTo(Enum o)方法中的Enum这个限定词太宽泛,这个时候有两种思路:

  1. 子类分别自己实现Comparable接口,这样就可以规定更详细的参数类型,但是由于前面所说,会出现基类劫持的问题
  2. 修改父类的代码,让父类不实现Comparable接口,让每个子类自己实现即可,但是这样会有大量一模一样的代码,只是传入的参数类型不同而已。

而更好的解决方案便是使用泛型的自限定类型:

public abstract class Enum<E extend Enum<E>> implements Comparable<E>,Serializable{
    @Override
    public int compareTo(E o) {
        return 0;
    }
    
}

泛型的自限定类型比起传统的自限定类型有个更大的优点就是它能使泛型的参数也变成协变的。

这样每个子类只用在集成的时候指定类型

public class Pen extends Enum<Pen>{}
public class Cup extends Cup<Cup>{}

便能够在定义的时候指定想要与那种类型进行比较,这样达到的效果便相当于每个子类都分别自己实现了一个自定义的Comparable接口。

自限定类型一般用在继承体系中,需要参数协变的时候。

尊重原创,转载请注明出处

参考文章:
Java不能实现真正泛型的原因? - RednaxelaFX的回答 - 知乎
深入理解 Java 泛型
java中,数组为什么要设计为协变? - 胖君的回答 - 知乎
java泛型中的自限定类型有什么作用-CSDN问答