Lecture notes — Higher-Order Functions: Lambdas as Parameters and Return Values

Published

2026-08-21 00:00

Keywords

ver. 1.0.0

← Higher-Order Functions: Lambdas as Parameters and Return Values

Where we are

Programming with Lambdas taught you to use filter and map. This unit is about writing them.

And then about the thing that makes writing them worthwhile. Higher-order functions are a good abstraction, and in Java they carry a cost: every lambda is an object, and calling through it is an indirection. In a hot loop that is a real reason to write the imperative version instead.

Kotlin’s answer is inline, and it is more thorough than it sounds. The compiler substitutes not only the function’s body but the lambda’s body too, so at runtime there is no object, no call, and nothing left of the abstraction.

The unit’s third part follows directly from the second — the rules about return inside a lambda look arbitrary until you know inlining is behind them.

What you will be able to do

  1. write-function-types — Write a function type and use it as a parameter or return type.
  2. declare-higher-order-functions — Declare a function that takes another function as a parameter and call it.
  3. use-defaults-and-nulls-for-function-parameters — Give a function-type parameter a default value or make it nullable.
  4. return-functions-from-functions — Return a function from a function to build behaviour that depends on runtime state.
  5. remove-duplication-with-lambdas — Remove duplication between similar functions by extracting a lambda parameter.
  6. explain-what-inline-does — Explain what the inline keyword does to the generated bytecode.
  7. judge-when-to-inline — Judge when inlining is worth it, and know its restrictions.
  8. use-inline-for-resource-management — Use an inlined lambda to manage a resource, as use and withLock do.
  9. control-returns-from-lambdas — Distinguish a non-local return from a labeled return, and use an anonymous function.

What we will cover

  • Function types — writing them down, including the nullable case.
  • Higher-order functions — taking lambdas, defaulting them, returning them.
  • Removing duplication — extracting a function where Java would extract a type.
  • Inlining — what it does, when it pays, what it forbids.
  • Resource managementuse and withLock, and why they are free.
  • Returns from lambdas — non-local, labeled, and anonymous functions.

Function types

Parameter types in parentheses, an arrow, and the return type.
val sum: (Int, Int) -> Int = { x, y -> x + y }
val action: () -> Unit = { println(42) }

Parameter types in parentheses, an arrow, then the return type. Once you can write the type down, a lambda becomes an ordinary value you can store, pass and return.

The return type is always written, even when it is Unit. In a lambda you may omit Unit; in a function type you may not, because the arrow needs something after it.

Nullable function types need care:

var canReturnNull: (Int, Int) -> Int? = { x, y -> null }     // returns Int?
var funOrNull: ((Int, Int) -> Int)? = null                   // the function may be null

Without the outer parentheses the question mark attaches to the return type instead. Read these slowly.

Type inference works here as everywhere. Assign a lambda to a variable with no declared type and the compiler works it out; declare the type explicitly and you may then omit the parameter types inside the lambda. You write them in one place or the other, never both.

NoteHow this differs from unit 5

There, passing a lambda to a Java method meant a SAM conversion into an interface instance, because Java has no function types.

Kotlin has proper function types, so a Kotlin function taking a lambda declares one, and no conversion happens or is needed.

Underneath, a function type compiles to an interface such as Function1, so Java can call these functions — passing an explicit instance and calling invoke. The same pattern as everywhere else: a cleaner Kotlin model with whatever Java needs generated beneath it.

Learning outcomes

  • write-function-types: Write a function type and use it as a parameter or return type.

Concepts

  • function-type: a real type, not an interface — storable, nullable, returnable

Taking functions as parameters

filter declared with a predicate parameter of type (Char) -> Boolean.
fun twoAndThree(operation: (Int, Int) -> Int) {
    val result = operation(2, 3)
    println("The result is $result")
}

twoAndThree { a, b -> a + b }      // The result is 5
twoAndThree { a, b -> a * b }      // The result is 6

Name the parameters inside the function type — (element: T) -> Boolean — because the IDE shows those names at the call site. That is real documentation for whoever writes the lambda.

Write filter yourself

