Kotlin泛型精讲:in/out、类型投影与reified完全指南
2026/9/10 19:34:44 网站建设 项目流程

1. 先从 Java 泛型通配符的痛点说起

如果你是从 Java 转到 Kotlin 的开发者,第一次看到List<out T>MutableList<in T>这样的写法时,多半会愣一下:outin是关键字还是什么修饰符?为什么一个 List 接口要搞出两种泛型声明?这背后其实藏着一个在 Java 里折磨了开发者很多年的老问题——泛型通配符的使用混乱。

在 Java 里,为了表达"这个集合只能读、不能写"或者"这个集合可以接受任意 T 的子类型",你不得不写出这样的签名:

void copy(List<? extends T> src, List<? super T> dest) { for (T item : src) { dest.add(item); } }

? extends T? super T就是 Java 的通配符。问题在于:它们把"这个类型参数到底扮演什么角色"这件事拖到了每个使用场景去声明。你在调用方写? extends,在参数里写? super,写多了之后,整个 API 的签名会变得又臭又长,而且逻辑稍有差错,编译器就会抛出让你摸不着头脑的类型不兼容错误。我在 Java 项目里最常看到的场景是:团队里大多数人分不清什么时候该用extends什么时候该用super,于是干脆全部写? extends,然后某一天在某个add操作上报了一堆编译错误。

Kotlin 的设计者显然受够了这种写法。他们的思路是:与其让每个调用方都去操心和书写通配符,不如把"这个类型参数是用来生产的还是用来消费的"直接写在类声明里。这就是 Kotlin 声明处变型(declaration-site variance)的核心:out表示这个类型参数只会作为输出(生产者)出现,in表示它只会作为输入(消费者)出现。你声明了一次,整个类体系和使用方都不用再写任何通配符,编译器天然就懂。

看到这里你应该明白,inout其实不是 Kotlin 独创的概念,而是对 Java 通配符使用经验的一次系统性总结和简化。理解这一点之后,再去看List<out T>Function<in T, out R>之类的签名,就不会觉得它们是深奥的黑魔法,而只是一套更清晰的类型契约。

2. out:协变与安全的生产者契约

2.1 List 为什么不能 add

先看 Kotlin 标准库里的真实声明:

public interface List<out E> : Collection<E> { override val size: Int override fun isEmpty(): Boolean override fun contains(element: @UnsafeVariance E): Boolean override fun iterator(): Iterator<E> public operator fun get(index: Int): E }

List声明了out E,这意味着它把E完全当作"只出不进"的类型来对待。所以List接口里有get(index: Int): E,可以有iterator(): Iterator<E>,但绝不会有add(element: E)这样的方法。你拿到的List<String>,在编译器层面就保证了它不可能被外部写入,因此任何拿到这个引用的人都可以放心地读取,不用担心集合内容被悄悄篡改。

这里有个非常容易踩的坑:很多人以为out只是"读方法能返回 E,写方法不能出现 E"这样一个简单的规则,但实际编译器检查得更细。比如下面这段代码就会报错:

class Box<out T>(private val value: T) { fun set(newValue: T) { // 编译错误:Type parameter T is declared as 'out' but occurs in 'in' position // ... } }

因为set的参数位置是"输入"位置,而out T已经声明了 T 只能出现在"输出"位置,编译器直接禁止这种用法。反过来说,下面这种就是合法的:

class Box<out T>(private val value: T) { fun get(): T = value }

构造函数里的T不受out限制,这算是一个特殊情况——Kotlin 允许在构造参数里出现out类型,因为对象一旦构造完成,构造参数就已经完全消费掉了,不会再对类型安全构成威胁。

2.2 协变到底在保护什么

用一个实际模型来讲。假设你有这样一个类型层级:

open class Animal class Dog : Animal() class Cat : Animal()

声明Box<out T>之后,Box<Dog>会被认为是一个Box<Animal>的子类型,这在类型系统里叫做协变。为什么这是安全的?因为Box承诺只往外吐数据,不往里面塞数据。那么,把Box<Dog>当作Box<Animal>来用,最多只是从盒子里取出 Dog 时把它当 Animal 看,类型信息只会变宽泛,永远不会出错。

反过来,如果允许对Box<Dog>写入一个Cat——因为CatAnimal,而Box<Dog>在类型上等价于Box<Animal>——那这个盒子内部实际存储的 Dog 就会混入一个 Cat,后续读取并把它当 Dog 使用的人就会当场爆炸。这就是编译器为什么死死按住out类型不能出现在参数位置的底层原因:它在用编译期约束换取运行期的绝对安全

我在设计 SDK 对外暴露的接口时非常喜欢用out。比如我封装了一层用户信息加载器:

interface UserLoader<out T> { fun load(): T }

内部实现可能是RealUserLoader,返回的是UserDetail这样更具体的类型,但对外暴露的时候,我可以放心地把UserLoader<UserDetail>当作UserLoader<User>使用,调用方拿到之后只能读取,永远没有机会往里面塞东西。这样从 API 层面就杜绝了一大类潜在 bug。

2.3 使用处协变:给只读操作戴上安全帽

除了声明处变型,Kotlin 还支持使用处变型(use-site variance),也就是类型投影。当你在某个函数参数里写MutableList<out String>时,你等于对这个MutableList加了一道"只读"限制:

fun printStrings(list: MutableList<out String>) { for (s in list) { println(s) } // list.add("hello") // 编译错误,这里不能 add }

这样做的好处是,调用方可以传入一个MutableList<String>,也可以传入一个MutableList<CharSequence>,只要元素是String的子类型就能用。如果你直接把参数声明成MutableList<String>,那么MutableList<CharSequence>就传不进来,因为MutableList本身是不变的(invariant)。投影之后,函数内部只能读取,不能写入,这个"只读保护"是由编译器强制施行的。

有个细节值得注意:out投影出来的引用,虽然不能调用add,但因为它底层可能仍然是个可变集合,所以在多线程环境里,如果多个线程共享这个集合,你仍然需要自己做好同步。out只是类型层面禁止当前这个引用去写,并不等于集合本身不可变。这一点经常被初学者误解,我见过有人在投影出的集合上做了并发读取,结果底层另一个线程正在修改集合,直接抛了ConcurrentModificationException

3. in:逆变与受控的消费者设计

3.1 逆变的反直觉之处

如果说out是"只进不出"的协变,那in就是"只进不出"的逆变,但它在类型关系上带来的效果非常反直觉:Box<Animal>反过来是Box<Dog>的子类型。写成代码:

interface Sink<in T> { fun accept(item: T) } val animalSink: Sink<Animal> = ... val dogSink: Sink<Dog> = animalSink // 合法!Sink<Animal> 是 Sink<Dog> 的子类型

为什么逆变是安全的?因为Sink只负责把东西吞进去,从来不往外吐。你把一个能接收任意 Animal 的Sink<Animal>当作Sink<Dog>来用,意味着往里塞 Dog,而Sink<Animal>本身就能处理 Animal,Dog 当然是 Animal,所以完全没问题。这就像你雇了一个"什么动物都能安排"的管家(Sink<Animal>),现在只需要他专门接待狗(Sink<Dog>),他当然干得了。

反过来想:如果Sink声明了out T,还把Sink<Dog>Sink<Animal>用,那就麻烦了,因为Sink<Animal>作为消费者,理论上可以接受 Cat,而底层实现只认识 Dog,一个 Cat 塞进去就乱了套。所以inout的约束方向是完全镜像的。

3.2 逆变在标准库里的真实身影

Kotlin 标准库和很多第三方库都大量使用了逆变。最典型的就是Comparator

public interface Comparator<in T> { fun compare(a: T, b: T): Int }

Comparator<Animal>肯定是能比较任意两只 Animal 的。如果你有一个Comparator<Animal>,用它来排序一个List<Dog>,从逻辑上完全合理——因为 Dog 就是 Animal,比较器内部只需要把传入的对象当 Animal 处理即可。所以Comparator<Animal>应该能安全地充当Comparator<Dog>。这正是in T带来的效果:Comparator<Animal>Comparator<Dog>的子类型,可以直接传递。

还有Function1<in P1, out R>,参数位置用了in,返回值位置用了out。这意味着一个(Animal) -> String的函数,可以安全地用在需要(Dog) -> String的地方;而一个(Any) -> Dog的函数,也可以用在需要(Any) -> Animal的地方。函数式接口里inout各管一头、互不干扰,这是 Kotlin 标准库设计很精妙的一处。

3.3 声明逆变接口时的几个注意事项

我在自己写EventHandlerDataSink这类接口时,经验是尽量只保留消费方法,不要混入生产方法。因为一旦接口里同时有"输入 T"和"输出 T"的方法,你就没法声明in Tout T,只能退回到不变(invariant)类型。这会让使用端失去很多灵活性。

另一个注意事项是:in类型的equals()是一个特例。Kotlin 允许你在in T声明下调用equals,因为equals的参数是Any?,不是 T,它不违反逆变约束。但如果你自定义了一个fun isSame(other: T): Boolean,编译器就会拦下来,因为它把 T 放到了输出位置的返回类型里。虽然从语义上"判断是否相等"看起来是只读操作,但编译器只看类型签名:T出现在了返回值位置,就必须禁止。

4. 类型投影与星投影:使用处变型的灵活性

4.1 投影是临时的、局部的

声明处变型是"写在类定义里,全类生效",而使用处变型是"写在某个函数的参数里,只对这次调用生效"。投影这个叫法很形象:你并没有改变底层类的定义,只是从某个角度给它打了一层聚光灯,照到的一面可见,照不到的一面直接隐藏。

举一个实际的例子,我封装过一个通用的数据上报接口:

fun <T> reportAll( items: List<out T>, reporter: (T) -> Unit ) { for (item in items) { reporter(item) } }

这个函数接受任何元素类型是 T 的只读列表。因为List本身声明了out E,这里的out T其实有些多余,但为了演示使用处投影,它展示了"即使集合没有声明 out,我也可以在函数参数这里临时加一道只读限制"。假如传入的是一个ArrayList<Dog>,编译器会把它投影成List<out Dog>,函数内部就只能读取不能写入。

4.2 星投影:不知道具体类型时的兜底

有时候你根本不在乎泛型具体是什么,只想知道"这个集合里有没有东西",或者"能不能遍历一下"。这时候星投影List<*>就派上用场了:

fun printAll(list: List<*>) { list.forEach { println(it) } }

List<*>本质上等价于List<out Any?>——因为你不知道元素类型,所以只能取出Any?,但遍历是安全的。如果是MutableList<*>,能做的事情就更少了:mutableListOf<String>("a").let { printAll(it) }这样的代码能编译,但你在MutableList<*>上调用add会直接报错,因为编译器不知道它可以接收的元素到底是什么类型,任何具体类型都可能不匹配。

我在做调试工具的时候特别喜欢用星投影。比如打印一个任意列表的容量和元素数量,或者判断一个容器是否为空,这些场景不需要知道 T 的具体信息,星投影能把签名写得非常简洁。

4.3 声明处变型和使用处变型怎么选

这里我给出一个自己在实践中验证过很多次的决策思路:

问题决策
这个类型参数在类的整个生命周期里只生产不出吗?声明为out T
这个类型参数在类的整个生命周期里只消费不进吗?声明为in T
类型参数既会生产又会消费?保持不变(默认),不要强行加in/out
类本身没声明变型,但某个函数里只需要读取?参数使用out投影
类本身没声明变型,但某个函数里只需要写入?参数使用in投影
完全不确定类型,只想做通用只读操作?使用*星投影

如果你在类声明里搞不清楚 T 是生产还是消费,那就老老实实别加in/out。强行加上的后果比不加更严重:编译器会在每个方法上疯狂报错,你可能会越想改签名越混乱。宁可一开始保持不变类型,等 API 稳定了再根据实际用法优化成inout

5. reified:把被擦除的类型找回来

5.1 类型擦除带来的老问题

JVM 上的泛型存在一个历史遗留问题:类型擦除。也就是说,List<String>List<Int>在运行时都是同一个List,元素的类型信息在编译后就被抹掉了。在 Java 里你没法写T.class,也没法写if (obj instanceof T)。Kotlin 在 JVM 上也继承了这些限制。但 Kotlin 提供了reified作为突破口,前提是函数必须是inline的。

先看一个我在实际项目里踩过的坑。最开始我用 Gson 做 JSON 解析时,写了一个工具函数:

// 这段代码编译不过 fun <T> fromJson(json: String): T { return Gson().fromJson(json, T::class.java) // Cannot use 'T' as reified type parameter... }

报错的原因就是类型擦除:T::class.java需要知道 T 的运行时信息,但普通泛型函数的 T 已经被擦除了。当时的解决方案要么是把Class<T>作为参数传进去,要么把函数改成inlinereified T。前者每个调用点都要手动传Class,非常啰嗦;后者干净得多。

5.2 inline + reified 的工作原理

reified之所以能工作,核心在于inline函数在编译期会被内联展开。编译器会把整个函数体直接搬到调用点,然后在这个展开的过程中,真正知道 T 的具体类型是什么。因为调用点的代码是带着具体类型写的,比如jsonToModel<User>("{}"),编译器内联时就能把T直接替换成User,于是T::class.java就变成了User::class.java,这一步在编译期就完成了,完全避开了运行时擦除。

所以reified本质是编译期类型替换,而不是运行时有什么魔法让类型信息起死回生。理解这一点很重要,因为它严格限制了reified的使用范围:只有inline函数才能声明reified,普通函数一概不行。

一个完整的 Gson 工具函数长这样:

inline fun <reified T> Gson.fromJson(json: String): T { return fromJson(json, T::class.java) } // 调用时自动推断类型 val user: User = gson.fromJson("""{"name": "张三"}""") // 或者显式指定 val listType = object : TypeToken<List<User>>() {}.type val userList: List<User> = gson.fromJson(json, listType)

这里的reified让调用端极其舒服,不需要传任何Class参数,编译器帮你全都办了。这几乎是 Kotlin 泛型最受欢迎的特性之一。

5.3 reified 还能做什么:is 判断、instanceof、构造实例

reified的能力远不止获取Class。我在实践中常用到这几类操作:

类型判断

inline fun <reified T> Any.isInstanceOf(): Boolean = this is T

没有reified时,this is T是编译不过的。有了reified,这段代码在编译期被展开成this is Stringthis is User这样的具体判断,写起来跟原生一样自然。

获取 KClass 或 Class

inline fun <reified T> loadService(): T? { val clazz = T::class val serviceImpl = ServiceRegistry.find(clazz.java) return serviceImpl as? T }

在编写简单的 ServiceLocator 或依赖注入容器时,这个模式非常常用。

创建泛型数组

inline fun <reified T> arrayOfType(size: Int, noinline init: (Int) -> T): Array<T> { return Array(size) { index -> init(index) } }

普通泛型函数里没法直接创建Array<T>,因为运行时不知道 T 到底是什么类型,JVM 无法确定数组的元素类型。reified内联后,Array<T>就变成了Array<String>或者Array<User>,创建起来没有任何问题。

结合构造函数使用

inline fun <reified T : Any> createInstance(noinline creator: () -> T = { T::class.java.getDeclaredConstructor().newInstance() }): T { return try { creator() } catch (e: Exception) { throw IllegalStateException("创建 ${T::class.simpleName} 失败", e) } }

注意这里我保留了creator参数作为兜底,因为不是所有类都有无参构造函数,直接反射实例化对带参构造的类无能为力。这是我多次踩坑之后总结的稳妥写法。

5.4 reified 的限制和三个避坑点

reified好用,但限制也很明确。我踩过的坑大概有三类:

第一,reified 只能用于 inline 函数的类型参数。如果函数被声明为非 inline,哪怕是 private 的,也不能用 reified。而且 reified 类型参数不能跨非 inline 边界传递,比如你不能在 reified 函数里把T传给另一个普通函数作为它的泛型参数;如果你确实需要,那个函数也必须拆解并重新引用。

第二,public inline 函数不能访问 private 成员。因为 public inline 函数体在编译期会被复制到调用方,如果函数体里引用了当前类的 private 变量,内联之后调用方根本访问不到这些 private 成员,编译器会直接报错。如果你的 reified 函数是 public 的,要么把它设计成不访问 private 状态,要么用 internal 修饰。

第三,性能不是完全免费。inline 会让函数体在每个调用点复制一份,调用点很多、函数体很大时,会增大最终的类文件体积。对于 reified 函数,如果你把大量逻辑都写在 inline 函数体里,会明显拖慢编译速度和增加包体。我的经验是:reified 函数体尽量精简,核心逻辑拆到非 inline 的 internal 方法里,只让 reified 函数负责类型相关的转发。

6. 综合实战:用 in、out、reified 做一个类型安全的轻量事件总线

6.1 需要考虑的泛型结构拆解

为了把inoutreified串起来用,我设计过一个类型安全的轻量事件总线。先想清楚需求:

  • 发布者把事件对象投递到总线;
  • 订阅者声明自己关心的事件类型,只接收对应类型的对象;
  • 不要用Any到处乱转,尽可能在编译期给出安全约束。

设计上最核心的问题是如何组织注册表。订阅者针对特定类型注册回调,回调的参数类型应该用in逆变来表达:回调是消费者,EventHandler<in T>让它能兼容更宽泛的类型。事件本身给订阅者的时候是"读",可以用out协变来保证列表的只读性。

整体接口设计如下:

interface EventBus { fun <T : Any> register(handler: EventHandler<T>): Registration fun publish(event: Any) } fun interface EventHandler<in T> { fun onEvent(event: T) } interface Registration { fun unregister() }

这里的EventHandler<in T>in逆变是刻意的。假设有人定义了一个EventHandler<Animal>,那他应该能订阅Dog事件,因为处理器内部本来就能接收任何 Animal。由于我们用inEventHandler<Animal>就是EventHandler<Dog>的子类型,这在注册时带来了很大的灵活性。

6.2 reified 在订阅注册中的关键作用

订阅端希望能这样写:

bus.subscribe<Dog> { dog -> println("收到一只狗:${dog.name}") }

为了让 DSL 看着干净,我封装了一个inline + reified的扩展函数:

inline fun <reified T : Any> EventBus.subscribe( crossinline handler: (T) -> Unit ): Registration { val eventClass = T::class.java val wrapped = EventHandler<T> { event -> handler(event) } return registerByClass(eventClass, wrapped) }

reified T在这里的价值是:调用者不用传Class<T>,编译器根据subscribe<Dog>的具体类型,在编译期内联出eventClass = Dog::class.java。subscribe 内部用Class作为 key 存 Handler 列表,发布事件时也根据事件的运行时 Class 找到对应的注册项。

发布端用起来很简单:

bus.publish(Dog("旺财")) bus.publish(Cat("咪咪"))

在查找订阅者的逻辑里,为了支持"发布 Dog 时,既激活 Dog 的订阅器,也激活 Animal 的订阅器",我会做一次类的继承链遍历。但这里有个性能注意点:每次发布都做isAssignableFrom遍历会比较慢,所以我的实现里会做一层缓存,把Class<?>到它所有父类和接口的列表在注册时就计算好。这种"用空间换时间"的思路在高频事件场景下很关键。

6.3 out 让订阅列表对上层只读

事件总线内部维护的注册表,不希望对用户暴露可写的集合。假设EventHandlerRegistry被外部拿到,如果它是MutableMap<Class<*>, MutableList<EventHandler<*>>>,外部就能随意篡改订阅关系。所以我对外提供的是Map<Class<*>, List<EventHandler<*>>>的只读视图,或者直接封装一层:

class EventHandlerRegistry { private val handlers = mutableMapOf<Class<*>, MutableList<EventHandler<*>>>() fun register(eventClass: Class<*>, handler: EventHandler<*>): Registration { val list = handlers.getOrPut(eventClass) { mutableListOf() } list.add(handler) return Registration { list.remove(handler) } } fun handlersFor(eventClass: Class<*>): List<EventHandler<*>> { return handlers[eventClass] ?: emptyList() } }

这里虽然EventHandler<*>本身带有星投影,但因为接口声明时用了in T,在handlersFor返回之后,调用方拿到的列表是一个List<EventHandler<*>>,逻辑上只读。即便List声明了out E,外部也没法往里面塞新的 handler。这体现了out在 API 设计中的一个隐形价值:声明即文档,看签名就知道能不能修改。

6.4 测试验证与类型安全体会

我用一个单元测试来验证整个链路是否符合预期:

class Animal class Dog(val name: String) : Animal() class Cat(val name: String) : Animal() @Test fun `事件总线正确分发事件`() { val bus = SimpleEventBus() val receivedDog = mutableListOf<Dog>() val receivedAnimal = mutableListOf<Animal>() bus.subscribe<Dog> { receivedDog.add(it) } bus.subscribe<Animal> { receivedAnimal.add(it) } bus.publish(Dog("旺财")) bus.publish(Cat("咪咪")) assertEquals(listOf(Dog("旺财")), receivedDog) assertEquals(listOf(Dog("旺财"), Cat("咪咪")), receivedAnimal) }

这个测试跑通之后,整个链路的设计就是成立的。EventHandler<in T>subscribe<Animal>的处理器既能收到 Dog 也能收到 Cat;reified让订阅端不需要传 Class,代码读起来接近原生语言特性;out则保证了暴露出去的集合无法被外部修改。三个特性各自负责一个角度的安全约束,合在一起才是一个完整的设计。

如果你把这个事件总线稍微扩展,加上coroutine支持或者线程切换,我建议把注册表设计得更加 immutable,或者至少用ConcurrentHashMap保证并发安全。泛型的in/out约束解决了类型层面的安全问题,但并发的数据竞争还需要运行时同步来兜底,两者缺一不可。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询