教你如何攻克Kotlin中泛型型變的難點(下篇)

極客熊貓發表於2018-11-14

簡述: 前幾天我們一起為Kotlin中的泛型型變做了一個很好的鋪墊,深入分析下型別和類,子型別和子類之間的關係、什麼是子型別化關係以及型變存在的意義。那麼今天將會講點更刺激的東西,也就是Kotlin泛型型變中最為難理解的地方,那就是Kotlin中的協變、逆變、不變。雖然很難理解,但是有了上篇文章基礎教你如何攻克Kotlin中泛型型變的難點(上篇)理解起來還是相對比較輕鬆。如果你是初學者不建議直接看這篇文章,還是建議把該系列的上篇理解下。

扯會皮,這幾天我一直在思考一個問題,因為官方給出的結論太過於正式化,而且估計好點的開發者只是記住官方的結論和它的使用規則,但是並沒有真正去了解為什麼是這樣的,這樣設計的意義何在呢?

廢話不多說,繼續上本篇文章的思維導圖

教你如何攻克Kotlin中泛型型變的難點(下篇)

一、泛型協變-保留子型別化關係

1、協變基本定義和介紹

還記得上篇的子型別化關係嗎?協變實際上就是保留子型別化關係,首先,我們需要去明確一下這裡所說的保留子型別化關係是針對誰而言的呢?

  • 基本介紹

來看個例子,StringString?的子型別,我們知道基礎型別List<out E>是協變的,那麼List<String>也就是List<String?>的子型別的。很明顯這裡針對的角色就是List<String>List<String?>,是它們保留了StringString?的子型別化關係。或者換句話說兩個具有相同的基礎型別的泛型協變型別,如果型別實參具有子型別化關係,那麼這個泛型型別具有一致方向的子型別化關係。那麼具有子型別化關係實際上子型別的值能在任何時候任何地方替代超型別的值。

  • 基本定義
interface Producer<out T> {//在泛型型別形參前面指定out修飾符
   val something: T
   fun produce(): T
}
複製程式碼

2、什麼是out協變點

從上面定義的基本結構來看,實際上協變點就是上面produce函式返回值的T的位置,Kotlin中規定一個泛型協變類,在泛型形參前面加上out修飾後,那麼修飾這個泛型形參在函式內部使用範圍將受到限制只能作為函式的返回值或者修飾只讀許可權的屬性。

interface Producer<out T> {//在泛型型別形參前面指定out修飾符
   val something: T//T作為只讀屬性的型別,這裡T的位置也是out協變點
   fun produce(): T//T作為函式的返回值輸出給外部,這裡T的位置就是out協變點
}
複製程式碼

以上協變點都是標準的T型別,實際上以下這種方式其實也是協變點,請注意體會協變點含義:

interface Producer<out T> {
   val something: List<T>//即使T不是單個的型別,但是它作為一個泛型型別修飾只讀屬性,所以它所處位置還是out協變點
   
   fun produce(): List<Map<String,T>>//即使T不是單個的型別,但是它作為泛型型別的型別實參修飾返回值,所以它所處位置還是out協變點
}
複製程式碼

3、out協變點基本特徵

協變點基本特徵: 如果一個泛型類宣告成協變的,用out修飾的那個型別形參,在函式內部出現的位置只能在只讀屬性的型別或者函式的返回值型別。相對於外部而言協變是生產泛型引數的角色,生產者向外輸出out

4、協變-List<out E>的原始碼分析

我們在上篇文章中就說過Kotlin中的List並不是Java中的List,因為Kotlin中的List是個只讀的List不具備修改集合中元素的操作方法。Java的List實際上相當於Kotlin中的MutableList具有各種讀和寫的操作方法。

Kotlin中的List<out E>實際上就是協變的例子,用它來說明分析協變最好不過了,還記得上篇文章說過的學習泛型步驟二嗎,就是通過分析原始碼來驗證自己的理解和結論。通過以下原始碼均可驗證我們上述所說的結論。

//通過泛型類定義可以看出使用out修飾符 修飾泛型型別形參E
public interface List<out E> : Collection<E> {
    override val size: Int
    override fun isEmpty(): Boolean
    override fun contains(element: @UnsafeVariance E): Boolean//咦! 咦! 咦! 和說的不一樣啊,為什麼還能出現在這個位置,還出來了個@UnsafeVariance 這個是什麼鬼? 告訴你,穩住,先不要急,請聽我在後面慢慢說來,先暫時保留神祕感
    override fun iterator(): Iterator<E>//這裡明顯能看出來E處於out協變點位置,而且還是泛型型別Iterator<E>出現的,正好驗證我們上述所說的協變的變種型別(E為型別實參的泛型型別)

