本节摘要:Kotlin 把集合接口分成两族——只读的 List、Set、Map 与可变的 MutableList、MutableSet、MutableMap。这一刀切下去,"谁能改这份数据"从运行时的悬念变成编译期的签名。本节讲接口分族的规则、只读不等于不可变的真相、防御性复制的时机、集合操作链如何替代命令式循环、以及 sequence 的惰性求值什么时候才划算。
后台线程在遍历购物车做价格计算,主线程同时收到"删除商品"操作:
// Java:同一个 ArrayList 被两个线程交叉读写 List<CartItem> cart = sharedCart.getCart(); // 返回的就是内部那个 ArrayList // 后台线程 long total = 0; for (CartItem item : cart) { // 遍历中 total += item.getPrice() * item.getCount(); } // 主线程(用户点了删除) cart.remove(position); // 结构性修改
偶现 ConcurrentModificationException——注意是"偶现",两个线程的时序要恰好咬合才触发。更糟的情况不抛异常:遍历恰好跳过或重复元素,总价悄悄算错。这类 bug 的排查难点在于**"cart 是谁、在哪、被谁改过"完全没有线索**——Java 里一个 List 引用,类型上无法回答这个问题。
Kotlin 的第一刀切在类型上:
val cart: List<CartItem> = listOf(...) // 只读接口:没有 add、没有 remove val working: MutableList<CartItem> = mutableListOf(...) // 可变接口:名字自带警示
List 接口上根本没有修改方法——调用点写 cart.remove(...) 直接编译失败。要改,必须持有 MutableList,而这个类型名要求你在每一处签名、每一份文档里把它标出来。修改权从口头约定升级为类型签名,这就是分界线的意义。
必须立刻补上的一句话:Kotlin 的只读集合是接口视图,不是不可变数据结构。三件事要说清。
第一,List 只承诺"通过这个引用改不了",不承诺"底层没人改"。一个 MutableList 可以被当作 List 传出去:
val source = mutableListOf(1, 2, 3) val readOnlyView: List<Int> = source // 同一个对象 只读视图 source.add(4) // 通过原引用改 println(readOnlyView) // 1 2 3 4 视图看到了修改
跨线程传"只读视图"而底层在另一处被改,并发问题照样发生。真正的隔离需要防御性复制:val frozen = readOnlyView.toList()——toList 造的是新列表,此后的修改与它无关。
第二,Java 互操作会击穿这层视图。Kotlin 的只读集合在字节码层面就是 java.util.List,Java 调用方拿到的"只读"列表照样能调 add(第 7 章的互操作坑清单里有这条)。给 Java 暴露集合时,要么复制,要么用 Java 侧的不可变包装。
第三,元素自身的可变性不受集合控制。List<MutableOrder> 里每个元素照样能改字段。集合的只读管住"结构"(增删),管不住"内容"(元素字段)——内容的不可变要靠第 3 章的数据类 val 字段,两个层次的纪律要配套。
下图画出 kotlin.collections 的接口分族与实际实现类的关系,标注三处容易误判的通道。

