Kotlin教程(九)泛型

胡奚冰發表於2018-04-11

寫在開頭:本人打算開始寫一個Kotlin系列的教程,一是使自己記憶和理解的更加深刻,二是可以分享給同樣想學習Kotlin的同學。系列文章的知識點會以《Kotlin實戰》這本書中順序編寫,在將書中知識點展示出來同時,我也會新增對應的Java程式碼用於對比學習和更好的理解。

Kotlin教程(一)基礎
Kotlin教程(二)函式
Kotlin教程(三)類、物件和介面
Kotlin教程(四)可空性
Kotlin教程(五)型別
Kotlin教程(六)Lambda程式設計
Kotlin教程(七)運算子過載及其他約定
Kotlin教程(八)高階函式
Kotlin教程(九)泛型


泛型型別引數

泛型允許你定義帶型別形參的型別,當這種型別的例項被建立出來的時候,型別形參被替換成稱為型別實參的具體型別。例如:

List<String>
Map<String, Person>
複製程式碼

和一般型別一樣,Kotlin編譯器也常常能推匯出型別實參:

val authors = listOf("Dimtry", "Sevelana")
複製程式碼

如果你想建立一個空的列表,這樣就沒有任何可以推匯出型別實參的線索,你就得顯式地指定它(型別形參)。

val readers: MutableList<String> = mutableListOf()

val readers = mutableListOf<String>()
複製程式碼

和Java不同,Kotlin始終要求型別實參要麼被顯式地說明,要麼能被編譯器推匯出來。因為泛型是1.5版本才引入到Java的,它必須保證和基於老版本的相容,所以它允許使用沒有型別引數的泛型型別——所謂的原生態型別。而Kotlin從一開始就有泛型,所以它不支援原生態型別,型別實參必須定義。

泛型函式和屬性

如果要編寫一個使用列表的函式,希望它可以在任何列表上使用,而不是某個具體型別的元素的列表,需要編寫一個泛型函式。

fun <T> List<T>.slice(indices: IntReange): List<T>
複製程式碼

基本上是和Java的宣告類似的,在方法名前宣告,即可在函式中使用。

還可以給類或介面的方法,頂層函式,擴充套件屬性以及擴充套件函式宣告型別引數。例如下面你這個返回列表倒數第二個元素的擴充套件屬性:

val <T> List<T>.penultimate: T
    get() = this[size -2]
複製程式碼

不能宣告泛型非擴充套件屬性

普通(非擴充套件)屬性不能擁有型別引數,不能再一個類的屬性中儲存多個不同型別的值,因此宣告泛型非擴充套件函式函式沒有任何意義。

宣告泛型類

和Java一樣,Kotlin通過在類名稱後面加上一對尖括號,並把型別引數放在尖括號內來宣告泛型類及泛型介面。一旦宣告之後,就可以在類的主體內像其他型別一樣使用型別引數。

interface List<T> {
    operator fun get(index: Int): T
}
複製程式碼

如果你的類繼承了泛型(或者實現了泛型介面),你就得為基礎型別的泛型形參提供一個型別實參。

class StringList: List<String> {
    override fun get(index: Int): String = ...
}
複製程式碼

型別引數約束

型別引數約束可以限制作為(泛型)類和(泛型)函式的型別實參的型別。 如果你把一個型別指定為泛型型別形參的上界約束,在泛型型別具體的初始化中,其對應的型別實參就必須是這個具體型別或其子型別。你是這樣定義約束:把冒號放在型別引數名稱之後,作為型別形參上界的型別緊隨其後:

fun <T : Number> List<T>.sum(): T
複製程式碼

相當於Java中的:

<T extends Number> T sum(List<T> list)
複製程式碼

一旦指定了型別形參T的上界,你就可以把型別T的值當做它的上界的值使用:

fun <T : Number> oneHalf(value: T): Double {
    return value.toDouble() //呼叫Number的方法
}
複製程式碼

極少數情況下,需要在一個型別引數上指定多個約束,這時你需要使用不同的語法:

fun <T> ensureTrailingPeriod(seq: T) 
    where T : CharSequence, T : Appendable {
    if(!seq.endWith('.') { //呼叫CharSequence的方法
        seq.append('.')//呼叫Appendable的方法
    }
}
複製程式碼

這種情況下,可以說明作為型別實參的型別必須同時實現CharSequence和Appendable兩個介面。

讓型別形參非空

如果你宣告的時泛型類或者泛型函式,任何型別實參,包括哪些可空的型別實參,都可以替換她的型別形參。事實上沒有指定上界的型別形參將會使用Any? 這個預設上界:

class Processor<T> {
    fun process(value: T) {
        value?.hashCode()
    }
}
複製程式碼

process函式中,引數value是可空的,儘管T並沒有使用問號標記。

如果你想保證替換型別形參的始終是非空型別,可以通過制定一個約束來實現。如果你除了可空性之外沒有任何限制,可以使用Any代替預設的Any?作為上界。

class Processor<T : Any> {
    fun process(value: T) {
        value.hashCode()
    }
}
複製程式碼

執行時的泛型:擦除和實化型別引數

你可能知道,JVM上的泛型一般是通過型別擦除實現的,就是說泛型類例項的型別實參在執行時是不保留的。

執行時的泛型:型別檢查和轉換

和Java一樣,Kotlin的泛型在執行時也被擦除了。這意味著泛型類例項不會攜帶用於建立它的型別實參的資訊。例如,如果你建立一個List<String>並將一堆字串放到其中,在執行時你只能看到它是一個List,不能識別出列表本打算包含的時哪種型別的元素。
隨著擦除型別資訊也帶來了約束。因為型別實參沒有被儲存下來,你不能檢查他們。例如,你不能判斷一個列表是一個包含字串的列表還是包含其他物件的列表:

>>> if (value is List<String>)
ERROR: Canot check for instance of erased type
複製程式碼

那麼如何檢查一個值是否是列表,而不是set或者其他物件。可以使用特殊的*投影語法來做這樣的檢查:

if (value is List<*>)
複製程式碼

這種表示擁有未知型別實參的泛型型別,類似於Java中的List<?>

注意,在asas?轉換中仍然可以使用一般的泛型型別。但是如果該類有正確的基礎型別但型別實參是錯誤的,轉換也不會失敗,因為在執行時轉換髮生的時候型別實參是未知的。因此,這樣的轉換會導致編譯器發出“unchecked cast”的警告。這僅僅是一個警告,你仍然可以繼續使用這個值。

fun printSum(c: Collection<*>) {
    //這裡會有警告:Unchecked cast:List<*> to List<Int>
    val intList = c as? List<Int> 
            ?: throw IllegalArgumentException("List is expected")
    println(intList.sum())
}

>>> printSum(listOf(1, 2, 3))
6
複製程式碼

編譯一切正常:編譯器只是發出了一個警告,這意味著程式碼是合法的。如果在一個整型的列表或者set上呼叫該函式,一切都會如預期發生:第一種情況會列印元素之和,第二種情況會丟擲IllegalArgumentException異常。但如果你傳遞了一個錯誤型別的值,如List<String>,執行時會得到一個ClassCastException。

宣告帶實化型別引數的函式

前面說過,Kotlin泛型在執行時會被擦除,泛型函式的型別實參也是這樣。在呼叫泛型函式的時候,在函式體中你不能決定呼叫它用的型別實參:

>>> fun <T> isA(value: Any) = value is T
Error: Cannot check for instance of erased type: T
複製程式碼

通常情況下都是這樣的,只有一種例外可以避免這種限制:行內函數。行內函數的型別形參能夠被實化,意味著你可以在執行時引用實際的型別實參。
在之前章節中,我們知道如果用inline關鍵字標記一個函式,編譯器會把每一次函式呼叫都換成函式實際的程式碼實現。使用行內函數還可以提升效能,如果該函式使用了lambda實參:lambda的程式碼也會內聯,所以不會建立任何匿名類。基於這種實現原理,應該也可以想象到,根據嵌入的上下文,泛型在class檔案中已經被確定了。
如果把前面例子中的isA函式宣告成inline並且用reified標記型別引數,你就能夠用該函式檢查value是不是T的例項了。

inline fun <reified T> isA(value: Any) = value is T

>>> println(isA<String>("abc"))
true
>>> println(isA<String>(123))
false
複製程式碼

一個實化型別引數能發揮作用的最簡單的例子就是標準庫函式filterIsInstance 。這個函式接收一個集合,選擇其中哪些指定類的例項,然後返回這些被選中的例項。

>>> val items = listOf("one", 2, "three")
>>> println(items.filterIsInstance<String>())
[one, three]
複製程式碼

通過指定<String>作為函式的型別實參,你表明感興趣的只是字串。因此函式的返回型別是List<String>。這種情況下,型別實參在執行時是已知的,函式filterIsInstance使用它來檢查列表中的值是不是指定為該型別實參的類的例項。
下面是Kotlin標準庫函式filterIsInstance宣告的簡化版本:

inline fun <reified T> Iterable<*>.filterIsInstance(): List<T> {
    val destination = mutableListOf<T>()
    for (element in this) {
        if (element is T) {
            destination.add(element)
        }
    }
    return destination
}
複製程式碼

在之前章節,我們提到把函式標記成內聯只有在一種情況下有效能優勢,即函式擁有函式型別的形參並且其對應的實參lambda和函式一起被內聯的時候。而現在我們是為了能夠使用實化引數而把函式標記成內聯。

為什麼實化只對行內函數有效

編譯器把實現行內函數的位元組碼插入每一次呼叫發生的地方。每次你呼叫帶實化型別引數的函式時,編譯器都知道這次特定呼叫中用作型別實參的切確型別。因此,編譯器可以生成引用作為型別實參的具體類的位元組碼。實際對filterIsInstance<String>掉用來說,生成的程式碼和下面這段程式碼是等價的:

for (element in this) {
    if (element is String) {
        destination.add(element)
    }
}
複製程式碼

因為生成的位元組碼引用了具體類,而不是型別引數,它不會被執行時發生的型別引數擦除影響。
注意,帶reified型別引數的inline函式不能再Java程式碼中呼叫。 普通行內函數可以像常規函式那樣在Java中呼叫——他們可以被呼叫而不能被內聯。帶實化引數型別的函式需要額外的處理,來把型別引數的值替換到位元組碼中,所以他們必須永遠是內聯的。這樣他們不可能用Java那樣的普通方式呼叫。

使用實化型別引數代替類引用

如果你是Android開發者,顯示Activity是一個最常用的方法。也可以使用實化型別引數來代替傳遞作為java.lang.Class的Activity類:

inline fun <reified T : Activity> Context.startActivity() {
    val intent = Intent(this, T::class.java)
    startActivity(intent)
}

>>> startActivity
複製程式碼

::class.java的語法展現瞭如何獲取java.lang.Class對應的Kotlin類。這和Java中的Service.class是完全等同的。

實化型別引數的限制

儘管實化型別引數是方便的工具,但它們也有一些限制。有一些事實化與生俱來的,而另外一些則是現有的實現決定的,而且可能在未來的Kotlin版本中放鬆這些限制。
具體來說,可以按下面的方式使用實化型別引數:

  • 用在型別檢查和型別轉換中(is!isasas?
  • 使用Kotlin反射API(::class
  • 獲取相應的java.lang.Class::class.java
  • 作為呼叫其他函式的型別實參

不能做下面的這些事情:

  • 建立指定為型別引數的類的例項
  • 呼叫型別引數類的伴生物件的方法
  • 呼叫帶實化型別引數函式的時候使用非實化型別形參作為型別實參
  • 把類、屬性或者非行內函數的型別引數標記成reified

變型:泛型和子型別化

變型的概念描述了擁有相同基礎型別和不同型別實參的(泛型)型別之間是如何關聯的:例如,List<String>List<Any>之間如何關聯。

為什麼存在變型:給函式傳遞實參

假如你有一個接收List<Any>作為實參的函式。把List<String>型別的變數傳給這個函式時候安全?毫無疑問,把一個字串傳給一個期望Any的函式是安全的,因為String繼承了Any。但當String和Any變成List介面的型別實參之後,情況就沒有這麼簡單了。

fun printContents(list: List<Any>) {
    println(list.joinToString())
}

>>> printContents(listOf("abc", "bac"))
abc, bac
複製程式碼

這看上去沒什麼問題,我們來看另一個例子:

fun addAnswer(list: MutableList<Any>) {
    list.add(42)
}

>>> val strings = mutableListOf("abc", "bac")
>>> addAnswer(strings)
Type mismatch. Required: MutableList<Any> Found: MutableList<String>
複製程式碼

這個例子和上面的例子中,區別僅僅是將List<Any>變成了MutableList<Any>,就無法將泛型為String的list傳遞給函式。
現在可以回答剛才那個問題了,把一個字串列表傳給期望Any物件列表的函式是否安全。如果函式新增或者替換了列表中的元素就是不安全的,因為這樣會產生型別不一致的可能性。在Kotlin中,可以通過根據列表是否可變選擇合適的介面來輕鬆的控制。如果函式接收的是隻讀列表,可以傳遞具有更具體的元素型別的列表。如果列表是可變的,就不能這麼做。

類、型別和子型別

為了討論型別之間的關係,需要熟悉子型別這個術語。任何時候如果需要的時型別A的值,你都能夠使用型別B的值(當做A的值),型別B就稱為型別A的子型別。舉例來說,Int是Number的子型別,但Int不是String的子型別。這個定義還標明瞭任何型別都可以被認為是它自己的子型別。
術語超型別是子型別的反義詞。如果A是B的子型別,那麼B就是A的超型別。
為什麼一個型別是否是另一個的子型別這麼重要?編譯器在每一次給變數賦值或者給函式傳遞實參的時候都要做這項檢查。

fun test(i: Int) {
    val n: Number = i  //可以編譯
    fun f(s: String) {/*...*/}
    f(i)  //不能編譯
}
複製程式碼

只有值得型別是變數型別的子型別時,才允許變數儲存該值。例如,變數n的初始化器i的型別Int是變數的型別Number的子型別,所以n的宣告是合法的。只有當表示式的型別是函式引數的型別的子型別時,才允許把該表示式傳給函式。這個例子中i的型別Int不是函式引數的型別String的子型別,所以函式f的呼叫會編譯失敗。
你可能認為子型別就是子類的概念,但是為什麼在Kotlin中稱之為子型別呢?因為,Kotlin存在可空型別。一個非空型別是它的可空版本的子型別,但它們都對應著同一個類。你始終能在可空型別的變數中儲存非空型別的值,但反過來卻不行。

var s: String = "abc"
val t: String? = s //編譯通過
s = t  //編譯不通過
複製程式碼

前面,我們把List<String>型別的變數傳給期望List<Any>的函式是否安全,現在可以使用子型別化術語來重新組織:List<String>List<Any>的子型別嗎?你已經瞭解了為什麼把MutableList<String>當成MutableList<Any>的子型別對待是不安全的。顯然,返回來也是不成立的:MutableList<Any>肯定不是MutableList<String>的子型別。
一個泛型類(例如MutableList)如果對於任意兩種型別A和B,MutableList<A>既不是MutableList<B>的子型別也不是他的超型別,他就是被稱為在該型別引數上是不變型的。Java中所有的類都是不變型的(儘管哪些類具體的使用可以標記成可變型的,稍後你就會看到)。

List類的型別化規則不一樣,Kotlin中的List介面表示的是隻讀集合,如果A是B的子型別,那麼List<A>就是List<B>的子型別。這樣的類或者介面被稱為協變的。

協變:保留子型別化關係

一個協變類是一個泛型類(我們以Producer<T>為例),對這種類來說,下面的描述是成立的:如果A是B的子型別,那麼Producer<A>就是Producer<B>的子型別。我們說子型別化被保留了。
在Kotlin中,要宣告類在某個型別引數上是可以協變的,在該型別引數的名稱前面加上out關鍵字即可:

interface Producer<out T> {
    fun produce(): T
}
複製程式碼

將一個類的型別引數標記為協變得,在該型別實參沒有精確匹配到函式中定義的型別形參時,可以讓該類的值作為這些函式的實參傳遞,也可以作為這些函式的返回值。例如,想象一下有這樣一個函式,它負責餵養用類Herd代表的一群動物,Herd類的型別引數確定了畜群中動物的型別。

open class Animal {
    fun feed() {...}
}

class Herd<T : Animal> {
    val size: Int
        get() = ...

    operator fun get(i: Int): T {...}
}

fun feeAll(animals: Herd<Animal>) {
    for (i in 0 until animals.size) {
        animals[i].feed()
    }
}
複製程式碼

假設這段程式碼的使用者有一群貓需要照顧:

class Cat : Animal() {
    fun cleanLitter() {...}
}

fun takeCareOfCats(cats: Herd<Cat>) {
    for(i in 0 until cats.size) {
        cats[i].cleanLitter()
        // feedAll(cats)  //錯誤:型別不匹配
    }
}
複製程式碼

如果嘗試把貓群傳給feedAll函式,在編譯期你就會得到型別不匹配的錯誤。因為Herd類中的型別引數T沒有用任何變型修飾符,貓群不是畜群的子類。可以使用顯示得型別轉換來繞過這個問題,但是這種方法囉嗦、易出錯,而且幾乎從來不是解決型別不匹配問題的正確方式。
因為Herd類有一個類似List的API,並且不允許它的呼叫者新增和改變畜群中的動物,可以把它變成協變並相應地修改呼叫程式碼。

class Herd<out T: Animal> {
    ...
}
複製程式碼

你不能把任何類都變成協變得:這樣不安全。讓類在某個型別引數變為協變,限制了該類中對該型別引數使用的可能性。要保證型別安全,它只能用在所謂的out位置,意味著這個類只能生產型別T的值而不能消費它們。
在類成員的宣告中型別引數的使用可以分為in位置和out位置。考慮這樣一個類,它宣告瞭一個型別引數T幷包含了一個使用T的函式。如果函式是把T當成返回型別,我們說它在out位置。這種情況下,該函式生產型別為T的值。如果T用作函式引數的型別,它就在in位置,這樣的函式消費型別為T的值。

interface Transformer<T> {
                //in位置 //out位置
    fun transform(t: T): T
}
複製程式碼

類的型別引數前的out關鍵字要求所有使用T的方法只能把T放在out位置而不能放在in位置。這個關鍵字約束了使用T的可能性,這保證了對應子型別關係的安全性。

重申一下,型別引數T上的關鍵字out有兩層含義:

  • 子型別化被保留
  • T只能用在out位置

現在我們看看List<Interface>介面。Kotlin的List是隻讀的,所以它只有一個返回型別為T的元素的方法get,而沒有定義任何把型別為T的元素儲存到列表中的方法。因此,它也是協變的。

interface List<out T> : Collection<T> {
    operator fun get(index: Int): T
}
複製程式碼

注意,型別形參不光可以直接當作引數型別或者返回型別使用,還可以當作另一個型別的型別實參。例如,List介面就包含了一個返回List<T>的subList方法:

interface List<out T> : Collection<T> {
    fun subList(fromIndex: Int, toIndex: Int): List<T>
}
複製程式碼

在這個例子中,函式subList中的T也用在out位置。
注意,不能把MutableList<T>在它的型別引數上宣告成協變的,因為它既含有接收型別為T的值作為引數的方法,也含有返回這種值得方法(因此,T出現在in和out兩種位置上)。

interface MutableList<T>
        : List<T>, MultableCollection<T> {
    override fun add(element: T): Boolean   
}
複製程式碼

編譯器強制實施了這種限制。如果這個類被宣告成協變得,編譯器會報錯:Type parameter T is declared as 'out' but occurs in 'in' position(型別引數T宣告為out但出現在in位置)。
注意,構造方法的引數即不在in位置,也不在out位置。即使型別引數宣告成了out,仍然可以在構造方法引數的宣告中使用它:

class Herd<out T: Animal>(vararg animals: T) {...}
複製程式碼

如果把類的例項當成一個更泛化的型別的例項使用,變型會防止該例項被誤用:不能呼叫存在潛在危險的方法。構造方法不是那種在例項建立之後還能呼叫的方法,因此它不會有潛在危險。
然後,如果你在構造方法的引數上使用了關鍵字val和var,同時就會宣告一個getter和一個setter(如果屬性是可變的)。因此,對只讀屬性來說,型別引數用在了out位置,而可變屬性在out位置和in位置都使用了它:

class Herd<T: Animal>(var leadAnimal: T, vararg animals: T) {...}
複製程式碼

上面這個例子中,T不能用out標記,因為類包含屬性leadAnimal的setter,它在in位置用到了T。
還需要注意的是,位置規則只覆蓋了類外部可見的(public、protected和internal)API。私有方法的引數即不在in位置也不在out位置。變型規則只會防止外部使用者對類的誤用但不會對類自己的實現起作用:

class Herd<out T: Animal>(private var leadAnimal: T, vararg animals: T) {...}
複製程式碼

現在可以安全地讓Herd在T上協變,因為屬性leadAnimal變成了私有的。

逆變:反轉子型別化關係

逆變的概念可以被看成是協變的映象:對一個逆變來說,它的子型別化關係與用作型別實參的類的子型別化關係是相反的。我們從Comparator介面的例子開始,這個介面定義了一個方法compare類,用於比較兩個給定的物件:

interface Comparator<in T> {
    fun compare(e1: T, e2: T): Int {...}
}
複製程式碼

一個為特定型別的值定義的比較器顯然可以比較該型別任意子型別的值。例如,如果有一個Comparator<Any>,可以用它比較任意具體型別的值。

interface Comparator<in T> {
    fun compare(e1: T, e2: T): Int
}

fun main(args: Array<String>) {
    val anyComparator = Comparator<Any> { e1, e2 -> e1.hashCode() - e2.hashCode() }
    val strings = listOf("a", "b", "c")
    strings.sortedWith(anyComparator)
}
複製程式碼

sortedWith函式期望一個Comparator<String>(一個可以比較字串的比較器),傳給它一個能比較更一般的型別的比較器是安全的。如果你要在特定型別的物件上執行比較,可以使用能處理該型別或者它的超型別的比較器。這說明Comparator<Any>Comparator<String>的子型別,其中Any是String的超型別。不同型別之間的子型別關係和這些型別的比較器之間的子型別化關係截然相反。
現在你已經為完整的逆變定義做好了準備。一個在型別引數上逆變的類是這樣的一個泛型類(我們以Consumer<T>為例),對這種類來說,下面的描述是成立的:如果B是A的子類,那麼Consumer<A>就是Consumer<B>的子型別,型別引數A和B交換了位置,所以我們說子型別化被反轉了。

in關鍵字的意思是,對應型別的值是傳遞進來給這個類的方法的,並且被這些方法消費。和協變得情況類似,約束型別引數的使用將導致特定的子型別化關係。在型別引數T上的in關鍵字意味著子型別化被反轉了,而且T只能用在in位置。

協變得,逆變的和不變型的類

協變 逆變 不變型
Producer<out T> Consumer<in T> MutableList<T>
類的子型別化保留了:Producer<Cat>Producer<Animal>的子型別 子型別化反轉了:Consumer<Animal>Consumer<Cat>的子型別 沒有子型別化
T 只能在out位置 T只能在in位置 T可以在任何位置

一個類可以在一個型別引數上協變,同時在另外一個型別引數上逆變。Function介面就是一個經典的例子。下面是一個單個引數的Function的宣告:

interface Function1<in P, out R> {
    operator fun invoke(p: P): R
}
複製程式碼

Kotlin的表達發(P) -> R是表達Function<P, R>的另一種更具可讀性的形式。可以發現用in關鍵字標記的P(引數型別)只用在in位置,而用out關鍵字標記的R(返回型別)只用在out位置。這意味著對這個函式型別的第一個型別引數來說,子型別化反轉了,而對於第二個型別引數來說,子型別化保留了。

fun enumerateCats(f: (Cat) -> Number) {...}
fun Animal.getIndex(): Int = ...
>>> enumerateCats(Animal::getIndex)
複製程式碼

在Kotlin中這點程式碼是合法的。Animal是Cat的超型別,而Int是Number的子型別。

使用點變型:在型別出現的地方指定變型

在類宣告的時候就能夠指定變型修飾符是很方便的,因為這些修飾符會應用到所有類被使用的地方。這被稱作宣告點變型。如果你熟悉Java的萬用字元型別(? extends 和 ? super),你會意識到Java用完全不同的方式處理變型。在Java中,每一次使用帶型別引數的型別的時候,還可以指定這個型別引數是否可以用他的子型別或者超型別替換。這叫做使用點變型。

Kotlin的宣告點變型 vs. Java萬用字元

宣告點變形帶來了更簡潔的程式碼,因為只用指定一次變型修飾符,所有這個類的使用者都不用再考慮這些了,在Java中,庫作者不得不一直使用萬用字元:Function<? super T, ? extends R>,來建立按照使用者期望的執行的API。如果你檢視Java 8標準庫的原始碼,你會在每次用到Function介面的地方發現萬用字元。例如,下面是Stream.map方法的宣告:

/* Java */
public interface Stream<T> {
    <R> Stream<R> map(Function<? super T, ? extends R> mapper);
}
複製程式碼

Kotlin也支援使用點變型,允許在型別引數出現的具體位置指定變型,即使在型別宣告時它不能被宣告成協變或逆變的。
你已經見過許多像MutableList這樣的介面,通常情況下即不是協變也不是逆變的,因為它同時生產和消費指定為它們型別引數的型別的值。但對於這個型別的變數來說,在某個特定函式中只被當成其中一種角色使用的情況挺常見的:要麼是生產者要麼是消費者。例如下面這個簡單的函式:

fun <T> copyData(source: MutableList<T>, 
        destination: MutableList<T>) {
    for (item in source) {
        destination.add(item)
    }
}
複製程式碼

這個函式從一個集合把元素拷貝到另一個集合中。儘管兩個集合都擁有不變型的型別,來源集合只是用於讀取,而目標集合只是用於寫入。這種情況下,集合元素的型別不需要精確匹配。例如,把一個字串的集合拷貝到可以包含任意物件的集合中一點兒問題也沒有。
要讓這個函式支援不同型別的列表,可以引入第二個泛型引數。

fun <T : R, R> copyData(source: MutableList<T>,
                destination: MutableList<R>) {
    for (item in source) {
        destination.add(item)
    }
}

>>> val ints = mutableListOf(1, 2, 3)
>>> val anyItems = mutableListOf<Any>()
>>> copyData(ints, anyItems)
>>> println(anyItems)
[1, 2, 3]
複製程式碼

你宣告瞭兩個泛型引數代表來源列表和目標列表中的元素型別。為了能夠把一個列表中的元素拷貝到另一個列表中,來源元素型別應該是目標列表中的元素的子型別(Int是Any的子型別)。
但是Kotlin提供了一種更優雅的表達方式。當函式的實現呼叫了那些型別引數只出現在out位置(或只出現在in位置)的方法時,可以充分利用這一點,在函式定義中給特定用途的型別引數加上變型修飾符。

fun <T> copyData(source: MutableList<out T>,
                 destination: MutableList<T>) {
    for (item in source) {
        destination.add(item)
    }
}
複製程式碼

可以為型別宣告中型別引數任意的用法指定變型修飾符,這些用法包括:形參型別、區域性變數型別、函式返回型別,等等。這裡發生的一切被稱作型別投影:我們說source不是一個常規的MutableList,而是一個投影(受限)的MutableList。只能呼叫返回型別是泛型型別引數的那些方法,或者嚴格的講,只在out位置使用它的方法。編譯器禁止呼叫使用型別引數做實參的那些方法(在in位置使用型別引數):

>>> val list: MutableList<out Number> = ...
>>> list.add(42)
Error: Out-projected type 'MutableList<out Number>' prohibits the use of 'fun add (element: E): Boolean'
複製程式碼

不要為使用投影型別後不能呼叫某些方法而吃驚,如果需要呼叫那些方法,你要用的時常規型別而不是投影。這可能要求你宣告第二個型別引數,它依賴的原本要進行投影的型別。

當然,實現copyData函式的正確方式應該是使用List<T>作為source實參的型別,因為我們只用了宣告在List中的方法,並沒有用到MutableList中的方法,而且List型別引數的變型在宣告時就指定了。但這個例子對展示這個概念依然十分重要,尤其是要記住大多數的類並沒有像List和MutableList這樣分開的兩個介面,一個是協變的讀取介面,另一個是不變型的讀取/寫入介面。
如果型別引數已經有out變型,獲取它的out投影沒有任何意義。就像List<out T>這樣。它和List<T>是一個意思,因為List已經宣告成了class List<out T>。編譯器會發出警告,標明這樣的投影是多餘的。

同理,可以對型別引數的用法使用in修飾符,來表明在這個特定的地方,相應的值擔當的時消費者,而且型別引數可以使用它的任意子型別替換。

fun <T> copyData(source: MutableList<T>,
                 destination: MutableList<in T>) {
    for (item in source) {
        destination.add(item)
    }
}
複製程式碼

Kotlin的使用點變型直接對應Java的限界萬用字元。Kotlin中的MutableList<out T>和Java中的MutableList<? extends T>是一個意思。in投影的MutableList<in T>對應到Java的MutableList<? super T>

星號投影:使用*代替型別引數

本章前面提到型別檢查和轉換的時候,我們提到了一種特殊的星號投影語法,可以用它來標明你不知道關於泛型實參的任何資訊。例如,一個包含未知型別的元素的列表用這種語法表示為List<*>。現在我們深入探討星號投影的語義。

首先需要注意的是MutableList<*>MutableList<Any?>不一樣。你確信MutableList<Any?>這種列表包含的時任意型別的元素。而另一方面,MutableList<*>是包含某種特定型別元素的列表,但是你不知道是哪個型別。這種列表被建立成一個包含某種特定型別元素的列表,比如String,而且建立它的程式碼期望只包含那種型別的元素。因為不知道是哪個型別,你不能像列表中寫入任何東西,因為你寫入的任何值都可能會違反呼叫程式碼的期望。但是從列表中讀取元素是可行的,因為你心裡有數,所有的儲存在列表中的值都能匹配所有Kotlin型別的超型別Any?:

fun main(args: Array<String>) {
    val list: MutableList<Any?> = mutableListOf('a', 1, "qwe")
    val chars = mutableListOf('a', 'b', 'c')
    val unknownElements: MutableList<*> = if (Random().nextBoolean()) list else chars
//    unknownElements.add(42) //編譯器禁止呼叫這個方法
    println(unknownElements.first()) //讀取元素是安全的
}

//輸出
a
複製程式碼

為什麼編譯器會把MutableList<*>當成out投影的型別?在這個例子的上下文中,MutableList<*>投影成了MutableList<out Any?>,當你沒有任何元素型別資訊的時候,讀取Any?型別的元素任然是安全的,但是向列表中寫入元素是不安全的。
Kotlin的MyType<*>相當於Java中的MyType<?>

對像Consumer<in T>這樣的逆變型別的引數來說,星號投影等價於<in Nothing>。實際上,在這種星號投影中無法呼叫任何簽名中有T的方法。如果型別引數是逆變的,它就只能表現為一個消費者,而且,我們之前討論過,你不知道它可以消費的到底是什麼。因此,不能讓它消費任何東西。

當型別實參的資訊並不重要的時候,可以使用星號投影的語法,不需要使用任何在簽名中引用型別引數的方法,或者只是讀取資料額不關心它的具體型別。例如,可以實現一個接收List<*>做引數的printFirst函式:

fun printFirst(list: List<*>) {
    if (list.isNotEmpty()) {
        println(list.first())
    }
}
複製程式碼

相關文章