    override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
    public operator fun get(index: Int): E//函式返回值的型別E,這裡明顯能看出來E處於out協變點位置,正好驗證我們上述所說的協變的標準型別(E直接為返回值的型別)
    public fun indexOf(element: @UnsafeVariance E): Int

    public fun lastIndexOf(element: @UnsafeVariance E): Int

    public fun listIterator(): ListIterator<E>//(E為型別實參的泛型型別),為out協變點

    public fun listIterator(index: Int): ListIterator<E>//(E為型別實參的泛型型別),為out協變點
    public fun subList(fromIndex: Int, toIndex: Int): List<E>//(E為型別實參的泛型型別),為out協變點
}
複製程式碼

原始碼分析完了,是不是感覺還是有點迷惑啊?就是E為啥還能在其他的位置上,還有@UnsafeVariance是個什麼鬼? 這些疑問先放一放,但是上述至少證明了泛型協變out協變的位置是返回值的型別以及只讀屬性的型別(這點原始碼中沒有表現出來,但是實際上卻是如此啊,這裡可以自行查閱其他例子)

二、泛型逆變-反轉子型別化關係

1、逆變基本定義和介紹

  • 基本介紹

逆變實際上就是和協變子型別化關係正好相反,它是反轉子型別化關係

來個例子說明下,我們知道StringString?的子型別,Comparable<in T>是逆變的,那麼Comparable<String>Comparable<String?>實際上是反轉了StringString?的子型別化關係,也就是和StringString?的子型別化關係相反,那麼Comparable<String?>就是Comparable<String>子型別, Comparable<String>型別值出現的地方都可用Comparable<String?>型別值來替代。

換句話說就是:兩個具有相同的基礎型別的泛型逆變型別,如果型別實參具有子型別化關係,那麼這個泛型型別具有相反方向的子型別化關係

  • 基本定義
interface Consumer<in T>{//在泛型型別形參前面指定in修飾符
   fun consume(value: T)
}
複製程式碼

2、什麼是in逆變點

從上面定義的基本結構來看,實際上逆變點就是上面consume函式接收函式形參的T的位置,Kotlin中規定一個泛型協變類,在泛型形參前面加上out修飾後,那麼修飾這個泛型形參在函式內部使用範圍將受到限制只能作為函式的返回值或者修飾只讀許可權的屬性。

interface Consumer<in T>{//在泛型型別形參前面指定in修飾符
   var something: T //T作為可變屬性的型別,這裡T的位置也是in逆變點
   fun consume(value: T)//T作為函式形參型別,這裡T的位置也就是in逆變點
}
複製程式碼

和協變類似,逆變也存在那種泛型型別處於逆變點的位置,這些我們都可以把當做逆變點:

interface Consumer<in T>{
   var something: B<T>//這裡雖然是泛型型別但是T所在位置依然是修飾可變屬性型別,所以仍處於逆變點
   fun consume(value: A<T>)//這裡雖然是泛型型別但是T所在位置依然是函式形參型別,所以仍處於逆變點
}
複製程式碼

3、in逆變點基本特徵

逆變點基本特徵: 如果一個泛型類宣告成逆變的,用in修飾泛型類的型別形參,在函式內部出現的位置只能是作為可變屬性的型別或者函式的形參型別。相對於外部而言逆變是消費泛型引數的角色,消費者請求外部輸入in

4、逆變-Comparable<in T>的原始碼分析

在Kotlin中其實最簡單的泛型逆變的例子就是Comparable<in T>

public interface Comparable<in T> {//泛型逆變使用in關鍵字修飾
    /**
     * Compares this object with the specified object for order. Returns zero if this object is equal
     * to the specified [other] object, a negative number if it's less than [other], or a positive number
     * if it's greater than [other].
     */
    public operator fun compareTo(other: T): Int//因為是逆變的,所以T在函式內部出現的位置作為compareTo函式的形參型別,可以看出它是屬於消費泛型引數的
}
複製程式碼

三、泛型不變-無子型別化關係

不變基本定義和介紹

  • 基本介紹

