Kotlin 知識梳理(13) 執行時的泛型

澤毛發表於2017-12-21

一、本文概要

本文是對<<Kotlin in Action>>的學習筆記,如果需要執行相應的程式碼可以訪問線上環境 try.kotlinlang.org,這部分的思維導圖為:

Kotlin 知識梳理(13)   執行時的泛型

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

2.1 執行時的泛型

Java一樣,Kotlin的泛型在執行時也被擦除了,這意味著 泛型類例項不會攜帶用於建立它的型別實參的資訊

例如,如果你建立了一個List<String>,在執行時你只能看到它是一個List,不能識別出列表本打算包含的是String型別的元素。

接下來我們談談伴隨著擦除型別資訊的約束,因為型別實參String沒有被儲存下來,你不能檢查它們。例如,你不能判斷一個列表是一個包含字串的列表還是包含其它物件的列表,也就是說,在is檢查中不可能使用型別實參中的型別,例如

fun main(args: Array<String>) {
    val authors = listOf("first", "second")
    if (authors is List<Int>) {}
}
複製程式碼

將會在編譯時丟擲下面的異常:

>> Cannot check for instance of erased type
複製程式碼

Kotlin不允許使用 沒有指定型別實參的泛型型別,如果希望檢查一個值是否是列表,而不是set或者其它物件,可以使用特殊的 星號投影 語法來做這個檢查:

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

實際上,泛型型別擁有的每個型別形參都需要一個*,現在你可以認為它就是 擁有未知型別實參的泛型型別

asas?轉換中仍然可以使用一般的泛型型別,但是如果該類 有正確的基礎型別但型別實參是錯誤的,轉換也不會失敗,因為在執行時轉換髮生的時候型別實參是未知的。因此,這樣的轉換會導致編譯器發出unchecked cast的警告,例如下面這段程式:

fun printSum(c: Collection<*>) {
    val intList = c as? List<Int>
            ?: throw IllegalArgumentException("List is expected")
    println(intList.sum())
}

fun main(args: Array<String>) {
    //(1) 正常執行。
    printSum(listOf(1, 2, 3))
    //(2) as 檢查是成功的,但是呼叫 intList.sum() 方法時會丟擲異常。
    printSum(listOf("a", "b", "c"))
}
複製程式碼

(2)呼叫時,並不會丟擲IllegalArgumentException異常,而是在呼叫sum函式時才發生,因為sum函式試著從列表中讀取Number值然後把它們加在一起,把String當做Number使用的嘗試會導致執行時的ClassCastException

假如在編譯期,Kotlin已經知道了相應的型別資訊,那麼is檢查是允許的:

fun printSum(c: Collection<Int>) {
    if (c is List<Int>) {
        println(c.sum())
    }
}

fun main(args: Array<String>) {
    printSum(listOf(1, 2, 3))
}
複製程式碼

c是否擁有型別List<Int>的檢查是可行的,因為我們將函式型別的形參型別宣告為了Collection<Int>,因此編譯期就確定了集合包含的是整型數字。

不過,Kotlin有特殊的語法結構可以允許你 在函式體中使用具體的型別實參,但只有inline函式可以,接下來讓我們來看看這個特性。

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

Kotlin泛型在執行時會被擦除,這意味著如果你有一個泛型類的例項,你無法弄清楚在這個例項建立時用的究竟是哪些型別實參。泛型函式的實參型別也是這樣,在呼叫泛型函式的時候,在函式體中你不能決定呼叫它用的型別實參。

//將會在編譯時丟擲 "Cannot check for instance of erased type : T" 的異常
fun <T> isA(value : Any) = value is T
複製程式碼

行內函數的型別形參能夠被實化

只有一種例外可以避免這種限制:行內函數。行內函數的型別形參能夠被實化,意味著你可以 在執行時引用實際的型別實參。前面我們介紹過行內函數的兩個優點:

  • 編譯器會把每一次函式呼叫都替換成函式實際的程式碼實現
  • 如果該函式使用了lambdalambda的程式碼也會內聯,所以不會建立匿名類

這裡,我們介紹它一個新的優點:對於泛型函式來說,它們的型別引數可以被實化。我們將方面的函式修改如下,宣告為inline並且用reified標記型別引數,就能用該函式檢查value是不是T的例項:

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

fun main(args: Array<String>) {
    println(isA<String>("abc"))
    println(isA<String>(123))
}
複製程式碼

執行結果為:

>> true
>> false
複製程式碼

filterIsIntance函式可以接收一個集合,選擇其中那些指定類的例項,然後返回這些被選中的例項:

fun main(args: Array<String>) {
    val items = listOf("one", 2, "three")
    println(items.filterIsInstance<String>())
}
複製程式碼

執行結果為:

[one, three]
複製程式碼

該函式的簡化實現為:

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

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

