Lecture notes — Programming with Lambdas

Published

2026-08-21 00:00

Keywords

ver. 1.0.0

← Programming with Lambdas

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

  1. write-lambda-expressions — Write lambda expressions and apply the syntactic conventions that shorten them.
  2. explain-variable-capture — Explain what a Kotlin lambda can capture and how that differs from Java.
  3. use-member-references — Use member references in place of a lambda that only calls one function.
  4. use-the-collection-apis — Transform collections with filter, map, and the predicate and grouping functions.
  5. use-sequences-for-lazy-evaluation — Use sequences to avoid building intermediate collections, and know when they help.
  6. create-sequences — Create a sequence from a collection, from a generator function, or by iteration.
  7. use-java-functional-interfaces — Pass a Kotlin lambda where Java expects a functional interface.
  8. use-sam-constructors — Use a SAM constructor when the conversion cannot happen implicitly.
  9. 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 APIfilter, 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 receiverswith, 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

The parts of a lambda: braces around the whole thing, parameters before the arrow, body after.

A lambda is always in braces: parameters, an arrow, then the body.

val sum = { x: Int, y: Int -> x + y }
println(sum(1, 2))    // 3

Storing 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.

NoteA caution about it

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.

ImportantThe consequence

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

The :: 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 constructor

The 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 Person

No 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 nothing

When 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 null

Two 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 book

When 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 all

A 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

Elements pass through the whole chain one at a time, rather than one operation at a time.

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 fewer

The 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.

TipWhen to use sequences

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 one

A 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.

NoteOne caution

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:

apply returns the receiver. with returns 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.

ImportantWhy this section matters beyond convenience

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: this inside 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, it for 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 filter before map is cheaper lazily and identical eagerly, and why find can 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.

    with and apply are 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.