Java的泛型詳解(一)

白羽流光發表於2020-05-20

Java的泛型詳解

泛型的好處

  • 編寫的程式碼可以被不同型別的物件所重用。
  • 因為上面的一個優點,泛型也可以減少程式碼的編寫。

泛型的使用

簡單泛型類

public class Pair<T> {

   private  T first;

   private T second;

   public Pair() {
       first = null;
       second = null;
   }

   public Pair(T first, T second){
       this.first = first;
       this.second = second;
   }

   public T getFirst(){
      return  first;
   }

   public T getSecond(){
       return second;
   }

   public void setFirst(T first) {
       this.first = first;
   }

   public void setSecond(T second) {
       this.second = second;
   }
}
  • 上面例子可以看出泛型變數為T;
  • 用尖括號(<>)括起來,並放在類名後面;
  • 泛型還可以定義多個型別變數比如上面的例子 first和second不同的型別:
    public class Pair<T, U> {....}

注: 型別變數的定義需要一定的規範:
(1) 型別變數使用大寫形式,並且要比較短;
(2)常見的型別變數特別代表一些意義:變數E 表示集合型別,K和V表示關鍵字和值的型別;T、U、S表示任意型別;

  • 類定義的型別變數可以作為方法的返回型別或者區域性變數的型別;

例如: private T first;

  • 用具體的型別替換型別變數就可以例項化泛型型別;
    例如: Pair<String> 代表將上述所有的T 都替換成了String
  • 由此可見泛型類是可以看作普通類的工廠

泛型方法

  • 我們應該如何定義一個泛型方法呢?
  • 泛型的方法可以定義在泛型類,也可以定義在普通類,那如果定義在普通類需要有一個尖括號加型別來指定這個泛型方法具體的型別;
public class TestUtils {
    public static <T> T getMiddle(T... a){
        return  a[a.length / 2];
    }
}
  • 型別變數放在修飾符(static)和返回型別的中間;
  • 當你呼叫上面的方法的時候只需要在方法名前面的尖括號放入具體的型別即可;
String middle = TestUtils.<String>getMiddle("a", "b", "c");

如果上圖這種情況其實可以省略,因為編譯器能夠推斷出呼叫的方法一定是String,所以下面這種呼叫也是可以的;

String middle = TestUtils.getMiddle("a", "b", "c");

但是如果是以下呼叫可能會有問題:

如圖:可以看到變意思沒有辦法確定這裡的型別,因為此時我們入參傳遞了一個Double3.14 兩個Integer17290 編譯器認為這三個不屬於同一個型別;
此時有一種解決辦法就是把整型寫成Double型別

型別變數的限定

  • 有時候我們不能無限制的讓使用者傳遞任意的型別,我們需要對我們泛型的方法進行限定傳遞變數,比如如下例子

計算陣列中最下的元素

  • 這個時候是無法編譯通過的,且編譯器會報錯
  • 因為我們的編譯器不能確定你這個T 型別是否有compareTo這個函式,所以這麼能讓編譯器相信我們這個T是一定會有compareTo呢?
  • 我們可以這麼寫<T extends Comparable> 這裡的意思是T一定是繼承Comparable的類
  • 因為Comparable是一定有compareTo這個方法,所以T一定有compareTo方法,於是編譯器就不會報錯了
  • 因為加了限定那麼min這個方法也只有繼承了Comparable的類才可以呼叫;
  • 如果要限定方法的泛型繼承多個類可以加extends 關鍵字並用&分割如:T extends Comparable & Serializable
  • 限定型別是用&分割的,逗號來分割多個型別變數<T extends Comparable & Serializable , U extends Comparable>

型別擦除

不論什麼時候定義一個泛型型別,虛擬機器都會提供一個相應的原始型別(raw type)。原始型別的名字就是刪掉型別引數後的泛型型別。擦除型別變數,並替換限定型別(沒有限定型別的變數用Object)

列如: Pair 的原始型別如下所示

 public class Pair {

    private  Object first;

    private Object second;

    public Pair() {
        first = null;
        second = null;
    }

    public Pair(Object first, Object second){
        this.first = first;
        this.second = second;
    }

    public Object getFirst(){
       return  first;
    }

    public Object getSecond(){
        return second;
    }

    public void setFirst(Object first) {
        this.first = first;
    }

    public void setSecond(Object second) {
        this.second = second;
    }
}
  • 因為上面的T是沒有限定變數,於是用Object代替了;
  • 如果有限定變數則會以第一個限定變數替換為原始型別如:
public class Interval<T extends Comparable & Serializable> implements Serializable{
   private T lower;
   private T upper;
}
  • 原始型別如下所示:
public class Interval  implements Serializable{
   private Comparable lower;
   private Comparable upper;
}

翻譯泛型表示式

  • 上面說到泛型擦除型別變數後對於無限定變數後會以Object來替換泛型型別變數;
  • 但是我們使用的時候並不需要進行強制型別轉換;
  • 原因是編譯器已經強制插入型別轉換;

例如:

 Pair<Employee> buddies = ...;
 Employee buddy = buddies.getFirst();
  • 擦除getFirst的返回型別後將返回Object型別,但是編譯器自動插入Employee的強制型別轉換,編譯器會把這個方法呼叫翻譯為兩條虛擬機器指令;
    • 對原始方法Pair.getFirst的呼叫
    • 將返回的Object型別強制轉換為Employee型別;

我們可以反編譯驗證一下

關鍵的位元組碼有以下兩條
9: invokevirtual #4 // Method com/canglang/Pair.getFirst:()Ljava/lang/Object;
12: checkcast #5 // class com/canglang/model/Employee

虛擬機器指令含義如下:

  • invokevirtual:虛擬函式呼叫,呼叫物件的例項方法,根據物件的實際型別進行派發,支援多型;
  • checkcast:用於檢查型別強制轉換是否可以進行。如果可以進行,checkcast指令不會改變運算元棧,否則它會丟擲ClassCastException異常;

由此我們可以驗證了上述的結論,在反編譯後的位元組碼中看到,當對泛型表示式呼叫時,虛擬機器操作如下:

  • 對於物件的實際型別進行替換泛型;
  • 檢查型別是否可以強制轉換,如果可以將對返回的型別進行強制轉換;

翻譯泛型方法

型別擦除也會出現在泛型方法裡面

public static <T extends Comparable> T min(T[] a)

型別擦除後

public static Comparable  min(Comparable[] a)

此時可以看到型別引數T已經被擦除了,只剩下限定型別Comparable;
方法的型別擦除帶來了兩個複雜的問題,看下面的示例:

public class DateInterval extends Pair<LocalDate> {
    public void setSecond(LocalDate second){
        System.out.println("DateInterval: 進來這裡了!");
    }
}

此時有個問題,從Pair繼承的setSecond方法型別擦除後為

public void setSecond(Object second)

這個和DateInterval的setSecond明顯是兩個不同的方法,因為他們有不同的型別的引數,一個是Object,一個LocalDate;
那麼看下面一個列子

public class Test {
    public static void main(String[] args) {
        DateInterval interval = new DateInterval();
        Pair<LocalDate> pair = interval;
        pair.setSecond(LocalDate.of(2020, 5, 20));
    }
}

Pair引用了DateInterval物件,所以應該呼叫DateInterval.setSecond。
我們看一下執行結果

但是看了反編譯的位元組碼可能發現一個問題:
17: invokestatic #4 // Method java/time/LocalDate.of:(III)Ljava/time/LocalDate;
20: invokevirtual #5 // Method com/canglang/Pair.setSecond:(Ljava/lang/Object;)V
這裡可以看到此處位元組碼呼叫的是Pair.setSecond

這裡有個重要的概念就是橋方法

引用Oracle中對於這個現象的解釋
為了解決此問題並在型別擦除後保留通用型別的 多型性,
Java編譯器生成了一個橋接方法,以確保子型別能夠按預期工作。
對於DateInterval類,編譯器為setSecond生成以下橋接方法:

public class DateInterval extends Pair {
    // Bridge method generated by the compiler
    //
    public void setSecond(Object second) {
        setSecond((LocalDate)second);
    }
    public void setSecond(LocalDate second){
        System.out.println("DateInterval: 進來這裡了!");
    }
}

那麼我們如何驗證是否生成這個橋方法呢?我們可以反編譯一下DateInterval.java看一下位元組碼;

public void setSecond(java.lang.Object);
descriptor: (Ljava/lang/Object;)V
flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: checkcast #5 // class java/time/LocalDate
5: invokevirtual #6 // Method setSecond:(Ljava/time/LocalDate;)V
8: return
我擷取了部分發現在 DateInterval的位元組碼中的確會有一個橋方法,同時驗證了上面的問題;

總結:

  • 虛擬機器中沒有泛型,只有普通的類和方法
  • 所有的型別引數都用他們的限定型別替換
  • 橋方法被合成來保持多型
  • 為保持型別安全性,必要時插入強制型別轉換

相關文章