掃盲:Kotlin 的泛型

南塵發表於2020-12-29

引子

相信總是有很多同學,總是在抱怨泛型無論怎麼學習,都只是停留在一個簡單使用的水平,所以一直為此而備受苦惱。

Kotlin 作為一門能和 Java 相互呼叫的語言,自然也支援泛型,不過 Kotlin 的新關鍵字 inout 卻總能繞暈一部分人,歸根結底,還是因為 Java 的泛型基本功沒有足夠紮實。

很多同學總是會產生這些疑問:

  • Kotlin 泛型和 Java 泛型到底有何區別?
  • Java 泛型存在的意義到底是什麼?
  • Java 的型別擦除到底是指什麼?
  • Java 泛型的上界、下界、萬用字元到底有何區別?它們可以實現多重限制麼?
  • Java 的 <? extends T><? super T><?> 到底對應了什麼?有哪些使用場景?
  • Kotlin 的 inout*where 到底有何魔力?
  • 泛型方法又是什麼?

今天,就用一篇文章為大家解除上述疑惑。

泛型:型別安全的利刃

總所周知,Java 在 1.5 之前,是沒有泛型這個概念的。那時候的 List 還只是一個可以裝下一切的集合。所以我們難免會寫上這樣的程式碼:

List list = new ArrayList();
list.add(1);
list.add("nanchen2251");
String str = (String) list.get(0);

上面的程式碼編譯並沒有任何問題,但執行的時候一定會出現常見的 ClassCastException 異常:

Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

這個體驗非常糟糕,我們真正需要的是在程式碼編譯的時候就能發現錯誤,而不是讓錯誤的程式碼釋出到生產環境中。

而如果上述程式碼我們增加上泛型,就會在編譯期就能看到明顯的錯誤啦。

List<String> list = new ArrayList<>();
list.add(1);
// ? 報錯 Required type:String but Provided:int
list.add("nanchen2251");
String str = list.get(0);

很明顯,泛型的出現,讓型別更加安全,使我們在使用 ListMap 等不再需要去專門編寫 StringListStringMap 了,只需要在宣告 List 的同時指定引數型別即可。

總的來說,泛型具備以下優勢:

  • 型別檢查,能在編譯時就幫開發檢查出錯誤;
  • 更加語義化,比如我們宣告一個 LIst<String>,我們可以很直接知道里面儲存的是 String 物件;
  • 能自動進行型別轉換,獲取資料的時候不需要再做強轉操作;
  • 能寫出更加通用化的程式碼。

型別擦除

可能有些同學思考過這樣一個問題,既然泛型是和型別相關的,那麼是不是也能使用型別的多型呢?

我們知道,一個子型別是可以賦值給父型別的,比如:

Object obj = "nanchen2251";
// ? 這是多型

Object 作為 String 的父類,自然可以接受 String 物件的賦值,這樣的程式碼我們早已司空見慣,並沒有什麼問題。

但當我們寫下這串程式碼:

List<String> list = new ArrayList<String>();
List<Object> objects = list;  
// ? 多型用在這裡會報錯 Required type:List<Object> Provided: List<String>

上面發生了賦值錯誤,這是因為 Java 的泛型本身具有「不可變性 Invariance」,Java 裡面認為 List<String>List<Object> 型別並不一致,也就是說,子類的泛型 List<String> 不屬於泛型 List<Object> 的子類。

由於 Java 的泛型本身是一種 「偽泛型」,Java 為了相容 1.5 以前的版本,不得以在泛型底層實現上使用 Object 引用,所以我們宣告的泛型在編譯時會發生「型別擦除」,泛型型別會被 Object 型別取代。比如:

class Demo<T> {
    void func(T t){
        // ...
    }
}

會被編譯成:

class Demo {
    void func(Object t){
        // ...     
    }
}

可能你會好奇,在編譯時發生型別擦除後,我們的泛型都被更換成了 Object,那為什麼我們在使用的時候,卻不需要強轉操作呢?比如:

List<String> list = new ArrayList<>();
list.add("nanchen2251");
String str = list.get(0);
// ? 這裡並沒有要求我們把 list.get(0) 強轉為 String

這是因為編譯器會根據我們宣告的泛型型別進行提前的型別檢查,然後再進行型別擦除,擦除為 Object,但在位元組碼中其實還儲存了我們的泛型的型別資訊,在使用到泛型型別的時候會把擦除後的 Object 自動做型別強轉操作。所以上面的 list.get(0) 本身就是一個經過強轉的 String 物件了。

