掃盲:Kotlin 的泛型
引子
相信總是有很多同學,總是在抱怨泛型無論怎麼學習,都只是停留在一個簡單使用的水平,所以一直為此而備受苦惱。
Kotlin 作為一門能和 Java 相互調用的語言,自然也支援泛型,不過 Kotlin 的新關鍵字 in
和 out
卻總能繞暈一部分人,歸根結底,還是因為 Java 的泛型基本功沒有足夠紮實。
很多同學總是會產生這些疑問:
- Kotlin 泛型和 Java 泛型到底有何區別?
- Java 泛型存在的意義到底是什麼?
- Java 的類型擦除到底是指什麼?
- Java 泛型的上界、下界、通配符到底有何區別?它們可以實現多重限制么?
- Java 的
<? extends T>
、<? super T>
、<?>
到底對應了什麼?有哪些使用場景? - Kotlin 的
in
、out
、*
、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);
很明顯,泛型的出現,讓類型更加安全,使我們在使用 List
、Map
等不再需要去專門編寫 StringList
、StringMap
了,只需要在聲明 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
的限制條件,這裡和定義class
的extends
關鍵字有點不一樣:- 它的範圍不僅是所有直接和間接子類,還包括上界定義的父類本身,也就是
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 開發,View
的 findViewById
不就是最好的例子么?
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 的泛型
甚至有一部分直接截取過來,主要本意是不想重複造輪子。文章中如有疏漏,歡迎在評論區進行留言。