Kotlin 集合框架的设计与实现
设计哲学:为什么把"读"和"写"分开?
在 Java 世界里,java.util.List 是一个"全能接口"——它既暴露了 get()、size() 等只读方法,也暴露了 add()、remove() 等修改方法。这意味着,当你把一个 List 对象传递给某个函数,你对它是否会被修改完全没有任何保证。你只能"祈祷"对方不要动它,或者用 Collections.unmodifiableList() 包装一层再传过去——但那只是运行时保护,编译器依然无法给你任何静态检查。
Kotlin 在集合设计上做出了一个关键决策:在接口层面把"只读能力"和"修改能力"彻底分离。
Iterable<T>
│
Collection<T>
┌─────┴─────┐
List<T> Set<T>
│ │
MutableList<T> MutableSet<T>
List<T> 只暴露读取方法:get()、size()、contains()、iterator() 等。MutableList<T> 继承 List<T>,额外增加 add()、remove()、clear() 等写入方法。
这个分层设计带来了两个核心收益:
一、意图明确,编译器帮你把关。 一个函数签名中写着 fun process(items: List<String>),意味着它承诺不会修改这个列表。调用者一眼就能看出来,也不需要做防御性拷贝。如果你错误地在函数内部调用 items.add(...),编译器会直接报错。
二、只读集合天然协变,类型系统更安全。 List<T> 在源码中声明为 interface List<out E>,out 修饰符表示 E 只出现在"生产者"位置(只被读出,不被写入)。因此 List<String> 是 List<Any> 的子类型——你可以把一个字符串列表当作任意对象列表来读取,这完全合理。而 MutableList<T> 不能这么做(它是不变的),因为如果允许,你就可以把 Cat 写入一个实际上装着 Dog 的列表,运行时崩溃无法避免。这一设计与前置文章《协变、逆变与型变的深度解析》中讲到的型变原理完全一脉相承。
运行时的真相:Kotlin 集合只是 Java 集合的"换皮"
这里藏着一个很多人忽视的关键事实:Kotlin 在 JVM 上没有任何自己的集合实现类。无论是 List<T> 还是 MutableList<T>,在字节码层面都是 java.util.List。Kotlin 的接口分层完全是编译期的约束,运行时 JVM 对此一无所知。
可以用这段代码验证:
val readOnly: List<String> = listOf("a", "b", "c")
val mutable: MutableList<String> = mutableListOf("x", "y", "z")
println(readOnly::class.java.name) // java.util.Arrays$ArrayList(或类似)
println(mutable::class.java.name) // java.util.ArrayList
这意味着:
- 与 Java 代码互操作时,零转换开销。Kotlin 的
List传给 Java 方法,Java 拿到的就是一个普通的java.util.List,不需要任何包装。 - 读写分离仅仅是编译器的"魔法",编译后的字节码里看不到任何
KotlinList之类的东西。
这种设计也暗示了一个陷阱,留到后面专门讨论。
工厂函数:集合是怎么被创建出来的
listOf / setOf / mapOf 家族
listOf() 不是构造函数,而是标准库中的顶层工厂函数。根据传入的元素数量不同,它在内部选择不同的实现:
// 来自 kotlin/collections/Collections.kt 的简化展示
public fun <T> listOf(): List<T> = emptyList() // 返回单例空列表
public fun <T> listOf(element: T): List<T> =
java.util.Collections.singletonList(element) // 单元素优化
public fun <T> listOf(vararg elements: T): List<T> =
if (elements.size > 0) elements.asList() // 底层是 Arrays$ArrayList
else emptyList()
注意两点:
listOf()单个元素时返回的是java.util.Collections.singletonList,它是一个只允许持有单个元素的固定大小实现,比完整的ArrayList更轻量。- 多元素的
listOf(a, b, c)最终调用的是elements.asList(),背后是java.util.Arrays.asList()——返回一个固定大小的java.util.Arrays$ArrayList(注意这不是java.util.ArrayList,它不支持add()/remove(),会抛出UnsupportedOperationException)。
而 mutableListOf() 的实现更直接:
public fun <T> mutableListOf(): MutableList<T> = ArrayList()
public fun <T> mutableListOf(vararg elements: T): MutableList<T> =
if (elements.isEmpty()) ArrayList() else ArrayList(ArrayAsCollection(elements, isVarargs = true))
它直接返回 java.util.ArrayList,一个完整的、支持动态扩容的可变列表。
各工厂函数对比
| 函数 | 返回类型 | 底层实现 | 可修改? |
|---|---|---|---|
listOf() |
List<T> |
EmptyList(单例) |
否 |
listOf(x) |
List<T> |
Collections.singletonList |
否 |
listOf(a,b,c) |
List<T> |
Arrays$ArrayList |
大小固定,不可 add/remove |
mutableListOf() |
MutableList<T> |
java.util.ArrayList |
是 |
arrayListOf() |
ArrayList<T> |
java.util.ArrayList |
是,且类型更具体 |
arrayListOf() 和 mutableListOf() 的底层实现相同,区别在于返回类型:前者声明为 ArrayList<T>(具体类),后者声明为 MutableList<T>(接口)。通常优先使用 mutableListOf(),因为面向接口编程是更好的实践。
buildList / buildMap / buildSet(Kotlin 1.6+ 稳定)
想象一个场景:你需要根据条件动态地构建一个列表,然后以只读方式暴露出去。传统写法很啰嗦:
// 旧式写法:丑陋的两阶段构建
val list = mutableListOf<String>()
list.add("always")
if (condition) list.add("sometimes")
val readOnlyList: List<String> = list // 伪只读,外部仍可强转
buildList 提供了一种更优雅的"构建器模式":
// 新式写法:一个表达式,直接得到只读 List
val list = buildList {
add("always")
if (condition) add("sometimes")
}
内部原理非常精妙:buildList 接受的 lambda 类型是 MutableList<E>.() -> Unit——它是一个带接收者的 lambda(lambda with receiver)。在 lambda 内部,this 就是正在构建的 MutableList,所以可以直接调用 add()、addAll() 等方法,无需任何显式引用。Lambda 执行完毕后,内部的可变列表被"密封",以只读 List 类型返回。
更重要的是,buildList 本身是 inline 函数,lambda 在编译时被内联到调用点,不会产生额外的函数对象开销。
从性能角度看,buildList 相比"旧式"写法没有任何额外开销,但语义上更安全——中间过程中的可变状态被完全封装在了 lambda 内部,对外只暴露不可变的结果。
函数式操作链的底层实现
Kotlin 集合提供了丰富的函数式操作:map、filter、flatMap、groupBy、associate、partition 等。理解它们的工作方式,是写出高性能代码的基础。
每个操作都会创建新集合
以 filter 为例,看一下标准库中的源码实现:
// kotlin/collections/Collections.kt(简化)
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
return filterTo(ArrayList<T>(), predicate)
}
public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(
destination: C,
predicate: (T) -> Boolean
): C {
for (element in this) if (predicate(element)) destination.add(element)
return destination
}
filter 内部创建了一个新的 ArrayList,把符合条件的元素一个个加入,然后返回。这是及早求值(Eager Evaluation)——操作执行时,新集合立刻被分配内存并填充。
当你链式调用多个操作时,就会产生"瀑布式"的中间集合:
val result = list
.filter { it.isNotEmpty() } // 创建 新ArrayList #1
.map { it.uppercase() } // 创建 新ArrayList #2
.take(5) // 创建 新ArrayList #3
对于 list 中有一万个元素、最终只需要 5 个的场景,上面的代码会:
- 遍历全部一万个元素做
filter,创建一个可能有几千个元素的中间列表。 - 遍历全部中间列表做
map,创建另一个同等大小的中间列表。 - 只取前 5 个,前两步大量的工作白费了。
这就是集合操作链的性能陷阱,也是序列(Sequence)存在的根本原因——序列采用懒求值,逐元素穿过整条操作链,不生成中间集合,take(5) 找到第 5 个元素后就停止处理剩余元素。本组的下一篇文章《序列与惰性求值》将深入解析这一机制。
常用操作速查
| 操作 | 含义 | 返回类型 | 典型用途 |
|---|---|---|---|
map { } |
一一变换每个元素 | List<R> |
字段提取、类型转换 |
filter { } |
过滤保留满足条件的元素 | List<T> |
条件筛选 |
flatMap { } |
变换后展平一层嵌套 | List<R> |
一对多展开 |
groupBy { } |
按 Key 分组 | Map<K, List<T>> |
分类汇总 |
associate { } |
变换为 Key→Value 映射 | Map<K, V> |
构建索引表 |
partition { } |
按条件拆分为两个列表 | Pair<List, List> |
同时需要匹配和不匹配项 |
fold(init) { } |
带初始值的累积归约 | R |
求和、拼接字符串 |
reduce { } |
以第一个元素为初始值的归约 | T |
同上,但不允许为空 |
为什么 Kotlin 不默认用 Java Stream API?
Java 8 引入了 Stream 作为惰性求值的集合管道。Kotlin 没有在 JVM 上默认使用 Stream,原因是多方面的:
-
内联彻底消除了小集合的 Lambda 开销。 Kotlin 集合操作是
inline函数。filter { ... }中的 lambda 在编译时直接内联为循环体,没有函数对象的分配开销。而Stream无法做到这一点(Java 不支持内联),每个 lambda 都是对象实例。对于小集合,这个差异足以让 Kotlin 集合比 Stream 更快。 -
多平台兼容需要统一抽象。 Kotlin 需要同时运行在 JVM、JS 和 Native 上。
Sequence是一套统一的抽象,而Stream只存在于 JVM,无法共用。 -
API 设计简洁性。 Kotlin 的集合操作和
Sequence操作使用完全相同的方法名,只需.asSequence()即可无缝切换,学习成本极低。Stream 的 API 风格与集合截然不同,增加了认知负担。
当然,Kotlin 在 JVM 上也能完全使用 Java Stream。需要时,list.stream() 或 list.parallelStream() 同样可用。
空安全与集合的配合
空安全在集合上的体现主要有两个维度:集合本身是否为 null 和 集合元素是否为 null。
val list: List<String>? = null // 集合本身可为 null
val list: List<String?> = listOf(null) // 元素可为 null
val list: List<String?>? = null // 两者都可为 null
标准库提供了一批专为"含 null 元素集合"设计的操作:
val mixed = listOf("a", null, "b", null, "c")
// filterNotNull:过滤掉 null,返回 List<String>
val clean: List<String> = mixed.filterNotNull() // ["a", "b", "c"]
// mapNotNull:map 后过滤 null 结果,跳过返回 null 的元素
val upper: List<String> = mixed.mapNotNull { it?.uppercase() } // ["A", "B", "C"]
// 查找元素时的安全版本
val first: String? = mixed.firstOrNull { it != null } // "a",找不到返回 null
val single: String? = mixed.singleOrNull { it == "b" } // "b",若多个或零个返回 null
这些方法的设计都遵循同一个准则:从签名上保证安全,而不是把运行时异常甩给调用者。对比 Java 的 stream().filter().findFirst().get(),最后的 .get() 在没有元素时会抛出 NoSuchElementException——这种坑完全可以在 API 设计阶段消灭。
Array<T> vs List<T>:何时选用数组
Kotlin 同时支持数组(Array<T>)和列表(List<T>),但它们在底层和语义上有本质区别:
| 维度 | Array<T> |
List<T> |
|---|---|---|
| JVM 表示 | JVM 原生数组 T[] |
java.util.List(对象) |
| 大小 | 固定(创建时确定) | 可变(MutableList 支持扩容) |
| 型变 | 协变(但不安全!) | 声明式协变(安全) |
| 性能 | 极致:连续内存,无额外对象 | 更高层封装,有对象头开销 |
| 与 Java 互操作 | int[]/String[] 等原生类型首选 |
java.util.List 首选 |
| 基本类型专用版本 | IntArray、LongArray 等 |
无(只有装箱版本) |
什么时候用 Array<T>?
- 性能极敏感的路径,特别是基本类型场景(
IntArray直接对应 JVM 的int[],避免了装箱)。 - 与 Java API 交互,对方明确要求传入
int[]或String[]时。 - 固定大小、不需要增删的场景,例如图像像素缓冲区。
绝大多数业务逻辑代码中,List<T> 是更好的选择——API 更丰富,型变语义更清晰,也不容易踩到数组协变的陷阱(Java 的数组协变允许 String[] → Object[],但运行时写入错误类型会抛 ArrayStoreException)。
只读集合的陷阱:只读不等于不可变
这是最容易产生误解的地方,也是许多 Bug 的来源。
只读(Read-Only) 表示:通过当前引用,你无法调用修改方法。
不可变(Immutable) 表示:这个对象在任何情况下都不会被修改。
Kotlin 的 List<T> 只保证前者,不保证后者。
// 场景一:多引用问题
val mutableList: MutableList<String> = mutableListOf("hello")
val readOnly: List<String> = mutableList // 同一个对象,只是换了个视角
mutableList.add("world") // 修改底层对象
println(readOnly) // [hello, world] ← 只读引用"看到"了变化!
这就像同一间房间,A 拿着带锁权限的钥匙,B 只有普通钥匙。A 进去重新布置了家具,B 进去后一样看到了变化——B 的"只读权限"根本没有阻止 A 修改房间。
场景二:强制转型
因为 listOf(a, b, c) 底层是 java.util.Arrays$ArrayList(一个固定大小的实现),所以:
val list = listOf("a", "b", "c")
val mutable = list as MutableList<String> // 编译通过(Kotlin 信任你)
mutable.add("d") // 运行时抛出 UnsupportedOperationException!
Arrays$ArrayList 支持 set()(可以替换元素),但不支持 add()/remove()(大小固定)。强制转型本身不会崩溃,但调用对应方法时会。
场景三:防御性拷贝策略
当你需要暴露一个集合给外部,同时确保外部的修改不会影响内部状态时,应当进行防御性拷贝:
class UserRepository {
private val _users = mutableListOf<User>()
// ❌ 错误:直接返回内部可变列表的只读视图
// 外部可以强转并修改内部状态
fun getUsers(): List<User> = _users
// ✅ 正确:返回一个拷贝,内外完全隔离
fun getUsers(): List<User> = _users.toList()
}
toList() 创建了一个新的 ArrayList,复制了所有元素,然后以 List<T> 类型返回。外部即使强转并调用 add(),也只是在副本上操作,不会影响 _users。
如果需要真正的不可变集合(在任何情况下都无法被修改),应当引入 kotlinx.collections.immutable 库,它提供了 PersistentList、PersistentMap 等真正意义上的不可变数据结构,依托持久化数据结构(Persistent Data Structures)实现高效的"写时复制"。
接口层级全景图
Iterable<T>
│
Collection<T>
┌─────────┴─────────┐
List<T> Set<T>
│ (out T) │ (out T)
MutableList<T> MutableSet<T>
│
LinkedHashSet<T>
HashSet<T>
TreeSet<T>
Map<K, V>
(out V, 协变仅限 V)
│
MutableMap<K, V>
│
LinkedHashMap / HashMap / TreeMap
关键细节:
List<out E>在E上协变,Set<out E>同样。Map<K, out V>在V上协变,但在K上不变——因为查找 Key 时需要精确类型匹配(equals/hashCode依赖具体类型)。- 所有可变接口(
Mutable*)均不变,因为需要同时承担读取和写入两个角色。
总结
Kotlin 集合框架以 Java 集合为地基,在其上构筑了一套编译期的接口约束:
- 读写分离是最核心的设计决策,通过接口层级实现,在编译期消灭意外修改的可能。
- 运行时,Kotlin 集合就是 Java 集合,零互操作开销。
listOf等工厂函数根据元素数量做了精细的内部实现选择,buildList系列则提供了更安全的动态构建方式。- 集合操作链是及早求值的,每个中间步骤都会创建新集合;大数据量场景应配合序列(
Sequence)使用。 - 只读不等于不可变,这是最重要的陷阱:需要真正隔离时,要做防御性拷贝(
toList())。