Lecture notes — Programming with Lambdas
ver. 1.0.0
Where we are
The previous three units were about declaring and calling things. This one begins the functional half of the language.
It is also the first unit whose ideas the last unit of the module depends on directly. The final section — lambdas with receivers — is the mechanism behind the HTML builder you saw in unit 1, and the DSL unit builds one from scratch. When you reach with and apply, read them as the foundation rather than the footnote.
What you will be able to do
write-lambda-expressions— Write lambda expressions and apply the syntactic conventions that shorten them.explain-variable-capture— Explain what a Kotlin lambda can capture and how that differs from Java.use-member-references— Use member references in place of a lambda that only calls one function.use-the-collection-apis— Transform collections with filter, map, and the predicate and grouping functions.use-sequences-for-lazy-evaluation— Use sequences to avoid building intermediate collections, and know when they help.create-sequences— Create a sequence from a collection, from a generator function, or by iteration.use-java-functional-interfaces— Pass a Kotlin lambda where Java expects a functional interface.use-sam-constructors— Use a SAM constructor when the conversion cannot happen implicitly.use-lambdas-with-receivers— Use with and apply, and explain what a lambda with a receiver is.
What we will cover
- Lambda syntax — the full form, and each convention that shortens it.
- Member references — when a lambda does nothing but forward.
- The collection API —
filter,map, the predicates,groupBy,flatMap. - Sequences — lazy evaluation, and when it is worth the trouble.
- Java functional interfaces — SAM conversion and SAM constructors.
- Lambdas with receivers —
with,apply, and where the module is heading.
Lambda syntax
Before lambdas, passing behaviour meant an anonymous inner class — several lines of ceremony around one line of intent:
button.setOnClickListener(object : OnClickListener {
override fun onClick(v: View) {
// the one line you cared about
}
})With a lambda:
button.setOnClickListener { /* the one line you cared about */ }That is the same argument unit 4 made when it recommended a lambda over an object expression, now with the syntax to back it up.
The full form

A lambda is always in braces: parameters, an arrow, then the body.
val sum = { x: Int, y: Int -> x + y }
println(sum(1, 2)) // 3Storing one in a variable is legal and rarely the point. Lambdas are for passing.
Four simplifications, in order
Start from the fully explicit call and remove one thing at a time. Walking the chain is what makes idiomatic Kotlin readable rather than cryptic:
people.maxBy({ p: Person -> p.age }) // full form
people.maxBy() { p: Person -> p.age } // 1. trailing lambda moves outside
people.maxBy { p: Person -> p.age } // 2. empty parentheses drop
people.maxBy { p -> p.age } // 3. parameter type inferred
people.maxBy { it.age } // 4. single parameter becomes `it`Every step is mechanical. The last line is a large change from the first, and there is no magic in between.
it is convenient and easy to overuse.
In nested lambdas, only the innermost it is accessible, and the reader has to work out which one they are looking at. Where the parameter’s meaning is not obvious from context, name it.
The compiler will not stop you writing it inside it. Your reader will.
The body’s value
If a lambda has several statements, the last expression is its result:
val result = { x: Int ->
println("computing")
x * 2 // this is the value
}That is the same rule as a when branch and a function expression body from unit 2. Third instance of one consistent rule — worth naming as such, because Kotlin never departs from it.
Variable capture
A lambda can use the enclosing function’s parameters and locals. Unlike Java, it can also modify them:
fun printProblemCounts(responses: Collection<String>) {
var clientErrors = 0
var serverErrors = 0
responses.forEach {
if (it.startsWith("4")) {
clientErrors++ // Java would reject this
} else if (it.startsWith("5")) {
serverErrors++
}
}
println("$clientErrors client errors, $serverErrors server errors")
}Java requires captured variables to be final or effectively final. Kotlin does not, because the compiler wraps the variable in an object so it can outlive the enclosing call — a val is captured by value, a var by wrapping.
A lambda that escapes its enclosing function still sees the variable.
Here that is what you want: the counters are correct after forEach returns. But a lambda stored in a listener, or handed to a thread, keeps that wrapper — and everything it references — alive for as long as the lambda lives.
Kotlin removed Java’s restriction. It did not remove the reason Java had one.
Learning outcomes
- write-lambda-expressions: Write lambda expressions and apply the syntactic conventions that shorten them.
- explain-variable-capture: Explain what a Kotlin lambda can capture and how that differs from Java.
Concepts
- lambda-expressions: braces, parameters, arrow, body — and the four conventions that shorten them
- variable-capture: Kotlin lambdas may modify captured locals, because the compiler wraps them
Member references
A lambda whose entire body is a single call to an existing function is pure forwarding — it names a parameter only to hand it straight on:
people.maxBy { it.age } // forwards to a property read
people.maxBy(Person::age) // says the same thing once
:: operator connects a type to one of its members.The syntax is Class::member, and it works for four things:
Person::age // a property
String::isEmpty // a member function
::salute // a top-level function — nothing before the colons
::Person // a constructorThe constructor case
Worth pausing on, because it is not obvious. A constructor reference is a function value that makes objects:
val createPerson = ::Person
val p = createPerson("Alice", 29)
val people = names.map(::Person) // list of names → list of PersonNo lambda is written at all.
Bound references
A reference can point at a member of a specific instance rather than a type, in which case the receiver is fixed and the resulting function takes no arguments:
val p = Person("Dmitry", 34)
val personsAgeFunction = Person::age
println(personsAgeFunction(p)) // 34 — takes a Person
val dmitrysAgeFunction = p::age
println(dmitrysAgeFunction()) // 34 — takes nothingWhen to use one
When the lambda would only forward. When it does anything else — even a small transformation — the lambda is clearer, because the reference form cannot express it and contorting the code to fit produces something worse than what it replaced.
Learning outcomes
- use-member-references: Use member references in place of a lambda that only calls one function.
Concepts
- member-references:
::for properties, functions and constructors, replacing a forwarding lambda
The collection API
The payoff of the previous two sections, and the part you will use every day.
filter and map

