Kotlin's Collection Slicing Functions Are a Masterclass in Performance Specialization
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:
- It holds a reference, not a copy of the data.
SubListdoes not store the list elements separately. It only holds a private property namedlist, which directly references the originalListthat calledsubList. - It records the offset and length.
SubListsaves two key pieces of metadata:fromIndexindicates where the current view starts in the original list, and_sizeindicates how many elements the view contains. These two values are calculated during initialization. - 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 tofromIndex, 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.
- Parameter check and boundary case. The function first checks if
nis non-negative. It then handles the case wheren == 0separately, directly returning the sharedemptyList(). This is the most convenient and efficient way to represent an empty collection. - Optimization for
Collection. The code usesif (this is Collection<T>)to check if the currentIterableis also aCollection. The size of aCollectionis 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 callstoList()to return a copy of the entire collection. - When
n == 1, there's also no need to set up a loop. The implementation directly callsfirst(), then wraps this element into a new list usinglistOf(). This is very efficient for a single element.
- When
- General traversal logic. If the receiver is not a
Collection, or if the value ofndoes 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 tonin advance to avoid multiple expansions of theArrayListduring element addition. - Early termination: The loop contains
if (++count == n) break. Oncenelements have been collected, the traversal stops immediately. Even when facing a very largeIterable, the function only processes the minimum number of elements necessary to complete the task.
- Pre-allocated capacity: The list is created via
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:
- Is the receiver a
Collection? If so, its size can be known in advance, and theArrayListcan be pre-allocated with the exact capacityresultSizefor the final result, reducing unnecessary memory expansions. - Is the receiver a
List? If it's a list, even more targeted implementations can be adopted.- Path A: Does it implement
RandomAccess?RandomAccessis a marker interface indicating that reading elements by index, i.e.,get(index), can usually be done in very fast constant time.ArrayListfalls into this category. In this case, the most efficient way to drop the firstnelements is to iterate from indexnto the end of the list, adding elements to the result list sequentially. - Path B: It is a
List, but does not implementRandomAccess. For example, structures likeLinkedList, where accessing elements by index can be slow, potentially reaching O(n) for a single access. Therefore, the implementation avoids repeatedly callingget(index)and instead useslistIterator(n), directly creating an iterator starting from then-th position, and then traversing sequentially from there.
- Path A: Does it implement
- Path C: It is just an
Iterable. If the receiver is not aCollection, such as a customIterablewhose size cannot be known in advance, the implementation falls back to the most general approach: traversing all elements from the beginning, using a counter to ignore the firstnelements, and then adding the remaining elements to a new list.
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:
- Parameter check. The function first ensures
nis not negative, avoiding meaningless parameters and potential issues in subsequent calculations. - Reframing the problem. The key insight of this implementation is: "dropping the last
nelements" is logically equivalent to "keeping the firstsize - nelements". Therefore, Kotlin doesn't write a new set of logic for dropping elements from the end, but directly reuses the already highly optimizedtake(n). The expressionsize - ncalculates exactly how many elements should be kept from the beginning of the list. - Safeguarding with
coerceAtLeast(0). A very critical point here is that the calculation result is followed by.coerceAtLeast(0), ensuringsize - nnever goes below 0.- Case 1:
nis less thansize. Suppose a list has 10 elements, anddropLast(3)is called. The calculation result is10 - 3 = 7, ultimately equivalent to executingtake(7), which yields the expected result. - Case 2:
nis greater than or equal tosize. Suppose a list has 10 elements, butdropLast(12)is called. Then10 - 12results in-2. If this negative number were passed directly totake(), it would throw anIllegalArgumentException. However,.coerceAtLeast(0)clamps the result to 0, so the final execution is equivalent totake(0), correctly and efficiently returning an empty list.
- Case 1:
This small handling allows the function to produce logical results for all valid inputs.
windowed
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:
- Pre-allocated capacity. It first estimates the number of windows to be generated,
resultCapacity, based on the collection size and step, and creates theArrayListwith 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 likepartialWindows = false, the actual number of windows generated may be fewer. - Calculating window size. Inside the
whileloop,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. - Deciding whether to keep partial windows. The code checks
partialWindows. If the currentwindowSizeis less than the full window size, andpartialWindowsisfalse, the loop terminates early and does not create an incomplete window at the end. - Efficient window creation. The most noteworthy part here is
result.add(List(windowSize) { this[it + index] }). The implementation uses theList(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 implementsRandomAccess,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, notsubListviews of the original list. - Moving the window. Finally, simply incrementing the index by
stepdetermines 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:
- Delegating the work to
windowed. The core idea here is to reframe "chunking a collection" as a special kind of "sliding window". Therefore,chunkeddirectly delegates all the work towindowed. - Using
sizeas the window size. Thesizepassed tochunkedis directly used as the window size forwindowed, determining the maximum number of elements each sublist can contain. - Also using
sizeas the step. This is the most critical difference betweenchunkedand 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. Passingsizeagain as thesteptowindowedensures the first chunk covers indices0tosize - 1, the second chunk coverssizeto2 * size - 1, and so on. - Setting
partialWindows = true. Whenchunkedcallswindowed, it specifiespartialWindowsastrue. 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 ofchunked: the last list in the result can have fewer elements than the givensize.
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.
subListreturns a lightweight view pointing to the original list. The creation process does not copy elements, with a time complexity of O(1).slicereturns a new, independent list; for contiguous ranges, it first usessubListto create a view, then completes the copy viatoList().takeanddropchoose different implementation paths based on whether the receiver is aCollection,List, orRandomAccess.windowedcreates a series of potentially overlapping window snapshots.chunkedis essentially awindowedcall where the window size and step are the same, and the retention of an incomplete final window is allowed.
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.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Sexy