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進階的文章,感謝關注

相關文章
相關標籤/搜索