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

mikyou發表於2018-11-27

簡述: 這是泛型型變最後一篇文章了,也是泛型介紹的最後一篇文章。順便再扯點別的,上週去北京參加了JetBrains 2018開發者日,主要是參加Kotlin專場。個人感覺收穫還是挺多的,bennyHuo和彥偉老師精彩演講確實傳遞很多幹貨啊,當然還有Hali佈道師大佬帶來了的Kotlin1.3版本的新特性以及Google中國技術推廣負責人鍾輝老師帶來的Coroutines在Android開發中的應用。所以準備整理如下幾篇文章為後續釋出:

  • 1、Kotlin中1.3版本新特性都有哪些?
  • 2、Kotlin中的Coroutine(協程)在Android上應用(協程學前班篇)
  • 3、Ktor非同步框架初體驗(Ktor學前班篇)
  • 4、Kotlin中data class的使用(benny大佬在大會上講的很清楚了,也很全面。主要講下個人之前踩過的坑,特別是用於後端開發坑更多)

那麼今天這篇文章主要是為了給上篇型變文章兩個尾巴以及泛型型變是如何被應用到實際開發中的去。並且我會用上篇部落格如何去選擇相應型變的方法一步步確定最終我們該使用協變、逆變、還是不變,我會用一個實際例子來說明。這篇文章比較簡單主要就以下四點:

  • 1、Kotlin宣告點變型與Java中的使用點變型進行對比
  • 2、如何使用Kotlin中的使用點變型
  • 3、Kotlin泛型中的星投影
  • 4、使用泛型型變實現可用於實際開發中的Boolean擴充套件

一、Kotlin宣告點變型與Java中的使用點變型進行對比

1、宣告點變型和使用點變型定義區別

首先,解釋下什麼是宣告點變型和使用點變型,宣告點變型顧名思義就是在定義宣告泛型類的時候指明型變型別(協變、逆變、不變),在Kotlin上表現形式就是在宣告泛型類時候在泛型形參前面加in或out修飾。使用點變型就是在每次使用該泛型類的時候都要去明確指出型變關係,如果你對Java中型變熟悉的話,Java就是使用了使用點變型.

2、兩者優點對比

宣告點變型:

  • 有個明顯優點就是隻需要在泛型類宣告時定義一次型變對應關係就可以了,那麼之後不管在任何地方使用它都不用顯示指定型變對應關係,而使用點變型就是每處使用的地方都得重複定義一遍特別麻煩(又找到一處Kotlin優於Java的地方)。

使用點變型:

  • 實際上使用點變型也是有使用場景的,可以使用的更加靈活;所以Kotlin並沒有完全摒棄這個語法點,下面會專門介紹它的使用場景。

3、使用對比

剛剛說使用點變型特別麻煩,一起來看看到底有多麻煩。這裡就是以Java為代表,我們都知道Java中要使用型變,是利用?萬用字元加(super/extends)來達到目的,例如: Function<? super T, ? extends E>, 其中的? extends E就是對應了協變,而? super T對應的是逆變。這裡以Stream API中的flatMap函式原始碼為例

@FunctionalInterface
public interface Function<T, R> {//宣告處就不用指定型變關係
    ...
}

//可以看到使用點變型非常麻煩,定義一個mapper的Function泛型類引數時,還需要指明後面一大串Function<? super T, ? extends Stream<? extends R>>
  <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
複製程式碼

宣告點變型到底有多方便,這裡就以Kotlin為例,Kotlin使用in, out來實現型變對應規則。這裡以Sequences API中的flapMap函式原始碼為例


public interface Sequence<out T> {//Sequence定義處宣告瞭out協變
    /**
     * Returns an [Iterator] that returns the values from the sequence.
     *
     * Throws an exception if the sequence is constrained to be iterated once and `iterator` is invoked the second time.
     */
    public operator fun iterator(): Iterator<T>
}

public fun <T, R> Sequence<T>.flatMap(transform: (T) -> Sequence<R>): Sequence<R> {//可以看到由於Sequence宣告瞭協變,所以flatMap函式Sequence中的泛型實參R就不用再次指明型變型別了
    return FlatteningSequence(this, transform, { it.iterator() })
}
複製程式碼

