Kotlin語言的泛型設計頗有意思,但並不容易看懂。關於這個部分的官方文檔,我反覆看了好幾回,終於弄明白Kotlin語言泛型設計的背後哲學。這篇文章將講述Kotlin泛型設計的整個思考過程及其背後的哲學思想,但願能夠解答你心中的疑問。不過,能夠預見地,即便看完,你也未必徹底明白這篇文章在說什麼,但至少但願你經過這篇文章能夠快速掌握Kotlin泛型的用法。html
咱們認爲,Kotlin是一門比Java更優秀的JVM編程語言,Kotlin泛型設計的初衷就是爲了解決Java泛型設計中一些不合理的問題。這樣說可能不夠直觀,看下面這個例子:node
List<String> strs = new ArrayList<>();
// 這裏將致使編譯錯誤,Java語言不容許這樣作
List<Object> objs = strs;
複製代碼
很明顯,String和Object之間存在着安全的隱式轉換關係。存放字符串的集合應該能夠自由轉換爲對象集合。這很合理,不是嗎?程序員
若是你這樣認爲的話,就錯了!繼續往下看,咱們擴展這個程序:編程
List<String> strs = new ArrayList<>();
List<Object> objs = strs;
objs.add(1);
String s = strs.get(0);
複製代碼
很明顯,這不合理!咱們在第一個位置存入了整型數值1,卻在取的時候將它當成了字符串。strs自己是一個字符串集合,用字符串接收讀取的數據的邏輯是合理的。卻由於錯誤的類型轉換致使了不安全寫入出現了運行時類型轉換問題,所以,Java語言不容許咱們這樣作。安全
大多數狀況下,這種限制沒有問題。但是,在某些狀況下,這並不合理。看下面的例子:bash
interface List<T> {
void addAll(List<T> t);
}
public void copy(List<String> from, List<Object> to) {
to.addAll(from);
}
複製代碼
這是一個類型絕對安全的操做,但在Java語言中這依然是不容許的。緣由是,泛型是一個編譯期特性,一旦指定,運行期類型就已經固定了。換而言之,泛型操做的類型是不可變的。這就意味着,List<String>並非List<Object>的子類型。框架
爲了容許正確執行上述操做,Java語言增長了神奇的通配符操做魔法。編程語言
interface List<T> {
void addAll(List<? extends T> t);
}
複製代碼
? extends T意味着集合中容許添加的類型不只僅是T還包括T的子類,但這個集合中能夠添加的類型在集合參數傳入addAll時就已經肯定了。所以,這並不影響參數集合中能夠存放的數據類型,它帶來的一個直接影響就是addAll方法參數中終於能夠傳入集合泛型參數是T或者T的子類的集合了,即上面的copy方法將再也不報錯。ide
這頗有意思,在使用通配符以前咱們並不能傳入類型參數爲子類型的集合。使用通配符以後,竟然能夠了!這個特性在C#被稱之爲協變(covariant)。flex
協變這個詞來源於類型之間的綁定。以集合爲例,假設有兩個集合L一、L2分別綁定數據類型F、C,而且F、C之間存在着父子關係,即F、C之間存在着一種安全的從C->F的隱式轉換關係。那麼,集合L1和L2之間是否也存在着L2->L1的轉換關係呢?這就牽扯到了原始類型轉換到綁定類型的集合之間的轉換映射關係,咱們稱之爲「可變性」。若是原始類型轉換和綁定類型之間轉換的方向相同,就稱之爲「協變」。
用一句話總結協變:若是綁定對象和原始對象之間存在着相同方向的轉換關係,即稱之爲協變。
PS:以上關於協變的概念來自筆者的總結,更嚴謹的概念請參考C#官方文檔。
文章開頭咱們將不可變泛型經過通配符使其成爲了可變泛型參數,如今咱們知道這種行爲叫作協變。很明顯,協變轉換中寫入是不安全的。所以,協變行爲僅僅用於讀取。若是須要寫入怎麼辦呢?這就牽扯到了另一個概念逆變(contravariance)。
逆變與協變偏偏相反,即若是F、C之間存在着父子轉換關係,L一、L2之間存在着從L1->L2的轉換關係。其綁定對象的轉換關係與原始對象的轉換關係剛好相反。Java語言使用關鍵字super(?super List)實現逆變。
舉個例子:假設有一個集合List<? super String>,你將能夠安全地使用add(String)或set(Int,String)方法。但你不能經過get(Int)返回String對象,由於你沒法肯定返回的對象是不是String類型,你最終只能獲得Object。
所以,咱們認爲,逆變能夠安全地寫入數據,但並不能安全地讀取,即最終不能獲取具體的對象數據類型。
爲了簡化理解,咱們引入官方文檔中 Joshua Bloch說的一句話:
Joshua Bloch calls those objects you only read from Producers, and those you only write to Consumers. He recommends: "For maximum flexibility, use wildcard types on input parameters that represent producers or consumers"
Joshua Bloch是Java集合框架的創始人,他把那些只能讀取的對象叫作生產者;只能寫入的對象叫作消費者。爲了保證最大靈活性,他推薦在那些表明了生產者和消費者的輸入參數上使用通配符指定泛型。
相對於Java的通配符,Kotlin語言針對協變和逆變引入兩個新的關鍵詞out和in。
out用於協變,是隻讀的,屬於生產者,即用在方法的返回值位置。而in用於逆變,是隻寫的,屬於消費者,即用在方法的參數位置。
用英文簡記爲:POCI = Producer Out , Consumer In。
若是一個類中只有生產者,咱們就能夠在類頭使用out聲明該類是對泛型參數T協變的:
interface Link<out T> {
fun node(): T
}
複製代碼
一樣地,若是一個類中只有消費者,咱們就能夠在類頭使用in聲明該類是對泛型參數T逆變的:
interface Repo<in T> {
fun add(t: T)
}
複製代碼
out 等價於Java端的 ? extends List 通配符,而 in 等價於Java端的 ? super List 通配符。所以,相似下面的轉換是合理的:
interface Link<out T> {
fun node(): T
}
fun f1(linkStr: Link<String>) {
// 這是一個合理的協變轉換
val linkAny: Link<Any> = linkStr
}
interface Repo<in T> {
fun add(t: T)
}
fun f2(repoAny: Repo<Any>) {
// 這是一個合理的逆變轉換
val repoStr: Repo<String> = repoAny
}
複製代碼
協變和逆變對於Java程序員來講是一個全新的概念,爲了便於理解,我用一個表格作一個簡單的總結:
- | 協變 | 逆變 |
---|---|---|
關鍵字 | out | in |
讀寫 | 只讀 | 可寫 |
位置 | 返回值 | 參數 |
角色 | 生產者 | 消費者 |
在上面的例子中,咱們直接在類體聲明瞭泛型參數的協變或逆變類型。在這種狀況下,就嚴格限制了該類中只容許出現該泛型參數的消費者或者生產者。很顯然,這種場景並很少見,大多數狀況下,一個類中既存在着消費者又存在着生產者。爲了適應這種場景,咱們能夠將協變或逆變聲明寫在方法參數中。Kotlin官方將這種方式叫作 類型投影(Type Projection)。
這裏咱們直接使用官方文檔的例子:
class Array<T>(val size: Int) {
fun get(index: Int): T { /* ... */ }
fun set(index: Int, value: 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>(3) { "" }
// 因爲泛型參數的不變性,這裏將出現問題
copy(ints, any)
複製代碼
很明顯,咱們但願from參數能夠接收元素爲Any或其子類的任意元素,但咱們並不但願修改from,以防止出現相似文章開頭的問題。所以,咱們能夠在from參數中添加out修飾,使其協變:
fun copy(from: Array<out Any>, to: Array<Any>) {
}
複製代碼
一旦添加out修飾符,你就會發現,當你嘗試調用set方法的時候,編譯器將會提示你在out修飾的狀況下禁止調用該方法。
注:Java語言在使用」協變「的狀況下,from參數依然能夠調用set方法。從這裏能夠看出,Kotlin語言在泛型安全控制上比Java更加精細。
除了上述明確的類型投影方式以外,還有一種很是特殊的投影方式,稱之爲星號投影(star projection)。
在某些狀況下,咱們並不知道具體的類型參數信息。爲了適應這種狀況,Java語言中咱們會直接忽略掉類型參數:
class Box<T> {
public void unPack(T t) {
...
}
}
// 在不肯定類型參數的狀況下,咱們會這樣作
Box box = new Box();
複製代碼
在Kotlin語言中,咱們使用星號對這種狀況進行處理。由於,Kotlin針對泛型有嚴格的讀寫區分。一樣地,使用*號將限制泛型接口的讀寫操做:
Foo<out T: TUpper>
,這種狀況下,T是協變類型參數,上邊界是TUpper。Foo<*>等價於Foo<out TUpper>,這意味着你能夠安全地從Foo<*>讀取TUpper類型。Foo<in T>
,在這種狀況下,T是逆變類型參數,下邊界是T。Foo<*>等價於Foo<in Nothing>,這意味着在T未知的狀況下,你將沒法安全寫入Foo<*>。Foo<T: TUpper>
,在這種狀況下,T是不可變的。Foo<*>等價於你可使用Foo<out TUpper>安全讀取值,寫入等價於Foo<in Nothing>,即沒法安全寫入。在泛型約束的控制上,Kotlin語言相對於Java也技高一籌。在大多數狀況下,泛型約束須要指定一個上邊界。這同Java同樣,Kotlin使用冒號代替extends:
fun <T: Animal> catch(t: T) {}
複製代碼
在使用Java的時候,常常碰到這樣一個需求。我但願泛型參數能夠約束必須同時實現兩個接口,但遺憾的是Java語言並無給予支持。使人驚喜的是,Kotlin語言對這種場景給出了本身的實現:
fun <T> swap(first: List<T>, second: List<T>) where T: CharSequence,
T: Comparable<T> {
}
複製代碼
能夠看到,Kotlin語言使用where關鍵字控制泛型約束存在多個上邊界的狀況,此處應該給Kotlin鼓掌。
Kotlin語言使用協變和逆變來規範可變泛型操做,out關鍵字用於協變,表明生產者。in關鍵字用於逆變,表明消費者。out和in一樣能夠用於方法參數的泛型聲明中,這稱之爲類型投影。在針對泛型類型約束的處理上,Kotlin增長了多個上邊界的支持。
Kotlin語言最初是但願成爲一門編譯速度比Scala更快的JVM編程語言!爲了更好地設計泛型,咱們看到它從C#中引入了協變和逆變的概念。這一次,我想,它至少同時站在了Scala和C#的肩膀上。
若是你也喜歡Kotlin語言,歡迎加入個人Kotlin交流羣: 329673958 ,一塊兒來參與Kotlin語言的推廣工做。