先把自己扔回一个常见场景:你在写一个通用解析框架,或者自制一个依赖注入容器,反正就是那种需要扫描类的字段、逐个判断类型的活儿。Kotlin反射包一引入,property.returnType拿回来一看,怎么有些类型长得和别人不一样?T、List<T>、Class<T>,这堆东西和普通的String、Int到底该怎么区分?我说的就是Kotlin泛型判断里最基础也最容易被绕晕的问题:怎么判断一个属性的类型是不是泛型。
这篇东西不聊虚的,直接围绕“属性类型判断”这个点拆开揉碎。你会搞明白Kotlin反射里KType是怎么构成的、KClass和KTypeParameter有什么区别、怎么写一个能递归识别List<T>这种嵌套泛型的判断函数,以及最常见的几个反射大坑。适合正在写框架、做代码生成器,或者单纯想把Kotlin反射搞明白的人。
1. 把问题翻译成人话:你说的“泛型”到底是哪一种
1.1 泛型参数和参数化类型是两码事
很多人卡在这一步,是因为“属性类型是泛型”这句话天然有歧义。我至少见过三种理解方式,对应的判断逻辑完全不同。
第一种,属性类型直接就是一个类型参数T。比如class Container<T> { val value: T },这里的value类型就是T,Kotlin反射里它的classifier会是一个KTypeParameter。
第二种,属性类型是一个带有泛型参数的类型,比如val items: List<T>或者val items: List<String>。这种类型在反射里的classifier是List,本身不是类型参数,但它的arguments里面装着T或者String。
第三种,你其实想知道的是:某个泛型参数最终被具体类型填成了什么。比如有个类UserHandler继承BaseHandler<User>,你想知道父类里的T是不是User。这属于“解析实际类型参数”的范畴,跟单纯的“是不是泛型”已经不是一个难度等级。
所以动手写代码之前,先问自己一句:我要判断的是哪一种?如果连问题的粒度都没定好,后面写出的判断函数大概率是一坨只在特定场景下才能跑的代码。
1.2 什么时候你会需要这个判断
判断属性类型是否为泛型,最典型的场景是写通用框架。
我做协议解析器的时候,需要把网络返回的JSON自动填充进数据模型,模型里经常会出现PageResult<T>、List<Data>、Map<String, List<Item>>这种字段。解析器就必须知道:这个字段是具体类型,还是泛型占位?如果是泛型占位,我暂时没法直接实例化,得放到后面等具体类型信息补全了再处理。
依赖注入容器也一样。构造函数参数如果是UserService,我直接按类查找Bean;如果参数是List<UserService>,我不仅要找到UserService的Bean,还得把多个Bean组装成List塞进去。这种“谁的参数带泛型”的判断,躲不开反射。
只要你在跟反射、序列化、代码生成打交道,迟早都会撞上这题。与其到时候在Stack Overflow上翻半天,不如现在就把原理搞清楚。
2. 判断前必须搞懂的三个Kotlin反射概念
2.1 KType的构成:classifier和arguments
Kotlin反射里,一个属性的类型对应的类是KType。它负责描述“类型的完整模样”,核心信息就两块:classifier和arguments。
classifier描述的是这个类型的“主体”是谁。拿List<String>举例,classifier就是List::class。拿String举例,classifier就是String::class。拿T举例,classifier就变成了类型参数T对应的KTypeParameter对象,而不是某个具体的KClass。
arguments描述的是类型参数的实际填充内容。List<String>的arguments就包含一个元素,这个元素指向String;Map<String, Int>的arguments包含两个元素,分别指向String和Int。
我习惯这样类比:classifier是“盒子”的标签,标签上写着“这是List还是String还是T”;arguments是盒子里面装的东西,装的是“List里面到底是什么”。判断属性是否泛型,本质就是看这两块信息里有没有藏着类型参数。
2.2 classifier里藏着KClass和KTypeParameter两条分支
这是整个判断逻辑的核心分支点。
拿到一个KType之后,你去看它的classifier,只有两种结果:
- 是
KClass:说明这一层是某个具体的类,比如String、List、User这种,不是类型参数本身。 - 是
KTypeParameter:说明这一层直接就是一个泛型参数,比如T、E这种。
判断代码只有一行:
if (type.classifier is KTypeParameter) { // 这一层就是泛型参数 }但是光判断这一层不够。List<T>这个类型的classifier是List,不是KTypeParameter,可它内部确实带着T。如果只做一层判断,List<T>会被误判成“不是泛型”,这在很多场景下都不可接受。
所以要用递归的方式,把arguments也翻一遍。至于List<String>这种,它的arguments里是具体的String,翻到底也找不到KTypeParameter,自然就判成“不包含泛型”,逻辑是自洽的。
2.3 在代码里拿到属性的KType
聊完原理,先明确最基础的一步:怎么拿到一个属性对应的KType。
第一种方式是Kotlin反射的属性引用。假设有一个类Container<T>,里面声明了若干属性:
class Container<T> { val value: T? = null val items: List<T>? = null val fixed: List<String>? = null val name: String = "" val clazz: Class<T>? = null }遍历成员属性的写法如下:
import kotlin.reflect.full.declaredMemberProperties fun main() { Container::class.declaredMemberProperties.forEach { prop -> val returnType = prop.returnType println("${prop.name}: ${returnType}") } }输出大致是:
value: T? items: kotlin.collections.List<T>? fixed: kotlin.collections.List<String>? name: kotlin.String clazz: java.lang.Class<T>?注意这里的打印结果,value的returnType.classifier就是KTypeParameter,而items的returnType.classifier是List::class,它内部藏着T。这个差异正好对应前面说的两种粒度。
第二种方式是用typeOf<T>()直接构造一个KType,这在写单测、验证反射逻辑时特别方便。但注意,typeOf只能用于具体类型,比如typeOf<List<String>>()没问题;如果想在泛型函数里写typeOf<T>(),除非把T声明成inline配合reified,否则编译器直接不让你过。
3. 核心实现:写一个函数彻底判断属性类型是否含泛型
3.1 基础版:只看当前层是不是泛型参数
先把最简版本写出来。如果你只需要判断属性类型“是不是类型参数本身”,那就很直接。
import kotlin.reflect.KType import kotlin.reflect.KTypeParameter fun KType.isTypeParameter(): Boolean { return classifier is KTypeParameter }这个函数的判断粒度很窄,只回答一个问题:这一层是不是T。用它判断Container里的value会返回true,判断items会返回false,因为items这一层是List。
实际项目中这个函数的用处有限,因为大多数时候我们想判断的是“这个属性类型里有没有泛型参数”,而不是“它这一层是不是泛型”。
3.2 递归版:连嵌套泛型List 一起识别
把判断范围扩到整个类型结构,写成递归版本:
import kotlin.reflect.KType import kotlin.reflect.KTypeParameter fun KType.containsTypeParameter(): Boolean { if (classifier is KTypeParameter) return true return arguments.any { it.type?.containsTypeParameter() == true } }这段代码的意图很清晰:先看自己这层,如果自己就是类型参数,直接返回true;否则翻自己的arguments,看看有没有哪一层参数里包含了类型参数。
注意arguments里的元素类型是KTypeProjection,它代表“类型参数位置上的投影”,可能是具体类型、泛型参数,也可能是*。当遇到List<*>这种星号投影时,投影里的type是null,这里用了?.空安全调用和== true兜底,不会崩溃,也能正确判断成“不包含明确的泛型参数”。
用它来跑Container里的四个属性,结果就是:
value: T?:classifier是KTypeParameter,判为true。items: List<T>?:classifier是List,但arguments里有T,所以也是true。fixed: List<String>?:classifier是List,arguments里是具体String,翻遍也没有类型参数,判为false。clazz: Class<T>?:classifier是Class,arguments里有T,所以true。
这个函数已经可以覆盖大部分业务场景了。你问我“List<String>算不算泛型”,那要看你业务里的定义。如果是想判断“是否需要后续补全类型信息”,那List<String>其实不需要补全,类型信息已经全了,containsTypeParameter()返回false是合理行为。
3.3 带缓存和star投影处理的完整版本
反射调用本身有性能开销,如果你的框架会频繁扫描同一个类的属性,建议加一层缓存。写一个对象封装起来,避免每次判断都重新遍历整个KType结构。
import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.KType import kotlin.reflect.KTypeParameter object TypeInspector { private val cache = ConcurrentHashMap<String, Boolean>() fun containsTypeParameter(type: KType): Boolean { val key = type.toString() cache[key]?.let { return it } return cache.getOrPut(key) { doContainsTypeParameter(type) } } private fun doContainsTypeParameter(type: KType): Boolean { if (type.classifier is KTypeParameter) return true return type.arguments.any { projection -> projection.type?.let { doContainsTypeParameter(it) } == true } } }用type.toString()做缓存key不算严谨,同一个类型理论上应该有同构的toString结果,实际用下来足够稳定。如果你想要更健壮的key,可以用classifier的名字加上arguments的递归结构去拼,但一般情况下没必要。
加上缓存后,属性上面那个Container的例子可以这样输出:
fun main() { Container::class.declaredMemberProperties.forEach { prop -> val type = prop.returnType println("${prop.name}: containsTypeParameter = ${TypeInspector.containsTypeParameter(type)}") } }输出结果:
value: containsTypeParameter = true items: containsTypeParameter = true fixed: containsTypeParameter = false name: containsTypeParameter = false clazz: containsTypeParameter = true4. 进阶:从“是不是泛型”到“泛型到底被填充成了什么”
4.1 override和泛型继承会悄悄替换类型
如果只是扫描一个类自己声明的字段,上面的判断函数已经够用了。但真实项目里经常遇到继承和override,这时候一个重要的陷阱会出现:你反射到的属性类型,可能不是你想的那个。
看这段代码:
open class Base<T> { open val content: T? = null } class StringChild : Base<String>() { override val content: String? = "hello" }如果直接扫描StringChild::class.declaredMemberProperties拿content属性的returnType,得到的会是kotlin.String?,而不是T?。
原因很简单——这个属性在子类里已经被override成具体类型了。Kotlin编译器在生成字节码时,会把override后确定的类型写进签名,反射自然拿到的是String。
但换个写法就有趣了:
class Child<T> : Base<T>() { override val content: T? = null }这里Child<T>本身还是泛型类,content的类型依然写的是T。用Child::class.declaredMemberProperties反射它,拿到的returnType.classifier还是KTypeParameter。
所以判断“属性类型是否为泛型”之前,先想清楚:你这个属性是声明在泛型类内部,还是被某个具体类型override了?不同情况下反射结果完全不同。
4.2 解析父接口/父类的实际类型参数
现在说第三种理解:想知道泛型参数最终被填成了什么。
最常见的是处理泛型父类。比如:
abstract class BaseHandler<T> { abstract fun handle(entity: T) } class UserHandler : BaseHandler<User>() { override fun handle(entity: User) {} }我想知道UserHandler里的T对应的是User,该怎么做?
Kotlin反射提供了一条路径:直接看UserHandler::class.supertypes,里面是UserHandler所有父类型的KType列表,其中classifier为BaseHandler::class的那个KType就是参数化之后的父类型。
import kotlin.reflect.full.supertypes import kotlin.reflect.KType fun findParameterizedSupertype(clazz: kotlin.reflect.KClass<*>, target: kotlin.reflect.KClass<*>): KType? { return clazz.supertypes.firstOrNull { it.classifier == target } } fun main() { val superType = findParameterizedSupertype(UserHandler::class, BaseHandler::class) println(superType) // BaseHandler<User> println(superType?.arguments?.firstOrNull()?.type) // User }Java反射也有对应的做法,而且对很多老框架来说更顺手:
fun getSuperclassTypeArguments(clazz: Class<*>): List<Type> { val superclass = clazz.genericSuperclass return if (superclass is ParameterizedType) { superclass.actualTypeArguments.toList() } else { emptyList() } }genericSuperclass返回一个Type,如果父类是带泛型参数的,这个Type就是ParameterizedType,它的actualTypeArguments就是被填充后的类型参数列表。UserHandler的例子中,这个列表里就是User::class。
如果actualTypeArguments里的元素还实现了TypeVariable接口,说明父类的泛型参数没有被完全填充,比如class GenericChild<T> : BaseHandler<T>这种,那这里拿到的就是T对应的TypeVariable,不是具体类。这个判断可以继续往后做,比如把TypeVariable和子类的泛型参数对应起来,这就是很多框架里“泛型传递解析”的底层原理。
4.3 Java反射和Kotlin反射的区别
写到这里,顺便把Java和Kotlin反射的对应关系理清楚。两者的底层是同一套字节码里的泛型签名信息,但API长得不一样。
Java反射里,java.lang.reflect.Type有三个重要实现:Class表示具体类型,ParameterizedType表示带泛型参数的类型,TypeVariable表示类型参数。Kotlin反射里,KClass对应Class,KType大致对应ParameterizedType加一层包装,KTypeParameter对应TypeVariable。
用Kotlin反射判断类型参数,看classifier is KTypeParameter;用Java反射判断类型参数,看type is TypeVariable。两套体系要做的事是一样的。
实际操作的时候,我的习惯是:能直接用Java反射解决的(比如只取类名、拿泛型父类参数),优先用Java反射,稳定、依赖少、性能好;需要处理Kotlin特有的可空性、协变逆变、reified信息时,再用Kotlin反射。反正kotlin-reflect引入之后两套可以混着用,没必要死守其中一种。
5. 实战现场与高频坑位笔记
5.1 一个真实的扫描调用场景
结合前面的函数,做一个完整的属性扫描器,把类型信息全部列出来:
import kotlin.reflect.KClass import kotlin.reflect.KType import kotlin.reflect.KTypeParameter import kotlin.reflect.full.declaredMemberProperties fun inspect(clazz: KClass<*>) { clazz.declaredMemberProperties.forEach { prop -> val type = prop.returnType val directParam = type.classifier is KTypeParameter val nestedParam = TypeInspector.containsTypeParameter(type) val argumentInfo = type.arguments.joinToString { projection -> val inner = projection.type?.let { it.toString() } ?: "*" "$inner (variance=${projection.variance})" } println("${prop.name} | directlyParam=$directParam | containsParam=$nestedParam | args=[$argumentInfo]") } }拿前面Container<T>跑一遍,输出大概是:
value | directlyParam=true | containsParam=true | args=[] items | directlyParam=false | containsParam=true | args=[T? (variance=INVARIANT)] fixed | directlyParam=false | containsParam=false | args=[kotlin.String (variance=INVARIANT)] name | directlyParam=false | containsParam=false | args=[] clazz | directlyParam=false | containsParam=true | args=[T (variance=INVARIANT)]这个输出表放在文档里当参考很直观。实际写框架时,directParam和containsParam两条判断往往都要保留,前者用于“能不能直接实例化默认值”,后者用于“这个类型是否还需要后续泛型信息注入”。
5.2 最容易被绕晕的4个坑
第一个坑是只判断classifier就下结论。很多新人写判断函数只看classifier is KTypeParameter,结果List<T>被判成“不是泛型”,之后处理PageResult<T>这种类型时彻底懵了。解决办法就是用递归遍历arguments,也就是前面写的containsTypeParameter()。
第二个坑是star投影引起的NPE。List<*>在反射里对应的KTypeProjection,它的type是null,因为它表示“不确定具体类型”。如果你在遍历arguments时直接写it.type!!.classifier,遇到List<*>立刻炸。记得用it.type?.let { ... }这种空安全写法,把它当成“不包含明确泛型参数”处理。
第三个坑是R8/ProGuard把泛型签名干掉了。在Android项目里跑反射,默认开启代码混淆后,类的泛型签名(Signature属性)可能被移除,拿到手的returnType.arguments就变成空列表了。这不是代码写错,是编译配置问题。解决办法是给需要反射的类加上混淆规则,保留Signature:
-keepattributes Signature -keep class com.example.model.** { *; }Signature属性对于泛型反射来说就是命根子,删了之后所有泛型判断都会退化。
第四个坑是分不清属性声明所在的类。反射属性时,用declaredMemberProperties只能拿到当前类自己声明的属性,拿不到父类声明的属性。如果你用UserHandler去反射handle方法里的T参数,但方法是父类BaseHandler里声明的,你需要先解析父类类型参数,然后再匹配。很多人栽在这里,是因为不知道declaredMemberProperties和memberProperties的区别,前者只管当前类,后者会包含继承来的public属性,但后者的类型解析结果同样会受到override影响。
5.3 排查思路速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
T被识别成了具体类 | override把类型替换成了具体类型 | 看属性声明在哪个类,是否被子类override |
List<T>判断成“不是泛型” | 只做了单层判断 | 改用递归遍历arguments |
List<*>直接抛NPE | star投影的type为null | 用空安全访问,不直接调!! |
| 反射拿不到泛型参数 | 混淆移除了Signature属性 | 检查R8/ProGuard规则,保留Signature |
typeOf<T>()编译不过 | T不是reified | 只能配合inline fun使用,或改用反射 |
| 属性在父类里扫描不到 | 用了declaredMemberProperties | 换成memberProperties,或先解析父类再自行递归 |
这张表我建议收藏。遇到反射泛型问题,先对号入座,再决定往哪个方向查,比自己从零debug快得多。
最后再分享一点个人习惯:写这类类型判断函数时,我会先把“泛型”这个词在业务里的语义定义清楚,是要判断“这一层是类型参数”,还是要判断“整个类型结构里藏了类型参数”,还是“把泛型参数解析成具体类型”。三者代码完全不同,但经常被混在一起讨论。你把粒度定清楚了,代码结构自然就清晰了。判断函数本身只做纯逻辑,不掺业务;真正决定“拿这个判断结果干什么”的代码,放到外层去写。以后Kotlin反射API就算有变动,需要改的也只是最里面那个纯函数而已。