這個技術看起來還蠻好的,但卻有一個弊端。就是既然擦成 Object 了,那麼在執行的時候,你根本不能確定這個物件到底是什麼型別,雖然你可以通過編譯器幫你插入的 checkcast 來獲得此物件的型別。但是你並不能把 T 真正的當作一個型別使用:比如這條語句在 Java 中是非法的。

T a = new T();
// ? 報錯:Type parameter 'T' cannot be instantiated directly

同理,因為都被擦成了 Object,你就不能根據型別來做某種區分。

比如 instanceof

if("nanchen2251" instanceof T.class){
                           // ? 報錯:Identifier expected Unexpected token        
}

比如過載:

void func(T t){
// ? 報錯:'func(T)' clashes with 'func(E)'; both methods have same erasure       
}
void func(E e){
}

同樣,因為基本資料型別不屬於 oop,所以也不能被擦除為 Object,所以 Java 的泛型也不能用於基本型別:

List<int> list;
// ? 報錯:Type argument cannot be of primitive type 

oop:物件導向的程式設計(Object Oriented Programming)

到這裡,是不是可以回答上面的第 3 個問題了:Java 的型別擦除到底是指什麼?

首先你要明白一點,一個物件的型別永遠不會被擦出的,比如你用一個 Object 去引用一個 Apple 物件,你還是可以獲得到它的型別的。比如用 RTTI。

RTTI:執行時型別資訊,執行時型別識別 (Run Time Type Identification)

Object object = new Apple();
System.out.println(object.getClass().getName());
// ? will print Apple

哪怕它是放到泛型裡的。

class FruitShop<T>{
    private T t;

    public void set(T t){
        this.t = t;
    }
    
    public  void showFruitName(){
        System.out.println(t.getClass().getName());
    }
}
FruitShop<Apple> appleShop = new FruitShop<Apple>();
appleShop.set(new Apple());
appleShop.showFruitName();
// ? will print Apple too

為啥?因為引用就是一個用來訪問物件的標籤而已,物件一直在堆上放著呢。

所以不要斷章取義認為型別擦除就是把容器內物件的型別擦掉了,所謂的型別擦除,是指容器類FruitShop<Apple>,對於 Apple 的型別宣告在編譯期的型別檢查之後被擦掉,變為和 FruitShop<Object> 等同效果,也可以說是 FruitShop<Apple>FruitShop<Banana> 被擦為和 FruitShop<Object> 等價,而不是指裡面的物件本身的型別被擦掉!

那,Kotlin 中有型別擦除麼?

C# 和 Java 在一開始都是不支援泛型的。Java 在 1.5 開始才加入了泛型。為了讓一個不支援泛型的語言支援泛型,只有兩條路可以走:

  • 以前的非泛型容器保持不變,然後平行的增加一套泛型化的型別。
  • 直接把已有的非泛型容器擴充套件為泛型,不新增任何新的泛型版本。

Java 由於 1.5 之前市面上一句有大量的程式碼,所以不得以選擇了第 2 種方式,而 C# 比較機智就選擇了第一種。

而 Kotlin 本身就是基於 Java 1.6 編寫的,一開始就有泛型,不存在相容老版本程式碼的問題,那 Kotlin 實現的泛型還具備型別擦除麼?

當然具備。上面其實已經說的很清楚了,Kotlin 本身就是基於 Java 1.6 編寫的,而且 Kotlin 和 Java 有極強的互調能力,當然也存在型別擦除。

不過...

你還是會發現有意思的點:

val list = ArrayList()
// ? 報錯:Not enough information to infer type variable E

在 Java 中,不指定泛型型別是沒問題的,但 Kotlin 這樣不好使了。想來也簡單,畢竟在 Java 1.5 之前是肯定不存在上述類似程式碼的,而泛型的設計初衷就不是用來裝預設的 Kotlin Any 的。

泛型的上界萬用字元

前面說到:因為 Java 的泛型本身具有「不可變性 Invariance」,所以即使 Fruit 類是 Apple 類的父類,但 Java 裡面認為 List<Fruit>List<Apple> 型別並不一致,也就是說,子類的泛型 List<Apple> 不屬於泛型 List<Fruit> 的子類。