filter keeps the elements matching a predicate.
map transforms every element.val list = listOf(1, 2, 3, 4)
println(list.filter { it % 2 == 0 }) // [2, 4]
println(list.map { it * it }) // [1, 4, 9, 16]Both return new collections, leaving the original untouched — the immutability preference from unit 1, in the API you touch most often.
They compose, and the composition is where the style becomes visible:
people.filter { it.age > 30 }.map(Person::name)That reads as a description of the result. The equivalent loop reads as a procedure for obtaining it, and you have to run it in your head to know which.
Maps get the same treatment with filterKeys, filterValues, mapKeys and mapValues, so the vocabulary carries over.
Predicates over the whole collection
val canBeInClub27 = { p: Person -> p.age <= 27 }
people.all(canBeInClub27) // does every element match?
people.any(canBeInClub27) // does at least one?
people.count(canBeInClub27) // how many?
people.find(canBeInClub27) // the first that does, or nullTwo details worth carrying away.
Prefer any to a negated all. !people.all(canBeInClub27) and people.any { !canBeInClub27(it) } mean the same thing, and the double negative in the first is a genuine reading hazard. Use any with the positive form of what you are looking for.
Use count, not filter(...).size. filter builds an entire intermediate collection so you can ask its size and throw it away. count counts.
groupBy

groupBy turns a list into a map from key to list of elements.val people = listOf(Person("Alice", 31), Person("Bob", 29), Person("Carol", 31))
println(people.groupBy { it.age })
// {31=[Person(Alice, 31), Person(Carol, 31)], 29=[Person(Bob, 29)]}This is the operation that most often replaces a hand-written loop with a HashMap, a lookup, a null check and a list creation on every insertion.
flatMap and flatten

flatMap maps each element to a collection, then flattens the results into one.val strings = listOf("abc", "def")
println(strings.flatMap { it.toList() }) // [a, b, c, d, e, f]
books.flatMap { it.authors }.toSet() // every author across every bookWhen there is nothing to transform and you only want to flatten a list of lists, flatten is the one to reach for.
Learning outcomes
- use-the-collection-apis: Transform collections with filter, map, and the predicate and grouping functions.
Concepts
- collection-functional-apis:
filter,map, the predicates,groupBy,flatMap— all returning new collections
Sequences and laziness
The problem
people.map(Person::name).filter { it.startsWith("A") }The map builds a list. The filter builds another. For a hundred elements this does not matter. For a million it is two full allocations and two full traversals where one would do.
Sequences
people.asSequence()
.map(Person::name)
.filter { it.startsWith("A") }
.toList()asSequence() makes the chain lazy; toList() converts back when you need a collection again.
Intermediate versus terminal
The distinction is the whole model:
- an intermediate operation returns another sequence and does nothing yet
- a terminal operation produces a result and forces the whole chain to run
listOf(1, 2, 3, 4).asSequence()
.map { print("map($it) "); it * it }
.filter { print("filter($it) "); it % 2 == 0 }
// prints nothing at allA chain of intermediate operations with no terminal operation does no work. That surprises everyone the first time, and printing from inside a lazy map is the fastest way to see it.
How elements flow

