05 · Collections Deep Dive¶
Level 1 covered List, Set, and Map
themselves. This module is about the functional operations that make
Kotlin collections so pleasant to work with — chains of map/filter/
reduce that replace hand-written loops — plus sequences, which change
when those operations run and can matter a lot for performance.
map, filter, and chaining¶
map transforms each element; filter keeps only elements matching a
predicate. Both return a new collection, so they chain naturally.
data class Product(val name: String, val price: Double, val inStock: Boolean)
fun main() {
val products = listOf(
Product("Widget", 9.99, true),
Product("Gadget", 24.99, false),
Product("Gizmo", 14.50, true),
Product("Doohickey", 5.00, true)
)
val affordableInStockNames = products
.filter { it.inStock }
.filter { it.price < 20.0 }
.map { it.name }
println(affordableInStockNames) // [Widget, Gizmo, Doohickey]
}
reduce and fold¶
Both combine every element into a single value, but fold takes an
explicit starting value and reduce uses the first element as its
starting point.
fun main() {
val prices = listOf(9.99, 24.99, 14.50, 5.00)
val total = prices.fold(0.0) { acc, price -> acc + price }
println(total) // 54.48
val maxPrice = prices.reduce { acc, price -> if (price > acc) price else acc }
println(maxPrice) // 24.99
// reduce throws on an empty collection -- there's no "first element" to start from
val empty = emptyList<Double>()
// empty.reduce { acc, x -> acc + x } // throws UnsupportedOperationException
println(empty.fold(0.0) { acc, x -> acc + x }) // 0.0 -- fold handles empty fine
}
fold is the safer default: it always has a well-defined answer for an
empty collection, since you supply the starting value yourself.
groupBy, associateBy, and partition¶
These reshape a flat list into a map or a pair of lists — extremely common for reporting and bucketing.
data class Employee(val name: String, val department: String, val salary: Int)
fun main() {
val employees = listOf(
Employee("Alice", "Engineering", 95000),
Employee("Bob", "Sales", 60000),
Employee("Carol", "Engineering", 105000),
Employee("Dave", "Sales", 65000)
)
val byDept = employees.groupBy { it.department }
println(byDept.keys) // [Engineering, Sales]
println(byDept["Engineering"]?.map { it.name }) // [Alice, Carol]
val byName = employees.associateBy { it.name }
println(byName["Bob"]?.salary) // 60000
val (highEarners, others) = employees.partition { it.salary > 70000 }
println(highEarners.map { it.name }) // [Alice, Carol]
println(others.map { it.name }) // [Bob, Dave]
}
flatMap: flattening nested collections¶
map alone would leave you with a list of lists; flatMap merges them
into one flat list.
data class Order(val id: Int, val items: List<String>)
fun main() {
val orders = listOf(
Order(1, listOf("apple", "banana")),
Order(2, listOf("cherry")),
Order(3, listOf("date", "elderberry"))
)
val allItems = orders.flatMap { it.items }
println(allItems) // [apple, banana, cherry, date, elderberry]
// compare to plain map, which would give a List<List<String>>:
println(orders.map { it.items }) // [[apple, banana], [cherry], [date, elderberry]]
}
Sorting with sortedBy and comparators¶
data class Person(val name: String, val age: Int)
fun main() {
val people = listOf(Person("Bob", 25), Person("Alice", 30), Person("Carol", 25))
println(people.sortedBy { it.age }) // youngest first
println(people.sortedByDescending { it.age }) // oldest first
// sort by age, then by name for ties -- compareBy supports multiple keys
val sorted = people.sortedWith(compareBy({ it.age }, { it.name }))
println(sorted)
}
[Person(name=Bob, age=25), Person(name=Carol, age=25), Person(name=Alice, age=30)]
[Person(name=Alice, age=30), Person(name=Bob, age=25), Person(name=Carol, age=25)]
[Person(name=Bob, age=25), Person(name=Carol, age=25), Person(name=Alice, age=30)]
Sequences: lazy evaluation¶
Every operation on a regular List (map, filter, ...) runs eagerly
and builds a brand-new intermediate list right away. Chain several of them
and you allocate one throwaway list per step. asSequence() switches to
lazy evaluation: nothing runs until you ask for a final result (like
.toList(), .first(), or .sum()), and elements flow through the whole
chain one at a time instead of one full pass per operation.
fun main() {
val numbers = (1..1_000_000).toList()
// Eager: filter builds a full intermediate list of ~500,000 elements,
// THEN map builds another full list from that, THEN first() looks at it.
val eagerResult = numbers.filter { it % 2 == 0 }.map { it * it }.first()
// Lazy: each number flows through filter -> map one at a time; evaluation
// stops the instant the first match is found -- no full intermediate lists.
val lazyResult = numbers.asSequence()
.filter { it % 2 == 0 }
.map { it * it }
.first()
println(eagerResult) // 4
println(lazyResult) // 4 -- same answer, far less allocation for large inputs
}
Sequences aren't automatically faster for everything
For small collections, or when you need to fully process every element
anyway (e.g. .map { }.toList() over 10 items), the eager List
version is simpler and just as fast — sequence setup has its own small
overhead. Reach for asSequence() when you have a long chain of
operations over a large collection, especially if an early operation
(like first(), find(), or take()) can short-circuit before
processing everything.
How It Actually Works¶
filter { it.inStock }.filter { it.price < 20.0 }.map { it.name } on a plain
List is eager and multi-pass: each of those three calls fully iterates
its input and allocates a brand-new ArrayList before the next call even
starts. Under the hood, filter is implemented (in
kotlin.collections.CollectionsKt) as roughly val result = ArrayList<T>();
for (e in this) if (predicate(e)) result.add(e); return result — a real
loop, a real allocation, every time. Chain four operations over a
million-element list and you've built three intermediate lists you never
otherwise wanted, purely as scaffolding between steps.
asSequence() changes the underlying strategy entirely: it wraps the
collection in a Sequence<T>, and each intermediate operation
(.filter, .map) no longer executes anything — it just returns a new
Sequence object that remembers the upstream sequence plus the
transformation to apply, building up a lazy chain of wrapper objects. No
actual work happens until a terminal operation (.toList(), .first(),
.sum(), .forEach { }) pulls values through the chain one element at a
time, applying every step to that one element before moving to the next.
This is why sequences can short-circuit — sequence.filter {...}.first {
predicate } can stop after finding the very first match instead of filtering
the entire collection first — something a List-based chain fundamentally
cannot do, since .filter on a List must finish before .first can even
be called.
fold/reduce are implemented as simple accumulator loops with no
allocation per step — fold(0.0) { acc, x -> acc + x } compiles to a for
loop carrying one running Double variable, calling the lambda's generated
invoke() on each iteration; reduce is identical except it seeds the
accumulator from first() instead of a supplied initial value, which is
exactly why it has nothing to seed with — and throws
UnsupportedOperationException — on an empty collection.
Cheat sheet¶
| Operation | Purpose |
|---|---|
map { } |
Transform each element |
filter { } |
Keep elements matching a predicate |
fold(initial) { acc, x -> } |
Combine into one value, safe on empty collections |
reduce { acc, x -> } |
Combine into one value, using the first element as the start (throws on empty) |
groupBy { } |
Bucket elements into a Map<Key, List<Element>> |
associateBy { } |
Build a Map<Key, Element> (last one wins on duplicate keys) |
partition { } |
Split into a Pair of (matching, non-matching) lists |
flatMap { } |
Map then flatten one level of nesting |
sortedBy { } / sortedWith(compareBy(...)) |
Sort by one or more keys |
asSequence() |
Switch to lazy, single-pass evaluation for a chain |
Exercise¶
Given a List<Order> where data class Order(val customer: String, val
total: Double, val isPaid: Boolean), write code that: groups orders by
customer using groupBy, then for each customer computes their total
paid revenue using filter + fold (or sumOf), and finally prints
customers sorted by revenue descending. Then rewrite the pipeline using
asSequence() and confirm you get the same result.