跪拜 Guibai
← Back to the summary

Kotlin's Collection Slicing Functions Are a Masterclass in Performance Specialization

08_20.png

Hello Kotlin enthusiasts, happy Monday.

Kotlin provides quite a few functions for extracting a portion of elements from a collection, or for getting specific content from a collection. With these functions, we can conveniently and efficiently extract data from a collection without modifying the original collection.

Let's take a look at several commonly used collection extraction functions in Kotlin, and the implementation principles behind them.

subList

subList(fromIndex, toIndex) is used to extract elements within a specified range from a list. This function can only be used with the List type.

val letters = listOf("A", "B", "C", "D", "E")
println(letters.subList(1, 4)) // Output: [B, C, D]

The interface implementation of subList is very simple: check if the indices are valid, then create a private SubList instance.

override fun subList(fromIndex: Int, toIndex: Int): List<E> =
    SubList(this, fromIndex, toIndex)

private class SubList<out E>(
    private val list: List<E>, // Reference to the original parent list
    private val fromIndex: Int, // Starting offset of the sublist
    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)
        // Index conversion is the most critical operation here
        return list[fromIndex + index]
    }

    override val size: Int get() = _size

    // ...
}

It's important to note the implementation of SubList. Its core mechanism mainly includes the following points:

  1. It holds a reference, not a copy of the data. SubList does not store the list elements separately. It only holds a private property named list, which directly references the original List that called subList.
  2. It records the offset and length. SubList saves two key pieces of metadata: fromIndex indicates where the current view starts in the original list, and _size indicates how many elements the view contains. These two values are calculated during initialization.
  3. It converts the index in get. get(index) is the key to the entire "view" mechanism. It does not look up elements from its own data structure. Instead, it adds the relative index in the sublist to fromIndex, converts it to the corresponding index in the original list, and then delegates the read operation to the original list.

For example, after calling subList(10, 15), the sublist's offset is 10. At this point, calling subList.get(2) effectively executes:

parentList.get(10 + 2)

Ultimately, it retrieves the 13th element from the original list.

Because creating a sublist only requires generating a very small wrapper object and saving a few integer values, creating a subList is a very fast constant-time operation with a time complexity of O(1), regardless of how large the original list and sublist are. The entire process does not copy elements one by one.

SubList's own implementation of subList further reflects this view-based design:

override fun subList(fromIndex: Int, toIndex: Int): List<E> {
    checkRangeIndexes(fromIndex, toIndex, _size)
    // The new SubList still points directly to the initial root list
    return SubList(list, this.fromIndex + fromIndex, this.fromIndex + toIndex)
}

When we continue to call subList on a sublist, it does not create a "view of a view". Instead, it recalculates the offset, i.e., this.fromIndex + fromIndex, and then creates a SubList that still points directly to the initial root list.

This avoids nested view objects and keeps the index conversion logic simple and efficient.

So, what is the difference between read-only lists and mutable lists here?

The internal implementation above targets the read-only interface List. However, it's important to note that Kotlin's List represents a read-only view, which does not mean the underlying data is truly immutable. If the original object itself can still be modified through another MutableList reference, then subList will still be affected by these modifications.

If the original list is a MutableList, this view-based mechanism forms a real-time bidirectional association: modifying the sublist will affect the original list; conversely, structural modifications to the original list may invalidate the sublist and potentially trigger a ConcurrentModificationException, similar to similar designs in the Java Collections Framework.

Therefore, Kotlin's distinction between List and MutableList can limit the modification capabilities provided by the current reference, but List cannot simply be understood as an absolutely immutable collection.

As a professional Kotlin blogger, this matter is also documented in Read-Only Does Not Mean Immutable

slice

slice can extract elements from a collection based on an index range or a set of indices. It creates a new list to hold the elements at the specified positions.

val items = listOf("rockbyte", "kotlin", "developer", "android")
val sliced = items.slice(1..2)
println(sliced) // Output: [kotlin, developer]

Looking at the internal implementation of slice, we can see how it works:

public fun <T> List<T>.slice(indices: IntRange): List<T> {
    if (indices.isEmpty()) return listOf()
    return this.subList(indices.start, indices.endInclusive + 1).toList()
}