所以這樣的程式碼並不被執行。

List<Apple> apples = new ArrayList<Apple>();
List<Fruit> fruits = apples;  
// ? 多型用在這裡會報錯 Required type:List<Fruit> Provided: List<Apple>

那假如我們想突破這層限制,怎麼辦?使用上界萬用字元 ? extends

List<Apple> apples = new ArrayList<Apple>();
List<? extends Fruit> fruits = apples;
    // ?使用上界萬用字元後,編譯不再報錯

「上界萬用字元」,可以使 Java 泛型具有「協變性 Covariance」,協變就是允許上面的賦值是合法的。

在繼承關係樹中,子類繼承自父類,可以認為父類在上,子類在下。extends 限制了泛型型別的父型別,所以叫上界。

它有兩層意思:

  • 其中 ? 是個萬用字元,表示這個 List 的泛型型別是一個未知型別。
  • extends 限制了這個未知型別的上界,也就是泛型型別必須滿足這個 extends 的限制條件,這裡和定義 classextends 關鍵字有點不一樣:
    • 它的範圍不僅是所有直接和間接子類,還包括上界定義的父類本身,也就是 Fruit
    • 它還有 implements 的意思,即這裡的上界也可以是 interface

這個突破限制有意義麼?

有的有的。

假如我們有一個介面 Fruit

interface Fruit {
    float getWeight();
}

有兩個水果類實現了 Fruit 介面:

class Banana implements Fruit {
    @Override
    public float getWeight() {
        return 0.5f;
    }
}

class Apple implements Fruit {
    @Override
    public float getWeight() {
        return 1f;
    }
}

假設我們有個需求是需要給水果稱重:

List<Apple> apples = new ArrayList<>();
apples.add(new Apple());
float totalWeight = getTotalWeight(apples); 
                                // ? 報錯:Required type: List<Fruit> Provided: List<Apple>

private float getTotalWeight(List<Fruit> fruitList) {
        float totalWeight = 0;
        for (Fruit fruit : fruitList) {
            totalWeight += fruit.getWeight();
        }
        return totalWeight;
    }

想來這也是一個非常正常的需求,秤可以稱各種水果的重量,但也可以只稱蘋果。你不能因為我只買蘋果就不給我稱重吧。所以把上面的程式碼加上上界萬用字元就可以啦。

List<Apple> apples = new ArrayList<>();
apples.add(new Apple());
float totalWeight = getTotalWeight(apples); 
                                // ? 不再報錯
                                // ? 增加了上界萬用字元 ? extends
private float getTotalWeight(List<? extends Fruit> fruitList) {
        float totalWeight = 0;
        for (Fruit fruit : fruitList) {
            totalWeight += fruit.getWeight();
        }
        return totalWeight;
    }

不過,上面使用 ? extends 上界萬用字元突破了一層限制,卻被施加了另一層限制:只可輸出不可輸入

什麼意思呢?

比如:

List<Apple> apples = new ArrayList<Apple>();
List<? extends Fruit> fruits = apples;
Fruit fruit = fruits.get(0);
fruits.add(new Apple());
            // ? 報錯:Required type: capture of ? extends Fruit Provided: Apple

宣告瞭上界萬用字元泛型的集合,不再允許 add 新的物件,Apple 不行,Fruit 也不行。擴充開來說:不止是集合,自己編寫一個泛型做輸入也不行

interface Shop<T> {
    void showFruitName(T t);
    T getFruit();
}

Shop<? extends Fruit> apples = new Shop<Apple>(){
    @Override
    public void showFruitName(Apple apple) { }

    @Override
    public Apple getFruit() {
        return null;
    }
};
apples.getFruit();
apples.showFruitName(new Apple());
                     // ? 報錯:Required type: capture of ? extends Fruit Provided: Apple

泛型的下界萬用字元

泛型有上界萬用字元,那有沒有下界萬用字元呢?

有的有的。

與上界萬用字元 ? extends 對應的就是下界萬用字元 ? super

下界萬用字元 ? super 所有情況和 ? extends 上界萬用字元剛剛相反:

  • 萬用字元 ? 表示 List 的泛型型別是一個 未知型別
  • super 限制了這個未知型別的下界,也就是泛型型別必須滿足這個 super 的限制條件
    • 它的範圍不僅是所有直接和間接子父類,還包括下界定義的子類本身。
    • super 同樣支援 interface