fun String.filter(predicate: (Char) -> Boolean): String {
    val sb = StringBuilder()
    for (index in 0 until length) {
        val element = get(index)
        if (predicate(element)) sb.append(element)
    }
    return sb.toString()
}

println("ab1c".filter { it in 'a'..'z' })    // abc

Doing this once changes how the standard library reads. filter, map and the rest stop being built-in and become functions you could have written — and, in this unit, functions you just did.

Making the parameter optional

Two approaches, and the choice matters.

A default value — a lambda written into the signature. Here is joinToString, the running example from unit 3, gaining a transform:

fun <T> Collection<T>.joinToString(
        separator: String = ", ",
        prefix: String = "",
        postfix: String = "",
        transform: (T) -> String = { it.toString() }
): String {
    val result = StringBuilder(prefix)
    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(transform(element))
    }
    result.append(postfix)
    return result.toString()
}

letters.joinToString()                            // uses toString
letters.joinToString { it.toLowerCase() }         // supplies its own

A nullable function type, called with ?.invoke():

fun <T> Collection<T>.joinToString(
        transform: ((T) -> String)? = null
): String {
    // ...
    val str = transform?.invoke(element) ?: element.toString()
    // ...
}

Which to use. The default-value form is usually cleaner — the caller sees a working default and the body has no null handling. The nullable form earns its place when “not supplied” must be distinguishable from “supplied something equivalent to the default”, or when the value arrives from elsewhere and may genuinely be absent.

Note the name invoke. That is another convention, from the previous unit: calling a value with parentheses means calling its invoke, which is why ?.invoke() is the safe-call form.

The last unit of the module uses the same convention deliberately, to make an object callable.

Learning outcomes

  • declare-higher-order-functions: Declare a function that takes another function as a parameter and call it.
  • use-defaults-and-nulls-for-function-parameters: Give a function-type parameter a default value or make it nullable.

Concepts

  • higher-order-function: a function that takes or returns another function

Returning functions, and removing duplication

Two uses of function types, and they are mirror images of each other.

Returning a function

enum class Delivery { STANDARD, EXPEDITED }

fun getShippingCostCalculator(delivery: Delivery): (Order) -> Double {
    if (delivery == Delivery.EXPEDITED) {
        return { order -> 6 + 2.1 * order.itemCount }
    }
    return { order -> 1.2 * order.itemCount }
}

val calculator = getShippingCostCalculator(Delivery.EXPEDITED)
println("Shipping costs ${calculator(Order(3))}")

The returned lambda captures the enclosing function’s parameters, so the values are baked in and the caller supplies only what remains.

The persuasive example is the second one — building a predicate from UI state:

class ContactListFilters {
    var prefix: String = ""
    var onlyWithPhoneNumber: Boolean = false

    fun getPredicate(): (Person) -> Boolean {
        val startsWithPrefix = { p: Person ->
            p.firstName.startsWith(prefix) || p.lastName.startsWith(prefix)
        }
        if (!onlyWithPhoneNumber) {
            return startsWithPrefix
        }
        return { startsWithPrefix(it) && it.phoneNumber != null }
    }
}

contacts.filter(contactListFilters.getPredicate())

Building that predicate by hand means a chain of ifs inside the filtering loop, re-evaluated per element. Returning a composed function moves the logic to one place and leaves the filtering as a single call.

Removing duplication

The other direction. The book analyses site visits — average duration for Windows users, then for Mac users, then filtered by page. Each is a small variation on one shape:

fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
    filter(predicate).map(SiteVisit::duration).average()

log.averageDurationFor { it.os == OS.WINDOWS }
log.averageDurationFor { it.os in setOf(OS.ANDROID, OS.IOS) }
log.averageDurationFor { it.os == OS.IOS && it.path == "/signup" }

Three functions become one, specialised at each call site.

TipWhy a lambda rather than a class

Java’s answer to “the varying part” is a strategy interface with an implementation per case — several files for what is here one parameter.

The lambda version is lighter, and more importantly the caller writes the behaviour inline at the call site, where a reader can see it without navigating anywhere.

The general lesson: in Kotlin, “extract the difference” often means extracting a function, where in Java it would mean extracting a type.

