泛型基础与类型擦除
从"代码复制粘贴"到"用类型做参数"
在没有泛型的世界里,如果你想写一个通用的"盒子"来装不同类型的东西,只有两条路可走:
路线一:为每种类型写一个盒子
class IntBox(private val value: Int) {
fun get(): Int = value
}
class StringBox(private val value: String) {
fun get(): String = value
}
class UserBox(private val value: User) {
fun get(): User = value
}
// … 有多少种类型,就要写多少个盒子
逻辑完全一样,只是类型不同——这是最典型的"复制粘贴"式编程。每增加一种类型,就要复制一份代码,维护成本线性增长。
路线二:用 Any 当万能容器
class AnyBox(private val value: Any) {
fun get(): Any = value
}
// 使用时
val box = AnyBox("Hello")
val str: String = box.get() as String // 必须手动强转——编译器无法帮你验证
val num: Int = box.get() as Int // 编译通过,运行时 ClassCastException!
类型信息丢失了——编译器无法知道盒子里装的到底是什么,所有类型检查都推迟到运行时。每一次 as 强转都是一个潜在的运行时炸弹。
泛型的本质:参数化类型(Parameterized Type)
泛型的核心思想是把"类型"本身变成一个参数——就像函数用参数接收数据一样,泛型用类型参数接收类型信息:
// T 是一个"类型参数"——声明时是占位符,使用时填入具体类型
class Box<T>(private val value: T) {
fun get(): T = value
}
// 使用时指定具体类型
val stringBox: Box<String> = Box("Hello")
val intBox: Box<Int> = Box(42)
val str: String = stringBox.get() // 编译器知道返回 String,无需强转
val num: Int = intBox.get() // 编译器知道返回 Int,无需强转
// stringBox.get() as Int // ❌ 编译错误——编译器阻止了类型不匹配
把泛型想象成一份合同模板。模板上有一个空白栏位写着"甲方(类型 T)"——签合同时(使用泛型类时)填入具体的名字(具体类型)。填入"张三"(
String),这份合同的所有条款就自动适配"张三";填入"李四"(Int),条款则自动适配"李四"。合同模板只写一次,但能适配无限多的签约方——这就是泛型消除代码重复的方式。
泛型同时解决了上面两条路的问题:
| 方案 | 代码复用 | 类型安全 |
|---|---|---|
| 为每种类型写一个类 | ❌ | ✅ |
用 Any 当万能容器 |
✅ | ❌ |
| 泛型 | ✅ | ✅ |
泛型的声明语法:类、函数与属性
泛型类
在类名后面用尖括号 <T> 声明一个或多个类型参数,这些类型参数可以在类内部的属性、方法参数和返回值中使用:
// 单个类型参数
class Container<T>(private val item: T) {
fun getItem(): T = item
fun isMatch(other: T): Boolean = item == other
}
// 多个类型参数
class Pair<A, B>(val first: A, val second: B) {
override fun toString(): String = "($first, $second)"
}
// 使用
val container = Container<String>("Kotlin") // 显式指定类型参数
val pair = Pair("name", 42) // 编译器从构造参数推断类型为 Pair<String, Int>
泛型函数
类型参数声明在 fun 关键字和函数名之间:
// 独立的泛型函数——T 由调用点的实参推断
fun <T> singletonList(item: T): List<T> {
return listOf(item)
}
// 泛型扩展函数
fun <T> T.toSingletonList(): List<T> {
return listOf(this)
}
// 使用
val list1 = singletonList("hello") // 推断 T = String
val list2 = 42.toSingletonList() // 推断 T = Int
注意:泛型函数的类型参数是独立于类的类型参数的。即使在一个泛型类内部,函数也可以拥有自己的类型参数:
class Converter<T> {
// R 是函数自己的类型参数,与类的 T 无关
fun <R> convert(item: T, transformer: (T) -> R): R {
return transformer(item)
}
}
泛型接口
接口同样可以声明类型参数。Kotlin 标准库大量使用了泛型接口——List<T>、Map<K, V>、Comparable<T> 都是泛型接口:
// 声明一个泛型接口
interface Repository<T> {
fun findById(id: Long): T?
fun save(item: T)
fun findAll(): List<T>
}
// 实现时指定具体类型
class UserRepository : Repository<User> {
override fun findById(id: Long): User? = TODO()
override fun save(item: User) = TODO()
override fun findAll(): List<User> = TODO()
}
类型约束:给类型参数划定边界
为什么需要类型约束
当类型参数完全不受限时,编译器对 T 一无所知——它只能假设 T 是 Any?(Kotlin 类型层级的最顶部),因此你只能调用 Any? 上的方法(equals()、hashCode()、toString()):
fun <T> findMax(a: T, b: T): T {
return if (a > b) a else b // ❌ 编译错误:> 操作符未定义于类型 T
}
编译器不知道 T 是否支持比较——它可能是 String(可比较),也可能是 User(不可比较)。要让编译器知道 T 具备比较能力,就需要 类型约束(Upper Bound)。
单一上界约束
用冒号 : 指定类型参数的上界——T 必须是指定类型或其子类型:
// T 必须是 Comparable<T> 的子类型——也就是说,T 必须是可比较的
fun <T : Comparable<T>> findMax(a: T, b: T): T {
return if (a > b) a else b // ✅ 编译通过——编译器知道 T 支持比较
}
findMax(3, 7) // ✅ Int 实现了 Comparable<Int>
findMax("apple", "banana") // ✅ String 实现了 Comparable<String>
// findMax(User("A"), User("B")) // ❌ User 未实现 Comparable<User>
在字节码层面,类型约束决定了泛型擦除后的替代类型。没有约束时,T 被擦除为 Object;有上界约束时,T 被擦除为上界类型:
// 无约束
fun <T> process(item: T) → void process(Object item)
// 有上界约束
fun <T : Number> process(item: T) → void process(Number item)
这意味着有了上界约束,字节码中就包含了更精确的类型信息。编译器在方法体内部可以直接使用上界类型的方法——比如 Number 的 intValue()——而不需要额外的强转。
多约束上界:where 子句
有时候,一个类型参数需要同时满足多个条件。例如,你想写一个函数,只处理那些"既是字符序列,又是可比较的"类型——这时需要用 where 子句:
// T 必须同时实现 CharSequence 和 Comparable<T>
fun <T> sortAndJoin(list: List<T>): String
where T : CharSequence,
T : Comparable<T> {
return list.sorted().joinToString(", ")
}
sortAndJoin(listOf("banana", "apple", "cherry")) // ✅ String 同时实现了 CharSequence 和 Comparable
// sortAndJoin(listOf(1, 2, 3)) // ❌ Int 不是 CharSequence
where 子句的语义是 "与"关系——T 必须同时满足所有约束条件。
把类型约束想象成 招聘门槛。单一上界就像要求"必须有驾照"(
T : Comparable<T>);where子句就像同时要求"必须有驾照,还必须会英语"(T : Comparable<T>且T : CharSequence)。只有同时满足所有条件的候选人(类型)才能通过。
隐含的默认上界:Any?
如果你没有为类型参数指定任何上界,默认上界是 Any?——这意味着类型参数可以是可空类型:
class Box<T>(val value: T) // T 的默认上界是 Any?
val box: Box<String?> = Box(null) // ✅ 允许——T = String?,它是 Any? 的子类型
如果你希望类型参数不能是可空类型,需要显式将上界设为 Any:
class NonNullBox<T : Any>(val value: T) // T 的上界是 Any
// val box: NonNullBox<String?> = NonNullBox(null) // ❌ 编译错误:String? 不是 Any 的子类型
val box: NonNullBox<String> = NonNullBox("hello") // ✅
这个细节在实际开发中非常重要——在本文最后一节"泛型与空性"中将进一步展开。
泛型类的字节码表示:类型参数的编译期生命
在理解类型擦除之前,先来看看泛型类在 JVM 层面到底是什么样子。这有助于你从底层理解擦除的物理过程。
一个泛型类的完整编译过程
class Box<T>(private val value: T) {
fun get(): T = value
fun isMatch(other: T): Boolean = value == other
}
// 调用点
fun main() {
val stringBox = Box<String>("Hello")
val result: String = stringBox.get()
}
编译后的字节码(等效 Java):
// Box 类——类型参数 T 被替换为 Object
public final class Box {
private final Object value; // T → Object
public Box(Object value) { // T → Object
this.value = value;
}
public Object get() { // T → Object
return this.value;
}
public boolean isMatch(Object other) { // T → Object
return Intrinsics.areEqual(this.value, other);
}
}
// main 方法中的调用点
public static void main() {
Box stringBox = new Box("Hello"); // 构造时传入 String,但参数类型是 Object
String result = (String) stringBox.get(); // 编译器自动插入 checkcast 强转!
}
关键观察:
- 类定义中:所有的
T都被替换为Object(因为没有上界约束) - 调用点:编译器在
get()的返回值上自动插入了(String)强转——这是编译器在擦除类型信息的同时,为了维持类型安全所做的补偿 - JVM 中只有一份
Box类:无论是Box<String>、Box<Int>还是Box<User>,运行时都是同一个Box类。JVM 不会为不同的类型参数生成不同的类
字节码级别的强转指令
在 JVM 字节码中,get() 调用处的强转对应一条 CHECKCAST 指令:
INVOKEVIRTUAL Box.get ()Ljava/lang/Object; // 调用 get(),返回 Object
CHECKCAST java/lang/String // 检查并转换为 String
ASTORE 2 // 存储到局部变量 result
CHECKCAST 在运行时做两件事:
- 检查:验证栈顶对象是否是指定类型(
String)的实例 - 如果不是:抛出
ClassCastException
这条指令的存在证明了一个重要事实:泛型的类型安全,一半靠编译期的静态检查,一半靠字节码中自动插入的运行时检查。
类型擦除的底层真相
历史包袱:为什么 JVM 要擦除泛型
类型擦除是 JVM 世界中最令人困惑的设计之一。要理解它,必须回到 2004 年——Java 5 引入泛型的历史背景。
在 Java 5 之前,Java 已经存在了近 10 年,积累了海量的生产代码和第三方库——所有这些代码都使用"原始类型"(Raw Types)的集合:
// Java 1.4 时代的代码——没有泛型
List names = new ArrayList();
names.add("Alice");
names.add(42); // 什么都能往里塞
String first = (String) names.get(0); // 手动转换
String second = (String) names.get(1); // 运行时 ClassCastException
当 Java 5 引入泛型时,语言设计者面临一个两难选择:
方案 A:具化泛型(Reified Generics)——像 C# 那样,让 JVM 在运行时为每种类型参数组合生成独立的类(如 ArrayList_String、ArrayList_Integer)。这需要修改 JVM 规范、所有已有的字节码格式和工具链——代价是破坏与所有 Java 1.4 代码的二进制兼容性。
方案 B:类型擦除(Type Erasure)——泛型只在编译期起作用,编译后擦除所有类型参数信息。老代码无需任何修改就能与新泛型代码互操作——代价是运行时丢失类型信息。
Java 选择了方案 B——向后兼容性战胜了类型完整性。这是一个务实但影响深远的工程妥协。而 Kotlin 运行在同一个 JVM 上,自然继承了这一约束。
把类型擦除想象成航空安检的行李标签。你在值机柜台(编译期)给行李贴上了标签——"这个箱子里装的是衣服(
String)"——安检员(编译器)据此检查了你的行李没问题。但当行李上了传送带(进入 JVM 运行时),标签被撕掉了——传送系统只看到一个普通的箱子(Object),不知道里面装的是衣服还是电子产品。
擦除的具体规则
类型擦除遵循以下规则:
| 原始声明 | 擦除后的 JVM 表示 | 擦除规则 |
|---|---|---|
<T> |
Object |
无约束 → 擦除为 Object |
<T : Number> |
Number |
有上界 → 擦除为上界类型 |
<T : Comparable<T>> |
Comparable |
有上界 → 擦除为上界(带泛型部分也被擦除) |
<T> where T : A, T : B |
A(第一个约束) |
多约束 → 擦除为第一个约束类型 |
多约束擦除的字节码验证:
fun <T> process(item: T): String
where T : CharSequence,
T : Comparable<T> {
return item.toString()
}
编译后的方法签名:
// 擦除为第一个约束 CharSequence
public static String process(CharSequence item) { ... }
JVM 只保留了第一个约束 CharSequence。那第二个约束 Comparable<T> 去哪了?编译器在需要使用 Comparable 的方法(如 compareTo())时,会在字节码中插入额外的 CHECKCAST Comparable 指令。
桥方法(Bridge Methods):擦除后的多态修补
类型擦除还会引入一个微妙的问题——当泛型类被子类继承并指定具体类型时,方法签名会发生冲突。
// 泛型接口
interface Processor<T> {
fun process(item: T)
}
// 指定具体类型的实现类
class StringProcessor : Processor<String> {
override fun process(item: String) {
println(item.uppercase())
}
}
擦除后,Processor 接口中的方法签名变为 process(Object),但 StringProcessor 的方法签名是 process(String)。在 JVM 层面,这两个方法签名不同——StringProcessor.process(String) 并没有真正"覆写" Processor.process(Object)。
为了修复这个问题,编译器会自动生成一个桥方法(Bridge Method):
// 编译器自动生成的桥方法
public final class StringProcessor implements Processor {
// 真正的实现方法
public void process(String item) {
System.out.println(StringsKt.uppercase(item));
}
// 桥方法——编译器自动生成,用于修复多态调度
public /* synthetic bridge */ void process(Object item) {
process((String) item); // 委托给真正的实现,并插入强转
}
}
桥方法的签名(process(Object))与接口擦除后的签名一致,从而保证了多态调度正常工作。当你通过 Processor<String> 的引用调用 process() 时,JVM 会调用桥方法,桥方法再委托给具体类型的实现方法。
擦除后的后果:运行时的类型盲区
类型擦除最直接的后果是:运行时无法区分不同类型参数的泛型实例。
val strings: List<String> = listOf("a", "b")
val ints: List<Int> = listOf(1, 2)
// 运行时,JVM 看到的都是 List——它不知道里面装的是 String 还是 Int
println(strings.javaClass == ints.javaClass) // true!两者的 Class 对象完全相同
// 以下操作在运行时是非法的
if (strings is List<String>) { } // ❌ 编译错误:Cannot check for instance of erased type
if (strings is List<Int>) { } // ❌ 同样的错误
if (strings is List<*>) { } // ✅ 可以——星投影不依赖类型参数
// 危险的未检查强转
val list: List<Any> = listOf("hello", 42)
val stringList = list as List<String> // ⚠️ 编译器警告:Unchecked cast
println(stringList[0]) // "hello"——偶然成功
println(stringList[1]) // ClassCastException——42 不是 String
绕过类型擦除的三种策略
尽管 JVM 在运行时丢失了泛型类型信息,但我们有多种方式可以"绕过"这个限制。
策略一:reified 类型参数(编译期类型替换)
在前面的文章《inline 与 reified 的编译器魔法》中,我们已经详细剖析了 reified 的工作原理。它利用 inline 函数的代码拷贝机制,在编译期将具体类型信息直接替换到调用点的字节码中:
// reified T——编译器在调用点将 T 替换为具体类型
inline fun <reified T> isType(value: Any): Boolean {
return value is T // 编译后变为 value instanceof ConcreteType
}
isType<String>("hello") // → "hello" instanceof String → true
isType<Int>("hello") // → "hello" instanceof Integer → false
reified 本质上不是"在运行时保留了类型信息",而是"在编译期就把类型信息织入了字节码"。因为 inline 函数的代码被拷贝到了调用点,而调用点的具体类型是已知的——编译器直接用具体类型替换了 T。
策略二:传递 Class<T> 参数(显式携带类型)
这是 Java 世界最经典的解决方案——既然运行时没有类型信息,那就手动把它传进去:
fun <T> parseJson(json: String, clazz: Class<T>): T {
return Gson().fromJson(json, clazz)
}
val user = parseJson(jsonString, User::class.java)
Class<T> 对象携带了类型的运行时信息(包括类名、方法、字段等),弥补了泛型擦除的缺失。缺点是调用点需要多传一个参数,语法不够简洁。
Kotlin 的 reified + inline 组合可以消除这种冗余(正如前面文章所展示的)。
策略三:TypeToken 技巧(匿名类保留泛型签名)
JVM 虽然在运行时擦除了泛型实例的类型参数,但在 类的定义信息(Signature 属性)中保留了泛型的元数据。如果一个类在编译时就确定了具体的类型参数(比如继承了 List<String> 而非 List<T>),这个类型参数信息会被写入 .class 文件——通过反射可以读取。
TypeToken 正是利用了这一特性。它通过创建匿名子类的方式,让编译器将泛型参数信息"固化"到匿名类的类型签名中:
// Java/Kotlin 中的 TypeToken 模式
val type = object : TypeToken<List<User>>() {}.type
// 匿名类的 Signature 属性中记录了 List<User> 的完整类型信息
// 通过反射可以在运行时还原 List<User> 这个参数化类型
val users: List<User> = Gson().fromJson(jsonString, type)
其工作原理是:匿名类 $1 继承了 TypeToken<List<User>>,编译器将 List<User> 写入了该匿名类的 .class 文件的 Signature 属性。TypeToken 的构造函数通过 Class.getGenericSuperclass() 反射读取这个签名信息,从而在运行时还原了泛型的完整类型。
三种策略的对比:
| 策略 | 原理 | 适用场景 | 限制 |
|---|---|---|---|
reified |
编译期内联替换具体类型 | 类型检查、类引用获取 | 只能用于 inline 函数 |
Class<T> 参数 |
显式传递类型的运行时元数据 | 反射创建实例、反序列化 | 调用点语法冗余 |
TypeToken |
匿名类的签名保留泛型信息 | 嵌套泛型类型(如 List<User>) |
每次创建一个匿名类开销 |
星投影(Star Projection):类型安全的"我不关心"
问题场景:"我只想知道它是不是一个 List"
当你想要处理一个泛型类型,但不关心它的具体类型参数时——比如"这个对象是不是 List?"——你需要一种方式来表达"泛型参数是什么都行"。
你可能会想:直接用 Any? 不就行了?
// 方案一:List<Any?>
fun printListSize(list: List<Any?>) {
println("Size: ${list.size}")
}
val strings: List<String> = listOf("a", "b")
printListSize(strings) // ✅ 可以——因为 List 声明为 out T(协变),List<String> 是 List<Any?> 的子类型
// 方案二:List<*>
fun printListSizeStar(list: List<*>) {
println("Size: ${list.size}")
}
printListSizeStar(strings) // ✅ 也可以
对于 List(只读列表,声明为 out T 协变),两者看起来效果相同。但对于可变集合,区别就出现了:
// MutableList 不是协变的(T 既出现在 out 位置也出现在 in 位置)
val mutableStrings: MutableList<String> = mutableListOf("a", "b")
// MutableList<Any?>——允许添加任何东西
val anyList: MutableList<Any?> = mutableListOf<Any?>(1, "hello", null)
anyList.add(42) // ✅ 可以添加——因为类型参数是 Any?
anyList.add("new") // ✅ 可以添加
anyList.add(null) // ✅ 可以添加
// MutableList<*>——不允许添加任何东西
val starList: MutableList<*> = mutableStrings
// starList.add("new") // ❌ 编译错误!
// starList.add(42) // ❌ 编译错误!
val item: Any? = starList[0] // ✅ 可以读取——返回类型为 Any?
这就是核心区别:
MutableList<Any?>说的是"这是一个装Any?类型元素的列表"——你知道里面可以装任何东西,也可以往里面放任何东西MutableList<*>说的是"这是一个装某种未知类型元素的列表"——你不知道它到底是MutableList<String>还是MutableList<Int>,所以为了类型安全,编译器禁止你往里面放任何东西(因为放错了类型会破坏列表的类型一致性)
把
List<Any?>想象成一个标注了"杂物间"的储物柜——标牌上写着"什么都能放",你确实可以什么都往里放。而List<*>则是一个锁住的储物柜——你知道里面有东西,可以拿出来看(读取返回Any?),但你不能往里面放任何东西——因为你不知道这个柜子原本是存什么的,放错东西会搞乱别人的物品。
星投影的编译器展开规则
当编译器遇到 * 时,它会根据类型参数的声明方式将 * 展开为具体的型变形式。这不是"魔法"——而是一套确定性的替换规则:
规则一:协变类型参数(out T)
如果类型参数声明为 out T(如 List<out T>),* 被展开为 out Any?:
// List<out T> 中的 T 是协变的
// List<*> ≡ List<out Any?>
val list: List<*> = listOf("hello", 42, null)
val item: Any? = list[0] // ✅ 读取返回 Any?
// list.add(xxx) // 不适用——List 没有 add 方法
语义:* 代表"某种未知类型的上界是 Any?"——你可以安全地读取元素(因为任何类型都是 Any? 的子类型),但不能写入。
规则二:逆变类型参数(in T)
如果类型参数声明为 in T(如 Comparable<in T>),* 被展开为 in Nothing:
// Comparable<in T> 中的 T 是逆变的
// Comparable<*> ≡ Comparable<in Nothing>
val comp: Comparable<*> = "hello" as Comparable<*>
// comp.compareTo(xxx) // ❌ 无法调用——参数类型是 Nothing,你无法提供一个 Nothing 的实例
语义:* 代表"某种未知类型的下界是 Nothing"——由于 Nothing 没有实例,你无法调用任何接受 T 的方法。
规则三:不变类型参数
如果类型参数没有声明型变(如 MutableList<T>),* 同时应用两条规则——读取时视为 out Any?,写入时视为 in Nothing:
// MutableList<T> 中的 T 是不变的
// MutableList<*> ≡ MutableList<out Any?> 用于读取
// ≡ MutableList<in Nothing> 用于写入
val list: MutableList<*> = mutableListOf("hello", 42)
val item: Any? = list[0] // ✅ 读取返回 Any?(out Any? 规则)
// list.add("new") // ❌ 无法写入——参数类型是 Nothing(in Nothing 规则)
// list[0] = "new" // ❌ 同理
list.clear() // ✅ clear() 不涉及类型参数 T,可以调用
list.size // ✅ size 也不涉及 T
展开规则汇总表:
| 类型参数声明 | 星投影 * 展开为 |
读取(T 在 out 位置) |
写入(T 在 in 位置) |
|---|---|---|---|
Foo<out T> |
Foo<out Any?> |
Any? |
不适用 |
Foo<in T> |
Foo<in Nothing> |
不适用 | 不可能 |
Foo<T> |
读:out Any?写: in Nothing |
Any? |
不可能 |
星投影与类型检查
星投影在运行时类型检查中扮演着关键角色——由于类型擦除,你只能对星投影的泛型类型进行 is 检查:
fun processAny(obj: Any) {
// ❌ 编译错误:Cannot check for instance of erased type: List<String>
// if (obj is List<String>) { ... }
// ✅ 可以——星投影不需要运行时泛型类型信息
if (obj is List<*>) {
println("It's a list with ${obj.size} elements")
// 但你不知道里面装的是什么类型——元素类型为 Any?
obj.forEach { println(it) }
}
}
泛型与空性:微妙但关键的交互
默认的类型参数是可空的
前面提到,未约束的类型参数默认上界是 Any?。这意味着以下代码可能产生意想不到的效果:
// T 的默认上界是 Any?,所以 T 可以是可空类型
class Container<T>(val value: T) {
fun printValue() {
println(value.toString()) // ⚠️ 如果 T = String?,value 可能是 null
// 但 toString() 是 Any?.toString() 的扩展,不会 NPE
}
}
val container: Container<String?> = Container(null)
container.printValue() // 输出:null(没有 NPE,因为 Any?.toString() 处理了 null)
看起来安全,但如果你在函数中对 T 做更多操作,问题就会暴露:
class Wrapper<T>(val value: T) {
// 这里没有编译错误——因为编译器不知道 T 是否可空
fun getLength(): Int {
// 如果 T = String?,value 可能为 null
// 直接调用 .toString().length 虽然不会 NPE,但语义不正确
return value.toString().length
}
}
T vs T?:声明意图的精确性
在泛型类内部,T 和 T? 表达了不同的语义:
class Repository<T : Any> { // T 的上界是 Any——T 不可能是可空类型
// 返回 T?——明确表示"可能找不到"
fun findById(id: Long): T? = TODO()
// 接受 T——明确要求传入非空值
fun save(item: T) = TODO()
// 接受 T?——允许传入 null(比如用来"清除"某个关联)
fun setRelated(item: T?) = TODO()
}
这里的关键设计决策是:
T : Any确保了类型参数本身是非空的——你不能创建Repository<String?>T?在方法级别增加可空性——findById的返回值可以是null,但save的参数不可以
如果不约束 T : Any,而是让 T 默认为 Any?,情况会变得模糊:
class UnsafeRepository<T> {
// 当 T = String? 时,T? = String?? = String?
// 可空性概念已经被"折叠"了——你无法区分"值本身为 null"和"找不到结果"
fun findById(id: Long): T? = TODO() // 如果 T = String?,这里的 T? 仍然是 String?
}
泛型集合中的空性层次
对于泛型集合,空性有三个层次,它们的含义完全不同:
// 层次 1: List<String>——列表非空 + 元素非空
val list1: List<String> = listOf("a", "b")
// 列表本身不能是 null,列表中的每个元素也不能是 null
// 层次 2: List<String?>——列表非空 + 元素可空
val list2: List<String?> = listOf("a", null, "b")
// 列表本身不能是 null,但元素可以是 null
// 层次 3: List<String>?——列表可空 + 元素非空
val list3: List<String>? = null
// 列表本身可以是 null,但如果列表存在,其中的元素不能是 null
// 层次 4: List<String?>?——列表可空 + 元素可空
val list4: List<String?>? = listOf("a", null)
// 列表本身可以是 null,元素也可以是 null
在处理这些集合时,需要对不同层次的空性分别处理:
fun processNames(names: List<String?>?) {
// 第一层:列表本身可能为 null
if (names == null) {
println("没有提供名单")
return
}
// 第二层:列表中的每个元素可能为 null
for (name in names) {
if (name != null) {
println("名字: ${name.uppercase()}")
} else {
println("名字: [未知]")
}
}
// 更简洁的写法
names.filterNotNull().forEach { println("名字: ${it.uppercase()}") }
}
类型参数约束与空性的完整对照表
| 声明 | T 可以是 String |
T 可以是 String? |
说明 |
|---|---|---|---|
<T> |
✅ | ✅ | 默认上界 Any?,任何类型都可以 |
<T : Any> |
✅ | ❌ | 上界 Any,只接受非空类型 |
<T : Comparable<T>> |
✅ | ❌ | 上界 Comparable——接口隐含 : Any |
<T : Any?> |
✅ | ✅ | 显式声明 Any?,等同于默认 |
最佳实践建议:
- 如果你的泛型类/函数不需要处理可空类型参数,始终声明
<T : Any>——这让调用者和编译器都清楚你的意图 - 如果你需要区分"无结果"和"结果为空",用
<T : Any>+ 返回T?的组合,而不是依赖T本身的可空性 - 集合类型参数的可空性要根据业务语义明确选择——
List<String>和List<String?>是完全不同的语义契约
综合实战:泛型机制的字节码全景
让我们用一段综合性代码来验证本文所有核心知识点:
// ① 泛型类——声明类型参数 T
class Result<T : Any>(
val data: T?,
val errorMessage: String? = null
) {
val isSuccess: Boolean get() = data != null
// ② 泛型方法——独立的类型参数 R
fun <R : Any> map(transform: (T) -> R): Result<R> {
return if (data != null) {
Result(transform(data))
} else {
Result(null, errorMessage)
}
}
}
// ③ 带类型约束的泛型函数
fun <T> findMin(list: List<T>): T
where T : Comparable<T>,
T : Any {
require(list.isNotEmpty()) { "List must not be empty" }
return list.reduce { min, item -> if (item < min) item else min }
}
// ④ reified 类型参数——绕过类型擦除
inline fun <reified T> List<*>.filterByType(): List<T> {
return this.filterIsInstance<T>()
}
// ⑤ 综合调用
fun main() {
// 泛型类实例化
val result: Result<String> = Result("Hello Kotlin")
// 泛型方法调用——R 推断为 Int
val mapped: Result<Int> = result.map { it.length }
// 类型约束
val min = findMin(listOf(3, 1, 4, 1, 5)) // T = Int
// reified 类型参数
val mixed: List<Any> = listOf("a", 1, "b", 2, "c")
val strings: List<String> = mixed.filterByType<String>()
// 星投影
val anyResult: Result<*> = result
val data: Any? = anyResult.data // ✅ 读取——返回 Any?
val isOk: Boolean = anyResult.isSuccess // ✅ 不涉及 T
println("mapped: ${mapped.data}") // 12
println("min: $min") // 1
println("strings: $strings") // [a, b, c]
}
这段代码的字节码编译产物清单:
| 源码构造 | 编译产物 | 关键字节码行为 |
|---|---|---|
Result<T : Any> |
Result 类,data 类型为 Object |
T 擦除为 Object(虽有 : Any 约束,但 Any 编译为 Object) |
result.map { it.length } |
transform 参数编译为 Function1 接口 |
调用点有 CHECKCAST String |
findMin(listOf(3, 1, 4, 1, 5)) |
方法签名 findMin(List),参数擦除为 Comparable |
reduce 中有 CHECKCAST Comparable |
mixed.filterByType<String>() |
filterIsInstance 的 element is T 变为 element instanceof String |
reified 在调用点替换为具体类型 |
anyResult.data |
getData() 返回 Object |
无额外强转——赋值到 Any? 类型变量 |
自行验证方法
在 IntelliJ IDEA 或 Android Studio 中验证上述所有编译产物:
- 打开任意 Kotlin 文件
- 菜单栏 → Tools → Kotlin → Show Kotlin Bytecode
- 在右侧面板点击 Decompile 按钮,查看等效 Java 代码
- 重点观察:(a) 类型参数在方法签名中的擦除形式;(b)
CHECKCAST指令出现的位置;(c) 桥方法的生成
小结
本文从"为什么需要泛型"出发,逐层深入到了 JVM 字节码层面的实现细节:
| 知识点 | 核心结论 |
|---|---|
| 泛型的本质 | 参数化类型——用类型做参数,一次编写适配无限种类型,同时保持编译期类型安全 |
| 类型约束 | 通过上界(T : Bound)和 where 子句限定类型参数的能力边界;上界决定了擦除后的替代类型 |
| 类型擦除 | JVM 为向后兼容 Java 1.4 所做的工程妥协——编译后泛型类型参数被替换为上界或 Object,编译器通过自动插入 CHECKCAST 维持类型安全 |
| 绕过擦除 | reified(编译期内联替换)、Class<T> 参数(显式携带类型)、TypeToken(匿名类签名保留泛型信息)三种策略 |
| 桥方法 | 编译器自动生成的合成方法,修复擦除后子类方法签名不匹配导致的多态调度问题 |
| 星投影 | * 是类型安全的"我不关心类型参数"——编译器根据型变声明将其展开为 out Any?(可读)和 in Nothing(禁写),与 Any? 有本质区别 |
| 泛型与空性 | 默认上界 Any? 允许可空类型参数;用 <T : Any> 约束非空;T 和 T? 在泛型类内部表达不同的空性语义 |
理解泛型的编译期与运行时行为差异,是避免泛型相关 Bug 的关键。下一篇文章将在此基础上深入协变、逆变与型变的世界——探讨 out/in 关键字如何让编译器在子类型关系中做出精确的安全判断,以及为什么 Kotlin 选择了声明处型变而非 Java 的使用处型变。