CodeGym /课程 /Kotlin SELF /类型擦除与 inline/reified:is T、as? T 以及 T::class 的检查

类型擦除与 inline/reified:is T、as? T 以及 T::class 的检查

Kotlin SELF
第 36 级, 课程 4
可用

1. JVM 上的类型擦除

当你刚开始编写泛型函数时,会产生一种很自然的直觉:“现在我要写一个 fun <T> ...,然后在里面检查 x is T,一切都会很美好。” 这就像你有一个贴着“内含饼干”标签的盒子,认为你可以随时打开它,确认里面装的真的是饼干而不是袜子。

但 Kotlin(更准确地说是运行在 JVM 上的 Kotlin)会突然告诉你:“不,你不能那样做。” 这并不是编译器在为难你,而是在防止出现错误的假设。 如果允许进行这样的检查,它往往会表现得“好像”可以运行,然后在最意想不到的地方出现诡异的崩溃。

原因就是 类型擦除 (type erasure)。

什么是类型擦除以及到底被“擦除”了什么

类型擦除是 JVM 世界的一条规则:类型参数(即 <...> 内部的内容)在大多数情况下仅存在于编译期,而在运行期,程序操作的是一种“通用形式”。例如, List<Int> 和 List<String> 在运行期看来都只是 List(大致理解为“某种类型的列表”)。 Kotlin 诚实地警告:泛型类型的实例不会存储关于其具体类型参数的信息,那些“尖括号”内容已被擦除。

为什么要这样做?从历史上看,JVM 的设计初衷是为了让 Java 的泛型不会破坏与旧代码的兼容性。 因此,泛型主要起到编译期的检查和保障作用,而不是在运行期对象内部留下的“魔法标签”。

这可以通过以下图表来直观理解:

flowchart LR
    A["源码:List⟨Int⟩, List⟨String⟩"] --> B["编译器:验证类型安全"]
    B --> C["字节码/JVM:运行期仅为 List"]
    C --> D["你无法再区分 ⟨Int⟩ 和 ⟨String⟩"]

为什么禁止类似 is List<Int> 和 is T 的检查,以及应该使用什么替代方案

现在应该更清楚为什么 Kotlin 禁止像 x is List<Int> 这样的检查了:在运行期,你无法可靠地区分“Int 列表”和“String 列表”,因为这些类型参数已被擦除。 Kotlin 直截了当地指出:由于类型擦除,没有通用方法可以检查泛型实例是用什么类型参数创建的,因此像 ints is List<Int> 或 list is T (其中 T 是类型参数)形式的 is 检查是被禁止的。

作为替代,一种安全的折中方案是被允许的:星号投影 (star-projection) 检查,例如 is List<*>。 这意味着“这是一个列表,装的是某种东西,但元素类型未知”。这个检查是诚实的:它不对元素做任何承诺,因此编译器允许这样做。

示例——“你可以做什么”:

fun main() {
    val x: Any = listOf(1, 2, 3)

    if (x is List<*>) {
        println("这是一个长度为 ${x.size} 的列表") // 这是一个长度为 3 的列表
        println("第一个元素:${x[0]}")     // 第一个元素:1
    }
}

注意:在执行 x is List<*> 之后,元素会被视为 Any?。 这是有道理的:既然我们不知道元素类型,最安全的假设就是“某种东西”。

没有 <...> 的“原始”类型:if (list is ArrayList) 的含义

还有一个有用的技巧,乍一看像是在作弊:有时你可以只检查“外部”类,而不触及类型参数。例如,ArrayList 是一种具体的列表实现,你可以检查“这是否是一个 ArrayList”,即使你不知道它里面是 <String> 还是 <Int>。 Kotlin 允许这样的检查,但有一个重要细节:你必须省略尖括号。

示例:

fun main() {
    val names: MutableList<String> = arrayListOf("Ann", "Bob")

    if (names is ArrayList) {
        println("是的,这是一个 ArrayList。size=${names.size}") // 是的,这是一个 ArrayList。size=2
    }
}

这里 Kotlin 可以执行智能转换 (smart cast) 到 ArrayList<String> (因为在编译期它已经知道 names 是一个 MutableList<String>)。 但运行期的检查本身是针对不带 <...> 的“原始” ArrayList。

3. inline 和 reified:如何合法地进行 is T、as? T 以及 T::class 检查

零点:如果我仍然需要知道类型怎么办?

在这一点上,会出现一个诚实的工程问题:“如果类型被擦除了,该如何编写依赖于类型的通用代码?” 主要有两种方法。第一种是显式传递一个“类型令牌”(在 Java 中通常是 Class<T>,在 Kotlin 中是 KClass<T>)。 第二种是使用 Kotlin 的特性:inline + reified。

本讲座我们关注第二种方法,因为它看起来像魔法,但实际上它是编译器精巧的“代码替换”。 Kotlin 强调:普通的泛型函数无法将类型参数用于 is 检查和类型转换,唯一的例外是 带有具体化类型参数 (reified type parameters) 的内联函数。

inline:在“调用处粘贴代码”是什么意思

inline 一词字面意思是:“编译器会在调用点直接替换函数体。” 这对于高阶函数(配合 Lambda)尤其有用,可以避免创建额外的对象。但对于我们现在来说,关键点在于:如果函数体在调用点被替换,那么具体类型 T 在那个位置对编译器来说就是已知的。

我们不需要深入研究字节码,但记住这个简单的图示很有帮助:

flowchart TD
    A["调用:foo<String>(x)"] --> B["编译器发现 T=String"]
    B --> C["在这里直接内联 foo 的函数体"]
    C --> D["在函数体内部你可以写 x is String"]

reified:如何让 T 在函数内部“可见”

reified 是一个类型参数修饰符,仅允许在 inline fun 上使用。 它的意思是:“类型 T 在该函数运行期内是可用的”,就像 T 是一个普通类一样。 Kotlin 直接展示了这一构想:使用 reified,你可以直接编写 x is T 和 x as? T,而无需使用反射或手动传递 Class<T>。

记住一个简短的规则很重要:reified 必须始终与 inline 配合使用。

为什么“普通”泛型函数无法编译

这是一个你很想写,但实际上无法编译的代码逻辑(这是正常的):

// 想法(无法编译):
// fun <T> isOfType(x: Any): Boolean = x is T

编译器禁止它,因为 T 在运行期是“擦除”的。

相同的想法,但正确的方式:inline + reified

inline fun <reified T> isOfType(x: Any): Boolean = x is T

fun main() {
    val a: Any = "hello"

    println(isOfType<String>(a)) // true
    println(isOfType<Int>(a))    // false
}

这里发生了一个重要的小技巧:当你调用 isOfType<String>(a) 时,编译器会将其替换为 a is String 的检查。 而对于 isOfType<Int>(a),它会替换为 a is Int。

as? T:通用工具中的安全转换

当你需要 Boolean 时,is 检查非常有用。 但在应用开发中,你通常更希望“尝试转换,如果不成功,就平静地继续”。 为此,安全类型转换 (safe cast) as? 是完美的——如果转换不可能实现,它会返回 null。

让我们创建一个通用函数 castOrNull:

inline fun <reified T> castOrNull(x: Any): T? = x as? T

fun main() {
    val x: Any = 42

    val s: String? = castOrNull<String>(x)
    val n: Int? = castOrNull<Int>(x)

    println("s=$s") // s=null
    println("n=$n") // n=42
}

现在让我们通过 Elvis 操作符 ?:(你在空安全中经常用到的)添加一条人性化的提示:

inline fun <reified T> castOrDefault(x: Any, default: T): T {
    return (x as? T) ?: default
}

fun main() {
    val x: Any = "text"

    val num = castOrDefault<Int>(x, default = -1)
    println(num) // -1
}

关键点在于我们不会崩溃,而是选择了一个后备方案。

T::class:用于消息、日志和小检查的类“令牌”

有时你需要的不仅仅是转换,而是清晰地说明你的预期。 这时 T::class 就很有用:它返回一个 KClass 对象,即“以值形式存在的类描述”。 Kotlin 指出,使用 reified,你可以访问 T::class。

让我们写一个小型函数,打印出预期的类型名称:

inline fun <reified T> expectedTypeName(): String {
    return T::class.simpleName ?: "<unknown>"
}

fun main() {
    println(expectedTypeName<String>())   // String
    println(expectedTypeName<List<*>>())  // List
}

现在是一个更实用的版本:“要求一种类型,否则抛出带明确消息的错误”。 这里我们使用 error(...),它返回 Nothing(你在讨论 Nothing 和“不返回的函数”时已经学过这一点)。

inline fun <reified T> requireType(x: Any): T {
    return (x as? T) ?: error("预期类型为 ${T::class}, 但实际获取到 ${x::class}")
}

fun main() {
    val x: Any = "hi"
    val s: String = requireType(x)

    println(s) // hi
}

这种辅助函数在内部检查逻辑时非常方便,当你确信业务逻辑,但希望错误消息不是“某个位置的 ClassCastException”,而是“预期是 A,实际得到 B”时,这非常有用。

reified 的局限性:为什么 List<String> 依然难以捉摸

在这一点上,可能会产生一个危险的想法:“如果 reified 让类型 T 可见,那我岂不是可以执行 x is List<String> 了?” 不幸的是,不行:泛型内部的类型参数同样被擦除了。 Kotlin 明确警告,即使使用了 reified,限制依然存在:如果 arg 本身是一个泛型类型,其类型参数仍然是被擦除的。

这意味着“这是一个字符串列表”的检查依然不能直接进行。但你可以分两步进行诚实、逻辑严密的检查: 首先确保它是 List<*>,然后再检查每个元素。

fun main() {
    val x: Any = listOf(1, 2, 3)

    if (x is List<*>) {
        val allStrings = x.all { it is String }
        println(allStrings) // false
    }
}

是的,它不像 x is List<String> 那么简洁,但它确实有效,且不会欺骗你。

4. 实践:一个“事件日志”并按类型筛选元素

让我们设想一下,在我们课程开发的教育类控制台应用中,我们需要保持一个简单的事件日志。 有时即使是小项目,人们也会这样做:不是为了架构,而是为了调试。 问题在于,事件可以有不同的类型,最简单的版本中你只能将它们放入 List<Any>。

这就是 reified 变得非常实用的地方:你可以编写一个通用工具——“从日志中提取所有特定类型的事件”,而无需手动编写 as 和一堆 if。

首先,我们描述两种事件类型:

data class ExpenseAdded(val amount: Double)
data class NoteAdded(val text: String)

现在编写一个函数“从 Any 列表中提取所有类型为 T 的元素”:

inline fun <reified T> pickAllOfType(items: List<Any>): List<T> {
    val result = mutableListOf<T>()
    for (item in items) {
        if (item is T) result.add(item)
    }
    return result
}

然后使用它:

fun main() {
    val log: List<Any> = listOf(
        ExpenseAdded(10.0),
        NoteAdded("coffee"),
        ExpenseAdded(25.5),
    )

    val expenses = pickAllOfType<ExpenseAdded>(log)
    println(expenses) // [ExpenseAdded(amount=10.0), ExpenseAdded(amount=25.5)]
}

这是一个很好的例子,reified 真正减少了代码中的“噪音”: 你不必手动编写带有 as? 的循环,也不必冒着风险进行不安全的 as 转换。

5. 使用类型擦除与 inline/reified 时的常见错误

错误 #1:期望 fun <T> 在没有 reified 的情况下执行 x is T。 这是一个常见的逻辑陷阱:人们认为既然代码里写了 T,那么它在运行期也“存在”。 在 JVM 上,它主要存在于编译器中,在运行期是被擦除的——所以 Kotlin 禁止这种检查。 唯一的出路是:要么显式传递一个“类型令牌”,要么使用 inline fun <reified T>——并记住,没有 inline 就没有 reified。

错误 #2:尝试检查嵌套类型参数,例如 x is List<String> 并认为它是可靠的。 即使你想在单个表达式中证明“这是一个字符串列表”,运行期也不知情,因为 List<...> 里的 String 已被擦除。 正确的风格是使用 is List<*> 检查外部类型,然后通过逻辑验证元素。 Kotlin 明确警告,即使使用 reified,泛型参数依然是被擦除的。

错误 #3:在泛型工具中使用 as 而不是 as?,导致“凭空”崩溃。 as 的意思是“相信我,我知道这个类型”,一旦出错它就会抛出异常。 在处理 Any 或“性质未知”的数据的工具中,使用 as? 并返回 T?,然后通过 ?:(提供默认值)或带明确消息的显式错误处理,几乎总是更合理的。

错误 #4:将 inline 视为一种免费的优化,并“以防万一”到处使用 inline。 inline 是一种改变代码生成方式的工具(函数体会被复制到调用点)。 它在需要 reified 时,或者确实受益于 Lambda 时很有用,但它不是“万能加速器”。 有时 inline 会导致字节码体积膨胀,并增加调试难度。 在本讲座中,我们使用 inline 并非为了性能,而是为了使 T 能够参与类型检查。

1
任务
Kotlin SELF, 第 36 级, 课程 4
已锁定
验证器信息
验证器信息
1
任务
Kotlin SELF, 第 36 级, 课程 4
已锁定
输入类型检查
输入类型检查
1
任务
Kotlin SELF, 第 36 级, 课程 4
已锁定
安全类型转换
安全类型转换
1
任务
Kotlin SELF, 第 36 级, 课程 4
已锁定
事件过滤器
事件过滤器
1
调查/小测验
深入理解泛型第 36 级,课程 4
不可用
深入理解泛型
深入理解泛型
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION