各位 Kotlin 爱好者们,周一好。
Kotlin 提供了不少函数,用来截取集合中的一部分元素,或者获取集合中的特定内容。借助这些函数,我们可以方便、高效地从集合中提取数据,同时不必修改原集合。
下面,我们就来看看 Kotlin 中几个常用的集合截取函数,以及它们背后的实现原理。
subList
subList(fromIndex, toIndex) 用于截取列表中指定范围内的元素。这个函数只能用于 List 类型。
val letters = listOf("A", "B", "C", "D", "E")
println(letters.subList(1, 4)) // 输出:[B, C, D]
subList 的接口实现很简单:检查索引是否合法,然后创建一个私有的 SubList 实例。
override fun subList(fromIndex: Int, toIndex: Int): List<E> =
SubList(this, fromIndex, toIndex)
private class SubList<out E>(
private val list: List<E>, // 指向原始父列表的引用
private val fromIndex: Int, // 子列表的起始偏移量
toIndex: Int
) : List<E>(), RandomAccess {
private var _size: Int = 0
init {
checkRangeIndexes(fromIndex, toIndex, list.size)
this._size = toIndex - fromIndex
}
override fun get(index: Int): E {
checkElementIndex(index, _size)
// 索引转换是这里最关键的操作
return list[fromIndex + index]
}
override val size: Int get() = _size
// ...
}
这里需要注意 SubList 的实现,它的核心机制主要包括以下几点:
- 保存的是引用,而不是数据副本。
SubList没有单独保存列表元素。它只持有一个名为list的私有属性,直接引用调用subList的原始List。 - 记录偏移量和长度。
SubList会保存两个关键的元数据:fromIndex表示当前视图从原列表的哪个位置开始,_size表示视图中包含多少个元素。这两个值会在初始化时计算出来。 - 在 get 中转换索引。
get(index)是整个“视图”机制的关键。它不会从自己的数据结构中查找元素,而是把子列表中的相对索引加上fromIndex,换算成对应的原列表索引,再把读取操作交给原列表。
例如,调用 subList(10, 15) 后,子列表的偏移量就是 10。此时再调用 subList.get(2),实际执行的相当于:
parentList.get(10 + 2)
最终取得的是原列表中的第 13 个元素。
由于创建子列表时只需要生成一个很小的包装对象,并保存几个整数值,因此无论原列表和子列表有多大,创建 subList 都是非常快的常数时间操作,时间复杂度为 O(1)。整个过程不会逐个复制元素。
SubList 自己对 subList 的实现,也进一步体现了这种基于视图的设计:
override fun subList(fromIndex: Int, toIndex: Int): List<E> {
checkRangeIndexes(fromIndex, toIndex, _size)
// 新 SubList 仍然直接指向最初的根列表
return SubList(list, this.fromIndex + fromIndex, this.fromIndex + toIndex)
}
当我们在一个子列表上继续调用 subList 时,它并不会创建一个“子视图的视图”。相反,它会重新计算偏移量,也就是 this.fromIndex + fromIndex,然后创建一个仍然直接指向最初根列表的 SubList。
这样做可以避免视图对象不断嵌套,也让索引转换逻辑始终保持简单和高效。
那么,只读列表和可变列表在这里有什么区别呢?
上面的内部实现针对只读接口 List。不过需要注意,Kotlin 的 List 表示的是只读视图,不等于底层数据一定真正不可变。如果原始对象本身仍然可以通过其他 MutableList 引用修改,那么 subList 依旧会受到这些修改的影响。
如果原列表是 MutableList,这种基于视图的机制就会形成实时的双向关联:修改子列表会影响原列表;反过来,对原列表进行结构性修改,也可能让子列表失效,并可能像 Java 集合框架中的同类设计一样触发 ConcurrentModificationException。
因此,Kotlin 对 List 和 MutableList 的区分可以限制当前引用所提供的修改能力,但不能把 List 简单理解成绝对不可变的集合。
作为一个专业的 Kotlin 博主,此事在 只读不等于不可变 中亦有记载
slice
slice 可以根据一个索引区间,或者一组索引,从集合中提取元素。它会创建一个新的列表,用来保存指定位置上的元素。
val items = listOf("rockbyte", "kotlin", "developer", "android")
val sliced = items.slice(1..2)
println(sliced) // 输出:[kotlin, developer]
查看 slice 的内部实现,可以看到它的工作方式如下:
public fun <T> List<T>.slice(indices: IntRange): List<T> {
if (indices.isEmpty()) return listOf()
return this.subList(indices.start, indices.endInclusive + 1).toList()
}
这里最值得注意的是,slice 并没有自己遍历并复制元素,而是把范围截取操作直接交给了 List.subList()。
前面已经讲过,subList 不会立刻在内存中创建一份新的元素副本,而是返回一个轻量级视图,把指定范围映射到原始列表上。
紧接着调用的 .toList(),才会真正创建一个新的、相互独立的列表,并把视图中的元素复制进去。对于 List 的连续区间截取来说,这种实现充分利用了底层已经提供的高效范围访问机制。
如果传给 slice 的是 Iterable<Int>,情况就不同了。
因为这些索引可能任意排列,也可能互不连续,例如 List.slice(listOf(5, 2, 8)),所以无法直接通过一个 subList 完成截取。此时,Kotlin 会回退到更加通用的遍历实现:
public fun <T> List<T>.slice(indices: Iterable<Int>): List<T> {
// ... 计算容量 ...
val list = ArrayList<T>(size)
for (index in indices) {
list.add(get(index))
}
return list
}
这个版本会依次遍历传入的索引,读取每个索引对应的元素,再把它们加入新的 ArrayList。面对顺序任意、位置不连续的索引,这也是最直接可靠的实现方式。
take 和 takeLast
take(n) 和 takeLast(n) 是两个最常用的集合截取函数:take(n) 获取集合开头的 n 个元素,takeLast(n) 获取集合末尾的 n 个元素。
val names = listOf("rockbyte", "kotlin", "developer")
println(names.take(2)) // 输出:[rockbyte, kotlin]
println(names.takeLast(2)) // 输出:[kotlin, developer]
它们的用途看起来很简单,但内部实现体现了 Kotlin 标准库务实而且重视性能的设计。先来看 take 的源码:
public fun <T> Iterable<T>.take(n: Int): List<T> {
// 1. 参数检查,以及 n 为 0 的边界情况
require(n >= 0) { "Requested element count $n is less than zero." }
if (n == 0) return emptyList()
// 2. 针对 Collection 的优化
if (this is Collection<T>) {
if (n >= size) return toList()
if (n == 1) return listOf(first())
}
// 3. 通用的遍历逻辑
var count = 0
val list = ArrayList<T>(n)
for (item in this) {
list.add(item)
if (++count == n)
break // 关键优化:收集到 n 个元素后立即停止
}
return list.optimizeReadOnlyList()
}
这段实现遵循了一棵非常清晰的判断树。
- 参数检查和边界情况。 函数首先检查
n是否为非负数。随后单独处理n == 0的情况,直接返回共享的emptyList()。这是表示空集合最省事也最高效的方式。 - 针对
Collection的优化。 代码通过if (this is Collection<T>)判断当前Iterable是否同时也是Collection。Collection的大小是已知的,因此可以进一步优化:- 当
n >= size时,需要获取的元素数量已经大于或等于整个集合的大小,不必再执行截断逻辑,直接调用toList()返回整个集合的副本即可。 - 当
n == 1时,也不必建立循环。实现会直接调用first(),然后通过listOf()把这个元素包装成一个新列表。对于单个元素来说,这种方式非常高效。
- 当
- 通用的遍历逻辑。 如果接收者不是
Collection,或者n的取值确实需要遍历,实现就会进入最后一段通用逻辑。即使在这里,代码仍然做了两项优化:- 预分配容量: 通过
ArrayList<T>(n)创建列表,提前把内部容量设置为n,避免ArrayList在添加元素的过程中多次扩容。 - 提前终止: 循环中存在
if (++count == n) break。一旦收集到n个元素,遍历就会立即停止。即使面对一个非常大的Iterable,函数也只处理完成任务所必需的最少元素。
- 预分配容量: 通过
需要补充的是,Kotlin 的 Sequence 并不是 Iterable 的子类型,它拥有自己对应的 take 操作。这里的通用分支适用于无法提前得知大小的自定义 Iterable 等类型,不能直接处理 Sequence。
takeLast(n) 和 take(n) 的整体思路没有太大区别,但它会根据列表是否支持快速随机访问,选择不同的遍历方式:
public fun <T> List<T>.takeLast(n: Int): List<T> {
// 1. 参数检查和边界情况
require(n >= 0) { "Requested element count $n is less than zero." }
if (n == 0) return emptyList()
val size = size
if (n >= size) return toList()
if (n == 1) return listOf(last())
// 2. 预分配容量
val list = ArrayList<T>(n)
// 3. 针对不同列表类型选择遍历方式
if (this is RandomAccess) {
// 路径 A:针对支持快速索引访问的列表
for (index in size - n until size)
list.add(this[index])
} else {
// 路径 B:针对更适合顺序访问的列表
for (item in listIterator(size - n))
list.add(item)
}
return list
}
List.takeLast(n) 很能体现 Kotlin 标准库中的“特化”思想。它没有强行使用一套通用算法解决所有问题,而是先为常见的边界情况提供快速路径,然后再检查当前列表在运行时的具体能力。
通过区分 RandomAccess 列表和非 RandomAccess 列表,takeLast(n) 可以选择更适合当前数据结构的遍历策略:对于类似数组的列表,使用基于索引的快速遍历;对于类似链表的结构,则通过迭代器进行高效的顺序访问。
这种根据数据结构选择不同路径的设计,可以让 takeLast(n) 在常见场景下尽量以较低的额外开销完成任务。
drop 和 dropLast
drop(n) 会返回一个新列表,其中不包含原集合开头的 n 个元素;dropLast(n) 则会返回一个新列表,其中不包含末尾的 n 个元素。
val numbers = listOf(1, 2, 3, 4, 5)
println(numbers.drop(2)) // 输出:[3, 4, 5]
println(numbers.dropLast(2)) // 输出:[1, 2, 3]
这里需要提个醒,take 和 drop 都不会修改源列表,因为它们都是返回一个新的列表。
用起来非常简单,不过 drop(n) 的内部实现仍然值得分析与学习:
public fun <T> Iterable<T>.drop(n: Int): List<T> {
// ... 前置检查 ...
if (this is Collection<*>) {
val resultSize = size - n
// ... 更多检查 ...
if (this is List<T>) {
if (this is RandomAccess) { // 路径 A:针对索引访问优化
for (index in n until size)
list.add(this[index])
} else { // 路径 B:针对顺序访问优化
for (item in listIterator(n))
list.add(item)
}
return list
}
}
// ... 路径 C:通用回退方案 ...
var count = 0
for (item in this) {
if (count >= n) list.add(item) else ++count
}
return list
}
它的实现同样遵循一棵清晰的判断树:
- 接收者是不是
Collection? 如果是,就可以提前知道它的大小,并按照最终结果的准确容量resultSize预分配ArrayList,减少不必要的内存扩容。 - 接收者是不是
List? 如果是列表,还可以继续采用更有针对性的实现。- 路径 A:它是否实现了
RandomAccess?RandomAccess是一个标记接口,表示通过索引读取元素,也就是get(index),通常能够以很快的常数时间完成,ArrayList就属于这种情况。此时,丢弃开头n个元素的最高效方式,就是从索引n开始遍历到列表末尾,并依次把元素加入结果列表。 - 路径 B:它是
List,但没有实现RandomAccess。 例如类似LinkedList的结构,通过索引访问元素可能很慢,单次访问甚至可能达到 O(n)。因此,实现会避开反复调用get(index),转而使用listIterator(n),直接创建一个从第n个位置开始的迭代器,然后从这里向后顺序遍历。
- 路径 A:它是否实现了
- 路径 C:它只是一个
Iterable。 如果接收者并不是Collection,例如某个无法提前得知大小的自定义Iterable,实现就会回退到最通用的方式:从头遍历所有元素,使用计数器忽略最开始的n个元素,然后再把剩余元素加入新列表。
相比之下,List.dropLast(n) 的完整实现只有一行核心代码,而且相当直观简单:
public fun <T> List<T>.dropLast(n: Int): List<T> {
// 1. 参数检查
require(n >= 0) { "Requested element count $n is less than zero." }
// 2. 核心逻辑
return take((size - n).coerceAtLeast(0))
}
我们可以把它拆成三个部分来理解:
- 参数检查。 函数首先确保
n不是负数,避免无意义的参数以及后续计算中可能出现的问题。 - 重新描述问题。 这段实现最关键的思路是:“丢弃末尾的
n个元素”,在逻辑上就等于“保留开头的size - n个元素”。因此,Kotlin 没有再写一套从末尾丢弃元素的逻辑,而是直接复用了已经高度优化的take(n)。表达式size - n计算的,就是应该从列表开头保留多少个元素。 - 通过
coerceAtLeast(0)兜底。 这里非常关键的一点,是计算结果后面调用了.coerceAtLeast(0),确保size - n永远不会小于 0。- 情况一:
n小于size。 假设列表包含 10 个元素,调用dropLast(3)后,计算结果是10 - 3 = 7,最终相当于执行take(7),结果符合预期。 - 情况二:
n大于或等于size。 假设列表包含 10 个元素,却调用了dropLast(12),那么10 - 12会得到-2。如果把这个负数直接传给take(),就会抛出IllegalArgumentException。而.coerceAtLeast(0)会把结果限制为 0,于是最终执行的相当于take(0),正确而高效地返回一个空列表。
- 情况一:
这个很小的处理,让函数能够在所有合法输入下都得到符合逻辑的结果。
windowed
windowed(size, step) 会以“窗口”的方式从集合中截取元素。其中,size 用来指定窗口大小,step 用来控制每次窗口向后移动多少个位置。窗口之间可以相互重叠。
val sequence = listOf(1, 2, 3, 4, 5)
val windows = sequence.windowed(3, step = 1)
println(windows) // 输出:[[1, 2, 3], [2, 3, 4], [3, 4, 5]]
windowed 的实现同样具有明确的分支逻辑,会优先为能力更强的集合类型选择经过优化的路径。
当接收者 Iterable 同时也是 List,并且实现了 RandomAccess 标记接口时,例如 ArrayList,就会进入第一个也是性能最高的分支:
public fun <T> Iterable<T>.windowed(
size: Int,
step: Int = 1,
partialWindows: Boolean = false
): List<List<T>> {
checkWindowSizeStep(size, step)
if (this is RandomAccess && this is List) {
val thisSize = this.size
val resultCapacity = thisSize / step + if (thisSize % step == 0) 0 else 1
val result = ArrayList<List<T>>(resultCapacity)
var index = 0
while (index in 0 until thisSize) {
val windowSize = size.coerceAtMost(thisSize - index)
if (windowSize < size && !partialWindows) break
result.add(List(windowSize) { this[it + index] })
index += step
}
return result
}
val result = ArrayList<List<T>>()
windowedIterator(iterator(), size, step, partialWindows, reuseBuffer = false).forEach {
result.add(it)
}
return result
}
对于由数组支撑的列表,这段代码很有参考价值:
- 预分配容量。 它首先根据集合大小和步长,预估将要生成的窗口数量
resultCapacity,并以此创建ArrayList。这可以减少结果列表在生成窗口过程中反复扩容的次数。需要注意,这个值主要用于容量预估;在partialWindows = false等情况下,实际生成的窗口数量可能更少。 - 计算窗口大小。 在
while循环中,size.coerceAtMost(thisSize - index)会计算当前窗口实际包含多少个元素。如果剩余元素数量少于要求的窗口大小,这个表达式就会返回剩余元素数量。 - 判断是否保留不完整窗口。 代码会检查
partialWindows。如果当前的windowSize小于完整窗口大小,并且partialWindows为false,循环就会提前结束,不再创建末尾不完整的窗口。 - 高效创建窗口。 这里最值得注意的是
result.add(List(windowSize) { this[it + index] })。实现通过List(size, init)工厂函数创建一个指定大小的新列表,并使用传入的 Lambda 逐个初始化其中的元素。由于原列表实现了RandomAccess,所以this[it + index]可以非常快速地按照索引读取元素。这些新列表保存的是各个窗口在创建时的元素快照,而不是原列表的subList视图。 - 移动窗口。 最后只需要让索引增加
step,就能确定下一个窗口的起始位置。
windowed 的内部机制再次体现了特化优化的思想。它没有对所有集合都使用同一套通用算法,而是先判断当前对象是否为支持随机访问的 List。如果是,就采用基于索引的高效循环,提前分配内存,并通过工厂函数快速创建每个窗口的快照。
而如果都不满足:
fun <T> windowedIterator(iterator: Iterator<T>, size: Int, step: Int, partialWindows: Boolean, reuseBuffer: Boolean): Iterator<List<T>> {
if (!iterator.hasNext()) return EmptyIterator
return iterator<List<T>> {
val bufferInitialCapacity = size.coerceAtMost(1024)
val gap = step - size
if (gap >= 0) {
var buffer = ArrayList<T>(bufferInitialCapacity)
var skip = 0
for (e in iterator) {
if (skip > 0) { skip -= 1; continue }
buffer.add(e)
if (buffer.size == size) {
yield(buffer)
if (reuseBuffer) buffer.clear() else buffer = ArrayList(size)
skip = gap
}
}
if (buffer.isNotEmpty()) {
if (partialWindows || buffer.size == size) yield(buffer)
}
} else {
var buffer = RingBuffer<T>(bufferInitialCapacity)
for (e in iterator) {
buffer.add(e)
if (buffer.isFull()) {
if (buffer.size < size) { buffer = buffer.expanded(maxCapacity = size); continue }
yield(if (reuseBuffer) buffer else ArrayList(buffer))
buffer.removeFirst(step)
}
}
if (partialWindows) {
while (buffer.size > step) {
yield(if (reuseBuffer) buffer else ArrayList(buffer))
buffer.removeFirst(step)
}
if (buffer.isNotEmpty()) yield(buffer)
}
}
}
}
这段代码涉及的复杂度就比较高了,有兴趣的读者可以去 github 的 Kotlin 源码看看。
如果有机会,后续我会更新一篇文章专门讲这个。
chunked
chunked(n) 会把集合切分成多个大小为 n 的列表。如果集合元素总数不能被 n 整除,最后一个分块就只包含剩余元素。
val data = listOf(1, 2, 3, 4, 5, 6)
val chunks = data.chunked(2)
println(chunks) // 输出:[[1, 2], [3, 4], [5, 6]]
面向 Iterable 的 chunked 实现非常简单:
public fun <T> Iterable<T>.chunked(size: Int): List<List<T>> {
return windowed(size, size, partialWindows = true)
}
同样可以把这段实现拆开理解:
- 把工作交给
windowed。 这里最核心的思路,是把“集合分块”重新描述成一种特殊的“滑动窗口”。因此,chunked直接把全部工作委托给windowed完成。 - 使用
size作为窗口大小。 传给chunked的size,会直接作为windowed的窗口大小,决定每个子列表最多包含多少个元素。 - 同时使用
size作为步长。 这是chunked与普通滑动窗口之间最关键的区别。分块要求每个窗口移动的距离恰好等于窗口本身的大小,这样各个分块才不会重叠。将size再次作为step传给windowed,就能确保第一个分块覆盖索引0到size - 1,第二个分块覆盖size到2 * size - 1,后面依此类推。 - 设置
partialWindows = true。chunked调用windowed时,把partialWindows指定为true。这样一来,即使集合末尾剩余的元素不足以组成一个完整分块,它们仍然会被收集到一个较小的“不完整窗口”中,并加入结果列表。这正符合chunked的定义:结果中的最后一个列表,元素数量可以小于给定的size。
总之
Kotlin 提供了 subList、slice、take、drop、windowed 和 chunked 等函数,用于灵活、高效地截取集合中的数据。虽然它们表面上完成的都是“获取集合的一部分”,但内部实现并不完全相同。
subList返回的是指向原列表的轻量级视图,创建过程不复制元素,时间复杂度为 O(1)。slice返回的是新的独立列表;对于连续范围,它会先利用subList建立视图,再通过toList()完成复制。take和drop会根据接收者是不是Collection、List或RandomAccess,选择不同的实现路径。windowed会创建一系列可能重叠的窗口快照。chunked本质上是窗口大小与步长相同,并允许保留末尾不完整窗口的windowed。
这些函数不仅为集合数据处理提供了简洁的 API,也展示了 Kotlin 标准库如何通过边界条件优化、容量预分配、提前终止和运行时类型判断,在通用性与执行效率之间取得平衡。
一点想法
即便在 AI 能直接生成大部分业务代码的今天,回头读一读这些标准库的底层实现仍然很值得。
它让我们看到的不仅仅是一组 API 怎么用,而是面对同一类问题时,成熟的工程师是如何权衡边界条件、数据结构特性和性能成本的。这种把“通用”拆成“特化路径”的思维方式,很难从一段 AI 给出的调用示例里直接体会到,但恰恰是写出高质量代码的底子。