如果我要从 List 中获取一段元素

各位 Kotlin 爱好者们,周一好。

Kotlin 提供了不少函数,用来截取集合中的一部分元素,或者获取集合中的特定内容。借助这些函数,我们可以方便、高效地从集合中提取数据,同时不必修改原集合。

下面,我们就来看看 Kotlin 中几个常用的集合截取函数,以及它们背后的实现原理。

subList

subList(fromIndex, toIndex) 用于截取列表中指定范围内的元素。这个函数只能用于 List 类型。

kotlin 复制代码
val letters = listOf("A", "B", "C", "D", "E")
println(letters.subList(1, 4)) // 输出:[B, C, D]

subList 的接口实现很简单:检查索引是否合法,然后创建一个私有的 SubList 实例。

kotlin 复制代码
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 的实现,它的核心机制主要包括以下几点:

  1. 保存的是引用,而不是数据副本。 SubList 没有单独保存列表元素。它只持有一个名为 list 的私有属性,直接引用调用 subList 的原始 List
  2. 记录偏移量和长度。 SubList 会保存两个关键的元数据:fromIndex 表示当前视图从原列表的哪个位置开始,_size 表示视图中包含多少个元素。这两个值会在初始化时计算出来。
  3. 在 get 中转换索引。 get(index) 是整个"视图"机制的关键。它不会从自己的数据结构中查找元素,而是把子列表中的相对索引加上 fromIndex,换算成对应的原列表索引,再把读取操作交给原列表。

例如,调用 subList(10, 15) 后,子列表的偏移量就是 10。此时再调用 subList.get(2),实际执行的相当于:

kotlin 复制代码
parentList.get(10 + 2)

最终取得的是原列表中的第 13 个元素。

由于创建子列表时只需要生成一个很小的包装对象,并保存几个整数值,因此无论原列表和子列表有多大,创建 subList 都是非常快的常数时间操作,时间复杂度为 O(1)。整个过程不会逐个复制元素。

SubList 自己对 subList 的实现,也进一步体现了这种基于视图的设计:

kotlin 复制代码
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 对 ListMutableList 的区分可以限制当前引用所提供的修改能力,但不能把 List 简单理解成绝对不可变的集合。

作为一个专业的 Kotlin 博主,此事在 只读不等于不可变 中亦有记载

slice

slice 可以根据一个索引区间,或者一组索引,从集合中提取元素。它会创建一个新的列表,用来保存指定位置上的元素。

kotlin 复制代码
val items = listOf("rockbyte", "kotlin", "developer", "android")
val sliced = items.slice(1..2)
println(sliced) // 输出:[kotlin, developer]

查看 slice 的内部实现,可以看到它的工作方式如下:

kotlin 复制代码
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 会回退到更加通用的遍历实现:

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 个元素。

kotlin 复制代码
val names = listOf("rockbyte", "kotlin", "developer")
println(names.take(2))      // 输出:[rockbyte, kotlin]
println(names.takeLast(2))  // 输出:[kotlin, developer]

它们的用途看起来很简单,但内部实现体现了 Kotlin 标准库务实而且重视性能的设计。先来看 take 的源码:

kotlin 复制代码
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()
}

这段实现遵循了一棵非常清晰的判断树。

  1. 参数检查和边界情况。 函数首先检查 n 是否为非负数。随后单独处理 n == 0 的情况,直接返回共享的 emptyList()。这是表示空集合最省事也最高效的方式。
  2. 针对 Collection 的优化。 代码通过 if (this is Collection<T>) 判断当前 Iterable 是否同时也是 CollectionCollection 的大小是已知的,因此可以进一步优化:
    • n >= size 时,需要获取的元素数量已经大于或等于整个集合的大小,不必再执行截断逻辑,直接调用 toList() 返回整个集合的副本即可。
    • n == 1 时,也不必建立循环。实现会直接调用 first(),然后通过 listOf() 把这个元素包装成一个新列表。对于单个元素来说,这种方式非常高效。
  3. 通用的遍历逻辑。 如果接收者不是 Collection,或者 n 的取值确实需要遍历,实现就会进入最后一段通用逻辑。即使在这里,代码仍然做了两项优化:
    • 预分配容量: 通过 ArrayList<T>(n) 创建列表,提前把内部容量设置为 n,避免 ArrayList 在添加元素的过程中多次扩容。
    • 提前终止: 循环中存在 if (++count == n) break。一旦收集到 n 个元素,遍历就会立即停止。即使面对一个非常大的 Iterable,函数也只处理完成任务所必需的最少元素。