對於不變就更簡單了,泛型型變中除去協變、逆變就是不變了。其實不變看起來就是我們常用的普通泛型,它既沒有in關鍵字修飾,也沒有out關鍵字修飾。它就是普通的泛型,所以很明顯它沒有像協變、逆變那樣那麼多的條條框框,它很自由既可讀又可寫,既可以作為函式的返回值型別也可以作為函式形參型別,既可以宣告成只讀屬性的型別又可以宣告可變屬性。但是注意了:不變型就是沒有子型別化關係,所以它會有一個侷限性就是如果以它作為函式形參型別,外部傳入只能是和它相同的型別,因為它根本就不存在子型別化關係說法,那也就是沒有任何型別值能夠替換它,除了它自己本身的型別 例如MutableList<String>和MutableList<String?>是完全兩種不一樣的型別,儘管StringString?子型別,但是基礎泛型MutableList<E>是不變型的,所以MutableList<String>和MutableList<String?>根本沒關係。

  • 基本定義

interface MutableList<E>{//沒有in和out修飾
   fun add(element: E)//E可以作為函式形參型別處於逆變點,輸入消費E
   fun subList(fromIndex: Int, toIndex: Int): MutableList<E>//E又可以作為函式返回值型別處於協變點,生產輸出E
}

複製程式碼

四、由協變、逆變、不變的規則引發一些思考

思考一:

協變泛型類的泛型形參型別T一定就只能out協變點位置嗎?能不能在in逆變點位置呢?

解惑一: 可以在逆變點,但是必須在函式內部保證該泛型引數T不存在寫操作行為,只能有讀操作

出現的場景: 宣告瞭協變的泛型類,但是有時候需要從外部傳入一個該型別形參的函式引數,那麼這個形參型別就處於in逆變點的位置了,但是函式內部能夠保證不會對泛型引數存在寫操作的行為。常見例子就是List<out E>原始碼,就是上面大家一臉懵逼的地方,就是那個為什麼定義成協變的泛型T跑到了函式形參型別上去。 如下面部分程式碼所示:

  override fun contains(element: @UnsafeVariance E): Boolean//咦! 咦! 咦! 和說的不一樣啊,為什麼還能出現在這個位置,還出來了個@UnsafeVariance 這個是什麼鬼? 現在回答你就是可能會出現在這,但是隻要保證函式不會寫操作即可
複製程式碼

上述的List中的contains函式形參就是泛型形參E,它是協變的出現在逆變點,但是隻要保證函式內部不會對它有寫操作即可

思考二:

逆變泛型類的泛型形參型別T就一定只能在in逆變點位置嗎?能不能在out協變點位置呢?

解惑二: 同理,也可以在協變點位置

思考三:

能在其他的位置嗎? 比如建構函式

解惑三: 可以在構造器函式中,因為這是個比較特殊的位置,既不在in位置也不在out位置


class ClassMates<out T: Student>(vararg students: T){//可以看到雖然定義成了協變,但是這裡的T不是在out協變點的位置,這種宣告依然是合法的
   ...
}
複製程式碼

注意: 這裡就是很特殊的場景了,所以開頭就說過了如果把這些規則,用法只是死記硬背下來,碰到這種場景的時候就開始懷疑人生了,規則中不是這樣的啊,規則中定義協變點就是隻讀屬性型別和函式返回值型別的位置啊,這個位置不上不下的該怎麼解釋呢?所以解決問題還是需要抓住問題的關鍵才是最主要的。

其實解釋這個問題也不難,回到型變的目的和初衷上去,型變是為了解決型別安全問題,是防止更加泛化的例項呼叫某些存在危險操作的方法。建構函式很特殊一般建立後例項物件後,在該物件基礎上建構函式是不能再被呼叫的,所以這裡T放在這裡是安全的。

思考四

為了安全,我是不是隻要把所有泛型類全都定義成協變或逆變或不變一種就可以了呢?

解惑四: 不行,這樣不安全,按照實際場景需求出發,一味定義成協變或逆變實際上限制了該泛型類對該型別形參使用的可能性,因為out只能是作為生產者,協變點位置有限制,而in只能是消費者逆變點的位置也有限制。那索性全都定義成不變型,那就在另一層面喪失了靈活性,就是它失去了子型別化關係, 就是把它作為函式引數型別,外部只能傳入和它相同的型別,不可能存在子型別化關係的保留和反轉了

五、由思考領悟到協變點、逆變點的本質

由上面的思考明白了一點,使用協變、逆變的時候並不是那麼死的按照協變點,逆變點規則來,可以更加靈活點,關鍵是不能違背協變、逆變根本宗旨。協變宗旨就是定義的泛型類內部不能存在寫操作的行為,對於逆變根本宗旨一般都是隻寫的。那Kotlin中List<out E>的原始碼來說都不是真正規則上說的那樣協變,泛型形參E並不都是在協變點out上,但是List<out E>內部能夠保證不會存在寫操作危險行為所以這種定義也是合法。實際上真正開發過程,很難做到協變泛型類中的泛型型別形參都是在out協變點上,因為有時候需求需要確實需要從外部傳入一個該型別形參的一個函式形參。

