Programming with Lambdas

Kotlin

2026-08-21 09:00

Where we are

The functional half begins

The previous three units were about declaring and calling.

This one starts the functional side of the language.

And it is the destination

The final section — lambdas with receivers

is the mechanism behind the HTML builder from unit 1.

Read with and apply as the foundation, not the footnote.

What you will be able to do

  1. Write lambdas and shorten them idiomatically.
  2. Explain variable capture.
  3. Use member references.
  4. Transform collections.
  5. Use sequences for lazy evaluation.
  6. Create sequences three ways.
  7. Pass lambdas to Java functional interfaces.
  8. Use SAM constructors.
  9. Use with and apply.

Lambda syntax

Before

button.setOnClickListener(object : OnClickListener {
    override fun onClick(v: View) {
        // the one line you cared about
    }
})

After

button.setOnClickListener { /* the one line you cared about */ }

The full form

Always in braces

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

Storing one is legal and rarely the point. Lambdas are for passing.

Four steps, in order

people.maxBy({ p: Person -> p.age })   // full

people.maxBy() { p: Person -> p.age }  // 1. trailing lambda out

people.maxBy { p: Person -> p.age }    // 2. empty parens drop

people.maxBy { p -> p.age }            // 3. type inferred

people.maxBy { it.age }                // 4. `it`

Every step mechanical. No magic in between.

A caution about it

In nested lambdas, only the innermost it is accessible.

Where the meaning is not obvious, name the parameter.

The compiler will not stop you. Your reader will.

The body’s value

The last expression is the result.

Same rule as a when branch. Same rule as a function expression body.

Third instance. Kotlin never departs from it.

Variable capture

var clientErrors = 0
responses.forEach {
    if (it.startsWith("4")) clientErrors++    // Java would reject this
}

Kotlin wraps the variable so it can outlive the call.

The consequence

A lambda that escapes still sees the variable.

Here that is what you want.

Stored in a listener, it keeps the wrapper — and everything it references — alive.

Member references

Pure forwarding

people.maxBy { it.age }     // names a parameter to hand it straight on
people.maxBy(Person::age)   // says it once

The :: operator

Four things

Person::age             // a property
String::isEmpty         // a member function
::salute                // a top-level function
::Person                // a constructor

The constructor case

val people = names.map(::Person)      // names → Person objects

A function value that makes objects. No lambda written at all.

Bound references

val p = Person("Dmitry", 34)
val f = p::age
println(f())              // 34 — takes nothing

When to use one

When the lambda would only forward.

When it does anything else, the lambda is clearer —

and contorting it to fit produces something worse.

The collection API

filter

map

Both return new collections

people.filter { it.age > 30 }.map(Person::name)

That reads as a description of the result.

The equivalent loop reads as a procedure, and you have to run it in your head.

Predicates

people.all(canBeInClub27)     // every element?
people.any(canBeInClub27)     // at least one?
people.count(canBeInClub27)   // how many?
people.find(canBeInClub27)    // the first, or null

Two details worth keeping

Prefer any to a negated all. The double negative is a reading hazard.

Use count, not filter(...).size. filter builds a whole collection to ask its size.

groupBy

What it replaces

A hand-written loop with a HashMap,

a lookup, a null check, and a list creation on every insertion.

flatMap

And flatten

books.flatMap { it.authors }.toSet()     // map then flatten
listOfLists.flatten()                    // just flatten

Sequences

The problem

people.map(Person::name).filter { it.startsWith("A") }

map builds a list. filter builds another.

A hundred elements: irrelevant. A million: two allocations and two traversals.

asSequence

people.asSequence()
      .map(Person::name)
      .filter { it.startsWith("A") }
      .toList()

Intermediate versus terminal

Intermediate — returns a sequence, does nothing yet.

Terminal — produces a result, forces the chain.

Nothing happens

listOf(1, 2, 3, 4).asSequence()
    .map { print("map($it) "); it * it }
    .filter { print("filter($it) "); it % 2 == 0 }

Prints nothing at all.

How elements flow

One at a time, all the way through

map(1) filter(1) map(2) filter(4) map(3) filter(9) ...

Not map across everything, then filter across everything.

Two consequences

Order matters. filter before map maps fewer elements.

Eagerly, the total work is identical either way.

Short-circuiting is free. find stops as soon as it has an answer.

Creating them

list.asSequence()

val naturals = generateSequence(0) { it + 1 }
println(naturals.takeWhile { it <= 100 }.sum())     // 5050

generateSequence(this) { it.parentFile }.any { it.isHidden }

Infinite sequences are safe, because nothing runs until asked.

When to use them

Large collections. Long chains. Chains that short-circuit.

For a list of ten, asSequence() is a pessimisation dressed as an optimisation.

Lambdas and Java interfaces

Java has no lambda type

Its lambdas are a compiler feature over functional interfaces.

So a Kotlin lambda must become an instance of one.

SAM conversion

Thread { println("Hello from a thread") }.start()
button.setOnClickListener { view -> /* ... */ }

Automatic. Which is what makes the Java ecosystem usable without wrappers.

The allocation detail

A lambda that captures nothing → one instance, reused.

A capturing lambda → a new object per call.

In a tight loop, that is an allocation per iteration.

SAM constructors

fun createAllDoneRunnable(): Runnable {
    return Runnable { println("All done!") }     // returning
}

val listener = OnClickListener { view -> /* ... */ }   // storing

The compiler infers the target from a parameter type. Here there is none.

And one more use

To unregister a listener later,

you need a reference to the exact object you registered.

A caution

SAM conversion is for Java interfaces.

Kotlin has proper function types — next unit.

Declaring a single-method interface in Kotlin to accept a lambda is Java in disguise.

Lambdas with receivers

The idea

An ordinary lambda has parameters.

A lambda with a receiver additionally has a receiver object,

which becomes this inside the body.

Exactly like an extension function

Unit 3: a function got a receiver.

Here: a lambda does.

with

return with(stringBuilder) {
    for (letter in 'A'..'Z') {
        append(letter)                          // no qualification
    }
    append("\nNow I know the alphabet!")
    toString()                                  // the value
}

apply

apply returns the receiver. with returns the lambda’s result.

Which is why apply configures

val textView = TextView(context).apply {
    text = "Sample"
    textSize = 20.0
    setPadding(10, 0, 0, 0)
}

Create, configure, use. No intermediate variable, no repeated name.

These are ordinary functions

No compiler support. Nothing special.

They work because a function can declare a parameter of extension function type.

Any function you write can do the same.

Now recall unit 1

createHTML().table {
    tr {
        td { +person.name }
    }
}

Each block is a lambda with a receiver, and the receiver is the enclosing element.

Which is why td is available inside tr and not outside it.

Summary

The six things to carry away

  • Idiomatic lambda syntax is four mechanical steps from the full form.
  • Kotlin lambdas may modify captured locals — convenient, and a lifetime you may not want.
  • The collection API returns a new collection at every step.
  • Laziness changes the order work happens in, not only how much.
  • SAM conversion is what makes the Java ecosystem usable without wrappers.
  • A lambda with a receiver is the module’s destination, arriving early.

Where next

The Kotlin Type System turns to the safety claim.

Nullability, primitives and boxing, Any/Unit/Nothing.

And the read-only versus mutable collection split — which this unit quietly relied on.