需要补充的是,Kotlin 的 Sequence 并不是 Iterable 的子类型,它拥有自己对应的 take 操作。这里的通用分支适用于无法提前得知大小的自定义 Iterable 等类型,不能直接处理 Sequence

takeLast(n)take(n) 的整体思路没有太大区别,但它会根据列表是否支持快速随机访问,选择不同的遍历方式:

kotlin 复制代码
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 个元素。

kotlin 复制代码
val numbers = listOf(1, 2, 3, 4, 5)
println(numbers.drop(2))      // 输出:[3, 4, 5]
println(numbers.dropLast(2))  // 输出:[1, 2, 3]

这里需要提个醒,takedrop 都不会修改源列表,因为它们都是返回一个新的列表。

用起来非常简单,不过 drop(n) 的内部实现仍然值得分析与学习:

kotlin 复制代码
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 个位置开始的迭代器,然后从这里向后顺序遍历。
  • 路径 C:它只是一个 Iterable 如果接收者并不是 Collection,例如某个无法提前得知大小的自定义 Iterable,实现就会回退到最通用的方式:从头遍历所有元素,使用计数器忽略最开始的 n 个元素,然后再把剩余元素加入新列表。

相比之下,List.dropLast(n) 的完整实现只有一行核心代码,而且相当直观简单:

kotlin 复制代码
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))
}

我们可以把它拆成三个部分来理解:

  1. 参数检查。 函数首先确保 n 不是负数,避免无意义的参数以及后续计算中可能出现的问题。
  2. 重新描述问题。 这段实现最关键的思路是:"丢弃末尾的 n 个元素",在逻辑上就等于"保留开头的 size - n 个元素"。因此,Kotlin 没有再写一套从末尾丢弃元素的逻辑,而是直接复用了已经高度优化的 take(n)。表达式 size - n 计算的,就是应该从列表开头保留多少个元素。
  3. 通过 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 用来控制每次窗口向后移动多少个位置。窗口之间可以相互重叠。

kotlin 复制代码
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,就会进入第一个也是性能最高的分支:

kotlin 复制代码
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
}

对于由数组支撑的列表,这段代码很有参考价值:

  1. 预分配容量。 它首先根据集合大小和步长,预估将要生成的窗口数量 resultCapacity,并以此创建 ArrayList。这可以减少结果列表在生成窗口过程中反复扩容的次数。需要注意,这个值主要用于容量预估;在 partialWindows = false 等情况下,实际生成的窗口数量可能更少。
  2. 计算窗口大小。while 循环中,size.coerceAtMost(thisSize - index) 会计算当前窗口实际包含多少个元素。如果剩余元素数量少于要求的窗口大小,这个表达式就会返回剩余元素数量。
  3. 判断是否保留不完整窗口。 代码会检查 partialWindows。如果当前的 windowSize 小于完整窗口大小,并且 partialWindowsfalse,循环就会提前结束,不再创建末尾不完整的窗口。
  4. 高效创建窗口。 这里最值得注意的是 result.add(List(windowSize) { this[it + index] })。实现通过 List(size, init) 工厂函数创建一个指定大小的新列表,并使用传入的 Lambda 逐个初始化其中的元素。由于原列表实现了 RandomAccess,所以 this[it + index] 可以非常快速地按照索引读取元素。这些新列表保存的是各个窗口在创建时的元素快照,而不是原列表的 subList 视图。
  5. 移动窗口。 最后只需要让索引增加 step,就能确定下一个窗口的起始位置。

windowed 的内部机制再次体现了特化优化的思想。它没有对所有集合都使用同一套通用算法,而是先判断当前对象是否为支持随机访问的 List。如果是,就采用基于索引的高效循环,提前分配内存,并通过工厂函数快速创建每个窗口的快照。

而如果都不满足:

kotlin 复制代码
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 整除,最后一个分块就只包含剩余元素。

kotlin 复制代码
val data = listOf(1, 2, 3, 4, 5, 6)
val chunks = data.chunked(2)
println(chunks) // 输出:[[1, 2], [3, 4], [5, 6]]

面向 Iterablechunked 实现非常简单:

kotlin 复制代码
public fun <T> Iterable<T>.chunked(size: Int): List<List<T>> {
    return windowed(size, size, partialWindows = true)
}

同样可以把这段实现拆开理解:

  1. 把工作交给 windowed 这里最核心的思路,是把"集合分块"重新描述成一种特殊的"滑动窗口"。因此,chunked 直接把全部工作委托给 windowed 完成。
  2. 使用 size 作为窗口大小。 传给 chunkedsize,会直接作为 windowed 的窗口大小,决定每个子列表最多包含多少个元素。
  3. 同时使用 size 作为步长。 这是 chunked 与普通滑动窗口之间最关键的区别。分块要求每个窗口移动的距离恰好等于窗口本身的大小,这样各个分块才不会重叠。将 size 再次作为 step 传给 windowed,就能确保第一个分块覆盖索引 0size - 1,第二个分块覆盖 size2 * size - 1,后面依此类推。
  4. 设置 partialWindows = true chunked 调用 windowed 时,把 partialWindows 指定为 true。这样一来,即使集合末尾剩余的元素不足以组成一个完整分块,它们仍然会被收集到一个较小的"不完整窗口"中,并加入结果列表。这正符合 chunked 的定义:结果中的最后一个列表,元素数量可以小于给定的 size

总之

Kotlin 提供了 subListslicetakedropwindowedchunked 等函数,用于灵活、高效地截取集合中的数据。虽然它们表面上完成的都是"获取集合的一部分",但内部实现并不完全相同。

  • subList 返回的是指向原列表的轻量级视图,创建过程不复制元素,时间复杂度为 O(1)。
  • slice 返回的是新的独立列表;对于连续范围,它会先利用 subList 建立视图,再通过 toList() 完成复制。
  • takedrop 会根据接收者是不是 CollectionListRandomAccess,选择不同的实现路径。
  • windowed 会创建一系列可能重叠的窗口快照。
  • chunked 本质上是窗口大小与步长相同,并允许保留末尾不完整窗口的 windowed

这些函数不仅为集合数据处理提供了简洁的 API,也展示了 Kotlin 标准库如何通过边界条件优化、容量预分配、提前终止和运行时类型判断,在通用性与执行效率之间取得平衡。

一点想法

即便在 AI 能直接生成大部分业务代码的今天,回头读一读这些标准库的底层实现仍然很值得。

它让我们看到的不仅仅是一组 API 怎么用,而是面对同一类问题时,成熟的工程师是如何权衡边界条件、数据结构特性和性能成本的。这种把"通用"拆成"特化路径"的思维方式,很难从一段 AI 给出的调用示例里直接体会到,但恰恰是写出高质量代码的底子。

相关推荐
三少爷的鞋1 小时前
一次朋友圈发布流程,理解 Kotlin 协程为什么重新定义异步代码组织方式
android
FungLeo2 小时前
Flutter/Android Release 包连不上网?AndroidManifest INTERNET 权限排查实录
android·flutter
wWYy.4 小时前
Mysql:一行数据是怎么存储的?
android·数据库·mysql
漏刻有时11 小时前
PHP GeoJSON转PNG地图渲染程序开发笔记、源码解读、问题复盘与整改方案
android·笔记·php
-SOLO-12 小时前
解决VMware 显示比例被重置的问题
android
alexhilton13 小时前
探究Android Views、Flutter和Compose如何渲染你的UI
android·kotlin·android jetpack
Lesile16 小时前
Android:Hilt框架入门 · 在ViewUI和ComposeUI下的应用
android·android jetpack
用户4238162290716 小时前
Android 16 WebView 页面顶部挖孔/通知栏不能显示UI问题排查
android
weixin_7275356217 小时前
Loop 已死,Graph 新生:AI 工作流的范式革命
android·人工智能·rxjava