所以最終的結論是: 協變點out和逆變點in的位置的規則是一般大體情況下要遵守的,但是需要具體情況具體分析,針對設計的泛型類具體情況,適當地在不違背根本宗旨以及滿足需求情況下變下協變點和逆變點的位置規則

六、由本質區別明白UnSafeVariance註解在開發中的應用

由上面的本質區別分析,嚴格按照協變點、逆變點規則來是不能完全滿足我們真實開發需求場景的,所以有時候需要一道後門,那就要用特殊方式告訴它。那就是使用UnSafeVariance註解。所以UnSafeVariance註解作用很簡單: 通過@UnSafeVariance告訴編譯器該處安全性自己能夠把控,讓它放你編譯通過即可,如果不加編譯器認為這是不合法的。註解的意思就是不安全的型變,例如在協變泛型類中有個函式是以傳入一個該泛型形參的函式形參的,通過UnSafeVariance註解讓編譯器閉嘴,然後把它放置在逆變點實際上是增加一層危險性,相當於把這層危險交給了開發者,只要開發者能保證內部不存在危險性操作肯定就是安全的。

七、協變、逆變、不變對比分析、使用和理解

1、分析對比

將從基本結構形式、有無子型別化關係(保留、反轉)、有無型變點(協變點out、逆變點in)、角色(生產者輸出、消費者輸入)、型別形參存在的位置(協變就是修飾只讀屬性和函式返回值型別;逆變就是修飾可變屬性和函式形參型別)、表現特徵(只讀、可寫、可讀可寫)等方面進行對比

協變 逆變 不變
基本結構 Producer<out E> Consumer<in T> MutableList<T>
子型別化關係 保留子型別化關係 反轉子型別化關係 無子型別化關係
有無型變點 協變點out 逆變點in 無型變點
型別形參存在的位置 修飾只讀屬性型別和函式返回值型別 修飾可變屬性型別和函式形參型別 都可以,沒有約束
角色 生產者輸出為泛型形參型別 消費者輸入為泛型形參型別 既是生產者也是消費者
表現特徵 內部操作只讀 內部操作只寫 內部操作可讀可寫

2、使用對比

實際上就是要明確什麼時候該使用協變、什麼時候該使用逆變、什麼時候該使用不變。 實際上通過上述分析對比的表格可以得出結論: 首先,表格有很多個條件特徵,到底是先哪個開始判定條件好呢?實際上這裡面還是需要選擇一下的。

假設一: 就比如一開始就以有無使用子型別化關係為條件做判定,這樣做法是有點問題的,試想下在實際開發中,先是去定義泛型類內部一些方法和屬性的,這時候很難知道在外部使用情況下存不存在利用子型別化關係,也就是存不存在用子型別的值替換超型別的值場景,所以在剛剛定義泛型類的時候很難明確的。故還是先從泛型類定義的內部特徵著手會更加明確點。

假設二: 比如先根據泛型類內部定義一些方法和屬性,由於剛開始定義並不能確定是否是協變out還是逆變in,所以上面的有無型變點不能作為判定條件,最開始還沒確定的時候一般當做不變泛型類來定義。

,最直白可以先看看型變點,然後根據型變點基本確定泛型類內部表現特徵,

  • 步驟1: 首先,根據型別形參存在的位置初步判定
  • 步驟2: 然後,通過判定表現特徵是在泛型類定義內部是不是隻涉及到該泛型形參只讀操作(協變或不變),還是寫操作(逆變或不變),還是既可讀又可寫(不變)這裡只能判斷出兩種組合情況(協變或不變)、(逆變或不變)中的一種,因為如果只涉及到讀操作那就是(協變或不變),如果只涉及寫操作(逆變或不變)
  • 步驟3: 最後,再去看是否存在子型別化關係,如果通過步驟2得到是 (協變或不變)外加有子型別化關係最終得到使用協變,如果通過步驟2得到是 (逆變或不變)外加有子型別化關係最終得到使用逆變,如果沒有子型別化關係就用不變。

補充一點,如果最終確定是協變的,可是在定義的時候通過步驟1得到型別形參存在的位置處於函式形參位置,那麼這時候就可以大膽藉助@UnSafeVariance註解告訴編譯器使得編譯通過,逆變同理。

來張圖理解下

教你如何攻克Kotlin中泛型型變的難點(下篇)

3、理解對比

是否還記得上一篇文章開頭的那個例子和那幅漫畫圖

  • 對於協變的理解:

例子程式碼如下:

fun main(args: Array<String>) {
    val stringList: List<String> = listOf("a", "b", "c", "d")
    val intList: List<Int> = listOf(1, 2, 3, 4)
    printList(stringList)//向函式傳遞一個List<String>函式實參,也就是這裡List<String>是可以替換List<Any>
    printList(intList)//向函式傳遞一個List<Int>函式實參,也就是這裡List<Int>是可以替換List<Any>
}

fun printList(list: List<Any>) {
//注意:List是協變的,這裡函式形參型別是List<Any>,函式內部是不知道外部傳入是List<Int>還是List<String>,全部當做List<Any>處理
    list.forEach {
        println(it)
    }
}
複製程式碼

理解:

對於printList函式而言,它需要的是List<Any>型別是個相對具體型別更加泛化的型別,且在函式內部的操作不會涉及到修改寫操作,然後在外部傳入一個更為具體的子型別肯定是滿足要求的泛化型別最基本需求。所以外部傳入更為具體子型別List<String>、List<Int>的相容性更好。

  • 對於逆變的理解:

例子程式碼如下:

class A<in T>{
    fun doAction(t: T){
        ...
    }
}

fun main(args: Array<String>) {

    val intA = A<Int>()
    val anyA = A<Any>()

    doSomething(intA)//不合法,
    doSomething(anyA)//合法
}

fun doSomething(a: A<Number>){//在doSomething外部不能傳入比A<Number>更為具體的型別,因為在函式內部涉及寫操作.
    ....
}
複製程式碼

理解:

對於doSomething,它需要的A<Number>是個相對泛化型別更加具體的型別,由於泛型類A逆變的,函式內部的操作放開寫操作許可權,試著想下在doSomething函式外部不能傳入比他更為具體的比較器物件了,因為只要有比A<Number>更為具體的,就會出問題,利用反證法來理解下,假如傳入A<Int>型別是合法的,那麼在內部函式還是當做A<Number>,在函式內部寫操作時候很有可能把它往裡面寫入一個Float型別的資料,因為往Number型別寫入Float型別是很合法的,但是外部實際上傳入的是A<Int>,往A<Int>寫Float型別不出問題才怪呢,所以原假設不成立。所以逆變放開了寫許可權,那麼對於外部傳入的型別要求就更加嚴格了。

引出另一個問題,為什麼逆變寫操作是安全的呢? 細想也是很簡單的,對於逆變泛型型別作為函式形參的型別,那麼在函式外部的傳入實參型別就一定要比函式形參的型別更泛化不能更具體,所以在函式內部操作的最具體的型別也就是函式形參型別,所以肯定可以大膽寫操作啊。就比如A<Number>型別形參型別,在doSomething函式中明確知道外部不能比它更為具體,所以在函式內部大膽在A<Number>基礎上寫操作是可以的。

  • 對於不變的理解 例子程式碼如下:
fun main(args: Array<String>) {
    val stringList: MutableList<String> = mutableListOf("a", "b", "c", "d")
    val intList: MutableList<Int> = mutableListOf(1, 2, 3, 4)
    printList(stringList)//這裡實際上是編譯不通過的
    printList(intList)//這裡實際上是編譯不通過的
}

fun printList(list: MutableList<Any>) {
    list.add(3.0f)//開始引入危險操作dangerous! dangerous! dangerous!
    list.forEach {
        println(it)
    }
}
複製程式碼

理解:

不變實際上就更好理解了,因為不存在子型別化關係,沒有所謂的子型別A的值在任何地方任何時候可以替換超型別B的值的規則,所以上述例子編譯不過,對於printList函式而言必須接收的型別是MutableList<Any>,因為一旦傳入和它不一樣的具體型別就會存在危險操作,出現不安全的問題。

八、結語

由於篇幅原因,所以星投影和協變、逆變實際例子的應用放到下一篇應用篇去了,但是到這裡Kotlin泛型型變重點和難點已經全部講完,後面一篇也就是實際開發中例子的運用。關於這篇文章還是需要好好消化一下,最後再根據下一篇實際例子就可以更加鞏固,下篇將會注重講開發中的例子實現,不會再扣概念了。下篇敬請關注~~~

Kotlin系列文章,歡迎檢視:

原創系列:

翻譯系列:

實戰系列:

教你如何攻克Kotlin中泛型型變的難點(下篇)

歡迎關注Kotlin開發者聯盟,這裡有最新Kotlin技術文章,每週會不定期翻譯一篇Kotlin國外技術文章。如果你也喜歡Kotlin,歡迎加入我們~~~

相關文章