通過以上原始碼對比,明顯看出Kotlin中的宣告點變型要比Java中的使用點變型要簡單得多吧。但是呢使用點變型並不是一無是處,它在Kotlin中還是有一定的使用場景的。下面即將揭曉

二、如何使用Kotlin中的使用點變型

實際上使用點變型在Kotlin中還是有一定的使用場景,想象一下這樣一個實際場景,儘管某個泛型類是不變的,也就是具有可讀可寫的操作,可是有時候在某個函式中,我們一般僅僅只用到只讀或只寫操作,這時候利用使用點變型它能使一個不變型的縮小型變範圍蛻化成協變或逆變的。是不是突然懵逼了,用原始碼來說話,你就明白了,一起來看個原始碼中的例子。

Kotlin中的MutableCollection<E>是不變的,一起來看了下它的定義

public interface MutableCollection<E> : Collection<E>, MutableIterable<E> {//沒有in和out修飾,說明是不變
    override fun iterator(): MutableIterator<E>
    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
}
複製程式碼

然後我們接著看filter和filterTo函式的原始碼定義

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

//注意: 這裡<T, C : MutableCollection<in T>>, MutableCollection<in T>宣告成逆變的了,是不是很奇怪啊,之前明明有說它是不變的啊,怎麼這裡就宣告逆變了
public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {
    for (element in this) if (predicate(element)) destination.add(element)
    return destination
}
複製程式碼

通過上面的函式是不是發現和MutableCollection不變相違背啊,實際上不是的。這裡就是一種典型的使用點變型的使用,我們可以再仔細分析下這個函式,destination在filterTo函式的內部只做了寫操作,遍歷Iterable中的元素,並把他們add操作到destination集合中,可以驗證我們上述的結論了,雖然MutableCollection是不變的,但是在函式內部只涉及到寫操作,完全就可以使用 使用點變型將它指定成一個逆變的型變型別,由不變退化成逆變明顯不會影響泛型安全所以這裡處理是完全合法的。可以再去看其他集合操作API,很多地方都使用了這種方式。

上述關於不變退化到逆變的,這裡再講個不變退化到協變的例子。

//可以看到source集合泛型型別宣告成了out協變了???
fun <T> copyList(source: MutableList<out T>, destination: MutableList<T>): MutableList<T>{
    for (element in source) destination.add(element)
}
複製程式碼

MutableList<E>就是前面常說的不變的型別,同樣具有可讀可寫操作,但是這裡的source的集合泛型型別宣告成了out協變,會不會又蒙了。應該不會啊,有了之前逆變的例子,應該大家都猜到為什麼了。很簡單就是因為在copyList函式中,source集合沒有涉及寫操作只有讀操作,所以可以使用 使用點變型將MutableList的不變型退化成協變型,而且很顯然不會引入泛型安全的問題。

所以經過上述例子和以前例子關於如何使用逆變、協變、不變。還是我之前說那句話,不要去死記規則,關鍵在於使用場景中讀寫操作是否引入泛型型別安全的問題。如果明確讀寫操作的場景了完全可以按照上述例子那樣靈活運用泛型的型變的,可以程式寫得更加完美。

三、Kotlin泛型中的星投影

1、星投影的定義

星投影是一種特殊的星號投影,它一般用來表示不知道關於泛型實參的任何資訊,換句話說就是它表示一種特定的型別,但是隻是這個型別不知道或者不能被確定而已。

2、MutableList<*>MutableList<Any?>區別

首先我們需要注意和明確的一點就是MutableList<*>MutableList<Any?>是不一樣的,MutableList<*>表示包含某種特定型別的集合;而MutableList<Any?>則是包含任意型別的集合。特定型別集合只不過不太確定是哪種型別,任意型別表示包含了多種型別,區別在於特定集合型別一旦確定型別,該集合只能包含一種型別;而任意型別就可以包含多種型別了。

3、MutableList<*>實際上一個out協變投影

MutableList<*>實際上是投影成MutableList<out Any?>型別

首先,我們來分析下為什麼會這樣投影,我們知道MutableList<*>只包含某種特定型別的集合,可能是String、Int或者其他型別中的一種,可想而知對於該集合操作需要禁止寫操作,不能往該集合中寫入資料,因為無法確定該集合的特定型別,寫操作很可能引入一個不匹配型別到集合中,這是一件很危險的事。但是反過來想下,如果該集合存在只讀操作,讀出資料元素型別雖然不知道,但是始終是安全的。只存在讀操作那麼說明是協變,協變就會存在保留子型別化關係,也就是讀出資料元素型別是不確定型別子型別,那麼可想而知它只替換Any?型別的超型別,因為Any?是所有型別的超型別,那麼保留型化關係,所以MutableList<*>實際上就是MutableList<out Any?>的子型別了。

四、使用泛型型變實現可用於實際開發中的Boolean擴充套件

關於Boolean擴充套件的實現,主要來源於看了BennyHuo大佬寫的一些程式碼中發現的,原來可以這麼方便的寫if-else,於是乎就去看了下它的實現 可能很多人都知道了它的實現,為什麼要講這個因為這是Kotlin泛型協變實際應用一個非常不錯的例子。

1、為什麼開發一個Boolean擴充套件

給出一個例子場景,判斷一堆數集合中是否全是奇數,如果全是返回輸出"奇數集合",如果不是請輸出"不是奇數集合"

首先問下大家是否寫過一下類似下面程式碼

//java版寫法

public void isOddList(){
    int count = 0;
    for(int i = 0; i < numberList.size(); i++){
        if(numberList[i] % 2 == 1){
            count++;
        }
    }
    if(count == numberList.size()){
       System.out.println("奇數集合");
       return;
    }
    System.out.println("不是奇數集合");
}

複製程式碼
//kotlin版寫法

fun isOddList() = println(if(numberList.filter{ it % 2 == 1}.count().equals(numberList.size)){"奇數集合"} else {"不是奇數集合"})
複製程式碼
//Boolean擴充套件版本寫法
fun isOddList() = println(numberList
          .filter{ it % 2 == 1 }
          .count()
          .equals(numberList.size)
          .yes{"奇數集合"}
          .otherwise{"不是奇數集合"})//有沒有發現Boolean擴充套件這種鏈式呼叫更加絲滑
複製程式碼

對比發現,雖然Kotlin中的if-else表示式自帶返回值的,但是if-else的結構會打斷鏈式呼叫,但是如果使用Boolean擴充套件,完全可以使你的鏈式呼叫更加絲滑順暢一路呼叫到底。

2、Boolean擴充套件使用場景

Boolean擴充套件的使用場景個人認為有兩個:

  • 配合函式式API一起使用,遇到if-else判斷的時候建議使用Boolean擴充套件,因為它不會像if-else結構一樣會打斷鏈式呼叫的結構。
  • 另一場景就是if的判斷條件組合很多,如果在外層再包裹一個if程式碼顯得更加臃腫了,此時使用Boolean會使程式碼更簡潔。

3、Boolean程式碼實現

通過觀察上述Boolean擴充套件的使用,我們首先需要明確幾點:

  • 第一點:我們知道yes、otherwise實際上就是兩個函式,為什麼能鏈式連結起來說明中間肯定有一個類似橋樑作用的中間型別作為函式的返回值型別。
  • 第二點:yes、otherwise函式的作用域是帶返回值的,例如上述例子它能直接返回字串型別的資料。
  • 第三點: yes、oterwise函式的都是一個lamba表示式,並且這個lambda表示式將最後表示式中的值返回
  • 第四點: yes函式是在Boolean型別呼叫,所以需要基於Boolean型別的實現擴充套件函式

那麼根據以上得出幾點特徵基本可以把這個擴充套件的簡單版本寫出來了(暫時不支援帶返回值的)

//作為中間型別,實現鏈式連結
sealed class BooleanExt 
object Otherwise : BooleanExt()
object TransferData : BooleanExt()

fun Boolean.yes(block: () -> Unit): BooleanExt = when {
    this -> {
        block.invoke()
        TransferData//由於返回值是BooleanExt,所以此處也需要返回一個BooleanExt物件或其子類物件,故暫且定義TransferData object繼承BooleanExt
    }
    else -> {//此處為else,那麼需要連結起來,所以需要返回一個BooleanExt物件或其子類物件,故定義Otherwise object繼承BooleanExt
        Otherwise
    }
}

//為了連結起otherwise方法操作所以需要寫一個BooleanExt類的擴充套件
fun BooleanExt.otherwise(block: () -> Unit) = when (this) {
    is Otherwise -> block.invoke()//判斷此時子類,如果是Otherwise子類執行block
    else -> Unit//不是,則直接返回一個Unit即可
}


