理解Kotlin泛型对新手来说就像解咒语------相信我,我也是这么过来的。让我们通过一个魔法寓言来轻松掌握它们。
通过这篇魔法指南,你将掌握:
- 型变三法则 :协变(
out
)、逆变(in
)、不变的运作奥秘。 - 类型擦除魔法 :运行时消失的类型,以及
reified
咒语的破解之道。 - 高阶技巧:位点变异、星投影的妙用,以及泛型空安全。
- 现实应用 :解密标准库中泛型API的设计应用。
类型世界的奇幻之旅
故事经过艺术加工以契合叙事框架

在泛型之地 ,三位安卓英雄------战士、法师和弓箭手------踏上了讨伐威胁国度的传奇恶魔------歧义领主的征程。
他们必须穿越禁忌森林 才能抵达歧义领主 的城堡,这片森林教会他们泛型之地的型变法则:
- 第1章:神秘之门与不变性
- 第2章:魔法卷轴与协变(
out
) - 第3章:歧义领主与逆变(
in
) - 第4章:终极战役与类型擦除
- 第5章:
reified
咒语 - 第6章:终章
- 番外篇:Kotlin泛型进阶
第1章:神秘之门与不变性

在禁忌森林 入口处矗立着神秘之门,守卫宣布:"只有携带Sheath<Sword>
的战士能通过!"。
战士试图使用Sheath<Excalibur>
(圣剑Excalibur
是Sword
的子类)进入,但仍被拒绝:
Kotlin
open class Sword
class Excalibur : Sword()
class Sheath<T>(val item: T)
val sword: Sheath<Sword> = Sheath<Excalibur>(Excalibur()) // 编译错误:类型不匹配
val excalibur: Sheath<Excalibur> = Sheath<Sword>(Sword()) // 编译错误:类型不匹配
规则很简单,默认泛型是不变(invariant) 的,即使Excalibur
是Sword
子类,但Sheath<Excalibur>
与Sheath<Sword>
没有继承关系。这种设计用来确保读写安全。
技术解析:型变与不变性
在Kotlin 中,型变(Variance) 定义了复杂类型之间的子类型关系与其类型参数的子类型关系如何关联。
泛型默认是不变 (直接写作<T>
),这意味着:
- 类型参数必须严格匹配
- 不允许用子类型或超类型替换泛型类型本身
之所以这样设计,是因为在Kotlin 中,不变性允许你进行读写操作:如果Kotlin 允许你将Sheath<Excalibur>
视为Sheath<Sword>
,那么读取Excalibur
是安全的,但是写入Sword
则会破坏类型安全。
Kotlin
class Sheath<T>(var item: T)
val fakeSheath: Sheath<Sword> = Sheath<Excalibur>(Excalibur()) // 假设允许以下操作
fakeSheath.item = Sword() // ❌ 实际容器要求 Excalibur 类型
不变性通过强制类型精确匹配,避免了潜在的冲突,确保读写操作的安全性。
第2章:魔法卷轴与协变(out
)

在经过一些重构后穿过神秘之门,英雄们发现了一个隐藏的知识宝库,里面摆满了魔法书架。
法师走近了一个魔法书架BookShelf<out T>
,她可以阅读各种卷轴:火卷轴、冰卷轴等等。
但当她试图添加一个新的卷轴时......书架拒绝了。
Kotlin
open class Scroll
class FireScroll : Scroll()
class IceScroll : Scroll()
class Bookshelf<out T>(val scroll: T) {
fun read(): T = scroll // ✅
// fun add(newScroll: T) { ... } // ❌ 编译错误:T 被声明为 'out'
}
val fireShelf: BookShelf<FireScroll> = BookShelf(FireScroll())
val generalShelf: BookShelf<Scroll> = fireShelf // ✅
这个宝库是协变的。它允许她安全地读取更具体类型中的通用类型,但不允许进行任何写入操作。
技术解析:使用out
的协变性
协变性,通过out
关键字声明(out T
),意味着你可以安全地读取类型为T
的值,但不能写入(只读)。这是因为实际对象可能是T
的子类型,而写入类型为T
的内容可能会破坏类型安全(毕竟你不知道实际是什么类型)。
这就解释了为什么Kotlin 集合中的List<out E>
可以读取列表中类型为T
的元素,但不允许添加元素,因为列表内部可能持有比T
更具体的类型。
Kotlin
// 声明
public interface List<out E> : Collection<E> { ... }
// 使用
val children: List<Child> = listOf(Child())
val parents: List<Parent> = children // ✅
而MutableList<E>
是不变的<E>
,支持读写访问,因此不允许使用子类型或超类型进行替换。
Kotlin
// 声明
public interface MutableList<E> : List<E>, MutableCollection<E> { ... }
// 使用
val children: MutableList<Child> = mutableListOf(Child())
// val parents: MutableList<Parent> = children // ❌ 编译错误:类型不匹配。
第3章:歧义领主与逆变(in
)