它被施加的新限制是:只可輸入不可輸出

Shop<? super Apple> apples = new Shop<Fruit>(){
    @Override
    public void showFruitName(Fruit apple) { }

    @Override
    public Fruit getFruit() {
        return null;
    }
};
apples.showFruitName(new Apple());
Apple apple = apples.getFruit();
 // ? 報錯:Required type: Apple Provided: capture of ? super Apple

解釋下,首先 ? 表示未知型別,編譯器是不確定它的型別的。

雖然不知道它的具體型別,不過在 Java 裡任何物件都是 Object 的子類,所以這裡只能把apples.getFruit() 獲取出來的物件賦值給 Object。由於型別未知,所以直接賦值給一個 Apple 物件肯定是不負責任的,需要我們做一層強制轉換,不過強制轉換本身可能發生錯誤。

Apple 物件一定是這個未知型別的子型別,根據多型的特性,這裡通過 showFruitName 輸入 Button 物件是合法的。

小結下,Java 的泛型本身是不支援協變和逆變的:

  • 可以使用泛型萬用字元 ? extends 來使泛型支援協變,但是「只能讀取不能修改」,這裡的修改僅指對泛型集合新增元素,如果是 remove(int index) 以及 clear 當然是可以的。
  • 可以使用泛型萬用字元 ? super 來使泛型支援逆變,但是「只能修改不能讀取」,這裡說的不能讀取是指不能按照泛型型別讀取,你如果按照 Object 讀出來再強轉當然也是可以的。

理解了 Java 的泛型之後,再理解 Kotlin 中的泛型,就比較容易了。

Kotlin 的 out 和 in

和 Java 泛型一樣,Kolin 中的泛型本身也是不可變的。

不過換了一種表現形式:

  • 使用關鍵字 out 來支援協變,等同於 Java 中的上界萬用字元 ? extends
  • 使用關鍵字 in 來支援逆變,等同於 Java 中的下界萬用字元 ? super
val appleShop: Shop<out Fruit>
val fruitShop: Shop<in Apple>

它們完全等價於:

Shop<? extends Fruit> appleShop;
Shop<? super Apple> fruitShop;

換了個寫法,但作用是完全一樣的。out 表示,我這個變數或者引數只用來輸出,不用來輸入,你只能讀我不能寫我;in 就反過來,表示它只用來輸入,不用來輸出,你只能寫我不能讀我。

泛型的上下界約束

上面講的都是在使用的時候再對泛型進行限制,我們稱之為「上界萬用字元」和「下界萬用字元」。那我們可以在函式設計的時候,就設定這個限制麼?

可以的可以的。

比如:

open class Animal
class PetShop<T : Animal?>(val t: T)

等同於 Java 的:

class PetShop<T extends Animal> {
    private T t;

    PetShop(T t) {
        this.t = t;
    }
}

這樣,我們在設計寵物店類 PetShop 就給支援的泛型設定了上界約束,支援的泛型型別必須是 Animal 的之類。所以我們使用的話:

class Cat : Animal()

val catShop = PetShop(Cat())
val appleShop = PetShop(Apple())
                      // ? 報錯:Type mismatch. Required: Animal? Found: Apple

很明顯,Apple 並不是 Animal 的子類,當然不滿足 PetShop 泛型型別的上界約束。

那....可以設定多個上界約束麼?

當然可以,在 Java 中,給一個泛型引數宣告多個約束的方式是,使用 &

class PetShop<T extends Animal & Serializable> {
                      // ? 通過 & 實現了兩個上界,必須是 Animal 和 Serializable 的子類或實現類
    private T t;

    PetShop(T t) {
        this.t = t;
    }
}

而在 Kotlin 中捨棄了 & 這種方式,而是增加了 where 關鍵字:

open class Animal
class PetShop<T>(val t: T) where  T : Animal?, T : Serializable

通過上面的方式,就實現了多個上界的約束。

Kotlin 的萬用字元 *

前面我們說的泛型型別都是在我們需要知道引數型別是什麼型別的,那如果我們對泛型引數的型別不感興趣,有沒有一種方式處理這個情況呢?

有的有的。

在 Kotlin 中,可以用萬用字元 * 來替代泛型引數。比如:

val list: MutableList<*> = mutableListOf(1, "nanchen2251")
list.add("nanchen2251")
      // ? 報錯:Type mismatch. Required: Nothing Found: String

這個報錯確實讓人匪夷所思,上面用萬用字元代表了 MutableList 的泛型引數型別。初始化裡面也加入了 String 型別,但在新 add 字串的時候,卻發生了編譯錯誤。

而如果是這樣的程式碼:

val list: MutableList<Any> = mutableListOf(1, "nanchen2251")
list.add("nanchen2251")
      //  ? 不再報錯

看來,所謂的萬用字元作為泛型引數並不等價於 Any 作為泛型引數。MutableList<*>MutableList<Any> 並不是同一種列表,後者的型別是確定的,而前者的型別並不確定,編譯器並不能知道這是一種什麼型別。所以它不被允許新增元素,因為會導致型別不安全。

不過細心的同學肯定發現了,這個和前面泛型的協變非常類似。其實萬用字元 * 不過是一種語法糖,背後也是用協變來實現的。所以:MutableList<*> 等價於 MutableList<out Any?>,使用萬用字元與協變有著一樣的特性。

在 Java 中,也有一樣意義的萬用字元,不過使用的是 ? 作為通配。

List<?> list = new ArrayList<Apple>(); 

Java 中的萬用字元 ? 也等價於 ? extends Object

多個泛型引數宣告

那可以宣告多個泛型麼?

可以的可以的。

HashMap 不就是一個典型的例子麼?

class HashMap<K,V>

多個泛型,可以通過 , 進行分割,多個宣告,上面是兩個,實際上多個都是可以的。

class HashMap<K: Animal, V, T, M, Z : Serializable>

泛型方法

上面講的都是都是在類上宣告泛型型別,那可以宣告在方法上麼?

可以的可以的。

如果你是一名 Android 開發,ViewfindViewById 不就是最好的例子麼?

public final <T extends View> T findViewById(@IdRes int id) {
    if (id == NO_ID) {
        return null;
    }
    return findViewTraversal(id);
}

很明顯,View 是沒有泛型引數型別的,但其 findViewById 就是典型的泛型方法,泛型宣告就在方法上。

上述寫法改寫成 Kotlin 也非常簡單:

fun <T : View?> findViewById(@IdRes id: Int): T? {
    return if (id == View.NO_ID) {
        null
    } else findViewTraversal(id)
}

Kotlin 的 reified

前面有說到,由於 Java 中的泛型存在型別擦除的情況,任何在執行時需要知道泛型確切型別資訊的操作都沒法用了。比如你不能檢查一個物件是否為泛型型別 T 的例項:

<T> void printIfTypeMatch(Object item) {
    if (item instanceof T) { // ? IDE 會提示錯誤,illegal generic type for instanceof

    }
}

Kotlin 裡同樣也不行:

fun <T> printIfTypeMatch(item: Any) {
    if (item is T) { // ? IDE 會提示錯誤,Cannot check for instance of erased type: T
        println(item)
    }
}

這個問題,在 Java 中的解決方案通常是額外傳遞一個 Class<T> 型別的引數,然後通過 Class#isInstance 方法來檢查:

                               ?
<T> void check(Object item, Class<T> type) {
    if (type.isInstance(item)) {
               ?
    }
}

Kotlin 中同樣可以這麼解決,不過還有一個更方便的做法:使用關鍵字 reified 配合 inline 來解決:

  ?          ?
inline fun <reified T> printIfTypeMatch(item: Any) {
    if (item is T) { // ? 這裡就不會在提示錯誤了

    }
}

上面的 Gson 解析的時候用的非常廣泛,比如我們們專案裡就有這樣的擴充套件方法:

inline fun <reified T> String?.toObject(type: Type? = null): T? {
    return if (type != null) {
        GsonFactory.GSON.fromJson(this, type)
    } else {
        GsonFactory.GSON.fromJson(this, T::class.java)
    }
}

總結

本文花了非常大的篇幅來講 Kotlin 的泛型和 Java 的泛型,現在再回過頭去回答文首的幾個問題,同學你有譜了嗎?如果還是感覺一知半解,不妨多看幾遍。

文章中有比較多的參考「碼上開學」的文章:Kotlin 的泛型

甚至有一部分直接擷取過來,主要本意是不想重複造輪子。文章中如有疏漏,歡迎在評論區進行留言。

相關文章