第086篇 泛型型变 in 与 out:声明处型变

简介: Kotlin型变核心是“位置约束”:`out T`仅用于返回位(协变,只读),`in T`仅用于参数位(逆变,只写);越界即编译报错。`in out T`等价于不变。声明处型变比Java使用处更安全——直接禁用非法操作(如`List<out T>`无`add`)。协程/Flow等API依赖此机制实现类型安全组合。

型变(variance)是 Kotlin 泛型里最容易"用过但说不清"的一题。它的核心只有一条规则——型变位置约束:out 类型只出现在返回位,in 类型只出现在参数位——但从这一条能推导出协变/逆变哪些签名合法、哪些非法,以及为什么 List<out T> 不能接受 add。答得全的人,都是从这条规则推出来的。

先把结论放在前面:Kotlin 用声明处型变(Java 用使用处型变,即通配符 ? extends / ? super)。out T 表示协变——List<Sub> 是 List<Super> 的子类型,T 只出现在返回位置;in T 表示逆变——Consumer<Super> 可以传给 Consumer<Sub>,T 只出现在参数位置。in + out 同时标注(in out T)在 Kotlin 里是声明不变但两次投影的变体,含义是"写时投影、读时投影",用在既读又写的场景(比如 MutableList<in out T> 实际等价于 MutableList<T>)。位置推断:有 in 的是逆变容器,有 out 的是协变容器,in out 是固定容器。

机制拆解

先看类型安全的根基——泛型参数在类型系统里是"不变"(invariant)的。Java 里 List<String> 与 List<Object> 完全无关,不能互相赋值。Kotlin 默认也是不变(MutableList<String> 不是 MutableList<Any> 的子类型)。型变是让程序员显式声明"这个泛型参数在这几个位置上应该允许子类型替换",编译器据此做类型检查。

out 的合法性来自"只读不写"这个事实:如果 T 只出现在返回位,那么 List<Sub> 里的元素被读出来当作 Super 使用是安全的(子类就是父类);但如果允许写入(add),把一个 Super 加进原本声明为 Sub 的容器就是错的。所以 List<out T> 在 Kotlin 里只有只读操作——get、size、iterator、contains 可用,add/remove 根本不存在(编译器不生成这些方法)。这是 Kotlin 相对 Java 更严格的地方:Java 的 List<? extends Number> 仍能调用 add(只是语义危险),Kotlin 直接不给你这个方法。

in 的合法性对称:如果 T 只出现在参数位,那么把 Consumer<Super> 传给期望 Consumer<Sub> 的地方是安全的(消费 Super 的函数当然能消费 Sub)。所以 Comparable<in T> 能接收任何 T 的比较器——compare(a: T, b: T) 是参数位。

in out 的存在是为了"读写都要但分别投影"的场景,典型是 Kotlin 的 MutableList<in out T>。它的意义是:MutableList<in Nothing> 可以当作 MutableList<String> 读(Nothing 是所有类型的子类型,投影后返回 Nothing 向下兼容),MutableList<out Any?> 可以当 MutableList<String> 写。在协程与序列化库里能看到这种写法。真实项目里更常见的是 MutableList<in Number> 这类"只写入特定子类型"的用法。

这些坑的正确绕法

最常见的坑是把 out 类型参数同时用在参数位,编译报错后乱加 @Suppress 掩盖设计问题。 表现是给一个方法写了 fun <T> setItem(item: T, index: Int),然后把类声明成 class Box<out T>,编译报 "Type parameter T is declared as 'out' but occurs in 'in' position"。如果加 @Suppress("UNCHECKED_CAST") 或去掉 out,类型安全就丢了:某处向 Box<Sub> 传了个 Super 实例,运行期 get 出来的 Sub 实际是 Super,ClassCastException。修法是回头改设计:容器本身只读就不标 out;若既要写又要保证读的类型,就分成"写入方法用 in"与"读取方法用 out"两个方法;最稳的是不变容器 + 在参数位用 in(fun <T> putAll(from: Collection<in T>))。

其次是把 List<T> 直接当 List<Any> 用(期望协变但没声明),整条链路被迫加 unchecked cast。 表现是工具函数里写 (list as List<Any>).firstOrNull() as? T,四周都是类型不安全的味道。根因是默认不变。修法是声明处标注 out T(只读场景)或用带投影的参数 Collection<in T>(只消费场景),让类型系统替你说清意图。Android 上典型场景是"把一个 List<Item> 传给只读的工具函数"——如果工具函数签名写成 fun <T> firstOf(list: List<T>),那它其实只需要读,写成 fun <T> firstOf(list: List<out T>) 语义更准确。