最终,英雄们抵达了神秘莫测的歧义领主面前。
无人知晓何种攻击最为致命。领主神秘莫测,他的弱点依旧成谜。但他确实能够承受伤害,任何种类的伤害。
每位英雄都尽其所能:
- 战士以普通伤害发起攻击
- 法师释放了火焰伤害
- 弓箭手则以箭矢伤害进行射击
而神秘莫测的歧义领主......承受了所有这些伤害。
Kotlin
open class Damage
class FireDamage : Damage()
class ArrowDamage : Damage()
class Enemy<in T> {
fun takeHit(damage: T) {
println("敌人受到了伤害: $damage")
}
// fun getWeakness(): T { } // ❌ 编译错误:T被声明为'in'
}
val enemy = Enemy<Damage>()
enemy.takeHit(Damage()) // ✅
enemy.takeHit(FireDamage()) // ✅
enemy.takeHit(ArrowDamage()) // ✅
借助Enemy<in T>
类型,英雄们能够施展多种攻击形式,但永远无法窥视实际的伤害类型。这就像是一场盲眼之战,充满了未知与挑战!
技术解析:与in
共舞的逆变
当谈及逆变时,伴随着in
这个关键字(in T
)翩翩起舞。你可以安心地向其中写入类型为T
的值,但切记不可从中读取。这在将数据传递给泛型类型,比如消费者时,显得格外有用。Kotlin 允许你将T
值传入结构中,因为任何T
的父类型都能安全地接纳它。但读取则不然,因为实际内容可能属于更通用的类型,此举风险重重。
这就解释了为何Kotlin 中的Comparable<in T>
能够安全地写入类型为T
的值,却从不涉及读取T
值。
Kotlin
// 声明
public interface Comparable<in T> {
public operator fun compareTo(other: T): Int
}
由于Comparable
只需要接受值以进行比较,我们无需知晓内部的确切类型------只需确保能够安全地传递一个T
。这使其成为使用in T
进行逆变设计的绝佳案例。
终极战役与类型擦除

