前言
泛型(Generics)的型變是Java中比較難以理解和使用的部分,“神秘”的通配符,讓我看了幾遍《Java編程思想》之后仍不明所以,直到最近學習了Kotlin,才對泛型型變有了更多的理解。這篇文章包括以下內容:
- 什么是泛型的型變(協變、逆變、不型變)
- 為什么需要泛型的型變
- Java和Kotlin分別是如何處理泛型型變的
如果你不了解Kotlin也沒有關系,只看Java部分也可以。
0. 幾個概念
- type variance(型變)
Type variance refers to the techniques by which we can allow, or not allow, subtyping in our parameterized types.
型變是指我們是否允許對參數類型進行子類型轉換。不明白沒關系,以上僅是為了提升文章逼格的。舉個例子你就明白了,假設Orange類是Fruit類的子類,Crate<T> 是一個泛型類,那么,Crate<Orange> 是 Crate<Fruit> 的子類型嗎?直覺可能告訴你,Yes。但是,答案是No。對于Java而言,兩者沒有關系。對于Kotlin而言,Crate<Orange>可能是Crate<Fruit>的子類型,或者其超類型,或者兩者沒有關系,這取決于Crate<T>中的 T 在類Crate中是如何使用的。簡單來說,型變就是指 Crate<Orange> 和 Crate<Fruit> 是什么關系這個問題,對于不同的答案,有如下幾個術語。
- invariance(不型變):也就是說,Crate<Orange> 和 Crate<Fruit> 之間沒有關系。
- covariance(協變):也就是說,Crate<Orange> 是 Crate<Fruit> 的子類型。
- contravariance(逆變):也就是說,Crate<Fruit> 是 Crate<Orange> 的子類型。
注意:
- 上面在解釋協變、逆變概念時的說法只是為了幫助理解,這種說法對于Java而言并不準確。在Java中,Crate<Orange> 和 Crate<Fruit> 永遠沒有關系,對于協變應該這么說, Crate<Orange> 是 Crate<? extends Fruit> 的子類型,逆變則是,Crate<Fruit> 是 Crate<? super Orange> 的子類型。
- 子類(subclass) 和 子類型(subtype)不是一個概念,子類一定是子類型,子類型不一定是子類,例如,Crate<Orange> 是 Crate<? extends Fruit> 的子類型,但是Crate<Orange> 并不是 Crate<? extends Fruit> 的子類。
那么為什么需要型變呢?根本目的是在保證泛型類 類型安全的基礎上,提高API的靈活性,手段是通過編譯器限制泛型類上某些方法的調用。(看完這篇文章后,你會理解這句話在說什么。)
1. Java的做法
Java處理型變的做法概括起來是:Java中的泛型類在正常使用時是不型變的,要想型變必須在使用處通過通配符進行(稱為使用處型變)。
Java中的泛型是不型變的??慈缦麓a,在Java中是無法編譯的:
List<String> strs = new ArrayList<String>();
List<Object> objs = strs; // ?。。〖磳砼R的問題的原因就在這里。Java 禁止這樣!
objs.add(1); // 這里我們把一個整數放入一個字符串列表
String s = strs.get(0); // !??! ClassCastException:無法將整數轉換為字符串
Java禁止這么做,理由如代碼中所述,主要目的是為了保證運行時的類型安全。但是這么一棒子打死,禁止型變,也會帶來一些別的影響。如上例,如果我們只是在objs上調用get方法,而不調用add方法(只讀取數據不寫入數據),這顯然不會有類型安全的問題。問題在于如何保證我們只調用get而不調用add呢,不能只靠我們的自覺吧,最好有編譯器的限制。通配符就是干這件事的,通知編譯器,限制我們對于某些方法的調用,以保證運行時的類型安全。
1.1 Java中的協變
以最常用的List類為例,協變如下:
List<? extends Fruit> fruits = new ArrayList<Orange>();
//編譯錯誤:不能添加任何類型的對象
//fruits.add(new Orange());
//fruits.add(new Fruit());
//fruits.add(new Object());
fruits.add(null);//可以這么做,但是沒有意義
//我們知道,返回值肯定是Fruit
Fruit f = fruits.get(0);
fruits的類型是List<? extends Fruit>,代表Fruit類型或者從Fruit繼承的類型的List,fruits可以引用諸如Fruit或Orange這樣類型的List,然后向上轉型為了List<? extends Fruit>。我們并不關心fruits具體引用的是ArrayList<Orange>(),還是ArrayList<Fruit>(),對于類型 List<? extends Fruit> 我們所能知道的就是:調用一個返回Fruit的方法是安全的,因為你知道,這個List中的任何對象至少具有Fruit類型。
我們之所以可以安全地將 ArrayList<Orange> 向上轉型為 List<? extends Fruit>,是因為編譯器限制了我們對于 List<? extends Fruit> 類型部分方法的調用。例如void add(T t)方法,以及一切參數中含有 T 的方法(稱為消費者方法),因為這些方法可能會破壞類型安全,只要限制這些方法的調用,就可以安全地將 ArrayList<Orange> 轉型為 List<? extends Fruit>。這就是所謂的協變,通過限制對于消費者方法的調用,使得像 List<? extends Fruit> 這樣的類型成為單純的“生產者”,以保證運行時的類型安全。
那么編譯器是如何決定哪些方法可以調用,哪些方法不可以呢?例如,我們顯然可以在fruits上調用contains方法:
fruits.contains(new Orange());//OK!因為contains的參數是Object
顯然,List中的contains方法并沒有修改List中的對象,那是不是說編譯器幫我們檢查了某個方法是否修改了泛型類中的對象,然后決定我們是否可以在協變中調用它?答案是編譯器并沒有那么聰明,一切取決于方法的簽名。對于List中void add(T t)方法,當你指定類型是List<? extends Fruit>時,add(T t)的參數就變成了“? extends Fruit”,從這個參數中,編譯器并不知道需要哪個具體的Fruit子類型,Orange、Banana甚至Fruit都可以,因此,為了保證類型安全,編譯器拒絕任何類型。而boolean contains(Object o)的參數其實是Object,編譯器當然不會限制對它的調用。
這里面其實還有個問題,contains方法其實是可以寫成更具體的形式:boolean contains(T t),畢竟List最主要的特點就是類型安全,有什么理由去詢問一個類型不一樣的對象是不是包含在List中的呢?但是,如果這么做,編譯器會像拒絕add一樣拒絕contains方法的調用,盡管我們確信在contains方法內部并不會修改List中的對象(因此不會有類型安全的問題)。在Java中我們沒有辦法解決這個問題,因此,只能寫成boolean contains(Object o)。不過,沒關系,不是還有Kotlin么。
1.2 Java中的逆變
協變的反方向是逆變,在協變中我們可以安全地從泛型類中讀?。◤囊粋€方法中返回),而在逆變中我們可以安全地向泛型類中寫入(傳遞給一個方法)。
List<Object> objs = new ArrayList<Object>();
objs.add(new Object());
List<? super Fruit> canContainFruits = objs;
//沒有問題,可以寫入Fruit類及其子類
canContainFruits.add(new Orange());
canContainFruits.add(new Banana());
canContainFruits.add(new Fruit());
//無法安全地讀取,canContainFruits完全可能包含Fruit基類的對象,比如這里的Object
//Fruit f = canContainFruits.get(0);
//總是可以讀取為Object,然而這并沒有太多意義
Object o = canContainFruits.get(1);
canContainFruits的類型是List<? super Fruit>,代表Fruit類型或者Fruit基類型的List,canContainFruits可以引用諸如Fruit或Object這樣類型的List,然后向上轉型為了List<? super Fruit>。
再次考慮,為什么編譯器會“半拒絕”在 List<? super Fruit> 上調用get方法。對于List中的 T get(int pos) 方法,當指定類型是 “? super Fruit” 時,get方法的返回類型就變成了 “? super Fruit”,也就是說,返回類型可能是Fruit或者任意Fruit的基類型,我們不能確定,因此編譯器拒絕調用任何返回類型為 T 的方法(除非我們只是讀取為Object類)。注意,這次拒絕的理由跟協變中是不一樣的。get方法并不會破壞泛型類的類型安全,主要原因在于我們不能確定get的返回類型。
對于類型 List<? super Fruit> 我們所能知道的就是:向一個方法傳入Fruit及其子類(Orange、Banana)是安全的,因為你知道,這個List包含的是Fruit或者Fruit基類的對象。
類似的,編譯器限制了我們對于 List<? super Fruit> 類型部分方法的調用。例如T get(int pos)方法,以及一切返回類型為 T 的方法(稱為生產者方法),因為我們不能確定這些方法的返回類型,只要限制這些方法的調用,就可以安全地將 ArrayList<Object> 轉型為 List<? super Fruit>。這就是所謂的逆變,通過限制對于生產者方法的調用,使得像 List<? super Fruit> 這樣的類型成為單純的“消費者”。
1.3 Java型變總結
extends限定了通配符類型的上界,所以我們可以安全地從其中讀取;而super限定了通配符類型的下界,所以我們可以安全地向其中寫入。
我們可以把那些只能從中讀取的對象稱為生產者(Producer),我們可以從生產者中安全地讀?。恢荒?strong>寫入的對象稱為消費者(Consumer)。因此可以這么說:Producer-Extends, Consumer-Super。
2. Kotlin的做法
Kotlin處理型變的做法概括起來是:Kotlin中的泛型類在定義時即可標明型變類型(協變或逆變,當然也可以不標明,那就是不型變的),在使用處可以直接型變(稱為聲明處型變)。因為Kotlin與Java是100%兼容的,你自己在Kotlin中定義的泛型類當然可以享受聲明處型變的方便,但是,如果引入Java庫呢?又或者你自己在Kotlin中定義的泛型類恰好是不型變的,然而你又想像Java那樣在使用處型變,該這么辦呢?Kotlin使用一種稱為 類型投影(type projections) 的方式來處理這種型變。這種方式其實跟Java處理型變的方式類似,只是換了一種說法,還是使用處型變。
2.1 Kotlin的小聰明
Java處理型變的問題在于:把一切都推給了使用處,增加了不明所以的通配符,代碼可讀性變差,并且很丑陋。那么,除了使用處,我們還可以在哪做點改進呢?請看下面的例子:
// Java
interface Source<T> {
T nextT();
}
這個接口是一個“生產者”,泛型參數 T 只作為返回類型,沒有任何把 T 作為參數的“消費者”方法。那么,將Source<Orange> 轉型為 Source<Fruit> ?是極為安全的,畢竟沒有消費者方法可以調用。但是 Java 并不知道這?點,并且仍然禁止這樣操作:
// Java
void demo(Source<Orange> oranges) {
Source<Fruit> fruits = oranges; // ?。?!在 Java 中不允許
}
為了修正這?點,我們必須聲明對象的類型為 Source<? extends Fruit>,這是毫無意義的,因為我們可以像以前?樣在該對象上調用所有相同的方法,所以更復雜的類型并沒有帶來價值。問題的關鍵在于,Java中的泛型是不型變的,原因是類中的方法可能會“干壞事”,破壞了類型安全,但是,這是不是有點矯枉過正了呢?!就如同上述接口一樣,我們定義的時候就可以保證它不會“干壞事”(保證它只是生產者或者消費者),問題是編譯器并不知道?。?br> 這么說,問題就簡單了,我們只需要告訴編譯器,我們定義的類是協變的還是逆變的,或者兩者都不是(即不型變的)。這樣就可以在聲明處定義型變,使用處不需要額外的處理直接使用。為此,Kotlin提供了in和out修飾符。
2.2 Kotlin中的協變
//kotlin
abstract class Source<out T> {
abstract fun nextT(): T
}
fun demo(oranges: Source<Orange>) {
val fruits: Source<Fruit> = oranges // 沒問題,因為 T 是一個 out-參數,Source<T>是協變的
val oneFruit: Fruit = fruits.nextT() //可以安全讀取
}
使用out修飾符,表明類型參數 T 在泛型類中僅作為方法的返回值,不作為方法的參數,因此,這個泛型類是個協變的。回報是,使用時Source<Orange>可以作為Source<Fruit>的子類型。
還記不記得我們在 Java中的協變 最后提出的問題,在協變的泛型類中,如果有方法需要將類型參數 T 用作參數,但是可以確定在該方法內部并沒有向泛型類寫入數據(因此該泛型類仍然只是生產者,不會有類型安全的問題),我們是否仍然可以將該泛型類標記為協變的? 換種簡單的說法,類型參數 T 標記為out,那么 T 是否可以既作為方法的返回值,也作為方法的參數呢? 這其實是可以的。如下:
//kotlin
public interface Collection<out E> : Iterable<E> {
...
public operator fun contains(element: @UnsafeVariance E): Boolean
...
}
使用注解 @UnsafeVariance 可以讓編譯器放我們一馬,它是在告訴編譯器,我保證這個方法不會向泛型類寫入數據,你放心。
2.3 Kotlin中的逆變
逆變類的?個很好的例子是Kotlin中的Comparable:
//kotlin
abstract class Comparable<in T> {
abstract fun compareTo(other: T): Int
}
fun demo(x: Comparable<Number>) {
val y: Comparable<Double> = x // OK!逆變,Comparable<Number>可以作為Comparable<Double>的子類型
y.compareTo(1.0) //1.0 擁有類型 Double
}
y的聲明類型是Comparable<Double>,引用的實際類型是Comparable<Number>,向compareTo(Number)中傳入Double當然沒什么問題。Double是Number的子類,但是對于泛型類而言,Comparable<Number> 卻是 Comparable<Double> 的子類型,所以這稱為逆變。
使用in修飾符,表明類型參數 T 在泛型類中僅作為方法的參數,不作為方法的返回值,因此,這個泛型類是個逆變的。回報是,使用時Comparable<Number>可以作為Comparable<Double>的子類型。
總結:out和in修飾符是自解釋的。out代表泛型類中,類型參數 T 只能存在于方法的返回值中,即是作為輸出,因此,泛型類是生產者/協變的;in代表泛型類中,類型參數T只能存在于方法的參數中,即是作為輸入,因此,泛型類是消費者/逆變的。如果在泛型類中,類型參數 T 既存在于方法的參數中,又存在于方法的返回值中,那么我們不能對 T 做標記(除了上面提到的 @UnsafeVariance 的情況),也就是說該泛型類是不型變的,這跟Java類似。
注意:以上所說的in/out修飾符對于類型參數 T 的限制,僅適用于非private(public, protected, internal)函數,對于private函數,類型參數 T 可以存在于任意位置,畢竟private函數僅用于內部調用,不會對泛型類的協變、逆變性產生影響。還有一點例外就是,如果類型參數 T 標記為out,我們仍可以在構造函數的參數中使用它,因為構造函數僅用于實例化,之后不能被調用,所以也不會破壞泛型類的協變性。
對于Kotlin而言,可以這么說:Consumer in, Producer out
2.4 Kotlin中的集合類
在Kotlin中,可以在泛型類定義時就標明其是協變的、逆變的還是不型變的。這也就是為什么Kotlin中的集合類分為可變的(例如MutableList)和不可變的(例如List),因為只有不可變的集合我們才能標明是協變(out)的??慈缦露x:
public interface Collection<out E> : Iterable<E> {
public val size: Int
public fun isEmpty(): Boolean
public operator fun contains(element: @UnsafeVariance E): Boolean
override fun iterator(): Iterator<E>
public fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
}
因為Collection是不變的,也就是沒有方法向其中寫入數據,所以我們可以將其定義為協變的。因此,Collection<Orange> 可以作為 Collection<Fruit> 的子類型:
fun demo(oranges: Collection<Orange>) {
val fruits: Collection<Fruit> = oranges
}
而可變的集合的定義如下:
public interface MutableCollection<E> : Collection<E>, MutableIterable<E> {
override fun iterator(): MutableIterator<E>
//向集合中添加或刪除元素,顯然 MutableCollection不能是協變的
public fun add(element: E): Boolean
public fun remove(element: E): Boolean
public fun addAll(elements: Collection<E>): Boolean
public fun removeAll(elements: Collection<E>): Boolean
public fun retainAll(elements: Collection<E>): Boolean
public fun clear(): Unit
}
可變集合增加了以 E 作為參數的方法,因此不再是協變的了(當然也不是逆變的)。也就是說,MutableCollection<Orange> 和 MutableCollection<Fruit> 之間沒有關系。
2.5 類型投影
“類型投影”是Kotlin中的使用處型變,跟Java類似,只是將“? extends T”換成了“out T”,代表協變;“? super T”換成了“in T”,代表逆變。
以Kotlin中的Array為例:
class Array<T>(val size: Int) {
fun get(index: Int): T { ///* …… */ }
fun set(index: Int, value: T) { ///* …… */ }
}
該類在 T 上既不是協變的也不是逆變的。這造成了?些不靈活性??紤]下述函數:
fun copy(from: Array<Any>, to: Array<Any>) {
assert(from.size == to.size)
for (i in from.indices)
to[i] = from[i]
}
那么我們將不能像如下這么使用:
val ints: Array<Int> = arrayOf(1, 2, 3)
val any: Array<Any> = Array<Any>(3) { "" }
copy(ints, any) // 錯誤:期望 (Array<Any>, Array<Any>)
這里我們遇到同樣熟悉的問題:Array<T> 在 T 上是不型變的,因此 Array<Int> 和 Array<Any> 之間沒有關系。為什么? 再次重復,因為 copy 可能“做壞事”,例如它可能嘗試寫?個 String 到 ints 中,這可能會引發ClassCastException 異常。盡管這里的 copy 方法并沒有“做壞事”,但這不能僅憑我們的自覺,還需要編譯器來限制我們的行為。我們將copy方法修改為:
fun copy(from: Array<out Any>, to: Array<Any>) {
// ……
}
val ints: Array<Int> = arrayOf(1, 2, 3)
val any: Array<Any> = Array<Any>(3) { "" }
copy(ints, any) //OK!
這里發生的事情稱為類型投影: from 不僅僅是?個Array,而是?個受限制的( 投影的)Array,我們只可以調用返回類型為 T 的方法(當然,與類型參數 T 無關的方法也能調用),如上,這意味著我們只能調用 get() 。這就是我們的使用處型變的用法,這對應于 Java 的 Array<? extends Object> , 但使用方式更簡單。
同樣,也有逆變的方式:
fun fill(dest: Array<in String>, value: String) {
if (dest.size > 0)
dest[0] = value
}
Array<in String> 對應于 Java 的 Array<? super String> ,也就是說,你可以傳遞?個 Array<CharSequence> 或?個 Array<Object> 對象給 fill() 函數。
2.6 類型參數的命名約定
類型參數一般使用一個大寫的字母表示,經常使用的類型參數的名稱有:
- E: Element(廣泛的用于Java Collection中)
- K: Key
- N: Number
- T: Type
- V: Value
- S,U,V: 第2, 3, 4個類型參數
3. 對比與總結
總體而言,Java和Kotlin中的泛型還是比較相像的。對于使用處型變,兩者幾乎等價,只是表現形式不同,Kotlin看上去更加簡潔一些,它們都是通過編譯器限制我們對一些方法的調用來實現的。Kotlin相較于Java中的泛型,最主要的提升在于,聲明處型變,即在泛型類定義時就可以把其聲明為協變(out)的,或者逆變(in)的。總而言之,Kotlin是以更加簡潔、靈活而嚴格的方式實現了泛型。
參考
《Java編程思想》
Kotlin語言中文站-參考
Kotlin泛型
《Programming Kotlin》