Learning outcomes

  • return-functions-from-functions: Return a function from a function to build behaviour that depends on runtime state.
  • remove-duplication-with-lambdas: Remove duplication between similar functions by extracting a lambda parameter.

Concepts

  • function-type: as a return type, capturing state; as a parameter, absorbing the difference between near-identical functions

Inline functions

The cost

A lambda compiles to an anonymous class, and each call to a higher-order function creates an instance of it — plus the indirection of invoking through it.

In ordinary code this is irrelevant. In a tight loop it is exactly why people abandon the functional style and write the loop.

What inline does

inline fun <T> synchronized(lock: Lock, action: () -> T): T {
    lock.lock()
    try {
        return action()
    } finally {
        lock.unlock()
    }
}

Marking a function inline makes the compiler substitute its body at every call site. And — the part that matters most — the lambda’s body is substituted too, not passed as an object.

So this:

fun foo(l: Lock) {
    println("Before sync")
    synchronized(l) {
        println("Action")
    }
    println("After sync")
}

compiles to roughly this:

fun foo(l: Lock) {
    println("Before sync")
    l.lock()
    try {
        println("Action")
    } finally {
        l.unlock()
    }
    println("After sync")
}

No object anywhere. The abstraction exists in the source and not in the output.

Where it does not apply

If a lambda parameter is stored in a variable or passed to another function, its body cannot be substituted — the code must exist as an object, because something is holding on to it.

The compiler reports this rather than silently doing nothing, and noinline on that parameter exempts it while the rest of the function is still inlined:

inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) { }

When to use it

Not by default. The judgement:

  • worth it for small functions taking lambdas — which is why the standard library’s collection functions are all inline, and why filter costs no more than a loop
  • not worth it for large function bodies, since the body is copied into every call site and the bytecode grows accordingly
NoteTwo different optimisations

filter and map being inline is why chaining them is efficient. But they still create intermediate collections — inlining removes the lambda overhead, not the intermediate lists.

That is what sequences from unit 5 were for. The two optimisations address different costs, and knowing which is which tells you which to reach for:

  • lambda overhead → already handled by inline, nothing to do
  • intermediate collections → asSequence(), on a large collection or a long chain

Learning outcomes

  • explain-what-inline-does: Explain what the inline keyword does to the generated bytecode.
  • judge-when-to-inline: Judge when inlining is worth it, and know its restrictions.

Concepts

  • inline-function: the function’s body and the lambda’s body substituted at the call site
  • noinline-modifier: exempting a lambda parameter that must exist as an object

Resource management with lambdas

A short section with a good moral.

The pattern: acquire a resource, run a lambda, release the resource in a finally so cleanup happens whatever occurs.

fun readFirstLineFromFile(path: String): String {
    BufferedReader(FileReader(path)).use { br ->
        return br.readLine()
    }
}

use closes the reader whether the read succeeds, returns early, or throws. withLock does the same for a lock:

lock.withLock {
    // guarded work
}
ImportantWhere Java adds syntax, Kotlin adds a function

Java added try-with-resources as a language feature — new syntax, in the compiler, for exactly this pattern.

Kotlin needs no syntax. use is an ordinary extension function taking a lambda.

And it costs nothing because it is inline. Without inlining, every use would allocate a lambda object, and a construct used everywhere would carry a real cost — which would be a reason to add the language feature after all.

This is the same reasoning that makes the DSL unit possible, where an entire builder syntax turns out to be library functions taking lambdas. Free higher-order functions are what let a library do a language’s job.

Note also the return inside that use block. It returns from readFirstLineFromFile, not from the lambda — which is the subject of the next section, and only possible because use is inline.

Learning outcomes

  • use-inline-for-resource-management: Use an inlined lambda to manage a resource, as use and withLock do.

Returns from lambdas

The closing section, and a direct consequence of inlining.

Non-local returns

fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name == "Alice") {
            println("Found!")
            return                    // returns from lookForAlice
        }
    }
    println("Alice is not among them")
}