歧义领主 愤怒地吼叫着。尽管他们对于变化有着深刻的理解,尽管每一次都进行了类型安全的攻击,但它依然屹立不倒。
"你知道如何攻击我",它低沉地说,"但你真的了解我是什么吗?"
这时,法师想起了她在知识宝库中读到的一段话:
"在这个泛型领域,大部分特定类型的信息在初次检查后就会消失。这种现象被称为类型擦除。"
这意味着,在程序运行时,他们无法检查敌人的真实类型。所有特定类型的信息在运行时常常只表现为一个星号(*
) ------代表着某种未知类型。
技术解析:运行时类型擦除
在Kotlin (以及Java )中,泛型类型参数在运行时被擦除。这意味着尽管在编译时你可以编写和交互 List<Int>
或 List<String>
,但在运行时,这两种类型都被简单地视为 List<*>
。
这个过程,称为类型擦除------不会在运行时保留泛型类型信息。这主要是因为历史原因(确保与不支持泛型的旧Java版本兼容),以及为了避免与每个泛型实例携带详细类型信息相关的潜在运行时性能开销。
注意:在某些特定上下文中可以保留一些类型信息,比如超类令牌或对类定义本身的反射。更多信息请参考:Baeldung - Java Super Type Tokens
因此,运行时类型安全无法区分像 List<Int>
和 List<String>
这样的泛型实例。
kotlin
// 运行时错误:两个构造函数具有相同的 JVM 签名
class Foo(val ints: List<Int>)
constructor(strings: List<String>) : this(strings.map { it.toInt() })
由于类型擦除,两个构造函数被视为具有相同的JVM 方法签名,并且在运行时会发生错误。(为了解决像这样的构造函数/函数签名冲突,工厂函数或使用 @JvmName
可能会有所帮助。更多信息请参考:Kt. Academy - Factory Functions
注意:类型擦除并不意味着泛型无用!只有在这些严格的编译时检查通过后,特定的类型信息通常才会被擦除,使其在运行时不检查。
第5章:reified
咒语
由于泛型类型参数在运行时被擦除,英雄们无法猜测出歧义领主的正确弱点。
kotlin
fun <T : Damage> isWeakTo(): Boolean {
// ❌ 编译错误:无法检查被擦除的类型实例:T
// return T is hiddenWeakness
}
但法师知道一种古老的技巧,一种带有具体化类型的内联咒语,即使在运行时也能保留类型信息。
她施放了以下咒语:
kotlin
inline fun <reified T : Damage> isWeakTo(): Boolean {
return hiddenWeakness is T // ✅ 在运行时可以进行检查
}
val weakToFire = isWeakTo<FireDamage>()
println("Is weak to FireDamage? $weakToFire") // 输出:false
val weakToIce = isWeakTo<IceDamage>()
println("Is weak to IceDamage? $weakToIce") // 输出:true
这一次,咒语在运行时回响了真相。
歧义领主有一个致命的弱点------他害怕冰霜伤害!
技术解析:内联具体化类型参数
通常情况下,正如在类型擦除中讨论的那样,你在运行时失去了对特定泛型类型参数T
的访问。尝试检查 hiddenWeakness is T
会导致编译错误,因为T
的具体类型信息没有被保留。但是,如果你在一个内联函数中使用 reified
,Kotlin 允许你在运行时安全地使用 is
、as
甚至 T::class
。
当你用一个函数标记为inline
时,编译器不会生成一个标准的函数调用,相反,它直接将内联函数的字节码复制到函数被调用的位置。这有时可以提高性能,特别是对于lambda
参数,因为它避免了创建函数对象的开销。
使用inline + reified
关键字,函数的代码直接被复制到调用站点,编译器知道在那个特定的调用站点正在使用实际类型参数。在特定内联函数调用的上下文中,类型信息没有被擦除,使其成为具体化的。这允许你执行通常对泛型类型禁止的运行时操作:
- 类型检查:
value is T
- 类型转换:
value as T
- 访问类型的 KClass:
T::class
(例如,T::class.java
)
注意:这只有在代码被内联时才有效。这也意味着具体化类型参数只能用于内联函数。更多信息请参考:Kotlin 官方文档 - 内联函数
这也解释了为什么Kotlin 集合中的filterIsInstance
操作可以保留类型为T
的元素,而无需使用反射:
kotlin
// 声明
inline fun <reified R> Iterable<*>.filterIsInstance(): List<...> {...}
// 使用
val list = listOf(1, "2", 3.0)
val ints = list.filterIsInstance<Int>() // [1]
第6章:终章

法师一使用冰霜伤害攻击,歧义领主就发出了尖叫。他的类型护盾碎裂了,不再受泛型模糊性的保护。
番外篇:Kotlin泛型进阶
虽然主线故事以积极的基调结束了,但泛型之地还包含更多的魔法力量。让我们继续探索吧!
使用位点变异性(Use-Site Variance)
还记得我们之前如何在 BookShelf(out T)
和 Enemy(in T)
上声明变异性吗?那是声明位点变异性 ------ 变异性规则在类定义中是固定的。然而,有时你可能更愿意使用不变性(如 MutableList<E>
)的类,但你只能在特定的函数或上下文中以协变或逆变的方式使用它们。
看看下面的例子,在从列表复制时,不需要在列表from
中进行写操作:
kotlin
fun copy(from: MutableList<out Any>, to: MutableList<Any>) {
for (i in from.indices) {
to[i] = from[i]
}
}
为了禁止向from
写入,只需使用out
关键字,它会执行类型投影。这意味着from
是一个受限制的列表。这可以临时使通常的读/写泛型类型在该特定用例中表现为只读(out
)或只写(in
)。
泛型中的空安全(Null Safety in Generics)
标准泛型类型参数T
有一个隐式的上界Any?
。这意味着T
本身可以代表一个可空类型!
这就是为什么List<T>
可以被实例化为List<String?>
。
kotlin
val list: List<String?> = listOf("null", null)
因此,如果你需要保证为T
提供的类型参数是不可空的,那么通过使用约束来强制非空类型是很重要的。
我们可以为T
设置边界以拥有特定的能力,而T : Any
确保类型参数本身不可空。
kotlin
class NonNullGeneric<T : Any>
// val n = NonNullGeneric<String?>() // ❌ 编译错误:超出其边界
也可以使非空函数类型参数具有可空类泛型类型。
kotlin
class NullableGeneric<T> {
fun nonNullOperation(t: T & Any) { ... }
}
val n = NullableGeneric<String?>() // ✅
// n.nonNullOperation(null) // ❌ 编译错误:接收非空类型 String
声明非空类型的最常见用例是当你想要重写包含@NotNull
作为参数的Java
方法时。 注意:如果T
需要满足多个条件,可以使用where
设置多个边界(Rust 也是用的where
)。更多信息请参考:Kotlin 官方文档
星号投影(Star-projections)
星号投影(*
)提供了一种在具体类型参数未知时安全处理泛型类型的方式,允许像Any?
一样安全地读取访问。
kotlin
val mysterySpells: List<*> = listOf(...)
这是Kotlin 的安全通配符,类似于Java的原始类型,但更安全。
- 对于像
List<out T>
这样的协变类型,List<*>
等同于List<out Any?>
。你可以安全地读取值为Any?
。 - 对于像
Comparator<in T>
这样的逆变类型,Comparator<*>
等同于Comparator<in Nothing>
。你不能安全地写入任何内容(因为Nothing
没有实例)。 - 对于像
MutableList<T>
这样的不变类型,MutableList<*>
对于读取值等同于MutableList<out T>
,对于写入值等同于MutableList<in Nothing>
。
变异性速查表 & PECS

这个概念被称为PECS。
Producer → out, Extends / Consumer → in, Super
Producer(out
) 对应于 PECS 中的Extends
,意味着你可以取出(读取)项目。Consumer(in
) 对应于PECS 中的Super
,意味着你可以放入(写入/消费)项目。(更多信息请参考:Baeldung)
结论
就是这些了!
当我刚开始学习Kotlin 时,我发现Kotlin 的泛型有点挑战性。但是,通过理解 in
、out
、reified
等关键字是如何设计的,你不仅仅是在学习语法;你正在迈出编写更类型安全代码的一步。
希望我们的英雄们在泛型之地的奇幻旅程能让这些概念更容易理解!