This is the part worth slowing down for. In a lazy chain, each element passes through every operation before the next element starts:
map(1) filter(1) map(2) filter(4) map(3) filter(9) map(4) filter(16)
Not map across everything, then filter across everything. Two consequences follow.
Order matters. Filtering before mapping is cheaper, because the mapping is then applied to fewer elements:
people.asSequence().map(Person::name).filter { it.length < 4 } // maps all
people.asSequence().filter { it.name.length < 4 }.map(Person::name) // maps fewerThe eager versions do the same total work either way. The lazy ones do not.
Short-circuiting comes free. find on a lazy chain stops as soon as it has an answer, and later elements are never processed at all.
Creating sequences
Three ways:
list.asSequence() // from a collection
val naturals = generateSequence(0) { it + 1 } // from a seed and a step
println(naturals.takeWhile { it <= 100 }.sum()) // 5050
fun File.isInsideHiddenDirectory() = // by walking a structure
generateSequence(this) { it.parentFile }.any { it.isHidden }generateSequence can produce an infinite sequence, and that is safe precisely because nothing is computed until a terminal operation asks — and a short-circuiting one, like any, stops the flow.
Not always. For small collections the eager operations are simpler and often faster, because a sequence adds per-element overhead.
Reach for sequences when the collection is large, the chain is long, or the chain can short-circuit.
For a list of ten items, asSequence() is a pessimisation dressed as an optimisation.
Learning outcomes
- use-sequences-for-lazy-evaluation: Use sequences to avoid building intermediate collections, and know when they help.
- create-sequences: Create a sequence from a collection, from a generator function, or by iteration.
Concepts
- sequences: lazy chains, intermediate versus terminal operations, and element-at-a-time flow
Lambdas and Java interfaces
Why anything is needed
Java has no lambda type. Its lambdas are a compiler feature over functional interfaces — interfaces with a single abstract method. So a Kotlin lambda passed to a Java method must become an instance of the interface that method expects.
SAM conversion
The compiler does this automatically for any interface with a single abstract method:
Thread { println("Hello from a thread") }.start()
button.setOnClickListener { view -> /* ... */ }No ceremony at all, which is what makes the entire Java ecosystem usable from Kotlin without wrapper layers.
The object-creation detail, worth knowing for hot code paths:
- a lambda that captures nothing is instantiated once and reused for every call
- a capturing lambda creates a new object per call, because each invocation captures different values
If a lambda in a tight loop captures a variable, that is an allocation per iteration.
SAM constructors
The compiler infers the target interface from the parameter’s declared type. When there is no such parameter, it cannot — which happens in two places:
fun createAllDoneRunnable(): Runnable {
return Runnable { println("All done!") } // returning one
}
val listener = OnClickListener { view -> /* ... */ } // storing oneA SAM constructor is named after the interface and makes the target explicit.
The stored case has a second use worth knowing: if you need to unregister a listener later, you must keep a reference to the exact object you registered, and a SAM constructor is how you get one.
SAM conversion applies to Java interfaces.
Kotlin has proper function types — the next unit covers them — so a Kotlin function taking a lambda declares a function type rather than an interface. No conversion happens, and none is needed.
If you find yourself declaring a single-method interface in Kotlin just to accept a lambda, you have written Java in Kotlin syntax.
Learning outcomes
- use-java-functional-interfaces: Pass a Kotlin lambda where Java expects a functional interface.
- use-sam-constructors: Use a SAM constructor when the conversion cannot happen implicitly.
Concepts
- sam-conversion: automatic conversion at parameter positions, explicit constructors everywhere else
Lambdas with receivers
The last section of the unit, and the one the module has been building towards.
The idea
An ordinary lambda has parameters. A lambda with a receiver additionally has a receiver object, which becomes this inside the body — so the receiver’s members can be called without naming it.
This is exactly the relationship an extension function has with its receiver, from unit 3. There, a function got a receiver. Here, a lambda does.
with
fun alphabet(): String {
val result = StringBuilder()
for (letter in 'A'..'Z') {
result.append(letter) // result. result. result.
}
result.append("\nNow I know the alphabet!")
return result.toString()
}With a receiver lambda:
fun alphabet(): String {
val stringBuilder = StringBuilder()
return with(stringBuilder) {
for (letter in 'A'..'Z') {
append(letter) // no qualification
}
append("\nNow I know the alphabet!")
toString() // the value of the with
}
}Inside the lambda, append and toString need no receiver, because the StringBuilder is the receiver.
The gain is obvious on three lines and substantial on twenty.
apply
Almost the same, with one difference that decides which to use:
applyreturns the receiver.withreturns the lambda’s result.
So apply is for configuring an object and getting it back:
fun alphabet() = StringBuilder().apply {
for (letter in 'A'..'Z') {
append(letter)
}
append("\nNow I know the alphabet!")
}.toString()
val textView = TextView(context).apply {
text = "Sample"
textSize = 20.0
setPadding(10, 0, 0, 0)
}That second example is the shape you will write most: create, configure, use — with no intermediate variable and no repetition of its name.
with and apply are ordinary library functions. No compiler support, nothing special. They work because a function can declare a parameter of extension function type — and any function you write can declare one too.
Now recall the HTML builder from unit 1:
createHTML().table {
tr {
td { +person.name }
}
}Nested blocks, where each one seems to know its enclosing element. That is this feature. The nesting works because each block is a lambda with a receiver, and the receiver is the enclosing element — which is also why td is available inside tr and not outside it.
The final unit of the module builds one properly. This section is where the mechanism arrives.
Learning outcomes
- use-lambdas-with-receivers: Use with and apply, and explain what a lambda with a receiver is.
Concepts
- lambdas-with-receivers:
thisinside the lambda is a supplied object, which is what makes nested builders work
What you can now express
You can write lambdas idiomatically, transform collections, choose between eager and lazy, use Java’s functional interfaces, and use lambdas with receivers.
Learning outcomes
- write-lambda-expressions: Write lambda expressions and apply the syntactic conventions that shorten them.
- explain-variable-capture: Explain what a Kotlin lambda can capture and how that differs from Java.
- use-member-references: Use member references in place of a lambda that only calls one function.
- use-the-collection-apis: Transform collections with filter, map, and the predicate and grouping functions.
- use-sequences-for-lazy-evaluation: Use sequences to avoid building intermediate collections, and know when they help.
- create-sequences: Create a sequence from a collection, from a generator function, or by iteration.
- use-java-functional-interfaces: Pass a Kotlin lambda where Java expects a functional interface.
- use-sam-constructors: Use a SAM constructor when the conversion cannot happen implicitly.
- use-lambdas-with-receivers: Use with and apply, and explain what a lambda with a receiver is.
Conclusion
Idiomatic lambda syntax is four mechanical steps from the full form.
Trailing lambda out, parentheses gone, types inferred,
itfor the single parameter. Nothing is hidden in between — and knowing the chain is what makes the short form readable rather than cryptic.Kotlin lambdas may modify captured locals, and that has a cost.
The compiler wraps the variable so it can outlive the call. Convenient inside
forEach; a lifetime you did not intend when the lambda escapes.The collection API returns new collections at every step.
That is the immutability preference in the API you use most — and the reason a long chain over a large collection is worth converting to a sequence.
Laziness changes the order work happens in, not just how much.
Each element flows through the whole chain before the next starts. That is why
filterbeforemapis cheaper lazily and identical eagerly, and whyfindcan stop early.SAM conversion is what makes the Java ecosystem usable without wrappers.
A lambda becomes an instance of a single-abstract-method interface automatically at parameter positions, and explicitly — via a SAM constructor — when returning or storing one.
A lambda with a receiver is the module’s destination, arriving early.
withandapplyare ordinary functions taking a parameter of extension function type. The same declaration is what makes nested builder blocks possible, and the last unit uses it to build a DSL.
Where next
The next unit, The Kotlin Type System, turns to the safety claim from unit 1: nullability tracked in the type system, primitive types and where boxing happens, Any, Unit and Nothing, and the read-only versus mutable collection distinction — which explains something this unit’s collection API quietly relied on.