使用`inline`和`reified`解決Kotlin中的類型擦除問題以及`noinline`、`crossinline`等用法詳解
Kotlin已经变得非常流行,特别是在编写原生Android应用程序方面,最近也用于多平台应用,。其中一个最难理解但非常强大的功能是**inline**
函数与**reified**
类型参数一起使用。这些函数让你可以编写类型安全的代码,并避免由于类型擦除引起的问题,,而不依赖于较慢的反射。
在这份指南中,我将解释这些功能是如何工作的,为什么它们如此有用,展示一个在Android中使用类型安全导航的实际例子,并讨论它们的性能好处。我们将一步步解释这些概念,让您彻底理解这种做法背后的道理。
问题:泛型和消失的类型在我们介绍解决方案之前,先来澄清一下问题。这一切都要从“泛型”说起。
泛型是编写可重用代码以处理不同类型数据的好方法。例如,你可能希望有一个列表可以存放各种元素——数字、字符串或自定义对象。泛型允许你这样来处理不同类型的数据而无需为每种类型编写单独的代码。
val stringList: List<String> = listOf("apple", "banana", "orange") // 定义了一个包含字符串的列表
val numberList: List<Int> = listOf(1, 2, 3) // 定义了一个包含整数的列表
在这个示例中,我们有两个列表:一个用于存储 String
,另一个用于存储 Int
。<String>
和 <Int>
是泛型类型,它们告诉编译器每个列表可以存储哪种类型的数据。
这个特性叫做编译时类型安全:编译器会在早期检测到错误。如果你试图将数字加到
stringList
上,编译器会报错。
然而,但是有个问题:当你运行代码时,具体的类型细节(String
,Int
或其他类型)会被擦除。这种现象称为 类型擦除现象。
当 Kotlin 编译你的代码时,它会去掉详细的泛型类型信息。比如说,List<String>
和 List<Int>
在实际运行时都会变成类型未知的 List<*>
,也就是类型未知的列表。
更准确的说法是将它们视为通配符类型 (
List<?>
),而不是简单地说“它们变成了List<Any>
”。但它们实际上更准确的说法是将它们视为通配符类型。
虽然泛型在编译时能帮助你发现错误,但在运行时它们不会保存详细类型信息,因为这些信息在运行时会被擦除。
类型擦除:为什么会有类型擦除?为什么要有类型擦除?
类型擦除的概念是因为Kotlin运行的Java虚拟机(JVM)处理泛型的方式而产生的。它是为了与旧版Java代码保持向后兼容而引入的,这些旧版代码不使用泛型。
为了使新的通用代码能够与旧库一起工作,JVM 在运行时会忽略具体的类型参数。因此,List<String>
和 List<Integer>
都变成了泛型 List<?>
,JVM 就不再区分它们。
这种“忘记”会产生以下几个重要的后果:
- 运行时无类型检查: 无法在运行时检查列表的类型,因为具体提到
List<String>
或List<Int>
可能不是最自然的表达方式。 - 类型转换限制: 你不能简单地将对象转换为泛型类型(例如,“将此对象转换为类型
T
”),因为在运行时无法得知T
是什么类型。
类型擦除限制示例 (Type Erasure 限制示例)
fun <T> 打印对象类名(obj: T) {
println(obj::class.java.name)
}
fun 主() {
val listOfStrings: List<String> = listOf("Kotlin", "Java")
打印对象类名(listOfStrings) // 运行时只将其视为 List,而不是泛型的 List<String>
}
我们希望 printClassName
输出 "List<String>"。但实际上它输出的是原始类型,例如 ArrayList
,因为泛型类型信息,如 <String>
,已经丢失了。该函数只能看到泛型 List
,而不是具体的 List<String>
类型。
内联实化
来拯救
这就是**inline**
和**reified**
发挥重要作用的地方。通过一起使用这两个关键字,你可以在运行时保留类型信息,从而克服由类型擦除带来的限制。
**内联**
: 这个关键字告诉编译器将整个函数的代码复制(或“内联”)到调用它的位置。而不是调用一个单独的函数,函数体直接嵌入到调用处。这在某些情况下也可以提高性能(稍后再详细讨论)。**实化**
: 这个关键字只能用于**内联**
函数,确保类型参数在运行时变为“真实”的状态(实化)。它指示编译器为该参数保留类型信息,以便在运行时使用。
我们来改一下之前的例子。
inline fun <reified T> printClassName(item: T) {
println(T::class.java.name) // 现在可以正常运行了!
}
fun main() {
val myList = listOf("hello", "world")
printClassName(myList) // 输出: java.util.ArrayList 或类似输出,关键是...
printClassName("hello") // 输出: java.lang.String
printClassName(123) // 输出: java.lang.Integer
}
咱们深入探索一下
为了更好地理解**inline**
和**reified**
之间的区别和原因,我举了一个实际的例子。
从 Android 导航 2.8 版本开始,内置了对类型安全的支持。
你在数据类中定义从一个屏幕传递到另一个屏幕的参数,比如。你也可以序列化数据类进行传递。然而,如果你需要传递自定义数据类型,则需要指定一个特定的 NavType
。这会帮助系统在屏幕间导航时序列化和反序列化自定义类型。
val PersonType = object : NavType<Person>(不允许为空 = false) {
override fun put(bundle: Bundle, key: String, value: Person) {
bundle.putParcelable(key, value)
}
override fun get(bundle: Bundle, key: String): Person? {
如果 (Build.VERSION.SDK_INT < 34) 则 返回 @Suppress("DEPRECATION") bundle.getParcelable(key)
否则 返回 bundle.getParcelable(key, Person::class.java)
}
override fun parseValue(value: String): Person {
// 解析值为Person类型
return Json.decodeFromString<Person>(value)
}
override fun serializeAsValue(value: Person): String {
// 序列化为值
return Json.encodeToString(value)
}
override val name = Person::class.java.name
}
通过这样的代码,你为Person
数据类定义了一个自定义NavType。现在你可以传递一个完整的Person
对象作为参数,使用新的导航组件。
但是看看这段代码有多长。它只针对一种类型有效,但如果你有多种类型——比如 Person
和 Car
——重复这种逻辑会导致重复代码和错误。
第一次尝试:通用函数(但它失败了)我们显然需要用类型
T
替代这里的Person
类型,这样当我们想要传递例如Car
时,就不会重复这些代码了。
我们可以将这段代码打包成一个泛型函数,用泛型类型 T
替换掉 Person
。这样我们就可以对任何 Parcelable
类型重用相同的逻辑,而无需对每种类型都重新编写代码。
// 定义一个泛型函数,泛型类型为可序列化对象T
fun <T : Parcelable> NavType的伴生对象的mapper(): NavType<T> {
// 返回一个匿名内部类,继承自NavType<T>
return object : NavType<T>(
// 不允许为null
isNullableAllowed = false
) {
// 将可序列化对象放入Bundle中
override fun put(bundle: Bundle, key: String, value: T) {
bundle.putParcelable(key, value)
}
// 从Bundle中获取可序列化对象,返回类型为T?
override fun get(bundle: Bundle, key: String): T? {
// 如果SDK版本小于TIRAMISU
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
@Suppress("DEPRECATION")
bundle.getParcelable(key)
} else {
bundle.getParcelable(key, T::class.java)
}
}
// 从字符串解析Json
override fun parseValue(value: String): T {
return Json.decodeFromString<T>(value)
}
// 将值编码为字符串
override fun serializeAsValue(value: T): String {
return Json.encodeToString(value)
}
// 获取T的类名
override val name = T::class.java.name
}
这看起来很完美,但无法编译。问题仍然是类型擦除问题。因为运行时无法确定 T
是什么,所以不能调用 T::class.java
。
reified
此时,reified
就派上用场了。通过在 T
前面加上 reified
关键字,我们告诉编译器要在运行时保持类型信息。
val personType = NavType.mapper<Person>()
val carType = NavType.mapper<Car>()
fun <reified T : Parcelable> NavType.Companion.mapper(): NavType<T> {
// 这里是相同的代码
}
现在,T::class.java
是有效的。由于 **reified**
使得 T
在函数中成为一个实际的类型,因此可以在运行时访问 T
的类。但是我们仍然会遇到编译错误:
‘实参化’类型参数修饰符仅能在内联函数中使用
inline
我们快要成功了。编译器要求 **reified**
只能在 **inline**
函数内使用。这是为什么?
关键是:为了让 reified
正常工作,编译器需要知道在函数调用处 T
的 实际 类型是什么。它不能只是传递一个泛型的 T
,而是需要将 T
替换为 User
、Product
或我们实际使用的任何其他类型。
内联关键字让编译器在调用点直接嵌入函数体,而不是像传统函数那样调用。
可以这样想:当我们调用 NavType.mapper<Person>()
方法时,编译器知道 T 是 Person
。因为函数是 inline 的,它会生成一个特化版本,将 T 替换为 Person
。
val personType = NavType.mapper<Person>()
val carType = NavType.mapper<Car>()
inline fun <reified T : Parcelable> NavType.Companion.mapper(): NavType<T> {
// 这里的代码相同
}
这就是为什么必须一起使用**inline**
和**reified**
的原因。reified
需要在调用位置的实际类型信息,这样才能获取正确的类型信息。而inline
提供的正是这种替换机制。
行内提炼的TL;DR
总结一下为什么**reified**
与**inline**
紧密相连
为了保持运行时的类型,任何标记为 **reified**
的类型 **T**
在编译时会被替换为其具体类型,从而在幕后生成一个新的函数版本,在这个新函数中,每个 **T**
都会被替换成具体类型。
编译器然后在调用位置内联这个函数的专用实现,因为 inline 允许将函数体复制并根据具体类型调整。如果没有使用内联,编译器无法生成或嵌入这个专门的函数版本。
内联关键字,性能提升inline
reified
提供了类型安全性,但是 **inline**
关键字还可以带来潜在的性能提升。
**内联**
会将函数代码直接嵌入调用位置,避免了通常的函数调用开销,比如栈操作和查找函数,这样更自然且符合口语表达习惯。
这在使用 lambda 表达式时特别有用。当你把一个 lambda 传递给普通函数时,会创建一个对象。inline 函数避免了创建这个对象,因此效率更高。Kotlin 的集合函数(map
、filter
、forEach
)都是 inline 函数,原因就在于此:它们大量依赖于 lambda 表达式。
然而,内联编译也有缺点。它可能会增加代码量,特别是当函数很大或在多个地方被频繁使用时(通常称为 代码膨胀问题)。
总之,除了解决 reified
问题之外,内联化本身是一种性能策略,通常最适合小而频繁调用的函数——特别是那些接受 lambda 表达式的函数。对于大型函数或从多个地方调用的函数,它可能并不适用,因为它可能导致代码膨胀/字节码增大,从而使你的应用体积变大。
尽管 inline
提供了显著的优势,Kotlin 还提供了两个重要的修饰符 **noinline**
和 **crossinline**
,用于进一步控制内联的过程。这些修饰符是用来与 inline
函数中的 lambda 参数一起使用的。
**noinline**
— 禁止内联Lambda表达式
有时,你可能希望利用inline
函数的性能优势(或reified
类型),但不想让某个特定的 lambda 参数被内联。这时就可以用noinline
来解决。用noinline
标记 lambda 参数就能避免它被内联。
主要有两个原因要使用noinline
。
1. 将Lambda传递给另一个非内联函数: 如果你需要将lambda传递给另一个不是内联的函数,你必须使用noinline
关键字。如果你尝试将一个内联的lambda传递给一个非内联函数,编译器会报错,提示不能传递。lambda需要作为一个独立的对象才能传递。
// 非内联函数(non-inline function)
fun anotherFunction(lambda: () -> Unit)
lambda()
}
// 内联函数(inline function)
inline fun doSomething(first: () -> Unit, noinline second: () -> Unit)
first() // 此 lambda 将被内联(inlined)
anotherFunction(second) // 'second' 当作常规 lambda 对象传递(不会被内联)
}
在这个例子中,first
将像平时一样被内联。但是,second
被标记为 noinline
,这使得其代码不会被复制到 doSomething
中,并允许它作为函数指针传递给 anotherFunction
函数。
2. 控制代码大小: 如果你有一个带有非常大 lambda 表达式的 inline
函数,反复内联这个 lambda 表达式会导致代码膨胀问题。使用 noinline
可以避免这种情况。
inline fun 处理数据(data: List<String>, noinline 大处理逻辑: (String) -> Unit) {
for (item in data) {
// 一些小且频繁执行的代码,可从中受益于内联
if (item非空) {
大处理逻辑(item)
}
}
}
fun main() {
val data = listOf("a", "b", "", "c", "d", "", "e")
处理数据(data) { item ->
// 想象这是一个巨大的 lambda 表达式,包含数百行复杂的逻辑,涉及数据库调用和网络请求等。
}
}
没有 noinline
,整个 largeProcessingLogic
lambda 每次调用 processData
时都会被复制到循环中,从而大幅增加字节码的体积。使用 noinline
,只会传递 lambda 的引用,避免了代码复制。
crossinline
帮助你控制 return
关键字在传递给 inline
函数的 lambda 中的行为。它使 return
的行为更容易预测。
- 普通
**返回**
(在普通函数或非内联 lambda 中):return
语句仅退出该return
所在的 lambda 或函数。 - 非本地
**返回**
(在**内联**
函数中,**没有**
`crossinline`):** 在内联 lambda 中的return
语句会跳出调用该inline
函数的函数。这可能会让你感到惊讶! - 局部
**返回**
(带有**crossinline**
):crossinline
防止非本地返回。在crossinline
lambda 中的return
仅退出该return
所在的 lambda,就像正常的return
一样。这意味着即使函数内联了,return
语句也不会跳出 lambda 外部。
// 示例 1:非局部返回(无 crossinline)
inline fun doSomething(action: () -> Unit) {
println("开始 doSomething")
action()
println("结束 doSomething") // 可能不被打印
}
fun test1() {
doSomething {
println("在 lambda 内")
return // 这会退出 test1,而不仅仅是 lambda!
}
println("不会被打印")
}
// 示例 2:局部返回(有 crossinline)
inline fun doSomethingSafely(crossinline action: () -> Unit) {
println("开始 doSomethingSafely")
action()
println("结束 doSomethingSafely") // 会被打印
}
fun test2() {
doSomethingSafely {
println("在 lambda 内")
return // 只会退出 lambda 内部
}
println("会被打印")
}
fun main() {
println("执行 test1:")
test1() // 输出:开始 doSomething, 在 lambda 内
println("\n执行 test2:")
test2() // 输出:开始 doSomethingSafely, 在 lambda 内, 结束 doSomethingSafely, 被打印
}
**test1**
: lambda 内的return
会直接退出test1
。“结束 doSomething” 和 “这行不会被打印” 这两行 绝对不会 被执行。**test2**
:crossinline
关键字 强制return
只从 lambda 退出,不退出test2
。“结束 doSomethingSafely” 和 “这行确实会被打印” 会被 执行。
编译错误:如果你将带有
crossinline
标记的 lambda 传递给另一个 lambda 或类似的,编译器会报错。
当使用crossinline时:
- 在 lambda 中的
return
行为就像普通的return
一样,仅退出 lambda。 - 你在制作一个库,并希望确保用户在使用你的
inline
函数时不会意外使用非局部跳出,这可能会以意想不到的方式改变他们的程序流程。
修饰符总结
**内联**
:将函数代码和 lambda 表达式(默认情况下)复制到调用位置。允许return
语句在 lambda 表达式中退出调用它的函数(非局部返回)。**非内联**
:防止特定 lambda 参数被内联。这在需要将 lambda 表达式传递给非内联函数时特别有用。**交叉内联**
:允许复制(内联) lambda 代码,但强制return
语句仅退出 lambda 本身(局部返回),不允许非局部返回。
限制和何时需要反思
内联化实现
是一个强大的工具,但它并不是适用于所有情况的万能解决方案。在某些特定情况下,它是不能使用的。在这些情况下,反射(或其他不太常见的替代方案)就变得必不可少。
以下是一些inline reified
无法使用的关键场景(情况):
- 动态类型发现: 如果你确实不知道对象类型直到运行时,就不能使用
inline reified
。reified
要求在调用inline
函数时类型是未知的。例如,如果你从文件读取数据,而文件格式决定了数据类型,你必须读取文件后才知道类型。在这种情况下,反射是必要的。 - 与非 Kotlin 代码交互(没有已知类型): 如果你调用的 Java 代码(或其他 JVM 语言)没有以 Kotlin 可以理解的方式提供泛型类型信息,你可能不能使用
reified
。你可能需要使用反射来与返回的对象进行交互。在这种情况下,你可能需要使用反射。 - 接口方法带有默认实现: 如果带有
reified
的方法在接口中带有默认实现,你可能需要使用反射。 - 使用
**inline**
的递归函数: 你不能使用inline
与调用自身的函数(“递归”函数)一起使用。每次调用时,计算机都会复制函数代码。如果函数调用自身,这种复制将永远不会停止。 - 访问非公共成员(没有
**@PublishedApi**
):inline
函数不能直接访问类的非公共成员,除非这些成员被标记为@PublishedApi
注解。 - 变量类型参数: 你不能使用变量作为
reified
类型参数。
inline fun <reified T> myFun() {
println(T::class.simpleName)
}
fun <T> otherFun() {
myFun<T>() // 会引发编译错误
}
下面的代码展示了尝试传递泛型参数到一个需要具体类型的函数中的错误示例.
在上面的代码里,我们尝试将类型变量 T
作为类型参数使用,但这是不允许的。
当不能使用inline reified
时,反射通常是备选方案。反射允许程序在运行时检查和操作对象的结构和行为特征。这包括检查类型、访问字段和调用方法,即使这些元素在编译时无法确定。
但是,这股力量需要付出代价,
- 速度: 反射比直接访问类型要慢得多。它涉及动态查找和检查,增加了开销。
- 类型安全性: 反射在编写代码时不检查类型,运行时可能会遇到错误。
- 代码复杂性: 使用反射的代码通常更长也更难懂。
反射可以完成与 inline reified
类似的任务,但通常它比较慢、不太安全,使用起来也比较困难。在大多数情况下,如果你需要在程序运行中了解类型,inline reified
是最好的选择。
另一种方法是这样的,当你只需要知道某个类型的类时,可以将 KClass
对象作为参数。KClass
是 Kotlin 中表示类的类型。
这类似于类的蓝图,提供了关于其属性、成员函数和构造函数的信息。
例如:
fun <T : Any> printClassName(obj: T, clazz: KClass<T>) {
println("类名是: ${clazz.simpleName}")
}
fun main() {
printClassName("Hello", String::class) // 传递的是 String::class
printClassName(123, Int::class) // 传递的是 Int::class
}
这样可以行得通,但会增加额外的代码,每次调用函数时都必须手动传入KClass
。而inline reified
则可以省去这一步,因为编译器会自动提供类型信息。
所以总的来说,结论是首先要明白
**_KClass_**
并不是**_inline reified_**
的完全替代品。_KClass_
提供了关于类型的的信息,而_inline reified_
允许你在函数中使用类型,就像它是一个具体的类型一样,即使存在类型擦除的问题。它们服务于不同的,但是相关的目的。
_inline reified_
在使用它的函数中赋予你更大的灵活性。
inline
**reified
是Kotlin中一个强大的特性,它解决了类型擦除**的难题,同时带来了性能上的提升。
通过结合使用**inline**
和**reified**
,你可以编写出类型安全且高效的泛型代码。这样既可以避免代码重复使用,又可以避免在访问运行时类型信息时所需的反射开销,如Android Navigation示例中的情况。
学习如何有效地使用内联实型能够帮助你制作更整洁、更易于维护且运行速度更快的Kotlin代码;不论是开发Android应用、处理复杂数据还是使用Java库。
这是提升您Kotlin项目质量和性能的必备工具。
更多阅读材料 Kotlin 官方文档- 内联函数:官方 Kotlin 文档解释了内联函数的性能上的优势以及它们与 lambda 表达式和
noinline
/crossinline
的工作方式。 - 实化类型参数在内联函数中的应用:专门讨论内联函数中的实化类型参数,详细说明了它们如何解决类型擦除问题。
- 泛型:对 Kotlin 中泛型的全面概述,理解类型擦除和
inline reified
所需的重要背景知识。
- Kotlin的noinline和crossinline,一次搞定:一个专门讲解
noinline
和crossinline
的全面指南。
共同學(xué)習(xí),寫下你的評(píng)論
評(píng)論加載中...
作者其他優(yōu)質(zhì)文章