还有一个更隐蔽的坑:泛型型变与 Java 互操作时,List<? extends T> 的位置搞错导致运行时崩溃。 表现是 Kotlin 侧传 MutableList<Sub> 给一个 Java 方法 void addAll(Collection<? extends Super> c),编译通过但运行抛 ClassCastException。原因是 Java 的通配符在字节码里是"签名擦除 + Signature 属性",Kotlin 侧检查了但擦除后仍需运行期保证。修法是跨语言边界用不变容器(Collection<Super>),或者在 Kotlin 侧显式 copy 一份(list.toMutableList())。

代码里见真章

看一段能直接跑的代码,把上面的机制落到具体写法上:

// 1) out:T 只在返回位 —— 只读容器
class Producer<out T>(private val item: T) {
   
    fun get(): T = item                       // ✅ T 在返回位
    fun getOrNull(): T? = item                 // ✅
    // fun add(x: T) {
   }                       // ❌ T 在参数位,编译报错
}
val p: Producer<Any> = Producer<String>("s")   // ✅ 协变赋值
// val q: Producer<String> = Producer<Any>(1)  // ❌ 反向不行

// 2) in:T 只在参数位 —— 只消费容器
class Consumer<in T> {
   
    fun accept(item: T) {
    Log.d("t", "$item") }   // ✅ T 在参数位
    // fun last(): T = null                     // ❌ T 在返回位,编译报错
}
val c: Consumer<Int> = Consumer<Any>()             // ✅ 逆变赋值

// 3) 投影修饰:只声明用得到的那一侧
fun <T> countFirst(items: List<out T>): Int? = items.size         // 只读 → out
fun <T> append(target: MutableList<in T>, item: T) {
    target.add(item) }  // 只写 → in
fun <T> firstOf(items: List<T>): T? = items.firstOrNull()          // 消费任意 T,in 更准
fun <T> sameSize(a: Collection<out T>, b: Collection<*>): Boolean = a.size == b.size

// 4) in out:读写分别投影
class Buffer<in out T> {
   
    fun put(x: T) {
    }                     // in 位置
    fun get(): Any? = null                // out 位置(T 擦成 Any)
}
// 实际用法:只写不读
val buf = Buffer<Number>(); buf.put(1)
// MutableList<in out T> 等价于 MutableList<T>(in out 同标 = 不变)

// 5) 协变接口的经典例子
interface Source<out T> {
    fun next(): T }
fun drain(s: Source<Any>) {
    repeat(3) {
    s.next() } }   // Source<String> 可传入
// Comparable 是 in:a.compareTo(b) 是参数位
fun <T : Comparable<T>> maxOf(a: T, b: T): T = if (a > b) a else b
val nums = listOf(3, 9, 1)
val top = nums.maxOfOrNull {
    it }       // T=Int,Comparable<Int> 逆变接收比较器

// 6) Java 互操作:通配符 vs 投影
// Java: void addAll(Collection<? extends Super> c)
// Kotlin 侧安全写法:传入不变容器或明确投影
fun javaInterop(list: MutableList<out Any>) {
    /* 只读消费,安全 */ }
fun javaInteropSafe(list: MutableList<Any>) {
    /* 不变容器,语义最清晰 */ }

这段代码值得盯三处:第一处,第 1、2 段用注释明确标出 // ❌ 的两处非法位置(add 与 last),让"位置约束"这条规则直接可见;第二处,第 3 段把"只读用 out / 只写用 in"两种投影修饰并列,给出可执行的选型规则;第三处,第 4 段用注释说明 in out 同标等价于不变,防止读者误以为它是"双向协变"。

这题在面试里怎么问、怎么答

"Kotlin 用声明处型变,Java 用使用处型变,哪个更好?"答:声明处型变的优势是一次声明、全局生效——List<out T> 声明一次,所有接收 List<T> 的函数自动都接受协变用法,不需要每个调用点写通配符;而且 Kotlin 的 out 在编译期就不生成修改方法,从根上杜绝了"传 List<Animal> 却塞进 Dog"这类错误。Java 的通配符更灵活(不写 ? extends 就不变),但容易在代码库里退化成"到处 ? extends"。Kotlin 的选择更保守也更安全。

