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代码用过ListString、MapInt, String这类基础泛型但对out、in、where、reified这些关键字总是似懂非懂或者每次写泛型函数都要查资料那这篇内容就是为你准备的。我会从类型擦除这个根本机制讲起把型变、约束、具体化这几个核心概念拆开揉碎再配合实际项目里常见的泛型设计模式让你看完之后能真正把泛型用起来而不是停留在能看懂但不会写的阶段。下面这张表先给你一个全局印象Kotlin泛型和Java泛型的关键差异特性JavaKotlin型变声明位置使用处通配符? extends/? super声明处out/in或使用处投影运行时类型信息完全擦除无法获取内联函数reified可获取泛型约束extends单继承where子句支持多约束星号投影?通配符*星号投影泛型数组不支持直接创建内联reified可创建2. 类型擦除到底擦掉了什么一个反直觉的真相2.1 编译期检查与运行时擦除的分离Kotlin泛型和Java一样采用类型擦除type erasure机制。什么意思就是你写的ListString和ListInt在编译成字节码之后类型参数String和Int都被擦掉了运行时看到的都是List。编译器在编译期用泛型信息做类型检查检查完就把这些信息扔掉。这个设计有历史原因——为了兼容老版本没有泛型的代码。但带来的后果是运行时你无法直接判断一个List到底是ListString还是ListInt。我见过不少人在代码里写if (list is ListString)编译器会直接警告无法检查被擦除的类型因为运行时根本没有这个信息。那为什么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在运行时不存在。解决办法是用reifiedinline fun reified T checkType(value: Any): Boolean { return value is T // 编译通过因为T被具体化了 }第二个陷阱是泛型数组创建。ArrayT在Kotlin里不能直接创建因为数组在运行时需要知道元素类型来分配内存。但用reified可以inline fun reified T createArray(size: Int): ArrayT? { return arrayOfNulls(size) }第三个陷阱是重载冲突。下面两个函数在编译后签名相同会报平台声明冲突fun process(list: ListString) { } fun process(list: ListInt) { } // 编译错误JVM签名冲突因为擦除后都是process(List)。这个坑在定义API的时候特别容易踩解决办法是给函数起不同的名字或者加一个JvmName注解来区分。2.3 擦除机制下的安全转换实践既然运行时拿不到泛型信息那从Any转换到泛型类型的时候就要格外小心。我常用的做法是配合reified写一个安全转换扩展inline fun reified T Any?.safeCast(): T? { return this as? T } // 使用 val result someValue.safeCastString()这里safeCast能正常工作是因为reified让T在编译期被替换成具体类型as?就能做真正的类型检查。如果不用reifiedas? T会因为擦除而永远返回非空只要原值不为null这就会埋下隐患。注意reified只能用在inline函数上因为类型信息需要在调用处展开。如果你把reified函数赋值给一个函数引用编译器会报错。3. out和in型变声明背后的类型安全逻辑3.1 为什么需要型变一个协变场景的推演先看一个看似合理但实际有问题的场景。假设有Animal和Dog两个类Dog继承Animal。直觉上ListDog应该能赋值给ListAnimal因为狗是动物狗列表当然也是动物列表。但在Java里ListDog不能赋值给ListAnimal因为List是不变的invariant。为什么Java要这么设计因为如果允许赋值就会出问题ListDog dogs new ArrayList(); ListAnimal animals dogs; // 假设允许 animals.add(new Cat()); // 往动物列表里加猫 Dog dog dogs.get(0); // 取出来当狗用但实际是猫崩溃所以Java用通配符? extends Animal来限制只能读不能写。Kotlin把这个逻辑提前到了声明处用out关键字标记这个泛型参数只用于输出返回。3.2 out协变的生产者角色out修饰的类型参数只能出现在返回值位置不能出现在参数位置。这样编译器就能保证类型安全interface Producerout T { fun produce(): T // fun consume(item: T) // 编译错误T不能出现在in位置 } val dogProducer: ProducerDog ... val animalProducer: ProducerAnimal dogProducer // 合法因为outProducerDog可以赋值给ProducerAnimal因为Producer只生产T不消费T。你从ProducerAnimal里拿到的任何东西都是Animal而ProducerDog生产的确实是Dog也是Animal所以安全。Kotlin标准库里的List就是out的典型例子interface Listout E。所以ListDog可以赋值给ListAnimal。但MutableList没有out因为它既能读又能写。3.3 in逆变的消费者角色in正好相反修饰的类型参数只能出现在参数位置不能出现在返回值位置interface Consumerin T { fun consume(item: T) // fun produce(): T // 编译错误T不能出现在out位置 } val animalConsumer: ConsumerAnimal ... val dogConsumer: ConsumerDog animalConsumer // 合法因为inConsumerAnimal可以赋值给ConsumerDog因为能消费Animal的消费者当然也能消费Dog。Comparable就是in的例子interface Comparablein T。3.4 使用处型变星号投影与类型投影有时候你不想在类定义的时候固定型变关系而是在使用的时候临时指定。Kotlin提供了类型投影fun copy(from: Arrayout Any, to: ArrayAny) { for (i in from.indices) { to[i] from[i] } }这里Arrayout Any表示一个能读出Any的数组相当于Java的? extends Any。对应的还有Arrayin String表示一个能写入String的数组。星号投影*则更彻底表示我不关心类型参数是什么fun printAll(list: List*) { list.forEach { println(it) } // it的类型是Any? }List*等价于Listout Any?你只能读取元素得到Any?不能写入。这个在只需要遍历、不需要关心具体类型的场景下非常实用。写法含义能读能写Listout T协变投影是T否Listin T逆变投影否是TList*星号投影是Any?否实操心得定义自己的泛型类时先问自己这个类型参数是只读、只写还是读写都有。只读用out只写用in读写都有就不加型变修饰。这个判断能帮你省掉大量使用处的投影代码。4. 泛型约束where子句与多边界实战4.1 上界约束的基本用法泛型约束用来限制类型参数的范围。最基本的写法是冒号加类型fun T : ComparableT max(a: T, b: T): T { return if (a b) a else b }这里T : ComparableT表示T必须实现Comparable接口所以函数体里才能用比较。如果不加约束编译器不知道T有没有compareTo方法直接报错。默认情况下类型参数的上界是Any?也就是可以为null。如果你想要非空上界显式写T : Any。4.2 where子句解决多约束问题当一个类型参数需要满足多个约束时冒号语法就不够用了这时候用wherefun T process(item: T) where T : ComparableT, T : CharSequence { // T必须同时实现Comparable和CharSequence }where子句可以写多个约束用逗号分隔。这个在实际项目里很常见比如你要写一个既能比较又能序列化的泛型缓存class CacheK, V where K : ComparableK, K : java.io.Serializable, V : Any { private val map mutableMapOfK, V() fun put(key: K, value: V) { map[key] value } fun getSortedKeys(): ListK map.keys.sorted() }这里K必须可比较用于排序且可序列化用于持久化V必须非空。where子句让这些约束一目了然。4.3 约束与型变的组合陷阱约束和型变组合的时候有个容易踩的坑。看这个例子interface RepositoryT : Entity { fun save(entity: T) fun findAll(): ListT }这个Repository既消费Tsave又生产TfindAll所以不能加out或in。但如果你只想要一个只读的查询接口interface ReadOnlyRepositoryout T : Entity { fun findAll(): ListT // 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 typeNameString() // 编译后相当于 return String编译器看到typeNameString()就把函数体里的T全部替换成StringT::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.parseJsonUser()这里T::class.java能拿到具体的Class对象Gson才能正确解析。如果不用reified就得手动传ClassT参数调用起来很啰嗦。场景二Activity跳转封装Android开发常见inline fun reified T : Activity Context.startActivity( vararg params: PairString, Any ) { val intent Intent(this, T::class.java) params.forEach { intent.putExtra(it.first, it.second.toString()) } startActivity(intent) } // 使用 startActivityDetailActivity(id to 123)场景三类型安全的查找inline fun reified T List*.filterByType(): ListT { return filterIsInstanceT() }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 ApiResultT( val code: Int, val message: String, val data: T? ) { val isSuccess: Boolean get() code 200 } // 配合reified解析 inline fun reified T String.toApiResult(): ApiResultT { return Gson().fromJson(this, ApiResult::class.java) }但这里有个坑ApiResult::class.java拿到的是擦除后的ApiResultGson解析时data字段会变成LinkedTreeMap而不是目标类型。解决办法是用TypeTokeninline fun reified T String.toApiResult(): ApiResultT { val type object : TypeTokenApiResultT() {}.type return Gson().fromJson(this, type) }TypeToken匿名子类在编译期保留了泛型信息Gson通过反射能拿到完整的ApiResultT类型。这个技巧在处理嵌套泛型时特别重要。6.2 泛型Repository数据层抽象数据层通常需要为每种实体提供增删改查。用泛型基类可以大幅减少重复代码abstract class BaseRepositoryT : Entity, ID : ComparableID( private val dao: BaseDaoT, ID ) { fun save(entity: T): T dao.insert(entity) fun findById(id: ID): T? dao.queryById(id) fun findAll(): ListT dao.queryAll() fun delete(entity: T) dao.delete(entity) }这里T : Entity约束实体类型ID : ComparableID约束主键可比较。子类只需要提供具体的dao实现class UserRepository(userDao: UserDao) : BaseRepositoryUser, 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.castAndRunSuccessData { updateUI(it) }这类扩展函数的优势是调用处非常简洁把类型判断、转换、执行的样板代码都封装起来了。6.4 泛型与密封类的组合状态建模密封类sealed class配合泛型可以优雅地建模加载中/成功/失败三态sealed class UiStateout T { object Loading : UiStateNothing() data class SuccessT(val data: T) : UiStateT() data class Error(val message: String) : UiStateNothing() }注意这里UiStateout T用了out因为T只出现在Success的data字段输出位置。Loading和Error用Nothing作为类型参数因为Nothing是所有类型的子类型可以赋值给任何UiStateT。使用的时候fun render(state: UiStateUser) { when (state) { is UiState.Loading - showLoading() is UiState.Success - showUser(state.data) is UiState.Error - showError(state.message) } }这个模式在Android的ViewModel里非常常见out型变让UiStateNothing能赋值给UiStateUser编译器能正确推断when分支的穷尽性。7. 泛型使用中的常见编译错误与排查思路7.1 Cannot check for instance of erased type的三种解法这个错误出现频率最高原因是运行时类型被擦除。三种解法解法一用reified内联函数。适合函数调用场景inline fun reified T isType(value: Any): Boolean value is T解法二传ClassT参数。适合不能内联的场景fun T isType(value: Any, clazz: ClassT): Boolean clazz.isInstance(value)解法三用filterIsInstance。适合集合过滤val strings mixedList.filterIsInstanceString()7.2 Type mismatch与型变冲突的定位型变冲突的报错通常比较隐晦。比如fun T copy(src: MutableListT, dest: MutableListT) { ... } val dogs: MutableListDog mutableListOf() val animals: MutableListAnimal mutableListOf() copy(dogs, animals) // 编译错误类型不匹配因为MutableList是不变的MutableListDog和MutableListAnimal没有子类型关系。解决办法是用类型投影fun T copy(src: MutableListout T, dest: MutableListin T) { ... }src用out只读dest用in只写这样copy(dogs, animals)就能编译通过了。排查型变问题的思路先确定每个泛型参数是读、写还是读写然后决定用out、in还是不加修饰。如果报错说T不能出现在某位置就检查是不是型变修饰和实际用法冲突了。7.3 泛型函数重载冲突的规避前面提到过擦除后签名相同的泛型函数会冲突。规避方法有三种方法一加JvmName注解JvmName(processStrings) fun process(list: ListString) { } JvmName(processInts) fun process(list: ListInt) { }方法二用不同的函数名。这是最直接的做法虽然不够优雅但可读性最好。方法三用reified区分。因为reified函数在编译期展开签名不同inline fun reified T processTyped(list: ListT) { }注意JvmName只影响JVM层面的签名Kotlin调用处还是用同一个名字。如果两个函数逻辑差异大建议直接用不同函数名避免混淆。8. 从泛型到类型系统一些进阶思考8.1 泛型与反射的配合边界泛型擦除和反射是一对矛盾体。反射需要运行时类型信息而泛型擦除恰恰把类型信息删了。但通过TypeToken、KType、typeOf这些工具可以在一定程度上恢复泛型信息。Kotlin 1.3之后引入了typeOfT()配合reified可以拿到KTypeinline fun reified T getKType(): KType typeOfT()KType比Class保留了更多信息比如泛型参数、可空性。这在写序列化框架、依赖注入容器的时候很有用。但要注意typeOf拿到的类型信息也是编译期生成的对于深层嵌套的泛型比如MapString, ListMapInt, String解析起来会比较复杂。实际项目里如果泛型嵌套超过三层建议重新设计数据结构不要硬用泛型。8.2 泛型设计的取舍原则写了这么多泛型代码我的体会是泛型是工具不是目标。有些场景用泛型反而增加复杂度不如直接写具体类型。判断标准很简单如果一份逻辑需要重复三次以上且重复的只是类型不同那就用泛型抽象。如果只重复一两次或者类型之间有复杂的转换关系直接写具体实现更清晰。另外泛型层次不要太深。我见过一个项目里泛型参数套了五层RepositoryT, ID, E, C, R每次看代码都要翻定义。这种时候应该把部分类型参数提升为类的字段或者拆成多个类。8.3 泛型代码的可读性维护泛型代码的可读性是个大问题。几个实用建议第一类型参数命名要有意义。T表示通用类型K/V表示键值E表示元素R表示返回。不要用A、B、C这种无意义的名字。第二约束尽量写在声明处。where子句虽然灵活但读代码的人需要跳转才能看到约束。如果约束简单直接写在类型参数后面更直观。第三给复杂的泛型类型起别名。Kotlin的typealias可以简化长泛型typealias UserMap MapString, ListUser typealias ApiCallbackT (ApiResultT) - Unit这样调用处就不用写一长串泛型了。第四文档注释要说明型变关系。如果一个泛型类用了out或in在KDoc里说明为什么这么设计方便后续维护的人理解。泛型这块内容光看是学不会的必须动手写。建议你从封装一个通用的ApiResult开始然后尝试写一个泛型Repository再试试用reified封装JSON解析。每写一个就回头看看编译器的报错理解它为什么报错。踩过几次坑之后out、in、where、reified这些关键字就会变成你的直觉反应而不是需要查文档的知识点。