Java 干货之深刻理解Java泛型

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

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

什么是泛型

自JDK 1.5 以后,Java 经过泛型解决了容器类型安全这一问题,而几乎全部人接触泛型也是经过Java的容器。那么泛型到底是什么?
泛型的本质是参数化类型
也就是说,泛型就是将所操做的数据类型做为参数的一种语法。数组

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

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

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

这里T就会被实例化为Integer微信

泛型的做用

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

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

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

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

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

在没有泛型以前,这种代码除非运行,不然你永远找不到它的错误。可是加入泛型后ide

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

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

- 泛型可以省去类型强制转换

在JDK1.5以前,Java容器都是经过将类型向上转型为Object类型来实现的,所以在从容器中取出来的时候须要手动的强制转换。ui

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进阶的文章,感谢关注

相关文章
相关标签/搜索