天天看點

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[]

,在編譯階段編譯器隻知道

nums

Object[]

類型,而運作時

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>

是沒有任何關系的,即使

Integer

Number

的子類

也就是對于

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

方法,是無法傳遞一個

List<Integer>

參數的

逆變一般常見于強制類型轉換。

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

原理便是Java 反射機制能夠記住變量

obj

的實際類型,在強制類型轉換的時候發現

obj

實際上是一個

String

類型,于是就正常的通過了運作。

泛型與向上轉型的實作

前面說了這麼多,應該關心的問題在于,如何解決既能使用數組協變帶來的友善性,又能得到泛型不變帶來的類型安全?

答案依然是

extend

,

super

關鍵字與通配符

?

泛型重載了

extend

super

關鍵字來解決通用泛型的表示。

注意:這句話可能比較熟悉,沒錯,前面說過

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

參數,你無法對裡面的元素存和取。這樣便達到了上面所說的安全類型的協變數組的效果。

但是覺得多數時候,我們還是希望對元素進行操作的,這就是

extend

super

的功能。

<? 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;
    }
}
           

這樣是沒有問題的,但是正如正常所說,假如

Pen

Cup

都繼承于

Enum

,但是按道理來說筆和杯子之間互相比較是沒有意義的,也就是說在

Enum

compareTo(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泛型
下一篇: LVS-DR部署

繼續閱讀