跳至主要内容

泛型:in、out、where

Kotlin 中的類別可以像 Java 一樣擁有類型參數 (type parameters):

class Box<T>(t: T) {
var value = t
}

要建立此類別的實例,只需提供類型引數:

val box: Box<Int> = Box<Int>(1)

但是,如果可以推斷參數,例如從建構函式引數中推斷,則可以省略類型引數:

val box = Box(1) // 1 的類型為 Int,所以編譯器會判斷它是 Box<Int>

變異數 (Variance)

Java 類型系統中最棘手的方面之一是萬用字元類型 (wildcard types)(請參閱 Java Generics FAQ)。 Kotlin 沒有這些。取而代之的是,Kotlin 具有宣告點變異數 (declaration-site variance) 和類型投影 (type projections)。

Java 中的變異數和萬用字元

讓我們想想為什麼 Java 需要這些神秘的萬用字元。首先,Java 中的泛型類型是 不變的 (invariant), 這表示 List<String> 不是 List<Object> 的子類型。如果 List 不是 不變的,那麼它 不會比 Java 的陣列好多少,因為以下程式碼可以編譯,但在執行時會導致例外:

// Java
List<String> strs = new ArrayList<String>();

// Java 在編譯時在此處報告類型不符。
List<Object> objs = strs;

// 如果沒有會怎樣?
// 我們就可以將 Integer 放入 String 的列表中。
objs.add(1);

// 然後在執行時,Java 會拋出
// ClassCastException:Integer 無法轉換為 String
String s = strs.get(0);

Java 禁止這種情況以保證執行時安全性。但這會產生影響。例如, 考慮 Collection 介面中的 addAll() 方法。這個方法的簽名是什麼?直觀地說, 你會這樣寫:

// Java
interface Collection<E> ... {
void addAll(Collection<E> items);
}

但是這樣,你將無法執行以下操作(這是完全安全的):

// Java

// 使用 addAll 的簡單宣告無法編譯以下內容:
// Collection<String> 不是 Collection<Object> 的子類型
void copyAll(Collection<Object> to, Collection<String> from) {
to.addAll(from);
}

這就是為什麼 addAll() 的實際簽名如下:

// Java
interface Collection<E> ... {
void addAll(Collection<? extends E> items);
}

萬用字元類型引數 (wildcard type argument) ? extends E 表示此方法接受 E 的物件集合 或是 E 的子類型,而不僅僅是 E 本身。這表示你可以安全地從 items 中 讀取 E (此集合的元素是 E 的子類別的實例),但 無法寫入 到 它,因為你不知道哪些物件符合 E 的未知子類型。 作為此限制的回報,你將獲得所需的行為:Collection<String> Collection<? extends Object> 的子類型。 換句話說,具有 extends-bound(上限 (upper) bound)的萬用字元使類型 協變 (covariant)

理解為什麼這有效的原因很簡單:如果你只能從集合中 取得 項目, 那麼使用 String 的集合並從中讀取 Object 是可以的。相反,如果你只能將項目 放入 集合中, 那麼取得 Object 的集合並將 String 放入其中是可以的:在 Java 中有 List<? super String>,它接受 String 或其任何父類型 (supertypes)。

後者稱為 逆變 (contravariance),並且你只能在 List<? super String> 上呼叫將 String 作為引數的方法 (例如,你可以呼叫 add(String)set(int, String))。如果你在 List<T> 中呼叫返回 T 的內容, 你不會得到 String,而是得到 Object

Joshua Bloch 在他的著作 Effective Java, 3rd Edition 中,很好地解釋了這個問題 (項目 31:"使用有界萬用字元來增加 API 的靈活性")。他將僅 從中讀取 的物件命名為 Producer (生產者),將僅 寫入 的物件命名為 Consumer (消費者)。他建議:

備註

"為了獲得最大的靈活性,請在表示生產者或消費者的輸入參數上使用萬用字元類型。"

然後他提出了以下助記符:PECS 代表 Producer-Extends, Consumer-Super

如果你使用生產者物件,例如 List<? extends Foo>,則不允許在此物件上呼叫 add()set(), 但這並不表示它是 不可變的 (immutable):例如,沒有什麼可以阻止你呼叫 clear() 以從列表中移除所有項目,因為 clear() 根本不接受任何參數。

萬用字元(或其他變異數類型)保證的唯一一件事是 類型安全 (type safety)。不可變性是一個完全不同的故事。

宣告點變異數

假設有一個泛型介面 Source<T>,它沒有任何將 T 作為參數的方法,只有返回 T 的方法:

// Java
interface Source<T> {
T nextT();
}

然後,將 Source<String> 實例的參考儲存在 Source<Object> 類型的變數中是完全安全的, 因為沒有可呼叫的消費者方法。但是 Java 不知道這一點,仍然禁止它:

// Java
void demo(Source<String> strs) {
Source<Object> objects = strs; // !!! 在 Java 中不允許
// ...
}

為了修復這個問題,你應該宣告 Source<? extends Object> 類型的物件。這樣做沒有意義, 因為你可以在這樣的變數上呼叫與以前相同的所有方法,因此更複雜的類型沒有增加任何價值。 但是編譯器不知道。

在 Kotlin 中,有一種方法可以向編譯器解釋這類事情。這稱為 宣告點變異數 (declaration-site variance): 你可以註解 Source類型參數 (type parameter) T,以確保它僅從 Source<T> 的成員中 返回(產生), 而從不被使用 (consumed)。 為此,請使用 out 修飾符:

interface Source<out T> {
fun nextT(): T
}

fun demo(strs: Source<String>) {
val objects: Source<Any> = strs // 這是 OK 的,因為 T 是一個 out-parameter
// ...
}

一般規則是:當類別 C 的類型參數 T 宣告為 out 時,它可能只會出現在 C 的成員中的 out-position 中, 但作為回報,C<Base> 可以安全地成為 C<Derived> 的父類型。

換句話說,你可以說類別 C 在參數 T 中是 協變的 (covariant),或者說 T 是一個 協變類型參數 (covariant type parameter)。 你可以將 C 視為 T生產者,而不是 T消費者

out 修飾符稱為 變異數註解 (variance annotation),並且由於它是在類型參數宣告位置提供的, 因此它提供了 宣告點變異數。 這與 Java 的 使用點變異數 (use-site variance) 形成對比,在 Java 中,類型用法中的萬用字元使類型協變。

除了 out 之外,Kotlin 還提供了一個互補的變異數註解:in。它使類型參數 逆變的 (contravariant),這表示 它只能被使用,而永遠不會被產生。逆變類型的一個很好的例子是 Comparable

interface Comparable<in T> {
operator fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
x.compareTo(1.0) // 1.0 的類型為 Double,它是 Number 的子類型
// 因此,你可以將 x 指派給 Comparable<Double> 類型的變數
val y: Comparable<Double> = x // OK!
}

inout 這兩個詞似乎是不言自明的(因為它們已經在 C# 中成功使用了很長時間), 因此實際上不需要上面提到的助記符。事實上,可以在更高的抽象層級重新表述:

存在主義 轉換:消費者 in,生產者 out! :-)

類型投影

使用點變異數:類型投影

將類型參數 T 宣告為 out 並避免在使用位置出現子類型問題非常容易, 但某些類別實際上 無法 限制為僅返回 T! 一個很好的例子是 Array

class Array<T>(val size: Int) {
operator fun get(index: Int): T { ... }
operator fun set(index: Int, value: T) { ... }
}

這個類別在 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)
// ^ 類型為 Array<Int>,但預期為 Array<Any>

在這裡,你遇到了同樣熟悉的難題:Array<T>T 中是 不變的,因此 Array<Int>Array<Any> 都不是對方的子類型。為什麼不呢?同樣,這是因為 copy 可能會有意想不到的行為,例如,它可能會嘗試將 String 寫入 from,如果你實際上傳遞了一個 Int 陣列,稍後將會拋出 ClassCastException

為了禁止 copy 函式 寫入from,你可以執行以下操作:

fun copy(from: Array<out Any>, to: Array<Any>) { ... }

這就是 類型投影 (type projection),這表示 from 不是一個簡單的陣列,而是一個受限制的(投影的)陣列。 你只能呼叫返回類型參數 T 的方法,在這種情況下,這表示你只能呼叫 get()。 這是我們處理 使用點變異數 的方法,它對應於 Java 的 Array<? extends Object>,但稍微簡單一些。

你也可以使用 in 投影類型:

fun fill(dest: Array<in String>, value: String) { ... }

Array<in String> 對應於 Java 的 Array<? super String>。這表示你可以將 StringCharSequenceObject 的陣列傳遞給 fill() 函式。

Star-projections (星號投影)

有時你想說你對類型引數一無所知,但你仍然想以安全的方式使用它。 這裡安全的方式是定義泛型類型的這種投影,該泛型類型的每個具體實例都將是該投影的子類型。

Kotlin 為此提供了所謂的 星號投影 (star-projection) 語法:

  • 對於 Foo<out T : TUpper>,其中 T 是一個具有上限 TUpper 的協變類型參數,Foo<*> 等同於 Foo<out TUpper>。 這表示當 T 未知時,你可以安全地從 Foo<*> 讀取 TUpper 的值。
  • 對於 Foo<in T>,其中 T 是一個逆變類型參數,Foo<*> 等同於 Foo<in Nothing>。這表示 當 T 未知時,沒有什麼可以安全地 寫入Foo<*>
  • 對於 Foo<T : TUpper>,其中 T 是一個具有上限 TUpper 的不變類型參數,Foo<*> 等同於 讀取值的 Foo<out TUpper> 和寫入值的 Foo<in Nothing>

