第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:回调接口的新写法

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

相关文章
|
1天前
|
存储 安全 编译器
第070篇 扩展函数原理:它到底是不是给类加方法
Kotlin扩展函数本质是**静态方法**,编译后以接收者为首个参数,置于文件类中;调用依**静态类型决议**,不参与多态,**不访问private成员**,且**成员方法永远优先于同名扩展**——它是语法糖,而非真正的方法增强。
21 1
|
2天前
|
Java 编译器 Android开发
第014篇 重载与重写:编译期与运行期的分野
重载与重写是Java多态的两大基石:重载发生于同一类,编译期按参数列表静态绑定;重写发生于父子类,运行期按实际类型动态分派。二者易混淆,但本质分属编译期与运行期,规则、边界与典型坑(如忘加`@Override`、static隐藏、泛型擦除冲突)须清晰辨析。
21 0
|
1天前
|
安全 编译器 API
第116篇 Kotlin 代码评审清单:团队规范与静态检查
本节聚焦Kotlin团队代码质量保障机制,提出“三层金字塔”模型:①工具层(ktlint/detekt/编译器)自动化查格式与确定性缺陷;②约定层(PR模板/基线/baseline)半强制落地规范;③设计层(架构/抽象/命名)依赖人工判断。核心是“机器管规则,人管设计”,避免告警泛滥与评审失焦。
19 0
|
1天前
|
安全 Java 编译器
第101篇 value class 与内联类:零开销包装类型
`value class`(内联类)是 Kotlin 1.5 引入的类型安全利器:用零运行时开销的编译期包装,将语义不同的同底层类型(如 `Int`)彻底隔离——传错 ID、金额、单位等 Bug 直接拦截在编译期,而非线上事故。性能是附带收益,类型安全才是核心价值。
12 0
|
1天前
|
安全 Java Android开发
第104篇 Kotlin 惯用法精选:一行替代十行 Java
本文精讲Kotlin五大高阶惯用法:空安全流、集合流水线、作用域函数选择、解构与数据类、契约式前置条件。聚焦面试真题——“如何写得更Kotlin”,强调每种惯用法的**适用场景**与**禁用时机**,避免为链而链。附机制解析、工程反例、调试避坑及团队落地规范。
17 0
|
1天前
|
缓存 API Android开发
第129篇前台服务与后台限制:通知与省电的平衡
前台服务本质是“用户可见即获优待”的系统契约,非绝对保活。需严格满足:Android 8+ 5秒内调`startForeground`、按API版本声明`foregroundServiceType`及对应权限、展示可感知通知。违背即崩溃或被拒。
24 0
|
1天前
|
传感器 安全 Android开发
第096篇 协程与生命周期:repeatOnLifecycle 的正确用法
本文深入解析 Android 协程与生命周期对齐的核心难题:明确区分 `lifecycleScope`(绑 Fragment)与 `viewLifecycleOwner.lifecycleScope`(绑 View),强调“碰 View 必用后者”这一关键判据;详解 `repeatOnLifecycle` 的启停机制与冷流重启代价,并给出 BaseFragment 模板、热流优化、Binding 安全置空等工程落地方案。
18 0
|
2天前
|
安全 Java 编译器
第025篇 泛型通配符与 PECS:一句话讲清 extends 与 super
泛型通配符与PECS(Producer Extends, Consumer Super)是泛型核心难点。上界`? extends T`用于只读(生产者),下界`? super T`用于只写(消费者)。讲清“为何上界不能add、下界不能get”,结合`Collections.copy`等JDK源码实践,才能体现真理解——而非死记口诀。
19 0
|
2天前
|
缓存 监控 Java
第021篇 异常体系 Throwable:Checked 与 Unchecked 的边界
Android面试高频题:Throwable异常体系,需透彻理解Error/Exception区别、受检/非受检划分逻辑,以及finally执行机制。重点在于“为什么这样设计”和“用错的后果”——如吞异常致线上脏数据、滥用异常控流程拖垮性能、finally抛异常掩盖根因等。真懂者必踩过坑、用过、复盘过。
20 0
|
2天前
|
缓存 Java 编译器
第018篇 包装类与自动装箱:Integer 缓存池的坑
包装类与自动装箱看似简单,实则暗藏三大雷区:`Integer`等缓存边界(-128~127)、`null`拆箱直接NPE、三元表达式隐式拆箱引发空指针。`==`比引用,`equals`比值;热点路径慎用包装类,集合/可空场景才需装箱。原理+踩坑+实战,一文讲透面试高频考点。
18 0

热门文章

最新文章