只读接口的最大收益出现在签名上。对比这两组声明:
fun render(items: List<Article>) // 承诺不改 fun cache(user: User, roles: Set<String>) // 承诺不改 fun normalize(raw: MutableList<String>) // 警告 会改调用者的数据
调用者看到 List 就知道传进去的东西安全,看到 MutableList 就知道要提防副作用。Java 里这种承诺只能写在 Javadoc 里,而文档会过时、签名不会。团队纪律由此可以机器化:暴露 API 一律只读接口,内部确需修改就先 toMutableList 复制一份。
fun topArticles(all: List<Article>, n: Int): List<Article> = all.sortedByDescending { it.score } // sortedBy 返回新列表 原列表不动 .take(n) .toList() // 再复制一次 与调用者彻底隔离
标准库的操作符几乎全部遵守"返回新集合"的纪律:filter、map、sortedBy、plus(+)都不动原集合。这个设计让操作链可以放心地接龙。
有了只读纪律,命令式循环可以改写成声明式管道。需求:从订单列表里取已支付订单、按用户分组、统计每人总额、取前三名。命令式 Java 版本约二十行三重循环加中间 Map。Kotlin 管道版:
val top3: List<Pair<String, Long>> = orders .filter { it.status == PAID } .groupBy { it.userId } .map { (userId, list) -> userId to list.sumOf { it.amount } } .sortedByDescending { it.second } .take(3)
每一步的输入输出都是集合,读代码像读加工流水线。常用操作符按用途分组记:
| 用途 | 操作符 | 说明 |
|---|---|---|
| 变换 | map、mapNotNull、flatMap | 逐元素转换;flatMap 顺带摊平嵌套 |
| 过滤 | filter、filterNotNull、take、drop、distinctBy | 保留满足条件的元素 |
| 聚合 | sumOf、maxByOrNull、count、fold | 收敛为单值,OrNull 后缀保证可空安全 |
| 定位 | firstOrNull、singleOrNull、find | 查找,找不到给 null 而不是抛异常 |
| 分组 | groupBy、associateBy、partition | 重塑结构 |
| 排序 | sortedBy、sortedWith、shuffled | 返回新序列 |
| 配对 | zip、associateWith | 两个集合或键值对齐 |
注意后缀文化:first 找不到抛异常,firstOrNull 返回 null——第 2 章的可空消解在这里接上。安卓开发建议默认用 OrNull 家族,把"没找到"当正常分支处理。
操作链有一个隐藏成本:每一步都物化一个中间列表。filter 造一个新列表,map 再造一个,链上五步就是五个临时集合。万级数据加长链时,开销可观。sequence 把求值推迟到末端、逐元素流过全链:
val firstBig = orders.asSequence() .filter { it.amount > 10_000 } .map { it.id } .firstOrNull() // 找到第一个就停 前面的元素根本没流过 map
普通链是"每步全量",序列是"逐个过完整条链"。选择标准:集合小、链短,用普通操作链(中间列表开销可忽略,物化结果本来就要用);数据大、链长、只要前几个或只要一次聚合,用 sequence。一个经典误区是给序列套 forEach 后又 toList——物化两遍,比普通链更慢。另一个误区是第 2 章提过的:把 by lazy 当集合惰性用,粒度错了。
标准库之外,安卓开发者还该认识一组为内存优化的特化容器,它们与只读接口的纪律可以并用。ArrayMap 与 SimpleArrayMap:小容量场景(几十个键值对)比 HashMap 省内存,牺牲大容量时的查找性能——适合"一个页面一份的小映射"。SparseArray 家族(SparseArray、SparseBooleanArray、SparseIntArray、SparseLongArray、LongSparseArray):键为 int 或 long 时避免自动装箱,键值对密集时内存与性能双优;代价是它不是标准 Map 接口的实现,不能直接当 Map 用,跨层传递时要在边界转换。Pair 的使用克制:解构虽方便,但 Pair 与 Triple 是匿名的语义——mapOf("retry" to 3) 这类字面量场景没问题,函数返回值与字段就该用有名字的数据类(第 3 章),Pair<Boolean, String> 在三个月后的可读性是灾难。
数组与集合的选择也有安卓语境:性能敏感的内层循环(如自定义 View 的 onDraw)里 Array 原生类型数组(IntArray)有装箱优势;业务与数据层一律用 List。两者之间用 toTypedArray 与 toList 转换,同样遵守"边界转换、内部统一"的分层思路。
问:listOf 返回的对象能不能被修改?为什么它说"只读"却有 size 和 get?
能读不能改——List 接口上根本没有 add、remove 这些方法,这是接口层面的"只读"含义(本节的视图语义)。至于底层实现,Kotlin 对小 listOf 有单例复用与数组的优化,但那是实现细节,不该被依赖。真正要防的是本节讲的三条漏风通道:同源可变引用、Java 侧转写、元素自身可变。跨线程或跨模块传出的列表,toList 复制一次是成本最低的保险。
问:sequence 在普通业务代码里值得默认用吗?
不值得。默认用普通操作链:小数据、短链时物化中间集合的开销可忽略,而 sequence 有两个隐性成本——多数操作符是冷懒的,collect 之前错误不会暴露;调试断点在链上不再逐行命中(元素驱动)。sequence 的正确出场是明确的大数据处理:上千元素加多步变换、或"只要前几个就停"的短路场景。先写普通链,profile 显示集合压力后再改 sequence,是一夜之间可以完成的重构(调用点几乎不变)。
问:接口签名写 List 还是写 MutableList,让调用方自己选择不是更灵活吗?
灵活是调用方的,事故也是调用方的。签名暴露 MutableList 等于宣布"我可能改你的数据",调用方要么提防(防御性复制)、要么中招(被原地修改)。签名统一只读接口是责任声明:我承诺不改,你可以放心传。需要修改时函数内部 toMutableList 复制一份,成本一次、责任清晰。这条纪律的价值随团队规模放大——三个人时是习惯,三十人时是护栏。
集合的结构性修改已经被类型看住,下一节把镜头拉到对象内容与引用本身:val 加可变对象为什么不算不可变,替换式更新如何成为并发安全的第一选择。