Kotlin's Collection Operators Are Wrappers Around a Single Delegate Pattern
A Thorough Guide to Kotlin Collection Operators
The previous article discussed Kotlin collections. This one continues with their transformation operators. Many people may not know what these operators are, so let's explore them together.
Kotlin provides a series of collection transformation operators to help you process collections more efficiently. These operators create new collections based on the original collection and apply different transformation logic.
map and mapIndexed
map applies a function to each element in a collection and returns a new collection containing the transformation results. If you need both the element and its index during transformation, you can use mapIndexed.
It is very useful when you need to convert a set of raw data into another set of data through several operations, and it is one of the most common functions in Kotlin projects.
val names = listOf("skydoves", "kotlin", "developer")
val uppercased = names.map { it.uppercase() }
println(uppercased) // Output: [SKYDOVES, KOTLIN, DEVELOPER]
The map function itself does not carry the core transformation logic. It acts more like a preparatory wrapper, delegating the main work to a more general helper function, mapTo.
Let's look at its source code:
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
// 1. Create a destination list with an optimized initial capacity.
return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}
internal fun <T> Iterable<T>.collectionSizeOrDefault(default: Int): Int =
if (this is Collection<*>) this.size else default
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(destination: C, transform: (T) -> R): C {
for (item in this)
destination.add(transform(item))
return destination
}
This mechanism is divided into two key steps.
Creating an optimized destination collection: Before the actual transformation begins,
mapfirst creates the destination list to hold the results, and this process includes an optimization. It callscollectionSizeOrDefault(10). This helper function checks if the currentIterableis aCollection. If it is aCollection, such as aListorSet, its size is known, so theArrayListis created with this size as its initial capacity. This is a key performance optimization because it avoids theArrayListrepeatedly expanding its internal array as elements are added. If it is not aCollection, such as aSequence, the size cannot be known in advance, so anArrayListwith a default capacity of10is created.Delegating to
mapTo: Subsequently,mapimmediately callsmapTo, passing in the newly created destinationArrayListand thetransformlambda. The actual transformation logic resides in this function: it iterates, transforms, and appends the results to the givenMutableCollection.
Therefore, the internal mechanism of map is a clear and efficient two-step process. It first acts like a factory, creating an ArrayList with a suitable initial capacity based on whether the size of the source Iterable is known; then it delegates the core work to the more general mapTo, which completes the transformation and populates the destination list through a simple loop.
flatMap and flatten
flatMap transforms each element into another collection and then flattens these results into a single list. flatten operates directly on nested collections.
val nestedLists = listOf(
listOf("kotlin", "android"),
listOf("developer", "tools")
)
val flattened = nestedLists.flatMap { it }
println(flattened) // Output: [kotlin, android, developer, tools]
Similar to map, the top-level flatMap function itself does not contain the core transformation logic. It is also a preparatory wrapper, delegating the main work to a more general helper function.
public inline fun <T, R> Iterable<T>.flatMap(transform: (T) -> Iterable<R>): List<R> {
// 1. Create a default destination list.
return flatMapTo(ArrayList<R>(), transform)
}
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.flatMapTo(destination: C, transform: (T) -> Iterable<R>): C {
// 1. Outer loop: iterate over each element in the source iterable.
for (element in this) {
// 2. Apply transform to get an inner iterable.
val list = transform(element)
// 3. Inner "loop": add all elements from the inner iterable to the destination collection.
destination.addAll(list)
}
return destination
}
This mechanism is relatively simple:
Creating the destination collection:
flatMapfirst creates a standard emptyArrayList<R>()as the destination collection for the final flattened result. Unlikemap, it is difficult to calculate the final size in advance, because each element can return anIterableof arbitrary length aftertransform. Therefore, it must start with a default-sizedArrayList.Delegating to
flatMapTo: It then immediately callsflatMapTo, passing in the newly createdArrayListand thetransformlambda. The actual transformation and flattening logic is here: iterate over the source collection, apply the transformation, and append all generated elements to the givenMutableCollection.
So, the internal mechanism of flatMap is delegation plus nested iteration.
The main flatMap function prepares an empty ArrayList and delegates the core logic to the general flatMapTo. flatMapTo iterates over each element in the source collection, transforms it to get an intermediate collection, and then immediately appends all elements from this intermediate collection to the same final destination list using addAll.
groupBy and associateBy
When you need to reshape a list into a map, you can use these two functions.
groupBy retains all elements under each key, i.e., a list of values; associateBy retains only one element per key, and when a key conflicts, the later element overwrites the earlier one. Prefer groupBy for aggregation; prefer associateBy for quick lookups by a unique, or "effectively unique," key.
groupBy groups elements according to a specified key function and returns a map containing the grouped lists. associateBy creates a map where each key comes from a property of the element, and the value is the element itself.
val developers = listOf("Alice", "Bob", "Charlie")
val groupedByLength = developers.groupBy { it.length }
println(groupedByLength) // Output: {5=[Alice, Bob], 7=[Charlie]}
The public groupBy function is a concise wrapper. It first prepares the destination Map and then delegates the main work to groupByTo.
public inline fun <T, K> Iterable<T>.groupBy(keySelector: (T) -> K): Map<K, List<T>> {
// 1. Create the destination map.
return groupByTo(LinkedHashMap<K, MutableList<T>>(), keySelector)
}
public inline fun <T, K, M : MutableMap<in K, MutableList<T>>> Iterable<T>.groupByTo(destination: M, keySelector: (T) -> K): M {
// 1. Iterate over each element.
for (element in this) {
// 2. Determine the key for the current element.
val key = keySelector(element)
// 3. Get or create the list corresponding to this key.
val list = destination.getOrPut(key) { ArrayList<T>() }
// 4. Add the element to the corresponding list.
list.add(element)
}
return destination
}
Creating the destination
Map:groupByfirst instantiates theMapthat will hold the final result. It deliberately choosesLinkedHashMap, which is an intentional and important design choice.LinkedHashMappreserves the insertion order of keys, meaning the keys in the final groupedmapwill appear in the order each key first appeared in the originalIterable. This provides a predictable, stable order, a characteristic that is often valuable.Delegating to
groupByTo: It then immediately callsgroupByTo, passing in the newly createdLinkedHashMapand thekeySelectorlambda. The core logic for classifying elements is in this function: iterate over the source collection, determine thekeyfor each element, and place the element into the corresponding list in the destinationmap.
The internal mechanism of groupBy is built on delegation and the practical getOrPut. The main groupBy prepares a LinkedHashMap to ensure stable key order, then delegates the core work to groupByTo. groupByTo iterates over the source collection, uses keySelector to determine the key for each element, and efficiently finds or creates the corresponding value list in the destination map via getOrPut.
zip and unzip
If you want to pair elements by position, you can use zip. It is well-suited for merging parallel lists like labels + values, and it stops at the length of the shorter collection.
unzip is its inverse operation: splitting a collection of Pairs back into two lists. It is useful after you have mapped data into Pairs or read rows similar to CSV.
zip combines two collections into Pairs. unzip splits a collection of Pairs into two separate lists.
val languages = listOf("Kotlin", "Java")
val versions = listOf("1.8", "11")
val paired = languages.zip(versions)
println(paired) // Output: [(Kotlin, 1.8), (Java, 11)]
Kotlin provides two main overloads for zip, reflecting a common design: building a simple, convenient API on top of a more general and flexible one.
The simple
zip(other)infix function: This is the most common version, used to create aList<Pair<T, R>>. Its implementation is just a delegation:public infix fun <T, R> Iterable<T>.zip(other: Iterable<R>): List<Pair<T, R>> { return zip(other) { t1, t2 -> t1 to t2 } }Delegation: This function does not contain any iteration logic; instead, it immediately calls the more powerful
zip(other, transform)overload. Defaulttransform:{ t1, t2 -> t1 to t2 }. Thislambdasimply receives two elements and combines them into a standardPair. Thus, the simple version ofzipis a convenient special case of the generalzipwithtransform.The
zip(other, transform)workhorse implementation: The actual merging logic resides in this core implementation. It is designed to be more flexible, allowing the caller to precisely define how the two elements at each index position should be combined.public inline fun <T, R, V> Iterable<T>.zip(other: Iterable<R>, transform: (a: T, b: R) -> V): List<V> { // 1. Get iterators for both collections val first = iterator() val second = other.iterator() // 2. Pre-allocate the result list val list = ArrayList<V>(minOf(collectionSizeOrDefault(10), other.collectionSizeOrDefault(10))) // 3. The merging loop while (first.hasNext() && second.hasNext()) { list.add(transform(first.next(), second.next())) } return list }Breaking it down, this core mechanism consists of the following parts.
Directly creating
Iterators: The first step is to get anIteratorfor the receiver, i.e.,this, and for the otherIterable. By using iterators directly, this function can handle anyIterable, such as aList,Set, orSequence, in a uniform way.Optimized pre-allocation: This is a key performance optimization. The function creates the destination
ArrayListwith an initial capacity set to the smaller of the two collection sizes.It calls
collectionSizeOrDefault(10)on bothIterables: if theIterableis aCollection, it returns the actual size; otherwise, it returns the default value10. UsingminOf(...), it correctly deduces the final size of the zipped list, because the zip process stops as soon as either iterator runs out of elements. This pre-allocation avoids theArrayListresizing its internal array, thus reducing memory and performance overhead.The synchronized traversal loop: The core of the function is the
whileloop.The condition is
while (first.hasNext() && second.hasNext()). This is the essence of thezipoperation: the loop continues only as long as both iterators have elements. As soon as either one—i.e.,first.hasNext()orsecond.hasNext()—returnsfalse, the loop terminates. This naturally ensures that the length of the result list equals the length of the shorter source collection. Inside the loop body,first.next()andsecond.next()fetch the next element from each iterator, respectively, and then pass these two elements to thetransformlambda, adding the transformation result to the destination list.This single loop accomplishes synchronized traversal, transformation, and result list population in one efficient pass.
filter, filterNot, and filterIndexed
These functions are used to select or exclude elements through clear, expressive predicates.
filter keeps matching items.
filterNot removes matching items, which is often clearer than writing !condition.
When you also need the element's position, such as "keep every 3rd element" or "skip the first match," you can use filterIndexed. They are very practical and common for filtering an Iterable based on specified conditions.
filter retains elements that satisfy a given condition. filterNot excludes elements that satisfy the condition. filterIndexed considers both the element and its index.
val items = listOf("skydoves", "kotlin", "android")
val filtered = items.filter { it.startsWith("k") }
println(filtered) // Output: [kotlin]
The public filter function is a concise wrapper. It sets up the operation and then hands over control.
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
// 1. Create a default destination list.
return filterTo(ArrayList<T>(), predicate)
}
public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {
// 1. Iterate over each element in the source iterable.
for (element in this)
// 2. Apply the predicate.
if (predicate(element))
// 3. If the predicate is true, add the element to the destination collection.
destination.add(element)
return destination
}
Creating the destination collection:
filterfirst creates an emptyArrayList<T>()as the destination collection to hold the elements that pass the filter. Unlike functions likemap,filtercannot know the final size of the result list in advance, so it must start with a default-sizedArrayList.Delegating to
filterTo: It then immediately callsfilterTo, passing in the newly createdArrayListand thepredicatelambda. After the loop has processed all elements, the destination collection is returned, containing only the elements that satisfied thepredicate.
The Role of inline
Most transformation operators, such as map, flatMap, groupBy, and filter, are marked as inline functions.
This is an important performance optimization. Through inlining, the Kotlin compiler can avoid the runtime overhead of creating a Function object for the predicate lambda at each call site.
Taking filter as an example, the compiler copies the bytecode of the filter loop and the function body of the predicate lambda directly to the location where filter is called. Therefore, the performance of someList.filter { ... } is almost equivalent to writing a for loop with an if condition by hand. Developers can use a clear, declarative API without incurring additional performance costs.
This copying mechanism also enables more advanced capabilities, because the lambda inherits the full context of the calling function.
For example, if an inline function is called inside a suspend function or a @Composable function, its lambda parameter can also legally call suspend or @Composable functions; this capability is not allowed in a regular, non-inline lambda.
This also explains why you can call suspend functions without restriction inside these transformation operators—inline gives the lambda access to the caller's context.
Some Thoughts
Kotlin's collection transformation operators, such as map, flatMap, groupBy, and zip, provide practical tools for manipulating collections in a functional style. They improve readability, reduce boilerplate code, and simplify the handling of complex data structures, making the Kotlin standard library an important resource for developers.
In real-world projects, these operators are often not used in isolation but are chained together into a data processing pipeline.
For example, first using filter to remove invalid items, then using map to convert raw data from one form to another, and finally using groupBy for grouping and aggregation. The entire flow reads like a clear, declarative pipeline. Coupled with the zero-overhead characteristic brought by inline, the runtime performance of this style is almost indistinguishable from a hand-written for loop, allowing developers to focus more energy on the business logic itself rather than on loop counters and temporary variables.
Mastering these operators is not just about writing shorter code; it is about expressing data flow in a way that is closer to the problem itself.