我們之所以可以在inline函式中使用element is T這樣的判斷,而不能在普通的類或函式中執行的原因是因為:編譯器把 實現行內函數的位元組碼 插入每一次呼叫發生的地方,每次你 呼叫帶實化型別引數的函式 時,編譯器都知道這次特定呼叫中 用作型別實參的確切型別,因此,編譯器可以生成 引用作為型別實參的具體類 的位元組碼。

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

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

另一種實化型別引數的常見使用場景是接收java.lang.Class型別引數的API構建介面卡。例如JDK中的ServiceLoader,它接收一個代表介面或抽象類的java.lang.Class,並返回實現了該介面的例項。

val serviceImpl = ServiceLoader.load(Service::class.java)
複製程式碼

::class.java的語法展現瞭如何獲取java.lang.Class對應的Kotlin類,這和Java中的Service.Class是完全等同的,現在我們用 帶實化型別引數的函式 重寫這個例子:

val serviceImpl = loadService<String>()
複製程式碼

loadService的定義為如下,要載入的服務類 現在被指定成了loadService 函式的型別實參

inline fun <reified T> loadService() {
    //把 "T::class" 當成型別形參的類訪問。
    return ServiceLoader.load(T::class.java)
}
複製程式碼

這種用在普通類上的::class.java語法也可以同樣用在實化型別引數上,使用這種語法會產生對應到指定為型別引數的類的java.lang.Class,你可以正常地使用它,最後我們以一個startActivity的呼叫來結束本節的討論:

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

>> startActivity<DetailActivity>()
複製程式碼

2.4 實化型別引數的限制

我們可以按下面的方式來使用實化型別引數

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

不能做下面的事情:

  • 建立指定為型別引數的類的例項
  • 呼叫型別引數類的伴生物件的方法
  • 呼叫 帶實化型別引數函式 的時候使用 非實化型別形參作為型別實參
  • 把類、屬性或者非行內函數的型別引數標記為reified,因為實化型別引數只能用在行內函數上,使用實化型別引數意味著函式和所有傳給它的lambda都會被內聯,如果行內函數使用lambda的方法導致lambda不能被內聯,或者你不想lambda因為效能的關係被內聯,可以使用noinline修飾符。

三、變型:泛型和子型別化

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

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

假設你有一個接受List<Any>作為實參的函式,那麼把List<String>型別的變數傳遞給這個函式是否安全呢?我們來看下面兩個例子:

  • 第一個例子
fun printContents(list: List<Any>) {
    println(list.joinToString())
}

fun main(args: Array<String>) {
    printContents(listOf("abc", "bac"))
}
複製程式碼

這上面的函式可以正常地工作,函式把每個元素都當作Any對待,而且因為每個字元都是Any,因此這是完全安全的,執行結果為:

>> abc, bac
複製程式碼
  • 第二個例子,與之前不同,它會修改列表:
fun addAnswer(list : MutableList<Any>) {
    list.add(42)
}

fun main(args: Array<String>) {
    val strings = mutableListOf("abc", "bac")
    addAnswer(strings)
}
複製程式碼

這裡宣告瞭一個型別為MutableList<String>的變數strings,然後嘗試把它傳遞給一個接收MutableList<Any>的函式,編譯器將不會通過呼叫。

因此,當我們將一個字串列表傳遞給期望Any物件的列表時,如果 函式新增或者替換了 列表中的元素(通過MutableList來推斷)就是不安全的,因為這樣會產生型別不一致的可能,否則它就是安全的。

3.2 類、型別和子型別

變數的型別 規定了 變數的可能值,有時候我們會把型別和類當成同樣的概念使用,但它們不一樣。

類、型別

非泛型類

對於非泛型類來說,類的名稱可以直接當作型別使用。例如,var x : String宣告瞭一個可以儲存String類的例項的變數,而var x : String?宣告瞭它的可空型別版本,這意味著 一個Kotlin類都可以用於構造至少兩種型別

泛型類

要得到一個合法的型別,需要首先得到一個泛型類,並用一個作為 型別實參的具體型別 替換泛型類的 型別形參

List是一個類而不是型別,下面列舉出來的所有替代品都是合法的型別:List<Int>List<String?>List<List<String>>,每一個 泛型類都可能生成潛在的無限數量的型別

子型別

子型別的含義為:

任何時候如果需要的是型別A的值,能夠使用型別B的值當做A的值,型別B就稱為型別A的子型別。

例如IntNumber的子型別,但Int不是String的子型別,這個定義還表明了任何型別都可以被認為是它自己的子型別。

超型別

超型別子型別 的反義詞

如果AB的子型別,那麼B就是A的超型別。

編譯器在每一次給變數賦值或者給函式傳遞實參的時候都要做這項檢查:

  • 只有 值的型別變數型別的子型別 時,才允許儲存變數的值
  • 只有當 表示式的型別函式引數的型別的子型別 時,才允許把該表示式傳給函式

子類、子型別

在簡單情況下,子類和子型別本質上是一樣的,例如Int類是Number的子類,因此Int型別是Number型別的子型別。

