Lambdas at Work: Transforming Collections
Where lambdas earn their keep: higher-order functions, the trailing-lambda convention, map/filter and the grouping toolkit, fold and reduce, chaining, and lazy sequences that turn a million-element pipeline into a handful of evaluations. Part 2 of five.
In part one we learned that a lambda is a value: code you can store and call. But a lambda you only ever call yourself isn’t worth much. The power shows up when you hand a lambda to another function and let it do the calling. Kotlin’s collection library is built almost entirely around this, and it’s where you’ll use lambdas every single day.
This is part two of five, run against Kotlin 2.4.10. By the end you’ll write the fluent list.filter { }.map { } style Kotlin is known for — and understand exactly what it costs, because the last section shows how to make a long chain lazy.
Functions that take functions
A function that accepts a lambda is a higher-order function. The simplest one on a list is forEach: give it a lambda, and it runs that lambda once per element.
val names = listOf("Ada", "Linus", "Grace")
names.forEach({ name -> println(name) })
forEach is being called with one argument, and that argument is a lambda — the kind of value from part one. forEach handles the looping; your lambda says what to do with each element.
The trailing-lambda convention
Kotlin developers almost never write the call that way. The convention: when a lambda is the last argument, you move it outside the parentheses, and if it’s the only argument, the parentheses vanish:
names.forEach { name -> println(name) }
names.forEach { println(it) } // with the `it` shortcut
This one convention is why idiomatic Kotlin looks the way it does — those trailing { } blocks everywhere are just lambdas passed as the last argument. Recognize the pattern and a lot of “magic-looking” Kotlin becomes ordinary function calls.
map and filter
forEach gives nothing back. map transforms — it runs your lambda on each element and collects the results into a new list:
val numbers = listOf(1, 2, 3, 4)
val squares = numbers.map { it * it } // [1, 4, 9, 16]
filter takes a predicate and keeps the elements it’s true for:
val evens = numbers.filter { it % 2 == 0 } // [2, 4]
Both return a new list; the original is untouched. Because each returns a list, they chain, and the code reads top-to-bottom in execution order:
val result = numbers
.filter { it % 2 == 0 } // [2, 4]
.map { it * it } // [4, 16]
This is the heart of everyday Kotlin: describe what you want as a pipeline of small transformations instead of a loop with a mutable accumulator and an index.
The rest of the per-element toolkit
map and filter are two of many, all the same shape — a method taking a lambda:
numbers.any { it > 3 } // true — any match?
numbers.all { it > 0 } // true — all match?
numbers.none { it > 9 } // true — no matches?
numbers.count { it % 2 == 0 } // 2 — how many match?
numbers.find { it > 2 } // 3 — first match, or null
numbers.sumOf { it * 2 } // 20 — add a value per element
numbers.maxByOrNull { it % 3 } // 2 — element with the largest key
Don’t memorize the list; memorize the pattern — a collection method taking a lambda that describes the per-element rule — and let autocomplete supply the name.
Grouping and restructuring
Beyond one-to-one transforms, a handful of operations reshape a collection wholesale, and they’re the ones that replace the fiddliest hand-written loops:
val words = listOf("ant", "bee", "cat", "ape", "bat")
words.groupBy { it.first() } // {a=[ant, ape], b=[bee, bat], c=[cat]}
words.associateBy { it.first() } // {a=ape, b=bat, c=cat} — last value wins per key
listOf(1, 2, 3, 4).partition { it % 2 == 0 } // ([2, 4], [1, 3]) — a Pair of lists
listOf("ab", "cd").flatMap { it.toList() } // [a, b, c, d] — map, then flatten
groupBy builds a Map from a key to the list of elements that share it; associateBy keeps only the last element per key (note a=ape, not ant); partition splits into matches and non-matches in one pass; flatMap maps each element to a collection and concatenates the results. These four cover an enormous fraction of real data-munging.
fold and reduce: collapsing to a single value
When you want to boil a collection down to one result with an accumulator, fold takes an initial value and a lambda of (accumulator, element):
val total = numbers.fold(0) { acc, n -> acc + n } // 10
val csv = words.fold("") { acc, w -> if (acc.isEmpty()) w else "$acc,$w" }
reduce is the same idea without a seed — it uses the first element as the initial accumulator, and therefore throws on an empty collection. Prefer fold when the collection might be empty or when the result type differs from the element type. (For the common cases, named functions like sumOf and joinToString beat a hand-rolled fold — reach for fold when there’s no prebuilt operation for what you’re accumulating.)
Writing your own higher-order function
These functions aren’t magic; you can write them. Here’s one that runs an action a given number of times:
fun repeatTimes(n: Int, action: (Int) -> Unit) {
for (i in 0..<n) action(i)
}
repeatTimes(3) { println("tick $it") }
The parameter action: (Int) -> Unit is just a function type from part one — this function accepts a lambda and calls it, and the trailing-lambda convention lets the caller pass it in clean braces. No compiler trick; the collection functions work the same way.
The cost of a chain — and lazy sequences
Now the part that matters for real workloads. Each step in list.filter { }.map { } builds a new intermediate list. On a four-element list that’s nothing. On a large one where you only need the first match, it’s wasteful — filter walks the whole list and allocates a full result, then map walks that and allocates again, even if you were about to take just one element.
A sequence makes the pipeline lazy. asSequence() turns the collection into a stream that pulls elements one at a time, running the whole chain per element and producing nothing until a terminal operation asks:
val log = mutableListOf<Int>()
val firstBig = (1..1_000_000).asSequence()
.map { log.add(it); it * 2 }
.first { it > 6 }
// firstBig = 8, and log.size == 4
That map ran four times, not a million — the sequence fed elements through until first was satisfied, then stopped. The eager version ((1..1_000_000).map { ... }.first { ... }) would allocate a million-element list and run map a million times before first ever looked. The rule of thumb: for a short collection or when you need every result, eager operations are simpler and often faster (no per-element iterator overhead); for a long chain, a large source, or an early exit, asSequence() turns the pipeline into a single lazy pass. Sequences are Kotlin’s equivalent of Java’s Stream, minus the .stream()/.collect() ceremony — and unlike a Stream, a Kotlin sequence is built from the same operations you already know.
A lambda also remembers
One more property before we go on: a lambda can use variables from the scope where it was defined — it “closes over” them. A lambda passed to filter can reference a threshold from the surrounding function. That ability, the closure, behaves differently in Kotlin than in Java and is subtle enough to cause real bugs, so it gets its own stop next.
Final thoughts
This is the part of lambdas you’ll use most: hand a small lambda to a collection function and let it own the looping and the bookkeeping. map transforms, filter selects, groupBy and fold restructure, and all of them are higher-order functions you could have written yourself. The one judgment call is eager versus lazy — default to eager for clarity, and reach for asSequence() when the chain is long, the source is large, or you’re bailing out early, where laziness turns a million evaluations into a handful.
Next: closures — what it means for a lambda to “remember” the variables around it, why Kotlin lets you change them when Java won’t, and the surprises that follow.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.
Comments