"为什么 out T 的类不能有 add 方法?"答:因为 add 让 T 出现在参数位,违反 out 的位置约束。Kotlin 编译器在声明时就拒绝生成这样的方法,而不是像 Java 那样允许你写出语义危险的操作。这是 Kotlin 相对 Java 的一处类型安全提升。

"in out T 的实际意义是什么?"答:写操作按 in 投影(能接受 T 的任意子类型实例),读操作按 out 投影(读出来当 Any?)。它适用于"往容器里塞不同子类型、只按基类或 Any 读出来"的场景。Kotlin 的 MutableList<in out T> 语义上等价于 MutableList<T>(不变),写法上常见于泛型封装的库代码。

"使用中遇到过什么问题?"案例一:一个只读工具函数写 fun <T> firstOf(list: List<T>),导致传 List<String> 时想当 List<Any> 用必须 unchecked cast;修复为把参数改成 List<out T>。案例二:给 Cache<out T> 加了一个 put(key: String, value: T) 方法,编译报错后有人去掉了 out,结果某处向 Cache<Sub> 传了 Super,运行期 ClassCastException;修复为把 put 移到另一个 in 的接口里。

再补一个工程上值得讲清的一点:在协程与流式 API 里,型变直接决定了 API 的可用性。 协程的 Deferred<out T>、Flow<out T> 都是协变的——因为它们只产出不消费;Continuation<in T> 是逆变的——因为它接收结果。这些声明不是随手加的,而是流式 API 能否组合的关键:Flow<Dog> 可以传给期望 Flow<Animal> 的函数,所以不同类型的流能在同一套操作符(map/filter/combine)里自由组合。写自定义流式 API 时,如果把返回类型声明成不变,调用方就要到处加投影或 copy,API 会显得很难用。Android 项目里做 Repository 层抽象时,接口的泛型参数该用 out 还是 in 直接决定了调用端的顺畅度。 这是型变在实际工程里最值钱的一处。

给正在准备面试的你

把这题画成一张"位置约束表":横轴是四种位置(返回类型、方法参数、属性类型、可变位置 out T 字段),纵轴是两种型变(out 标注 / in 标注)。在"标注 out"的行里,把"返回类型"格打勾(合法),把"方法参数"格打叉(非法),并画一条大字标"越界即编译报错"。第二行反之。图右侧再画一个"选型速查"三行清单:只读返回用 out、只写入用 in、读写都要就别标(或 in out)。这张图覆盖了本篇所有考点。

再补工程案例与踩坑——应用落点是审查项目里所有泛型接口,标注"是否只读/只写"并补上 out/in;把为了绕过报错而加的 @Suppress("UNCHECKED_CAST") 找出来改成正确的投影;给协程/流式接口确认协变声明;跨 Java 边界的地方用不变容器显式表达。

复习时别孤立刷题:函数类型与 typealias——(T) -> R 里的 T 在参数位、R 在返回位,函数类型天生就是"参数逆变、返回协变"的组合,这是型变最自然的应用场景。

划两句重点:out 只在返回位、in 只在参数位,越界编译报错;Kotlin 的 out 类根本不生成修改方法(比 Java 更安全);in out 同标等价于不变;投影修饰只在声明的位置生效;协程的 Flow/Deferred 协变、Continuation 逆变是 API 可组合的前提。

下一篇聊函数类型与 typealias:回调接口的新写法——沿着今天这条主线继续往前走。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:类委托-by:装饰器模式的一行实现

下一篇预告:函数类型与-typealias:回调接口的新写法

有任何问题欢迎在评论区留言交流。

相关文章
|
18天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8625 25
|
17天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
3072 14
|
16天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2115 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
5天前
|
人工智能 JSON Linux
【全网最详细】ComfyUI使用教程:下载+本地部署+配置+工作流搭建一篇搞定(2026最新版)
ComfyUI是一款免费开源的本地AI绘图工具,采用节点式工作流设计,支持文生图、图生图、局部重绘、放大、换脸等多种功能。可离线运行,依赖显卡加速,无需联网。支持自定义流程保存与分享,插件生态丰富,适合进阶用户。(239字)
|
17天前
|
云安全 人工智能 安全
|
11天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
11天前
|
人工智能 JSON 编解码
【2026最新版】ComfyUI本地部署教程,新手也能看懂!
ComfyUI是本地运行的AI绘画工具,采用节点式工作流设计:通过拖拽连接“加载模型”“提示词编码”“采样”“解码”等模块,实现高度可控的文生图。新手推荐使用秋叶整合包,一键启动、内置模型管理与插件安装器,轻松上手。(239字)

热门文章

最新文章