A return inside a lambda passed to an inline function returns from the enclosing function. So forEach with a return behaves exactly like a for loop with a return — which is what you want, and what makes the functional version a genuine replacement for the loop rather than an approximation of it.

ImportantWhy this only works when inlined

The lambda’s body has been compiled into the enclosing function, so a return there is an ordinary return from that function.

In a non-inlined lambda the body lives in a separate object, and it cannot return from a function it is not part of. The compiler forbids it rather than doing something surprising.

State it as a rule: non-local return works only with inline functions. Knowing why removes what otherwise looks arbitrary.

Labeled returns

A label marks the lambda, and return@label returns from it.

To return from the lambda rather than the enclosing function, label it:

people.forEach label@{
    if (it.name != "Alice") return@label      // explicit label
}

people.forEach {
    if (it.name != "Alice") return@forEach    // the function's name works too
}

This is a local return — the lambda’s equivalent of continue.

Anonymous functions

The third option, and it inverts the default:

people.forEach(fun (person) {
    if (person.name == "Alice") return        // returns from the anonymous function
    println("${person.name} is not Alice")
})

Written with fun and no name. Inside one, a plain return returns from the anonymous function itself.

TipOne rule resolves all three

return returns from the nearest enclosing function declared with fun.

  • in a lambda, the nearest fun is the outer function → non-local return
  • in an anonymous function, the nearest fun is the anonymous function → local return

Nothing is special-cased. Once you have the rule, all three behaviours follow from it.

Which to choose. Lambdas for nearly everything; they read better. An anonymous function when you have several returns and labelling each would be noise.

Learning outcomes

  • control-returns-from-lambdas: Distinguish a non-local return from a labeled return, and use an anonymous function.

Concepts

  • non-local-return: returning from the enclosing function, possible only in an inlined lambda
  • labeled-return: return@forEach to leave the lambda alone
  • anonymous-function: a lambda whose plain return is local by default

What you can now build

You can write function types, take and return functions, remove duplication with lambdas, use inline deliberately, and control where a return goes.

Learning outcomes

  • write-function-types: Write a function type and use it as a parameter or return type.
  • declare-higher-order-functions: Declare a function that takes another function as a parameter and call it.
  • use-defaults-and-nulls-for-function-parameters: Give a function-type parameter a default value or make it nullable.
  • return-functions-from-functions: Return a function from a function to build behaviour that depends on runtime state.
  • remove-duplication-with-lambdas: Remove duplication between similar functions by extracting a lambda parameter.
  • explain-what-inline-does: Explain what the inline keyword does to the generated bytecode.
  • judge-when-to-inline: Judge when inlining is worth it, and know its restrictions.
  • use-inline-for-resource-management: Use an inlined lambda to manage a resource, as use and withLock do.
  • control-returns-from-lambdas: Distinguish a non-local return from a labeled return, and use an anonymous function.

Conclusion

  • Higher-order functions in Kotlin are free, and that changes what a library can do.

    inline substitutes the function’s body and the lambda’s body, so nothing is allocated and nothing is called. The abstraction exists in the source and not in the bytecode.

  • Which is why use is a function where Java needed syntax.

    try-with-resources is a language feature. use is an extension function taking a lambda, and it costs the same. Free higher-order functions let a library do a language’s job.

  • Write filter once and the standard library stops being magic.

    A predicate parameter, a loop, an accumulator. Six lines. Everything in unit 5’s collection API is that shape.

  • Extract a function where Java would extract a type.

    A strategy interface with one implementation per case becomes one lambda parameter, written inline at each call site where a reader can see it.

  • A default lambda beats a nullable function parameter most of the time.

    The caller sees a working default, and the body has no null handling. Reach for the nullable form only when absence is genuinely meaningful.

  • Non-local return is a consequence, not a rule.

    return returns from the nearest enclosing fun. In an inlined lambda that is the outer function — which is why forEach behaves like a loop, and why the compiler forbids it when the lambda is not inlined.

Where next

The next unit, Generics, is about type parameters: declaring generic functions and classes, constraining them, and the fact that the JVM erases them at runtime.

Kotlin’s answer to erasure — reified type parameters — works only because of inlining. It is one more thing this unit has paid for in advance.