1. 从类型擦除说起:为什么Kotlin泛型值得单独开一个专题
很多人学Kotlin泛型的时候,第一反应是"这不就是Java那套吗"。我一开始也这么想,直到在一个数据解析模块里踩了个坑——写了一个看似完美的泛型转换函数,编译通过,运行时却抛出了类型转换异常。那一刻我才意识到,Kotlin的泛型虽然脱胎于Java,但在类型系统设计上走了完全不同的路。
Kotlin泛型要解决的核心问题其实很朴素:让一份代码安全地处理多种类型。比如你写一个容器类,不希望为String、Int、自定义数据类各写一遍;你写一个网络请求回调,不希望每个接口都重复定义解析逻辑。泛型就是把这些"类型"变成参数,让编译器帮你做检查。
但Kotlin泛型真正有意思的地方在于它和Java的三个关键差异。第一,Kotlin的泛型在编译期做了更严格的检查,很多Java里能编译通过的代码在Kotlin里直接报错,这反而帮你提前拦住了隐患。第二,Kotlin引入了声明处型变(declaration-site variance),用out和in两个关键字在定义泛型类的时候就确定型变关系,而不是像Java那样在使用处写通配符。第三,Kotlin有具体化类型参数(reified type parameter),配合内联函数可以在运行时拿到泛型的实际类型,这在Java里是做不到的。
这篇文章适合谁看?如果你已经写过一些Kotlin代码,用过List<String>、Map<Int, String>这类基础泛型,但对out、in、where、reified这些关键字总是似懂非懂,或者每次写泛型函数都要查资料,那这篇内容就是为你准备的。我会从类型擦除这个根本机制讲起,把型变、约束、具体化这几个核心概念拆开揉碎,再配合实际项目里常见的泛型设计模式,让你看完之后能真正把泛型用起来,而不是停留在"能看懂但不会写"的阶段。
下面这张表先给你一个全局印象,Kotlin泛型和Java泛型的关键差异:
| 特性 | Java | Kotlin |
|---|---|---|
| 型变声明位置 | 使用处(通配符? extends/? super) | 声明处(out/in)或使用处(投影) |
| 运行时类型信息 | 完全擦除,无法获取 | 内联函数+reified可获取 |
| 泛型约束 | extends单继承 | where子句支持多约束 |
| 星号投影 | ?通配符 | *星号投影 |
| 泛型数组 | 不支持直接创建 | 内联+reified可创建 |
2. 类型擦除到底擦掉了什么:一个反直觉的真相
2.1 编译期检查与运行时擦除的分离
Kotlin泛型和Java一样,采用类型擦除(type erasure)机制。什么意思?就是你写的List<String>和List<Int>,在编译成字节码之后,类型参数String和Int都被擦掉了,运行时看到的都是List。编译器在编译期用泛型信息做类型检查,检查完就把这些信息扔掉。
这个设计有历史原因——为了兼容老版本没有泛型的代码。但带来的后果是:运行时你无法直接判断一个List到底是List<String>还是List<Int>。我见过不少人在代码里写if (list is List<String>),编译器会直接警告"无法检查被擦除的类型",因为运行时根本没有这个信息。
那为什么Kotlin还要沿用类型擦除?因为JVM的字节码规范就是这样,Kotlin编译到JVM上必须遵守这个规则。不过Kotlin在语言层面做了很多补偿,比如reified内联函数,就是在编译期把类型信息"内联"到调用处,绕过了擦除的限制。
2.2 擦除带来的三个典型陷阱
第一个陷阱是类型判断失效。看这段代码:
fun <T> checkType(value: Any): Boolean { return value is T // 编译错误:Cannot check for instance of erased type }编译器直接拒绝,因为T在运行时不存在。解决办法是用reified:
inline fun <reified T> checkType(value: Any): Boolean { return value is T // 编译通过,因为T被具体化了 }第二个陷阱是泛型数组创建。Array<T>在Kotlin里不能直接创建,因为数组在运行时需要知道元素类型来分配内存。但用reified可以:
inline fun <reified T> createArray(size: Int): Array<T?> { return arrayOfNulls(size) }第三个陷阱是重载冲突。下面两个函数在编译后签名相同,会报"平台声明冲突":
fun process(list: List<String>) { } fun process(list: List<Int>) { } // 编译错误:JVM签名冲突因为擦除后都是process(List)。这个坑在定义API的时候特别容易踩,解决办法是给函数起不同的名字,或者加一个@JvmName注解来区分。
2.3 擦除机制下的安全转换实践
既然运行时拿不到泛型信息,那从Any转换到泛型类型的时候就要格外小心。我常用的做法是配合reified写一个安全转换扩展:
inline fun <reified T> Any?.safeCast(): T? { return this as? T } // 使用 val result = someValue.safeCast<String>()这里safeCast能正常工作,是因为reified让T在编译期被替换成具体类型,as?就能做真正的类型检查。如果不用reified,as? T会因为擦除而永远返回非空(只要原值不为null),这就会埋下隐患。
注意:
reified只能用在inline函数上,因为类型信息需要在调用处展开。如果你把reified函数赋值给一个函数引用,编译器会报错。
3. out和in:型变声明背后的类型安全逻辑
3.1 为什么需要型变:一个协变场景的推演
先看一个看似合理但实际有问题的场景。假设有Animal和Dog两个类,Dog继承Animal。直觉上,List<Dog>应该能赋值给List<Animal>,因为狗是动物,狗列表当然也是动物列表。但在Java里,List<Dog>不能赋值给List<Animal>,因为List是不变的(invariant)。
为什么Java要这么设计?因为如果允许赋值,就会出问题:
List<Dog> dogs = new ArrayList<>(); List<Animal> animals = dogs; // 假设允许 animals.add(new Cat()); // 往动物列表里加猫 Dog dog = dogs.get(0); // 取出来当狗用,但实际是猫,崩溃所以Java用通配符? extends Animal来限制:只能读不能写。Kotlin把这个逻辑提前到了声明处,用out关键字标记"这个泛型参数只用于输出(返回)"。
3.2 out:协变的生产者角色
out修饰的类型参数只能出现在返回值位置,不能出现在参数位置。这样编译器就能保证类型安全:
interface Producer<out T> { fun produce(): T // fun consume(item: T) // 编译错误:T不能出现在in位置 } val dogProducer: Producer<Dog> = ... val animalProducer: Producer<Animal> = dogProducer // 合法,因为outProducer<Dog>可以赋值给Producer<Animal>,因为Producer只生产T,不消费T。你从Producer<Animal>里拿到的任何东西都是Animal,而Producer<Dog>生产的确实是Dog(也是Animal),所以安全。
Kotlin标准库里的List就是out的典型例子:interface List<out E>。所以List<Dog>可以赋值给List<Animal>。但MutableList没有out,因为它既能读又能写。
3.3 in:逆变的消费者角色
in正好相反,修饰的类型参数只能出现在参数位置,不能出现在返回值位置:
interface Consumer<in T> { fun consume(item: T) // fun produce(): T // 编译错误:T不能出现在out位置 } val animalConsumer: Consumer<Animal> = ... val dogConsumer: Consumer<Dog> = animalConsumer // 合法,因为inConsumer<Animal>可以赋值给Consumer<Dog>,因为能消费Animal的消费者,当然也能消费Dog。Comparable就是in的例子:interface Comparable<in T>。
3.4 使用处型变:星号投影与类型投影
有时候你不想在类定义的时候固定型变关系,而是在使用的时候临时指定。Kotlin提供了类型投影:
fun copy(from: Array<out Any>, to: Array<Any>) { for (i in from.indices) { to[i] = from[i] } }这里Array<out Any>表示"一个能读出Any的数组",相当于Java的? extends Any。对应的还有Array<in String>,表示"一个能写入String的数组"。
星号投影*则更彻底,表示"我不关心类型参数是什么":
fun printAll(list: List<*>) { list.forEach { println(it) } // it的类型是Any? }List<*>等价于List<out Any?>,你只能读取元素(得到Any?),不能写入。这个在只需要遍历、不需要关心具体类型的场景下非常实用。
| 写法 | 含义 | 能读 | 能写 |
|---|---|---|---|
List<out T> | 协变投影 | 是(T) | 否 |
List<in T> | 逆变投影 | 否 | 是(T) |
List<*> | 星号投影 | 是(Any?) | 否 |
实操心得:定义自己的泛型类时,先问自己"这个类型参数是只读、只写,还是读写都有"。只读用
out,只写用in,读写都有就不加型变修饰。这个判断能帮你省掉大量使用处的投影代码。
4. 泛型约束:where子句与多边界实战
4.1 上界约束的基本用法
泛型约束用来限制类型参数的范围。最基本的写法是冒号加类型:
fun <T : Comparable<T>> max(a: T, b: T): T { return if (a > b) a else b }这里T : Comparable<T>表示T必须实现Comparable接口,所以函数体里才能用>比较。如果不加约束,编译器不知道T有没有compareTo方法,直接报错。
默认情况下,类型参数的上界是Any?,也就是可以为null。如果你想要非空上界,显式写T : Any。
4.2 where子句解决多约束问题
当一个类型参数需要满足多个约束时,冒号语法就不够用了,这时候用where:
fun <T> process(item: T) where T : Comparable<T>, T : CharSequence { // T必须同时实现Comparable和CharSequence }where子句可以写多个约束,用逗号分隔。这个在实际项目里很常见,比如你要写一个既能比较又能序列化的泛型缓存:
class Cache<K, V> where K : Comparable<K>, K : java.io.Serializable, V : Any { private val map = mutableMapOf<K, V>() fun put(key: K, value: V) { map[key] = value } fun getSortedKeys(): List<K> = map.keys.sorted() }这里K必须可比较(用于排序)且可序列化(用于持久化),V必须非空。where子句让这些约束一目了然。
4.3 约束与型变的组合陷阱
约束和型变组合的时候有个容易踩的坑。看这个例子:
interface Repository<T : Entity> { fun save(entity: T) fun findAll(): List<T> }这个Repository既消费T(save)又生产T(findAll),所以不能加out或in。但如果你只想要一个只读的查询接口:
interface ReadOnlyRepository<out T : Entity> { fun findAll(): List<T> // fun save(entity: T) // 编译错误:out位置不能有T参数 }out T : Entity是合法的,out和上界可以同时写。但要注意,out位置不能出现T作为函数参数,这是编译器强制的。
注意:
where子句和out/in可以组合,但out修饰的类型参数不能出现在where子句的逆变位置。实际写的时候如果报错,先检查是不是型变和约束冲突了。
5. reified:让泛型在运行时"活"过来
5.1 reified的编译期展开原理
前面反复提到reified,现在深入讲讲它为什么能绕过类型擦除。核心在于inline:内联函数在编译期会把函数体直接复制到调用处,同时把reified标记的类型参数替换成实际类型。
inline fun <reified T> typeName(): String { return T::class.simpleName ?: "Unknown" } // 调用 val name = typeName<String>() // 编译后相当于 return "String"编译器看到typeName<String>(),就把函数体里的T全部替换成String,T::class变成String::class。这样运行时就有真实的类型信息了。
这个机制的关键限制是:reified函数必须是inline的,且不能把reified类型参数传给非内联函数。因为一旦传给非内联函数,类型信息又会被擦除。
5.2 实际项目中的reified应用场景
场景一:JSON解析封装。很多项目里会写一个通用的解析函数:
inline fun <reified T> String.parseJson(): T { return Gson().fromJson(this, T::class.java) } // 使用 val user = jsonString.parseJson<User>()这里T::class.java能拿到具体的Class对象,Gson才能正确解析。如果不用reified,就得手动传Class<T>参数,调用起来很啰嗦。
场景二:Activity跳转封装(Android开发常见):
inline fun <reified T : Activity> Context.startActivity( vararg params: Pair<String, Any> ) { val intent = Intent(this, T::class.java) params.forEach { intent.putExtra(it.first, it.second.toString()) } startActivity(intent) } // 使用 startActivity<DetailActivity>("id" to 123)场景三:类型安全的查找:
inline fun <reified T> List<*>.filterByType(): List<T> { return filterIsInstance<T>() }filterIsInstance本身就是标准库里的reified扩展函数,能根据运行时类型过滤。
5.3 reified的边界与性能考量
reified虽然好用,但有几个边界要注意。第一,内联会导致代码膨胀。如果reified函数体很大,每个调用处都会复制一份,编译后的字节码体积会明显增加。所以reified函数应该尽量短小。
第二,不能用于非内联的lambda。比如:
inline fun <reified T> runLater(noinline block: () -> Unit) { // block里不能用T,因为block不是内联的 }第三,反射调用受限。reified函数不能通过反射调用,因为反射调用时类型信息已经丢失了。
| 场景 | 是否可用reified | 原因 |
|---|---|---|
| 普通内联函数 | 是 | 编译期展开 |
| 非内联函数 | 否 | 类型信息被擦除 |
| 内联函数的noinline lambda | 否 | lambda不展开 |
| 反射调用 | 否 | 运行时无类型信息 |
实操心得:
reified最适合用在"薄封装"场景——函数体只有几行,主要是为了拿到T::class或做类型判断。如果函数逻辑复杂,建议拆成两层:外层reified负责拿类型,内层普通泛型函数负责业务逻辑。
6. 泛型在真实项目中的设计模式
6.1 泛型Result封装:统一接口返回处理
实际项目里,接口返回格式通常是{code, message, data},其中data的类型因接口而异。用泛型可以统一处理:
data class ApiResult<T>( val code: Int, val message: String, val data: T? ) { val isSuccess: Boolean get() = code == 200 } // 配合reified解析 inline fun <reified T> String.toApiResult(): ApiResult<T> { return Gson().fromJson(this, ApiResult::class.java) }但这里有个坑:ApiResult::class.java拿到的是擦除后的ApiResult,Gson解析时data字段会变成LinkedTreeMap而不是目标类型。解决办法是用TypeToken:
inline fun <reified T> String.toApiResult(): ApiResult<T> { val type = object : TypeToken<ApiResult<T>>() {}.type return Gson().fromJson(this, type) }TypeToken匿名子类在编译期保留了泛型信息,Gson通过反射能拿到完整的ApiResult<T>类型。这个技巧在处理嵌套泛型时特别重要。
6.2 泛型Repository:数据层抽象
数据层通常需要为每种实体提供增删改查。用泛型基类可以大幅减少重复代码:
abstract class BaseRepository<T : Entity, ID : Comparable<ID>>( private val dao: BaseDao<T, ID> ) { fun save(entity: T): T = dao.insert(entity) fun findById(id: ID): T? = dao.queryById(id) fun findAll(): List<T> = dao.queryAll() fun delete(entity: T) = dao.delete(entity) }这里T : Entity约束实体类型,ID : Comparable<ID>约束主键可比较。子类只需要提供具体的dao实现:
class UserRepository(userDao: UserDao) : BaseRepository<User, Long>(userDao)这个模式的关键是把变化的部分(dao)通过构造器注入,把不变的部分(CRUD逻辑)放在基类。泛型在这里起到了"类型参数化"的作用,让基类能适配任意实体。
6.3 泛型扩展函数:给现有类型加能力
Kotlin的扩展函数配合泛型,能给任意类型添加通用能力。比如一个"条件执行"扩展:
inline fun <T> T.applyIf(condition: Boolean, block: T.() -> T): T { return if (condition) block() else this } // 使用 val user = User().applyIf(isAdmin) { this.role = "admin" }再比如一个"安全转换并执行"的扩展:
inline fun <reified T> Any?.castAndRun(block: (T) -> Unit) { (this as? T)?.let(block) } // 使用 response.castAndRun<SuccessData> { updateUI(it) }这类扩展函数的优势是调用处非常简洁,把类型判断、转换、执行的样板代码都封装起来了。
6.4 泛型与密封类的组合:状态建模
密封类(sealed class)配合泛型可以优雅地建模"加载中/成功/失败"三态:
sealed class UiState<out T> { object Loading : UiState<Nothing>() data class Success<T>(val data: T) : UiState<T>() data class Error(val message: String) : UiState<Nothing>() }注意这里UiState<out T>用了out,因为T只出现在Success的data字段(输出位置)。Loading和Error用Nothing作为类型参数,因为Nothing是所有类型的子类型,可以赋值给任何UiState<T>。
使用的时候:
fun render(state: UiState<User>) { when (state) { is UiState.Loading -> showLoading() is UiState.Success -> showUser(state.data) is UiState.Error -> showError(state.message) } }这个模式在Android的ViewModel里非常常见,out型变让UiState<Nothing>能赋值给UiState<User>,编译器能正确推断when分支的穷尽性。
7. 泛型使用中的常见编译错误与排查思路
7.1 "Cannot check for instance of erased type"的三种解法
这个错误出现频率最高,原因是运行时类型被擦除。三种解法:
解法一:用reified内联函数。适合函数调用场景:
inline fun <reified T> isType(value: Any): Boolean = value is T解法二:传Class<T>参数。适合不能内联的场景:
fun <T> isType(value: Any, clazz: Class<T>): Boolean = clazz.isInstance(value)解法三:用filterIsInstance。适合集合过滤:
val strings = mixedList.filterIsInstance<String>()7.2 "Type mismatch"与型变冲突的定位
型变冲突的报错通常比较隐晦。比如:
fun <T> copy(src: MutableList<T>, dest: MutableList<T>) { ... } val dogs: MutableList<Dog> = mutableListOf() val animals: MutableList<Animal> = mutableListOf() copy(dogs, animals) // 编译错误:类型不匹配因为MutableList是不变的,MutableList<Dog>和MutableList<Animal>没有子类型关系。解决办法是用类型投影:
fun <T> copy(src: MutableList<out T>, dest: MutableList<in T>) { ... }src用out(只读),dest用in(只写),这样copy(dogs, animals)就能编译通过了。
排查型变问题的思路:先确定每个泛型参数是读、写还是读写,然后决定用out、in还是不加修饰。如果报错说"T不能出现在某位置",就检查是不是型变修饰和实际用法冲突了。
7.3 泛型函数重载冲突的规避
前面提到过,擦除后签名相同的泛型函数会冲突。规避方法有三种:
方法一:加@JvmName注解:
@JvmName("processStrings") fun process(list: List<String>) { } @JvmName("processInts") fun process(list: List<Int>) { }方法二:用不同的函数名。这是最直接的做法,虽然不够优雅,但可读性最好。
方法三:用reified区分。因为reified函数在编译期展开,签名不同:
inline fun <reified T> processTyped(list: List<T>) { }注意:
@JvmName只影响JVM层面的签名,Kotlin调用处还是用同一个名字。如果两个函数逻辑差异大,建议直接用不同函数名,避免混淆。
8. 从泛型到类型系统:一些进阶思考
8.1 泛型与反射的配合边界
泛型擦除和反射是一对矛盾体。反射需要运行时类型信息,而泛型擦除恰恰把类型信息删了。但通过TypeToken、KType、typeOf这些工具,可以在一定程度上恢复泛型信息。
Kotlin 1.3之后引入了typeOf<T>(),配合reified可以拿到KType:
inline fun <reified T> getKType(): KType = typeOf<T>()KType比Class保留了更多信息,比如泛型参数、可空性。这在写序列化框架、依赖注入容器的时候很有用。
但要注意,typeOf拿到的类型信息也是编译期生成的,对于深层嵌套的泛型(比如Map<String, List<Map<Int, String>>>),解析起来会比较复杂。实际项目里如果泛型嵌套超过三层,建议重新设计数据结构,不要硬用泛型。
8.2 泛型设计的取舍原则
写了这么多泛型代码,我的体会是:泛型是工具,不是目标。有些场景用泛型反而增加复杂度,不如直接写具体类型。
判断标准很简单:如果一份逻辑需要重复三次以上,且重复的只是类型不同,那就用泛型抽象。如果只重复一两次,或者类型之间有复杂的转换关系,直接写具体实现更清晰。
另外,泛型层次不要太深。我见过一个项目里泛型参数套了五层,Repository<T, ID, E, C, R>,每次看代码都要翻定义。这种时候应该把部分类型参数提升为类的字段,或者拆成多个类。
8.3 泛型代码的可读性维护
泛型代码的可读性是个大问题。几个实用建议:
第一,类型参数命名要有意义。T表示通用类型,K/V表示键值,E表示元素,R表示返回。不要用A、B、C这种无意义的名字。
第二,约束尽量写在声明处。where子句虽然灵活,但读代码的人需要跳转才能看到约束。如果约束简单,直接写在类型参数后面更直观。
第三,给复杂的泛型类型起别名。Kotlin的typealias可以简化长泛型:
typealias UserMap = Map<String, List<User>> typealias ApiCallback<T> = (ApiResult<T>) -> Unit这样调用处就不用写一长串泛型了。
第四,文档注释要说明型变关系。如果一个泛型类用了out或in,在KDoc里说明为什么这么设计,方便后续维护的人理解。
泛型这块内容,光看是学不会的,必须动手写。建议你从封装一个通用的ApiResult开始,然后尝试写一个泛型Repository,再试试用reified封装JSON解析。每写一个,就回头看看编译器的报错,理解它为什么报错。踩过几次坑之后,out、in、where、reified这些关键字就会变成你的直觉反应,而不是需要查文档的知识点。