Lambdas, Sharpened: References, Returns, and Anonymous Functions
Function references and the difference between bound and unbound, property references and the reflection behind them, the surprising rules for return inside a lambda and the labels that tame them, and anonymous functions. Part 4 of five.
By now you can write a lambda, hand it to a function, and reason about what it captures. This part covers three things that make lambdas nicer to write, and one that genuinely surprises people coming from Java. None of it is hard once you’ve seen it; the goal is that nothing trips you up later. Everything was run against Kotlin 2.4.10.
This is part four of five.
When a function already exists: references
Sometimes a lambda does nothing but call a function that already exists:
val words = listOf("ada", "linus")
words.map { it.uppercase() }
That lambda — { it.uppercase() } — is pure overhead; it only forwards to uppercase. Kotlin lets you point at the function directly with ::, the function reference operator:
words.map(String::uppercase)
String::uppercase is a value of the right type, so it goes wherever a lambda fits. There are four flavors, all the same idea — “refer to a function instead of wrapping it”:
// 1. A top-level function — :: in front of its name
fun isEven(n: Int) = n % 2 == 0
numbers.filter(::isEven)
// 2. A member, by its type — Type::member
words.map(String::uppercase)
// 3. A member, bound to one object — instance::member
val logger = Logger()
messages.forEach(logger::log)
// 4. A constructor — :: in front of the type name
val users = ids.map(::User) // calls User(id) for each
The rule of thumb: if your lambda is just { someFunction(it) }, replace it with ::someFunction. If it does anything more, keep the lambda.
Bound vs unbound: where does the receiver come from?
Flavors 2 and 3 look similar and behave differently, and the difference is worth making explicit because it changes the reference’s type. An unbound reference names a member by its type and leaves the receiver open — so the receiver becomes the reference’s first parameter:
val lengthOf: (String) -> Int = String::length // receiver is a parameter
lengthOf("kotlin") // 6
A bound reference fixes the receiver to a specific instance, so that instance is baked in and no longer a parameter:
val ada = Person("Ada", 36)
val adaName: () -> String = ada::name // receiver already chosen
adaName() // "Ada"
String::length is (String) -> Int; ada::name is () -> String. This is exactly why people.sortedBy(Person::age) works — sortedBy wants a (Person) -> R, and the unbound Person::age is that function, taking each person and yielding their age. Reach for unbound references to transform a collection by one of its elements’ members; reach for bound references to pin behavior to an object you already have.
Property references and the reflection underneath
:: works on properties too, and a property reference is more than a getter — it’s a small reflection handle:
val nameProp = Person::name
nameProp.get(ada) // "Ada" — call the getter
nameProp.name // "name" — the property's own name, as a String
Person::name is a KProperty, a reified view of the property that carries its name and its accessors. That’s what powers libraries that map objects to columns or JSON fields by referencing Entity::field instead of a stringly-typed "field". The one caution: reaching into these reflective capabilities pulls in kotlin-reflect, which has a real startup and memory cost. Using a reference as a plain function value (filter(::isEven), map(String::uppercase)) does not — those compile to ordinary function objects. It’s only the reflective surface (.name, .get, .annotations) that asks for the reflection library.
The surprise: return inside a lambda
Here’s the one that catches Java developers. You might expect return inside a lambda to exit just the lambda, the way returning from an anonymous class’s method would. It doesn’t. A plain return inside a lambda returns from the enclosing function:
fun findFirstEven(numbers: List<Int>): Int? {
numbers.forEach {
if (it % 2 == 0) return it // returns from findFirstEven, not the lambda
}
return null
}
That’s actually useful here: return it jumps straight out of findFirstEven with the answer. This is a non-local return, and it works because functions like forEach are inlined (the subject of part five) — the lambda’s body is physically copied into the calling function, so a return in it genuinely can leave that function. A crucial corollary: a non-local return is only possible from a lambda passed to an inline function. Try it with a non-inline higher-order function and the compiler requires a label — there’s no enclosing frame to return from once the lambda is a separate object.
Labels: returning from just the lambda
What if you only want to skip the current element — the equivalent of continue? Return from the lambda itself, using a label. Every lambda passed to a named function gets an automatic label matching that name:
numbers.forEach {
if (it < 0) return@forEach // skip this element, continue the loop
println(it)
}
return@forEach means “return from the forEach lambda,” not from the enclosing function. So the two behaviors sit side by side:
return— leaves the whole enclosing function (non-local, inline-only).return@forEach— leaves only the lambda, moving to the next element.
This is the single most confusing thing about Kotlin lambdas, so it’s worth pausing until it clicks: the @label chooses which thing you’re returning from.
Anonymous functions: the normal rules, on demand
If the non-local return bothers you, there’s an alternative that behaves the way Java taught you. An anonymous function is a function with no name, written with fun:
val isEven = fun(n: Int): Boolean {
return n % 2 == 0 // returns from the anonymous function — normal rules
}
It’s interchangeable with a lambda as a value, but inside it a plain return returns from itself, exactly as a regular function would. The trade-offs: it’s more verbose, and it can’t do non-local returns. In practice lambdas are the default and anonymous functions the occasional tool — reach for one when you specifically want ordinary return behavior, or when you need to write the return type explicitly.
Final thoughts
Three tools, one a genuine gotcha. References (::) drop the lambda when a function already exists, and the bound/unbound distinction decides whether the receiver is baked in or becomes a parameter. Property references double as reflection handles, carrying a name and accessors for the libraries that need them. Labels (return@forEach) let you choose whether return exits the lambda or the whole function — a bare return leaves the enclosing function, and only from an inline call. And anonymous functions give back Java’s familiar return semantics when you want them.
One question remains: we keep passing lambdas around — doesn’t creating an object for each one cost something? The answer is the key to the whole design, and it’s what makes non-local returns possible in the first place. Next: why lambdas are free, and lambdas that read like syntax.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.
Comments