如果泛型類型具有多個類型參數,則可以獨立地投影每個類型參數。 例如,如果類型宣告為 interface Function<in T, out U>,則可以使用以下星號投影:

  • Function<*, String> 表示 Function<in Nothing, String>
  • Function<Int, *> 表示 Function<Int, out Any?>
  • Function<*, *> 表示 Function<in Nothing, out Any?>
備註

星號投影非常像 Java 的原始類型 (raw types),但更安全。

泛型函式 (Generic functions)

並非只有類別可以具有類型參數。函式也可以。類型參數位於函式名稱 之前

fun <T> singletonList(item: T): List<T> {
// ...
}

fun <T> T.basicToString(): String { // 擴充函式 (extension function)
// ...
}

要呼叫泛型函式,請在呼叫位置 函式名稱 之後 指定類型引數:

val l = singletonList<Int>(1)

如果可以從上下文中推斷出類型引數,則可以省略它們,因此以下範例也有效:

val l = singletonList(1)

泛型約束 (Generic constraints)

可以替換給定類型參數的所有可能類型的集合可能會受到 泛型約束 (generic constraints) 的限制。

上限

最常見的約束類型是 上限 (upper bound),它對應於 Java 的 extends 關鍵字:

fun <T : Comparable<T>> sort(list: List<T>) {  ... }

冒號後指定的類型是 上限,表示只有 Comparable<T> 的子類型才能替換 T。例如:

sort(listOf(1, 2, 3)) // OK。Int 是 Comparable<Int> 的子類型
sort(listOf(HashMap<Int, String>())) // 錯誤:HashMap<Int, String> 不是 Comparable<HashMap<Int, String>> 的子類型

預設上限(如果未指定)是 Any?。只能在角括號內指定一個上限。 如果同一個類型參數需要多個上限,則需要一個單獨的 where-clause:

fun <T> copyWhenGreater(list: List<T>, threshold: T): List<String>
where T : CharSequence,
T : Comparable<T> {
return list.filter { it > threshold }.map { it.toString() }
}

傳遞的類型必須同時滿足 where 子句的所有條件。在上面的範例中,T 類型 必須 同時 實作 CharSequenceComparable

絕對不可為 null 的類型 (Definitely non-nullable types)

為了更容易與泛型 Java 類別和介面進行互通,Kotlin 支援將泛型類型參數宣告為 絕對不可為 null 的 (definitely non-nullable)

要將泛型類型 T 宣告為絕對不可為 null 的,請使用 & Any 宣告類型。例如:T & Any

絕對不可為 null 的類型必須具有可為 null 的 上限

宣告絕對不可為 null 的類型最常見的用例是當你想要覆寫 (override) 包含 @NotNull 作為引數的 Java 方法時。例如,考慮 load() 方法:

import org.jetbrains.annotations.*;

public interface Game<T> {
public T save(T x) {}
@NotNull
public T load(@NotNull T x) {}
}

為了在 Kotlin 中成功覆寫 load() 方法,你需要將 T1 宣告為絕對不可為 null 的:

interface ArcadeGame<T1> : Game<T1> {
override fun save(x: T1): T1
// T1 絕對不可為 null
override fun load(x: T1 & Any): T1 & Any
}

當僅使用 Kotlin 時,你不太可能需要明確宣告絕對不可為 null 的類型,因為 Kotlin 的類型推斷 (type inference) 會為你處理這個問題。

Type erasure (類型擦除)

Kotlin 對泛型宣告用法執行的類型安全檢查是在編譯時完成的。 在執行時,泛型類型的實例不保存有關其實際類型引數的任何資訊。 類型資訊被稱為 被擦除 (erased)。例如,Foo<Bar>Foo<Baz?> 的實例被擦除為 僅僅是 Foo<*>

Generics type checks and casts (泛型類型檢查和轉換)

由於類型擦除,沒有通用的方法來檢查泛型類型的實例是否在執行時使用某些類型 引數建立,並且編譯器禁止諸如 ints is List<Int>list is T(類型參數)之類的 is-checks。但是,你可以根據星號投影類型檢查實例:

if (something is List<*>) {
something.forEach { println(it) } // 項目類型為 `Any?`
}

同樣,當你已經靜態地(在編譯時)檢查了實例的類型引數時, 你可以進行 is-check 或轉換,其中涉及該類型的非泛型部分。請注意, 在這種情況下,會省略角括號:

fun handleStrings(list: MutableList<String>) {
if (list is ArrayList) {
// `list` 智慧轉換為 `ArrayList<String>`
}
}

相同的語法,但省略了類型引數,可用於不考慮類型引數的轉換:list as ArrayList

泛型函式呼叫的類型引數也僅在編譯時檢查。在函式本體中, 類型參數不能用於類型檢查,並且對類型參數的類型轉換(foo as T)未經檢查。 唯一的例外是具有 具體化類型參數 的內聯函式, 它們在每個呼叫位置都內聯了其實際類型引數。這使得可以對類型參數進行類型檢查和轉換。 但是,上述限制仍然適用於檢查或轉換中使用的泛型類型的實例。 例如,在類型檢查 arg is T 中,如果 arg 本身是泛型類型的實例,則其類型引數仍會被擦除。


inline fun <reified A, reified B> Pair<*, *>.asPairOf(): Pair<A, B>? {
if (first !is A || second !is B) return null
return first as A to second as B
}

val somePair: Pair<Any?, Any?> = "items" to listOf(1, 2, 3)

val stringToSomething = somePair.asPairOf<String, Any>()
val stringToInt = somePair.asPairOf<String, Int>()
val stringToList = somePair.asPairOf<String, List<*>>()
val stringToStringList = somePair.asPairOf<String, List<String>>() // 編譯但會破壞類型安全!
// 展開範例以取得更多詳細資訊

fun main() {
println("stringToSomething = " + stringToSomething)
println("stringToInt = " + stringToInt)
println("stringToList = " + stringToList)
println("stringToStringList = " + stringToStringList)
//println(stringToStringList?.second?.forEach() {it.length}) // 這將拋出 ClassCastException,因為列表項目不是 String
}

Unchecked casts (未經檢查的轉換)

無法在執行時檢查對具有具體類型引數的泛型類型(例如 foo as List<String>)的類型轉換。 當高階程式邏輯暗示類型安全,但編譯器無法直接推斷時,可以使用這些未經檢查的轉換。請參閱下面的範例。

fun readDictionary(file: File): Map<String, *> = file.inputStream().use { 
TODO("Read a mapping of strings to arbitrary elements.")
}

// 我們將帶有 `Int` 的對應儲存到此檔案中
val intsFile = File("ints.dictionary")

// 警告:未經檢查的轉換:`Map<String, *>` 到 `Map<String, Int>`
val intsDictionary: Map<String, Int> = readDictionary(intsFile) as Map<String, Int>

最後一行的轉換會顯示警告。編譯器無法在執行時完全檢查它,並且不保證對應中的值是 Int

為了避免未經檢查的轉換,你可以重新設計程式結構。在上面的範例中,你可以使用 具有不同類型安全實作的 DictionaryReader<T>DictionaryWriter<T> 介面。 你可以引入合理的抽象概念,以將未經檢查的轉換從呼叫位置移動到實作細節。 正確使用 泛型變異數 也有幫助。

對於泛型函式,使用 具體化類型參數 使得諸如 arg as T 之類的轉換被檢查,除非 arg 的類型有 自己的 被擦除的類型引數。

可以使用 @Suppress("UNCHECKED_CAST") 註解 發生語句或宣告來隱藏未經檢查的轉換警告:

inline fun <reified T> List<*>.asListOfType(): List<T>? =
if (all { it is T })
@Suppress("UNCHECKED_CAST")
this as List<T> else
null
備註

在 JVM 上陣列類型 (Array<Foo>) 保留有關其元素擦除類型 的資訊,並且對陣列類型的類型轉換會進行部分檢查: 元素類型的可為 null 性和實際類型引數仍會被擦除。例如, 如果 foo 是一個包含任何 List<*> 的陣列,則轉換 foo as Array<List<String>?> 將成功,無論它是可為 null 的還是不可為 null 的。

Underscore operator for type arguments (用於類型引數的底線運算子)

底線運算子 _ 可用於類型引數。當其他類型明確指定時,請使用它來自動推斷引數的類型:

abstract class SomeClass<T> {
abstract fun execute() : T
}

class SomeImplementation : SomeClass<String>() {
override fun execute(): String = "Test"
}

class OtherImplementation : SomeClass<Int>() {
override fun execute(): Int = 42
}

object Runner {
inline fun <reified S: SomeClass<T>, T> run() : T {
return S::class.java.getDeclaredConstructor().newInstance().execute()
}
}

fun main() {
// T 推斷為 String,因為 SomeImplementation 繼承自 SomeClass<String>
val s = Runner.run<SomeImplementation, _>()
assert(s == "Test")

// T 推斷為 Int,因為 OtherImplementation 繼承自 SomeClass<Int>
val n = Runner.run<OtherImplementation, _>()
assert(n == 42)
}