fun main(args: Array<String>) {
    val numberList: List<Int> = listOf(1, 2, 3)
    //使用定義好的擴充套件
    (numberList.size == 3).yes {
        println("true")
    }.otherwise {
        println("false")
    }
}
複製程式碼

上述的簡單版基本上把擴充套件的架子搭出來但是呢,唯一沒有實現返回值的功能,加上返回值的功能,這個最終版本的Boolean擴充套件就實現了。

現在來改造一下原來的版本,要實現返回值那麼block函式不能再返回Unit型別,應該要返回一個泛型型別,還有就是TransferData不能使用object物件表示式型別,因為需要利用構造器傳入泛型型別的引數,所以TransferData用普通類替代就好了。

關於是定義成協變、逆變還是不變型,我們可以借鑑上篇文章使用到流程選擇圖和對比表格

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

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

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

  • 第一步:首先根據型別形參存在位置以及表現特徵確定
sealed class BooleanExt<T>

object Otherwise : BooleanExt<Any?>()

class TransferData<T>(val data: T) : BooleanExt<T>()//val修飾data

inline fun <T> Boolean.yes(block: () -> T): BooleanExt<T> = when {//T處於函式返回值位置
    this -> {
        TransferData(block.invoke())
    }
    else -> Otherwise//注意: 此處是編譯不通過的
}

inline fun <T> BooleanExt<T>.otherwise(block: () -> T): T = when (this) {//T處於函式返回值位置
    is Otherwise ->
        block()
    is TransferData ->
        this.data
}
複製程式碼

通過以上程式碼我們可以基本確定是協變或者不變,

  • 第二步:判斷是否存在子型別化關係

由於yes函式else分支返回的是Otherwise編譯不通過,很明顯此處不是不變的,因為上述程式碼就是按照不變方式來寫的。所以基本確定就是協變。

然後接著改,首先將sealed class BooleanExt<T>改為sealed class BooleanExt<out T>協變宣告,然後發現Otherwise還是報錯,為什麼報錯啊,報錯原因是因為yes函式要求返回一個BooleanExt<T>型別,而此時返回Otherwise是個BooleanExt<Any?>(),反證法,假如上述是合理,那麼也就是BooleanExt<Any?>要替代BooleanExt<T>出現的地方,BooleanExt<Any?>BooleanExt<T>子型別,由於BooleanExt<T>協變的,保留子型別型化關係也就是Any?T子型別,明顯不對吧,我們都知道Any?是所有型別的超型別。所以原假設明顯不成立,所以編譯錯誤很正常,那麼逆向思考下,我是不是隻要把Any?位置用所有的型別的子型別Nothing來替換不就符合了嗎,那麼我們自然而然就想到Nothing,在Kotlin中Nothing是所有型別的子型別。所以最終版本Boolean擴充套件程式碼如下

sealed class BooleanExt<out T>//定義成協變

object Otherwise : BooleanExt<Nothing>()//Nothing是所有型別的子型別,協變的類繼承關係和泛型引數型別繼承關係一致

class TransferData<T>(val data: T) : BooleanExt<T>()//data只涉及到了只讀的操作

//宣告成inline函式
inline fun <T> Boolean.yes(block: () -> T): BooleanExt<T> = when {
    this -> {
        TransferData(block.invoke())
    }
    else -> Otherwise
}

inline fun <T> BooleanExt<T>.otherwise(block: () -> T): T = when (this) {
    is Otherwise ->
        block()
    is TransferData ->
        this.data
}
複製程式碼

五、結語

到這裡Kotlin中有關泛型的所有文章就結束,當然泛型很重要深入於實際開發各個地方,特別是開發一些框架東西比較多,可以看到上述Boolean實現就是按照上篇文章教你如何攻克Kotlin中泛型型變的難點(下篇)規則來決定使用哪種型變型別以及稍加分析下就出來了。總的來說有了那張圖做指導還是很方便的。其實關於泛型型變,還是得需要多理解,不能死記規則,只有這樣才能更加靈活運用。最後非常感謝bennyHuo大佬提供的Boolean擴充套件實現。

Kotlin系列文章,歡迎檢視:

原創系列:

翻譯系列:

實戰系列:

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

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

相關文章