Higher-order Function und Lambda in Kotlin

1. Übersicht

In Kotlin sind Functions „Bürger erster Klasse”. Du kannst eine Function einer Variablen zuweisen, sie als Parameter übergeben oder sie von einer anderen Function zurückgeben. Eine Function, die eine andere Function entgegennimmt oder zurückgibt, heißt Higher-order Function. Die prägnanteste Schreibweise, um eine solche Function zu erstellen, ist eine Lambda.

Dieser Artikel behandelt Function Types, Lambda-Syntax, Function References, Closures, Lambda with Receiver und das Schlüsselwort inline. Am Ende wirst du in der Lage sein, deine eigenen Higher-order Functions zu schreiben. Du wirst auch wissen, wie du häufige Fehler vermeidest.

2. Function Type

Jede Function in Kotlin hat einen Typ, den man Function Type nennt. Die Syntax besteht aus einer Liste von Parametertypen in Klammern, gefolgt von einem Pfeil und dem Rückgabetyp:

fun main() {
    val add: (Int, Int) -> Int = { a, b -> a + b }
    val greet: (String) -> Unit = { name -> println("Hallo, $name") }
    val now: () -> Long = { System.currentTimeMillis() }

    println(add(2, 3))
    greet("Lan")
    println(now() > 0)
}

Erklärung:

  • (Int, Int) -> Int ist eine Function, die zwei Int entgegennimmt und Int zurückgibt.
  • (String) -> Unit ist eine Function, die keinen aussagekräftigen Wert zurückgibt, ähnlich wie void in Java.
  • () -> Long ist eine Function ohne Parameter.
  • Du rufst die Function-Variable wie eine normale Function auf: add(2, 3). Dies ist äquivalent zu add.invoke(2, 3).
5
Hallo, Lan
true

Ein Function Type kann auch nullable sein. In diesem Fall musst du den gesamten Typ in Klammern setzen: ((Order) -> Unit)?. Um es sicher aufzurufen, nutzt du callback?.invoke(order).

3. Deine erste Higher-order Function

Wir verwenden ein einfaches Datenmodell im gesamten Artikel. Der Betrag verwendet den Typ Long (in Dong), um Fehler bei Gleitkommazahlen zu vermeiden:

data class Order(val id: String, val customer: String, val amount: Long)

fun calculateTotal(orders: List<Order>, priceOf: (Order) -> Long): Long {
    var total = 0L
    for (order in orders) {
        total += priceOf(order)
    }
    return total
}

fun main() {
    val orders = listOf(
        Order("A1", "Lan", 100_000),
        Order("A2", "Minh", 250_000),
        Order("A3", "Lan", 50_000),
    )

    val full = calculateTotal(orders) { it.amount }
    val discounted = calculateTotal(orders) { it.amount * 90 / 100 }

    println(full)
    println(discounted)
}

calculateTotal ist eine Higher-order Function, weil der Parameter priceOf den Typ (Order) -> Long hat. Diese Function weiß nicht, wie der Preis jeder Bestellung berechnet wird. Sie überlässt dies dem Aufrufer. Dadurch kann dieselbe Schleife sowohl für Originalpreise als auch für ermäßigte Preise verwendet werden.

400000
360000

4. Lambda-Syntax

4.1. Verschiedene Schreibweisen

Lambda stehen immer in geschweiften Klammern {}. Parameter stehen vor ->, der Körper danach. Der letzte Ausdruck ist der Rückgabewert – du brauchst kein return zu schreiben:

val isLarge: (Order) -> Boolean = { order ->
    val threshold = 200_000
    order.amount > threshold   // Rückgabewert
}

val isLargeShort: (Order) -> Boolean = { it.amount > 200_000 }

val ignoreSecond: (Order, Int) -> String = { order, _ -> order.id }
  • Wenn die Lambda nur einen Parameter hat, kannst du die Deklaration weglassen und den impliziten Namen it verwenden.
  • Unbenutzte Parameter solltest du als _ schreiben, um die Absicht klar zu machen und Compilerwarnungen zu vermeiden.

4.2. Trailing Lambda

Wenn der letzte Parameter einer Function vom Typ Function ist, darfst du die Lambda außerhalb der Klammern platzieren. Wenn es der einzige Parameter ist, kannst du die Klammern ganz weglassen:

val orders = listOf(
    Order("A1", "Lan", 100_000),
    Order("A2", "Minh", 250_000),
)

val large = orders.filter { it.amount > 200_000 }
val total = calculateTotal(orders) { it.amount }
val sorted = orders.sortedByDescending { it.amount }.map { it.id }

println(large.map { it.id })
println(sorted)
[A2]
[A2, A1]

Das ist der Grund, warum Kotlin-Code mit Collection APIs fast wie natürliche Sprache klingt. Alle filter, map, sortedByDescending in der Standardbibliothek sind Higher-order Functions.

5. Function Reference

Wenn du bereits eine Function mit der richtigen Signatur hast, brauchst du sie nicht in eine Lambda zu wrappen. Verwende stattdessen den Operator :::

fun isVip(order: Order): Boolean = order.amount >= 200_000

class TaxService(private val ratePercent: Long) {
    fun withTax(order: Order): Long = order.amount * (100 + ratePercent) / 100
}

fun main() {
    val orders = listOf(Order("A1", "Lan", 100_000), Order("A2", "Minh", 250_000))
    val taxService = TaxService(10)

    println(orders.filter(::isVip).map { it.id })   // top-level function
    println(orders.map(Order::amount))              // property reference
    println(calculateTotal(orders, taxService::withTax)) // bound reference
}
  • ::isVip referenziert eine top-level Function.
  • Order::amount referenziert eine Property. Sie hat den Typ (Order) -> Long.
  • taxService::withTax ist eine bound reference: die Function ist bereits an das Objekt taxService gebunden.
[A2]
[100000, 250000]
385000

Function References sind oft lesbarer als Lambdas, wenn der Function-Name die Absicht bereits klar macht.

6. Returning Functions und Closures

6.1. Function, die eine Function zurückgibt

Higher-order Functions können auch eine Function zurückgeben. Dies ist nützlich, wenn du Verhalten basierend auf einer Konfiguration erstellen möchtest:

fun discountFor(level: String): (Long) -> Long {
    val percent = when (level) {
        "GOLD" -> 80
        "SILVER" -> 90
        else -> 100
    }
    return { amount -> amount * percent / 100 }
}

fun main() {
    val goldPrice = discountFor("GOLD")
    val normalPrice = discountFor("NONE")

    println(goldPrice(500_000))
    println(normalPrice(500_000))
}
400000
500000

Die zurückgegebene Lambda „merkt sich” die Variable percent, obwohl discountFor bereits beendet ist. Dieser Mechanismus wird Closure genannt.

Hinweis: Schreibe nicht "GOLD" -> { amount -> amount * 80 / 100 } im when-Ausdruck. An dieser Stelle werden die {} als Block interpretiert, nicht als Lambda, daher wird der Code nicht kompiliert. Berechne den Wert zuerst und gib dann die Lambda zurück wie im Beispiel oben. Du kannst die Lambda auch in runde Klammern setzen: ({ amount -> ... }).

6.2. Closures können Variablen ändern

Anders als Lambdas in Java dürfen Kotlin-Lambdas die var-Variablen, die sie erfassen, ändern:

fun orderIdGenerator(prefix: String): () -> String {
    var next = 1
    return { "$prefix-${next++}" }
}

fun main() {
    val nextId = orderIdGenerator("ORD")
    println(nextId())
    println(nextId())
    println(nextId())
}
ORD-1
ORD-2
ORD-3

Der Compiler wrappet next in ein Referenzobjekt, genannt Ref, sodass die Lambda und die äußere Function denselben Wert teilen.

Hinweis: Closures, die gemeinsame Variablen ändern, sind nicht thread-sicher. Wenn mehrere Threads gleichzeitig nextId() aufrufen, kannst du doppelte IDs erhalten. Verwende in diesem Fall AtomicInteger.

7. Lambda with Receiver

Kotlin hat einen speziellen Function Type: T.() -> R. Innerhalb dieser Lambda ist this das Objekt vom Typ T. Du greifst direkt auf seine Member zu, ohne Präfix. Dies ist die Grundlage von DSLs wie buildString, apply oder dem Gradle Kotlin DSL:

class OrderBuilder {
    var id: String = ""
    var customer: String = ""
    var amount: Long = 0

    fun build(): Order = Order(id, customer, amount)
}

fun order(block: OrderBuilder.() -> Unit): Order =
    OrderBuilder().apply(block).build()

fun main() {
    val o = order {
        id = "B1"
        customer = "Hoa"
        amount = 120_000
    }
    println(o)
}
Order(id=B1, customer=Hoa, amount=120000)

block hat den Typ OrderBuilder.() -> Unit. In der Lambda ist id = "B1" daher tatsächlich this.id = "B1". apply(block) ruft die Lambda mit dem gerade erstellten Builder als Receiver auf und gibt dann diesen Builder zurück.

8. inline und return in Lambda

8.1. Kosten von Lambdas

Auf der JVM wird jede Lambda normalerweise in ein Objekt kompiliert. Wenn die Lambda Variablen erfasst, kann jeder Aufruf ein neues Objekt erzeugen. Bei Functions, die sehr häufig aufgerufen werden, summieren sich diese Kosten erheblich.

Wenn du eine Function als inline markierst, behebt dies das Problem. Der Compiler kopiert den Function-Body und den Lambda-Body direkt an die Aufrufstelle, sodass keine Objekte erstellt werden:

inline fun <T> measure(label: String, block: () -> T): T {
    val start = System.nanoTime()
    val result = block()
    println("$label: ${(System.nanoTime() - start) / 1_000} µs")
    return result
}

fun main() {
    val orders = (1..1_000).map { Order("O$it", "C$it", it * 1_000L) }
    val total = measure("Gesamtsumme") { calculateTotal(orders) { it.amount } }
    println(total)
}
Gesamtsumme: 412 µs
500500000

Die gemessene Zeit wird auf jedem Rechner unterschiedlich sein. filter, map, forEach in der Standardbibliothek sind alle inline.

8.2. Non-local return und Labels

In einer Lambda, die an eine inline Function übergeben wird, beendet ein normales return die äußere Function, nicht nur die Lambda:

fun findFirstLarge(orders: List<Order>, threshold: Long): Order? {
    orders.forEach {
        if (it.amount > threshold) return it   // beendet findFirstLarge
    }
    return null
}

fun printPaidOrders(orders: List<Order>) {
    orders.forEach {
        if (it.amount == 0L) return@forEach    // überspringt nur dieses Element
        println(it.id)
    }
    println("Fertig")
}
  • return it ist ein non-local return. Es beendet sofort findFirstLarge.
  • return@forEach ist ein return mit Label. Es beendet nur die aktuelle Ausführung der Lambda, ähnlich wie continue.

Bei Functions, die nicht inline sind, wird ein normales return in einer Lambda vom Compiler als Fehler gemeldet. Du musst zwingend ein Label verwenden.

Hinweis: Wenn eine inline Function die Lambda an einen anderen Kontext weitergibt, z. B. läuft sie in einem anderen Thread, markiere den Parameter mit crossinline, um non-local return zu verbieten. Wenn du die Lambda in einer Variablen oder einem Feld speichern möchtest, verwende noinline.

9. Testen von Higher-order Functions

Füge Test-Dependencies zu pom.xml hinzu. Das Projekt muss das kotlin-maven-plugin bereits konfiguriert haben:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.25.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Higher-order Functions sind sehr leicht zu testen. Du brauchst nur eine einfache Lambda übergeben und das Ergebnis überprüfen:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class PricingTest {

    private val orders = listOf(
        Order("A1", "Lan", 100_000),
        Order("A2", "Minh", 250_000),
        Order("A3", "Lan", 50_000),
    )

    @Test
    fun `calculateTotal applies the given price function`() {
        assertThat(calculateTotal(orders) { it.amount }).isEqualTo(400_000L)
        assertThat(calculateTotal(orders) { 1L }).isEqualTo(3L)
    }

    @Test
    fun `discountFor returns function matching customer level`() {
        assertThat(discountFor("GOLD")(500_000)).isEqualTo(400_000L)
        assertThat(discountFor("SILVER")(500_000)).isEqualTo(450_000L)
        assertThat(discountFor("UNKNOWN")(500_000)).isEqualTo(500_000L)
    }

    @Test
    fun `generator keeps its own state per closure`() {
        val first = orderIdGenerator("ORD")
        val second = orderIdGenerator("INV")

        assertThat(listOf(first(), first(), second()))
            .containsExactly("ORD-1", "ORD-2", "INV-1")
    }
}

Der dritte Test bestätigt einen wichtigen Punkt: Jeder Aufruf von orderIdGenerator erstellt eine separate Closure mit einer separaten Variablen next.

10. Häufige Fehler und wann man es nicht verwenden sollte

  • Zu lange Lambdas. Wenn der Lambda-Body mehrere Zeilen überschreitet, extrahiere ihn in eine benannte Function und übergebe ihn mit ::. Der Code wird lesbarer und leichter zu testen.
  • Verschachteltes it missbrauchen. Wenn du orders.map { it.items.filter { it.price > 0 } } schreibst, verdeckt das innere it das äußere it. Verwende aussagekräftige Parameternamen.
  • Alles inlinen. Inline nur kleine Functions mit Lambda-Parametern. Große inline Functions lassen den Bytecode anschwellen. Inline Functions ohne Lambda-Parameter bringen kaum Vorteile, und der Compiler wird dich warnen.
  • return verwechseln. Ein normales return in forEach beendet die gesamte äußere Function. Dies ist die Quelle vieler schwer zu findender Bugs. Um ein Element zu überspringen, verwende return@forEach.
  • Schwere Objekte erfassen. Lambdas, die lange gespeichert werden, z. B. als Listener, halten Referenzen zu allen erfassten Variablen. Dies kann zu Speicherlecks führen, z. B. wenn du eine ganze Activity auf Android hältst.
  • Keine unnötige Abstraktion. Wenn es nur einen Weg gibt, etwas zu verarbeiten, und er ändert sich nicht, ist normalerweise eine Function ausreichend. Füge keine Lambda-Parameter nur für „zukünftige Möglichkeiten” hinzu.

11. Fazit

In diesem Artikel hast du gesehen, dass Function Types die Signatur einer Function beschreiben. Lambdas und Function References sind zwei Möglichkeiten, Function-Werte zu erstellen. Higher-order Functions nehmen Functions an oder geben sie zurück, um das „Gerüst” vom „Verhalten” zu trennen. Du bist auch mit Closures vertraut geworden, Lambda with Receiver zum Erstellen von DSLs und inline zum Eliminieren von Object-Overhead sowie wie non-local return funktioniert.

Wenn du diese Konzepte beherrschst, wirst du die Kotlin-Standardbibliothek viel leichter verstehen. Du wirst auch wissen, wann du Higher-order-APIs für deinen Code schreiben solltest.

kotlinlambdahigher-order functionfunktionale Programmierung