掃盲:Kotlin 的泛型

引子

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

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 的泛型

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

Tags: