Java 干货之深入理解Java泛型

一般的类和方法,只能使用具体的类型,要么是基本类型,要么是自定义的类。如果要编写可以应用多中类型的代码,这种刻板的限制对代码得束缚会就会很大。 —《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问答

如果觉得写得不错,欢迎关注微信公众号:逸游Java ,每天不定时发布一些有关Java进阶的文章,感谢关注

Java 干货之深入理解Java泛型

原文 

https://juejin.im/post/5dc51c54f265da4d542095d2

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » Java 干货之深入理解Java泛型

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址