The most noteworthy thing here is that slice does not traverse and copy elements itself. Instead, it directly delegates the range extraction operation to List.subList().

As mentioned earlier, subList does not immediately create a new copy of elements in memory. Instead, it returns a lightweight view that maps the specified range to the original list.

The subsequent call to .toList() is what actually creates a new, independent list and copies the elements from the view into it. For extracting contiguous ranges from a List, this implementation fully leverages the efficient range access mechanism already provided by the underlying layer.

If an Iterable<Int> is passed to slice, the situation is different.

Because these indices can be arbitrarily arranged and may not be contiguous, such as List.slice(listOf(5, 2, 8)), it's impossible to complete the extraction directly through a single subList. In this case, Kotlin falls back to a more general traversal implementation:

public fun <T> List<T>.slice(indices: Iterable<Int>): List<T> {
    // ... calculate capacity ...
    val list = ArrayList<T>(size)
    for (index in indices) {
        list.add(get(index))
    }
    return list
}

This version iterates through the passed-in indices in order, reads the element corresponding to each index, and adds them to a new ArrayList. For indices that are arbitrarily ordered and non-contiguous, this is also the most direct and reliable implementation.

take and takeLast

take(n) and takeLast(n) are two of the most commonly used collection extraction functions: take(n) gets the first n elements of a collection, and takeLast(n) gets the last n elements of a collection.

val names = listOf("rockbyte", "kotlin", "developer")
println(names.take(2))      // Output: [rockbyte, kotlin]
println(names.takeLast(2))  // Output: [kotlin, developer]

Their usage seems simple, but the internal implementation reflects the pragmatic and performance-conscious design of the Kotlin standard library. Let's first look at the source code for take:

public fun <T> Iterable<T>.take(n: Int): List<T> {
    // 1. Parameter check and boundary case for n == 0
    require(n >= 0) { "Requested element count $n is less than zero." }
    if (n == 0) return emptyList()

    // 2. Optimization for Collection
    if (this is Collection<T>) {
        if (n >= size) return toList()
        if (n == 1) return listOf(first())
    }

    // 3. General traversal logic
    var count = 0
    val list = ArrayList<T>(n)
    for (item in this) {
        list.add(item)
        if (++count == n)
            break // Key optimization: stop immediately after collecting n elements
    }
    return list.optimizeReadOnlyList()
}

This implementation follows a very clear decision tree.

  1. Parameter check and boundary case. The function first checks if n is non-negative. It then handles the case where n == 0 separately, directly returning the shared emptyList(). This is the most convenient and efficient way to represent an empty collection.
  2. Optimization for Collection. The code uses if (this is Collection<T>) to check if the current Iterable is also a Collection. The size of a Collection is known, so further optimization is possible:
    • When n >= size, the number of elements to be retrieved is greater than or equal to the size of the entire collection. There's no need to execute truncation logic; it directly calls toList() to return a copy of the entire collection.
    • When n == 1, there's also no need to set up a loop. The implementation directly calls first(), then wraps this element into a new list using listOf(). This is very efficient for a single element.
  3. General traversal logic. If the receiver is not a Collection, or if the value of n does require traversal, the implementation enters the final general logic block. Even here, the code still performs two optimizations:
    • Pre-allocated capacity: The list is created via ArrayList<T>(n), setting the internal capacity to n in advance to avoid multiple expansions of the ArrayList during element addition.
    • Early termination: The loop contains if (++count == n) break. Once n elements have been collected, the traversal stops immediately. Even when facing a very large Iterable, the function only processes the minimum number of elements necessary to complete the task.

It should be added that Kotlin's Sequence is not a subtype of Iterable; it has its own corresponding take operation. The general branch here applies to types like custom Iterables whose size cannot be known in advance, and cannot directly handle Sequence.

takeLast(n) is not much different from take(n) in overall approach, but it chooses different traversal methods based on whether the list supports fast random access:

public fun <T> List<T>.takeLast(n: Int): List<T> {
    // 1. Parameter check and boundary cases
    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. Pre-allocated capacity
    val list = ArrayList<T>(n)

    // 3. Choose traversal method based on list type
    if (this is RandomAccess) {
        // Path A: For lists supporting fast indexed access
        for (index in size - n until size)
            list.add(this[index])
    } else {
        // Path B: For lists better suited for sequential access
        for (item in listIterator(size - n))
            list.add(item)
    }
    return list
}

List.takeLast(n) well embodies the "specialization" philosophy in the Kotlin standard library. It doesn't force a single general algorithm to solve all problems. Instead, it first provides fast paths for common boundary cases, and then checks the specific capabilities of the current list at runtime.

By distinguishing between RandomAccess lists and non-RandomAccess lists, takeLast(n) can choose a traversal strategy better suited to the current data structure: for array-like lists, it uses fast index-based traversal; for linked-list-like structures, it uses an iterator for efficient sequential access.

This design of choosing different paths based on data structure allows takeLast(n) to complete its task with as little additional overhead as possible in common scenarios.

drop and dropLast

drop(n) returns a new list that excludes the first n elements of the original collection; dropLast(n) returns a new list that excludes the last n elements.

val numbers = listOf(1, 2, 3, 4, 5)
println(numbers.drop(2))      // Output: [3, 4, 5]
println(numbers.dropLast(2))  // Output: [1, 2, 3]

A reminder here: take and drop do not modify the source list, because they both return a new list.

Usage is very simple, but the internal implementation of drop(n) is still worth analyzing and learning from:

public fun <T> Iterable<T>.drop(n: Int): List<T> {
    // ... pre-checks ...
    if (this is Collection<*>) {
        val resultSize = size - n
        // ... more checks ...
        if (this is List<T>) {
            if (this is RandomAccess) { // Path A: Optimized for index access
                for (index in n until size)
                    list.add(this[index])
            } else { // Path B: Optimized for sequential access
                for (item in listIterator(n))
                    list.add(item)
            }
            return list
        }
    }
    // ... Path C: General fallback ...
    var count = 0
    for (item in this) {
        if (count >= n) list.add(item) else ++count
    }
    return list
}

Its implementation also follows a clear decision tree:

In contrast, the complete implementation of List.dropLast(n) is just one core line of code, and it's quite intuitive and simple:

public fun <T> List<T>.dropLast(n: Int): List<T> {
    // 1. Parameter check
    require(n >= 0) { "Requested element count $n is less than zero." }

    // 2. Core logic
    return take((size - n).coerceAtLeast(0))
}

We can break this down into three parts to understand:

  1. Parameter check. The function first ensures n is not negative, avoiding meaningless parameters and potential issues in subsequent calculations.
  2. Reframing the problem. The key insight of this implementation is: "dropping the last n elements" is logically equivalent to "keeping the first size - n elements". Therefore, Kotlin doesn't write a new set of logic for dropping elements from the end, but directly reuses the already highly optimized take(n). The expression size - n calculates exactly how many elements should be kept from the beginning of the list.
  3. Safeguarding with coerceAtLeast(0). A very critical point here is that the calculation result is followed by .coerceAtLeast(0), ensuring size - n never goes below 0.
    • Case 1: n is less than size. Suppose a list has 10 elements, and dropLast(3) is called. The calculation result is 10 - 3 = 7, ultimately equivalent to executing take(7), which yields the expected result.
    • Case 2: n is greater than or equal to size. Suppose a list has 10 elements, but dropLast(12) is called. Then 10 - 12 results in -2. If this negative number were passed directly to take(), it would throw an IllegalArgumentException. However, .coerceAtLeast(0) clamps the result to 0, so the final execution is equivalent to take(0), correctly and efficiently returning an empty list.

This small handling allows the function to produce logical results for all valid inputs.

windowed

08_54.png

windowed(size, step) extracts elements from a collection in a "windowed" manner. Here, size specifies the window size, and step controls how many positions the window moves each time. Windows can overlap with each other.

val sequence = listOf(1, 2, 3, 4, 5)
val windows = sequence.windowed(3, step = 1)
println(windows) // Output: [[1, 2, 3], [2, 3, 4], [3, 4, 5]]

The implementation of windowed also has clear branching logic, prioritizing optimized paths for more capable collection types.

When the receiver Iterable is also a List and implements the RandomAccess marker interface, such as ArrayList, it enters the first and highest-performance branch:

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
}

For array-backed lists, this code is very instructive:

  1. Pre-allocated capacity. It first estimates the number of windows to be generated, resultCapacity, based on the collection size and step, and creates the ArrayList with this capacity. This reduces the number of expansions of the result list during window generation. Note that this value is mainly used for capacity estimation; in cases like partialWindows = false, the actual number of windows generated may be fewer.
  2. Calculating window size. Inside the while loop, size.coerceAtMost(thisSize - index) calculates how many elements the current window actually contains. If the number of remaining elements is less than the required window size, this expression returns the number of remaining elements.
  3. Deciding whether to keep partial windows. The code checks partialWindows. If the current windowSize is less than the full window size, and partialWindows is false, the loop terminates early and does not create an incomplete window at the end.
  4. Efficient window creation. The most noteworthy part here is result.add(List(windowSize) { this[it + index] }). The implementation uses the List(size, init) factory function to create a new list of the specified size, and uses the passed-in lambda to initialize its elements one by one. Because the original list implements RandomAccess, this[it + index] can read elements by index very quickly. These new lists hold snapshots of the elements in each window at the time of creation, not subList views of the original list.
  5. Moving the window. Finally, simply incrementing the index by step determines the starting position of the next window.

windowed's internal mechanism again reflects the idea of specialization optimization. It doesn't use the same general algorithm for all collections. Instead, it first checks if the current object is a List supporting random access. If so, it uses an efficient index-based loop, pre-allocates memory, and quickly creates snapshots of each window via a factory function.

And if none of these conditions are met:

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)
            }
        }
    }
}

The complexity involved in this code is relatively high. Interested readers can check the Kotlin source code on GitHub.

If there's an opportunity, I will update with an article specifically about this later.

chunked

chunked(n) splits a collection into multiple lists of size n. If the total number of elements in the collection is not divisible by n, the last chunk will only contain the remaining elements.

val data = listOf(1, 2, 3, 4, 5, 6)
val chunks = data.chunked(2)
println(chunks) // Output: [[1, 2], [3, 4], [5, 6]]

The chunked implementation for Iterable is very simple:

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

Again, we can break this implementation down to understand it:

  1. Delegating the work to windowed. The core idea here is to reframe "chunking a collection" as a special kind of "sliding window". Therefore, chunked directly delegates all the work to windowed.
  2. Using size as the window size. The size passed to chunked is directly used as the window size for windowed, determining the maximum number of elements each sublist can contain.
  3. Also using size as the step. This is the most critical difference between chunked and a regular sliding window. Chunking requires each window to move by a distance exactly equal to the window size itself, so that chunks do not overlap. Passing size again as the step to windowed ensures the first chunk covers indices 0 to size - 1, the second chunk covers size to 2 * size - 1, and so on.
  4. Setting partialWindows = true. When chunked calls windowed, it specifies partialWindows as true. This way, even if the remaining elements at the end of the collection are not enough to form a complete chunk, they will still be collected into a smaller "incomplete window" and added to the result list. This aligns with the definition of chunked: the last list in the result can have fewer elements than the given size.

In Summary

Kotlin provides functions like subList, slice, take, drop, windowed, and chunked for flexibly and efficiently extracting data from collections. Although they all accomplish "getting a part of a collection" on the surface, their internal implementations are not exactly the same.

These functions not only provide a concise API for collection data processing but also demonstrate how the Kotlin standard library balances generality and execution efficiency through boundary condition optimization, capacity pre-allocation, early termination, and runtime type checking.

A Thought

Even in an era where AI can directly generate most business code, it's still very worthwhile to go back and read the underlying implementations of these standard libraries.

What it shows us is not just how to use a set of APIs, but how, when facing the same type of problem, mature engineers weigh boundary conditions, data structure characteristics, and performance costs. This way of thinking—breaking down "general" into "specialized paths"—is hard to directly grasp from an AI-generated usage example, but it is precisely the foundation for writing high-quality code.

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

Sky233

Sexy