一個非空型別是它的可空版本的子型別,但它們都對應著同一個類,你始終能夠在可空型別的變數中儲存非空型別值。

當開始涉及泛型類時,子型別和子類之間的差異就顯得格外重要。正如我們上面見到的,MutableList<String>不是MutableList<Any>的子型別。

對於泛型類MutableList而言,無論AB是什麼關係,MutableList<A>既不是MutableList<B>的子型別也不是它的超型別,它就被稱為 在該型別引數上是不變型的

Java中的所有類都是不變型的。在前一節中,我們見到了List類,對它來說,子型別化規則不一樣,Kotlin中的List介面表示的是隻讀集合。如果AB的子型別,那麼List<A>就是List<B>的子型別,這樣的類或者介面被稱為 協變的

3.3 協變:保留子型別化關係

一個協變類是一個泛型類,如果AB的子型別,那麼Producer<A>就是Producer<B>的子型別,我們說 子型別化被保留了

Kotlin中,要宣告類在某個型別引數上是可以協變的,在該型別引數的名稱前加上out關鍵字即可,下面例子就可以表達為:Producer類在型別引數T上是可以協變的。

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

將一個類的型別引數標記為協變的,在 該型別實參沒有精確匹配到函式中定義的型別形參時,可以讓該類的值作為這些函式的實參傳遞,也可以作為這些函式的返回值

你不能把任何類都變成協變的,這樣不安全。讓類在某個型別引數變為協變,限制了該類中對該型別引數使用 的可能性,要保證型別安全,你只能用在所謂的out位置,意味著這個類 只能生產型別T的值而不能消費它們

在類成員的宣告中型別引數的使用分為inout位置,考慮這樣一個類,它宣告瞭一個型別引數T幷包含了一個使用T的函式:

  • 如果函式把T當成返回型別,我們說它在out位置,這種情況下,該函式生產型別為T的值
  • 如果T用作函式引數的型別,它就在in的位置,這樣函式消費型別為T的值。

因此型別引數T上的關鍵字有兩層含義:

  • 子型別化會被保留,即前面談到的Producer<Cat>Producer<Animal>的子型別
  • T只能用在out位置

在構造方法的引數上使用 out

構造方法的引數既不在in位置,也不再out位置,即使型別引數宣告成了out,仍然可以在構造方法引數的宣告中使用它。

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

如果把類的例項當成一個更泛化的型別的例項使用,變型會防止該例項被誤用,不能呼叫存在潛在危險的方法。構造方法不是那種在例項建立之後還能呼叫的方法,因此它不會有潛在的危險。

然而,如果你在構造方法的引數上使用了關鍵字varval,同時就會宣告一個gettersetter,因此,對只讀屬性來說,型別引數用在了out位置,而可變屬性在outin位置都使用了它。

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

上面這個例子中,T不能用out標記,因為類包含屬性leadAnimalsetter,它在in位置用到了T

位置規則只覆蓋了類外部可見的 API

位置規則只覆蓋了類外部可見的api,私有方法的引數既不在in位置,也不在out位置,變型規則只會防止外部使用者對類的誤用,但不會對類自己的實現起作用。

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

現在可以安全地讓HerdT上協變,因為屬性leadAnimal被宣告成了私有。

3.4 逆變:反轉子型別化關係

逆變的概念可以看成是協變的映象,對一個逆變類來說,它的子型別化關係與用作型別實參的類的子型別化關係是相反的:如果BA的子型別,那麼Consumer<A>就是Consumer<B>的子型別。

Comparator介面為例,這個介面定義了一個compare方法,用於比較兩個指定的物件:

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

這個介面方法只是消費型別為T的值,這說明T只在in位置使用,因此它的宣告之前用了in關鍵字。

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

val anyComparator = Comparator<Any> { e1, e2 -> e1.hashCode() - e2.hashCode() }
val strings : List<String> = ...
strings.sortedWith(anyComparator)
複製程式碼

sortedWith期望一個Comparator<String>,傳給它一個能比較更一般的型別的比較器是安全的。如果你要在特定型別的物件上執行比較,可以使用能處理該型別或者它的超型別的比較器。

這說明Comparator<Any>Comparator<String>的子型別,其中AnyString的超型別。不同型別之間的子型別關係這些型別的比較器之間的子型別關係 截然相反。

in關鍵字的意思是,對應型別的值是傳遞進來給這個類的方法的,並且被這些方法消費。和協變的情況類似,約束型別引數的使用將導致特定的子型別化關係。

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

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

這意味著對這個函式型別的第一型別引數來說,子型別化反轉了,而對於第二個型別引數來說,子型別化保留了。例如,你有一個高階函式,該函式嘗試對你所有的貓進行迭代,你可以把一個接收動物的lambda傳遞給它。

fun enumerate(f : (Cat) -> Number) { ... }
fun Animal.getIndex() : Int = ...

>> enumerate(Animal :: getIndex)
複製程式碼

更多文章,歡迎訪問我的 Android 知識梳理系列:

相關文章