Lesson 07 / 23
Lambdas and Higher-Order Functions
Introduces lambda syntax, function types, and functions that take functions; measures that two lambda expressions are not equal, while a lambda that captures nothing is a single object.
Published Updated
In this lesson
- Uses a lambda expression, the `it` name, and the trailing lambda syntax
- Writes a function type and a higher-order function that takes or returns a function
- Measures that two lambdas fall under the identity criterion, while a lambda that captures nothing is a single object
Contents
In the previous lesson we saw that a function reference such as ::plain gives a separate but equal object each time it is taken. Now we write the function in place. A lambda is a function value without a name; it can be assigned to a variable, given to a function as an argument, or returned from a function. The question of this lesson continues the question of the previous one: are two lambdas with the same body equal?
Lambda and Function Type
A lambda is written inside curly braces: first the parameters, then ->, then the body. In a lambda with a single parameter whose type is known from the context, the parameter is not declared; the implicit name it is used in its place. A function type has the form (Int) -> Int: the parameter types are inside parentheses, and the return type follows the arrow.
A function that takes a function or returns a function is called a higher-order function. When a function whose last parameter has a function type is called, the lambda can be written outside the parentheses: this is called a trailing lambda. The inline keyword appears in this lesson only as a definition: the body of a higher-order function marked inline, and the lambda given to it, are placed at the call site at compile time. inline was not measured in this lesson.
In this lesson too, the measurements are made with the helper file introduced in the first lesson. The Criterion class takes a value, its twin, and a different value, and asks three questions; the file is given below exactly as it was.
// Criterion.kt — classifies the criterion that makes two values equal
class Criterion(val label: String, first: Any?, twin: Any?, other: Any?) {
val sameObject = first === twin
val equalTwin = first == twin
val equalOther = first == other
val name: String = when {
sameObject -> "singleton"
!equalTwin -> "identity"
!equalOther -> "content"
else -> "partial-content"
}
fun report() {
println("$label: same-object=$sameObject, twin=$equalTwin, other=$equalOther -> $name")
}
}
fun summarize(checks: List<Criterion>) {
for (name in listOf("singleton", "identity", "content", "partial-content")) {
println("$name: ${checks.count { it.name == name }}")
}
}
A function type is an ordinary type: it can be written as the type of a variable, a parameter, or a return value. In the line val increment: (Int) -> Int = { it + 1 } we wrote the type explicitly, so there is no need to declare a parameter in the lambda, and it can be used. When the type is not written, the parameter is declared inside the lambda in the form n: Int ->; the compiler has to learn the parameter type from one of the two ways.
The program below shows these together. applyTwice applies an operation two times, and makeAdder returns a function.
// Main.kt — lambda, it, trailing lambda, and function type
fun applyTwice(value: Int, operation: (Int) -> Int): Int = operation(operation(value))
fun makeAdder(amount: Int): (Int) -> Int = { it + amount }
fun main() {
val twice = { n: Int -> n * 2 }
val increment: (Int) -> Int = { it + 1 }
println(twice(4))
println(applyTwice(3, increment))
println(applyTwice(3) { it * 10 })
val addFive = makeAdder(5)
println(addFive(10))
println(listOf(1, 2, 3).map { it * it })
}
8 5 300 15 [1, 4, 9]
twice(4) called a lambda directly and gave 8. applyTwice(3, increment) added one to three twice, and 5 came out. applyTwice(3) { it * 10 } is the trailing lambda syntax: three, thirty, three hundred; the result is 300. makeAdder(5) returned a function that adds five, and this function gave 15 on 10. In the last line map applied the lambda to each element, and [1, 4, 9] came out.
LB45. it was shown only in lambdas with a single parameter; the naming of parameters in a lambda with several parameters stayed limited to the single parameter in the twice example.
LB46. Higher-order functions were shown only on Int and with map; the other collection functions such as filter and fold are the work of a later topic.
Are Two Lambdas Equal
The measurement is built from four observations. two-lambdas: the expression { 1 } written in two separate places. same-call-site: the makeConstant function, which returns the lambda { 1 } written in one place, called twice. capturing-lambda: a lambda that uses an outer variable, that is, the amount parameter, with makeAdderFor(1) called twice. function-reference: ::inc twice, one more repetition of the observation of the previous lesson.
Capture is when a lambda uses a variable outside its own body. The lambda inside makeAdderFor captures the value of amount; { 1 }, on the other hand, captures nothing from outside.
// Main.kt — lambdas and identity
fun makeConstant(): () -> Int = { 1 }
fun makeAdderFor(amount: Int): (Int) -> Int = { it + amount }
fun inc(n: Int): Int = n + 1
fun dec(n: Int): Int = n - 1
fun main() {
val checks = listOf(
Criterion("two-lambdas", { 1 }, { 1 }, { 2 }),
Criterion("same-call-site", makeConstant(), makeConstant(), { 2 }),
Criterion("capturing-lambda", makeAdderFor(1), makeAdderFor(1), makeAdderFor(2)),
Criterion("function-reference", ::inc, ::inc, ::dec),
)
for (check in checks) {
check.report()
}
summarize(checks)
}
two-lambdas: same-object=false, twin=false, other=false -> identity same-call-site: same-object=true, twin=true, other=false -> singleton capturing-lambda: same-object=false, twin=false, other=false -> identity function-reference: same-object=false, twin=true, other=false -> content singleton: 1 identity: 2 content: 1 partial-content: 0
two-lambdas is identity: two lambdas with the same body that are written separately are not equal, twin=false. capturing-lambda is also identity: when makeAdderFor(1) was called twice, two separate lambda objects were born, and even though they captured the same value, they are not equal.
same-call-site is a singleton: makeConstant() returned the same object in two calls, same-object=true. This lambda captures nothing from outside; in the sandbox it was observed as the same instance at every call. function-reference is content, and the result of the previous lesson was verified again here. The summary lines give one singleton, two identity, one content, and zero partial content.
LB47. That a lambda that captures nothing is a single object was measured on the JVM target of Kotlin 2.4.21; this is not a language contract but a result observed in that version.
LB48. The capturing-lambda observation was made with the capture of a single Int; it is not claimed that the result stays the same when the type or the number of captured variables changes.
The Captured Variable Is Separate for Each Creation
A lambda can read and change a var variable outside itself. The question is how many copies of this variable exist. The block below writes a function that produces a counter, sets up two separate counters, and gives the same counter two separate names.
// Main.kt — a captured var is separate for each creation
fun makeCounter(): () -> Int {
var count = 0
return { ++count }
}
fun main() {
val first = makeCounter()
val second = makeCounter()
println(first())
println(first())
println(second())
println(first())
val shared = makeCounter()
val alias = shared
println(shared())
println(alias())
}
1 2 1 3 1 2
first was called twice and gave 1 and 2; second gave 1 at its first call. The two counters captured separate count variables, because a new count is created each time makeCounter is called. When first was called again, it printed 3, and the call of second did not affect it. The last two lines gave 1 and 2: since alias points to the same lambda object as shared, both incremented the same counter.
The rule is this: the captured variable lives together with the lambda object, and there is one copy per object. The frequent mistake is to assume that the counter is reset each time the function is called, or to assume that all lambdas share the same variable. In Java, a lambda can capture only variables that are effectively final, while Kotlin allows capturing a var; behind this, the variable is known to be wrapped in a reference box, but this was not measured in this lesson.
LB49. The counter measurement was made on a single thread; what happens when several threads increment the same captured variable was not measured in this lesson.
Returning from a Lambda
Writing return inside a lambda sometimes ends the function and sometimes gives an error. The block below tries three cases: an unlabeled return inside forEach, return@forEach inside forEach, and return@label with its own label inside map.
// Main.kt — returning from a lambda
fun findFirstEven(values: List<Int>): Int {
values.forEach {
if (it % 2 == 0) return it
}
return -1
}
fun main() {
println(findFirstEven(listOf(1, 3, 4, 6)))
println(findFirstEven(listOf(1, 3)))
val visited = mutableListOf<Int>()
listOf(1, 2, 3, 4).forEach {
if (it == 2) return@forEach
visited.add(it)
}
println(visited)
val labeled = listOf(1, 2, 3).map label@{
if (it == 2) return@label 20
it
}
println(labeled)
}
4 -1 [1, 3, 4] [1, 20, 3]
findFirstEven(listOf(1, 3, 4, 6)) gave 4: forEach is an inline function, so the return inside the lambda ends the outer function, and 4, the first even number, is returned. In the second call there was no even number, so the loop ended and -1 was returned.
The third line gave [1, 3, 4]. return@forEach ends only that run of the lambda, that is, it behaves like continue: 2 was skipped, and the rest were added. The fourth line gave [1, 20, 3]; return@label sets the value of the lambda, that is, inside map the value 2 was replaced with 20.
The frequent mistake is to expect an unlabeled return inside forEach to skip only one element: it ends the whole function. This behavior is possible because forEach is inline; in a lambda that is not inline, an unlabeled return gives a compile error, but this branch was not measured. In Java, a return inside a lambda exits only the lambda and does not end the outer method.
LB50. The return behavior was measured with forEach and map; the error message given when an unlabeled return is written in a higher-order function that is not inline was not measured in this lesson.
Lambda with Receiver and SAM Conversion
Some lambdas reach the members of a receiver object directly. The block below sets up a lambda of type StringBuilder.() -> Unit, writes lambdas for the Java interfaces Runnable and Comparator, and produces a lambda that combines two functions.
// Main.kt — lambda with receiver and SAM conversion
fun main() {
val build: StringBuilder.() -> Unit = {
append("kot")
append("lin")
}
println(StringBuilder().apply(build).toString())
val task = Runnable { println("ran") }
task.run()
val byLength = Comparator<String> { a, b -> a.length - b.length }
println(listOf("ccc", "a", "bb").sortedWith(byLength))
val compose: ((Int) -> Int, (Int) -> Int) -> (Int) -> Int = { f, g -> { x -> g(f(x)) } }
val doubleThenInc = compose({ it * 2 }, { it + 1 })
println(doubleThenInc(5))
}
kotlin ran [a, bb, ccc] 11
The first line gave kotlin. The build variable is a lambda with a receiver: append is called directly in its body, because a StringBuilder is given as this. apply(build) ran it on a new StringBuilder. The second line is ran: the Runnable { ... } syntax creates a Java interface that has a single abstract method with a lambda. This conversion is called SAM conversion (single abstract method). The third line gave [a, bb, ccc]: Comparator was also written with a lambda in the same way and sorted by length.
The fourth line gave 11. compose takes two functions and returns a third; doubleThenInc(5) first multiplies 5 by two (10), then adds one (11). The order matters: since it is written g(f(x)), f runs first. The frequent mistake is to assume the reverse order in a composition.
LB51. SAM conversion was measured with Java interfaces; that an interface written in Kotlin needs a separate fun interface declaration was not measured in this lesson.
Habits That Come from the Measurements
Three questions should be asked when working with lambdas: what is captured, where does the code return from, and which type is expected. On capture, the measurement gave a clear rule: the captured variable lives together with the lambda object, and a separate copy is created at each creation. This makes it possible to build small objects that carry state, such as a counter or a cache, without writing a class; but because where the state is stored is not visible in the code, it is a hidden dependency for the reader. If the state needs to be long-lived or shared, an explicit class is more readable.
On returning, the most important distinction is whether the lambda is inline. An unlabeled return inside forEach ends the outer function; in the measurement findFirstEven found the first even number and returned. This behavior is useful, because it expresses an early exit without writing a loop. But if it is used wrongly where continue is expected, it quietly ends the whole function. To skip, return@forEach is written; to set the value, return@label is written. For readability, lambdas with more than one return point should be split into small named functions.
On types, a lambda with a receiver (StringBuilder.() -> Unit) works with this in the body and reaches the members of the object directly. This is the basis for writing builder code, and it is the mechanism of scope functions in the next lesson. SAM conversion makes it possible to build Java interfaces with a lambda; for interfaces written in Kotlin, fun interface is needed, and this was not measured.
The compose example, which combines functions, shows that functions are values. Since the order is g(f(x)), f runs first, and for the input 5 the result was 11. The reverse order would give a different result, but that calculation was not measured; only the output 11 came from the sandbox. For this reason, testing the order of a composition with a small example is enough. In function composition libraries this order is often confused, so it needs to be verified with a small example.
Comparison with Java: a Java lambda captures only variables that are not reassigned, and return exits only the lambda. Kotlin offers a wider behavior in both; the cost of the flexibility is that the reader must know which return ends what. When reviewing code, looking first at whether the enclosing function is inline and then at whether there is a label, for each return, removes this ambiguity quickly.
The Bounding Measurement
The identity criterion for lambdas is broken by two limits. The first is capture: a lambda that captures nothing becomes a single object at the call site and comes out as a singleton, while a lambda that captures becomes a new object at each call and comes out as identity. That is, whether two lambdas are equal is determined not by their bodies but by the place where they are written and whether they capture something. The second is the function reference: when ::inc is taken twice, the objects are separate but equal.
Summary
- A lambda is written in the form
{ parameters -> body }; a single parameter can take the nameit, and if the last parameter has a function type, the lambda can be written outside the parentheses. - A function type has the form
(Int) -> Int; a function that takes or returns a function is called a higher-order function. - Two lambdas with the same body are not equal if they are written separately: identity. A capturing lambda is a new object at every call.
- A lambda that captures nothing gave the same object every time from the same call site: singleton.
::inctaken twice is content. - This lesson’s criterion: identity; bounding measurement: a lambda that captures nothing is a singleton, and the
::increference is content.
Next Step
We have seen how to carry lambdas as values. In the last lesson we examine five ready-made higher-order functions that run a lambda in the context of an object: scope functions. let, run, with, apply, and also work on the same object, but one gives the object as this, another as it; we also measure which of them returns what.
To keep your progress and take notes, Log in
My notes
Log in to take notes.