
這篇文章會講清楚 out(協變)、in(逆變)、*(星號投影) 和 reified 這四個泛型進階概念,用 TDD 手刻 myFilterIsInstance 來體驗 reified 的威力
| Kotlin | C# | 備註 |
|---|---|---|
out T(協變) |
out T |
語法一樣,概念一樣 |
in T(逆變) |
in T |
語法一樣,概念一樣 |
List<*> |
沒有直接對應 | 依需求用 IEnumerable<object?> 或非泛型 IList |
reified T |
不需要 | C# 沒有型別擦除,泛型在 runtime 可用 |
C# 和 Kotlin 在型變的語法上幾乎一模一樣,都用 out 和 in
最大的差別在 reified,C# 的泛型是具體化的(reified generics),runtime 保留型別資訊,不需要特殊語法,Kotlin(和 Java) 的泛型有型別擦除,需要靠 inline + reified 來繞過限制
型別擦除(type erasure)的意思是,泛型的型別引數只活在編譯期,編譯完的 bytecode 裡 List<String> 和 List<Int> 都只剩下 List,runtime 拿不到 String 或 Int 這個資訊。完整的來龍去脈後面講 reified 的時候會再說
out 表示型別參數只能被「產出」,不能被「消費」
// Kotlin 的 List 定義
public interface List<out E> : Collection<E> {
operator fun get(index: Int): E // 產出 E ← 合法
// fun add(element: E) // 消費 E ← 不存在,因為 List 是唯讀的
}
List<out E> 讓 List<String> 可以賦值給 List<Any>
val strings: List<String> = listOf("a", "b", "c")
val anys: List<Any> = strings // 合法,因為 List 是 out E
為什麼可以?因為 List 只會「給你」元素(get),不會「收你的」元素(add)。你從 List<String> 拿出來的東西一定是 String,而 String 是 Any 的子型別,所以放進 List<Any> 的變數裡完全安全
in 表示型別參數只能被「消費」,不能被「產出」
// Kotlin 的 Comparable 定義
public interface Comparable<in T> {
operator fun compareTo(other: T): Int // 消費 T ← 合法
// fun getItem(): T // 產出 T ← 不允許
}
逆變的方向反過來,靠 Comparator<in String> 這種使用處型變,Comparator<Any> 就能用在需要 Comparator<String> 的地方
fun acceptComparator(c: Comparator<in String>) =
listOf("c", "a", "b").sortedWith(c)
val anyComparator: Comparator<Any> = Comparator { a, b ->
a.toString().compareTo(b.toString())
}
acceptComparator(anyComparator) // 合法
為什麼反過來?因為 Comparator<Any> 能比較「任何東西」,那比較 String 當然也沒問題。能力越強的(接受 Any),可以用在要求越窄的地方(只要 String)
要注意 kotlin.Comparator 其實是 java.util.Comparator 的 typealias,本身是不變的(invariant),直接寫 val c: Comparator<String> = anyComparator 會編譯失敗。上面的例子能成立,是因為函式參數宣告成 Comparator<in String>,這是使用處型變(use-site projection),並不是 Comparator 自己有宣告處逆變
回顧之前寫過的函式
// myFilter 的 predicate 消費 T
inline fun <T> Iterable<T>.myFilter(predicate: (T) -> Boolean): List<T>
// (T) -> Boolean 其實是 Function1<in T, out Boolean>
Function1<in T, out Boolean>:T 是 in(被 Lambda 消費),Boolean 是 out(被 Lambda 產出)。day 16 用的 Comparator<in T> 也是逆變的,讓 Comparator<Any> 能排序 List<String>
// 宣告處型變(declaration-site):定義 class 時宣告
interface List<out E> // 所有使用 List 的地方都是協變的
interface Comparable<in T> // 所有使用 Comparable 的地方都是逆變的
// 使用處型變(use-site):使用時才宣告
fun copy(from: Array<out Any>, to: Array<Any>) { ... }
// 只有這個函式的 from 參數是協變的
Java 只有使用處型變(wildcards:? extends T、? super T)。Kotlin 兩種都支援,但建議盡量用宣告處型變,因為宣告一次就夠了
* 等同 Java 的 ?(unbounded wildcard)
val list: List<*> = listOf(1, "hello", 3.0)
// List<*> 等於 List<out Any?>
// 可以讀(拿出來的型別是 Any?),不能寫
val first: Any? = list[0]
什麼時候用 *?當你不在乎具體型別,只想操作容器本身的時候
fun printSize(list: List<*>) {
println("Size: ${list.size}") // 不需要知道元素型別
}
filterIsInstance 是 stdlib 裡面少數用到 reified 的函式。它從混合型別的集合裡過濾出特定型別的元素
先看沒有 reified 的問題
// 這段無法編譯
fun <T> Iterable<*>.filterByType(): List<T> {
val result = ArrayList<T>()
for (element in this) {
if (element is T) { // 編譯錯誤:Cannot check for instance of erased type: T
result.add(element)
}
}
return result
}
Java/Kotlin 的泛型有型別擦除(type erasure)。T 在 runtime 不存在,compiler 不知道 T 到底是什麼型別,所以 element is T 無法編譯
reified 搭配 inline 可以解決這個問題。因為 inline 會把函式體展開到呼叫處,compiler 可以把具體的型別(例如 String)直接替換掉 T
型別擦除與 Java 泛型的向後相容需求有關
Java 在 1.5(2004)導入泛型時,已經有將近十年的 codebase,Vector、ArrayList、HashMap 滿天飛。Java 1 寫的 .class 檔案要在 Java 1.5 的 JVM 上跑,Java 1.5 寫的泛型程式碼也要能跟舊 codebase 互通。如果 JVM 改成保留泛型型別資訊,ArrayList<String> 跟舊 ArrayList 會變成不同的 class,binary compatibility 就毀了
所以 Java 設計者選擇 erasure:編譯期做型別檢查,bytecode 把 ArrayList<String>、ArrayList<Integer> 都擦成 ArrayList。runtime 看到的還是同一個 raw type,跟舊程式碼可以共存。代價就是 element is T 寫不出來。Kotlin 跑在 JVM 上,只能繼承這個包袱,靠 reified 在編譯期繞過
@Test
fun `filter strings from mixed list`() {
val mixed: List<Any> = listOf(1, "hello", 2.0, "world", 3)
val result = mixed.myFilterIsInstance<String>()
assertEquals(listOf("hello", "world"), result)
}
@Test
fun `filter ints from mixed list`() {
val mixed: List<Any> = listOf(1, "hello", 2, "world", 3)
val result = mixed.myFilterIsInstance<Int>()
assertEquals(listOf(1, 2, 3), result)
}
@Test
fun `filter with no matches returns empty`() {
val mixed: List<Any> = listOf(1, 2, 3)
val result = mixed.myFilterIsInstance<String>()
assertEquals(emptyList<String>(), result)
}
@Test
fun `filter drops null elements`() {
val mixed: List<Any?> = listOf("a", null, "b", null)
val result = mixed.myFilterIsInstance<String>()
assertEquals(listOf("a", "b"), result)
}
@Test
fun `filter keeps nulls when type argument is nullable`() {
val mixed: List<Any?> = listOf("a", null, "b", null)
val result = mixed.myFilterIsInstance<String?>()
assertEquals(listOf("a", null, "b", null), result)
}
最後兩個測試是一組對照。myFilterIsInstance<String> 會把 null 全部濾掉,因為 null is String 是 false;換成 nullable 的型別引數 myFilterIsInstance<String?>,null is String? 為 true,null 就留下來了。同一份實作、同一份輸入,只差在型別引數帶不帶 ?,結果就不一樣,因為 reified 在呼叫處連 nullability 一起帶進去了
inline fun <reified R> Iterable<*>.myFilterIsInstance(): List<R> {
val result = ArrayList<R>()
for (element in this) {
if (element is R) {
result.add(element)
}
}
return result
}
reified 讓 compiler 在 inline 呼叫處取得具體的 R,因此 element is R、R::class 等受支援的型別操作可以編譯。它不會改變 JVM 的泛型型別擦除
這個寫法有三項限制
inline
reified
<String>),不能是另一個還沒 reified 的型別參數reified 也無法檢查被擦除的巢狀型別引數。例如可以判斷 value is List<*>,卻不能靠 is 確認它是 List<String>;後者在 runtime 只看得到 List
這個 Green 版已涵蓋 stdlib 的核心迴圈。stdlib 另外委派給 filterIsInstanceTo,並加入 @NoInfer 標記,後面的原始碼比較會再說明
C# 不需要 reified,因為 C# 的泛型在 runtime 本來就是 reified 的
// C# 版
public static List<T> FilterByType<T>(this IEnumerable<object> source) {
return source.OfType<T>().ToList();
}
typeof(T) 和 is T 直接可用,不需要任何特殊處理
差別在 CLR 的設計。.NET 2.0(2005)導入泛型時,CLR 從頭設計,把泛型的型別資訊保留在 IL metadata 裡。對 value type(像 int),JIT 會為每個具體型別產生各自特化的原生碼,順便避開 boxing,對 reference type(像 string、object),各種具體化的版本共用同一份原生碼(canonical shared instantiation),但 runtime 上 List<string> 跟 List<object> 仍是各自獨立的 Type,型別資訊都還在
代價是 .NET 1.x 的非泛型 collection 跟泛型 collection 是兩套並存(ArrayList vs List<T>),沒有像 Java 那樣的平滑升級
兩邊採用不同的相容策略,Kotlin 跑在 JVM 上,也受到 Java 型別擦除的限制,再以 reified 支援部分需要具體型別的操作
原始碼位置:kotlin.collections 的 _Collections.kt
// stdlib 的 filterIsInstance
public inline fun <reified R> Iterable<*>.filterIsInstance(): List<@kotlin.internal.NoInfer R> {
return filterIsInstanceTo(ArrayList<R>())
}
stdlib 多了一個 @NoInfer 標記和 filterIsInstanceTo 的委派模式(跟之前的 xxxTo 系列一樣,day 03 首見、day 10 詳講過)。核心邏輯跟我們的版本一樣
out 和 in 控制泛型的子型別關係方向
out 讓「子→父」的賦值合法(List),in 讓「父→子」的賦值合法(Comparator)。* 在你不在乎型別的時候用
reified 搭配 inline 可以繞過型別擦除,讓 is T 這種 runtime 型別檢查變成可能,C# 開發者轉 Kotlin 要特別注意型別擦除這個差異,C# 不需要 reified 是因為它根本沒有這個問題
下一篇講 Contract,看 Kotlin 怎麼讓 compiler 更聰明地理解 Lambda 的行為
同步刊登於 Blog
圖片來源:AI 產生