Scope Functions in Kotlin: let, run, apply, also, with

1. Übersicht

Kotlin hat fünf Funktionen, die sich sehr ähneln: let, run, with, apply und also. Sie alle führen einen Codeblock auf einem Objekt aus, unterscheiden sich aber darin, wie du auf das Objekt zugreifst und welcher Wert von der Funktion zurückgegeben wird. Viele Menschen wählen einfach eine Funktion aus, und der Code läuft zwar noch, ist aber schwerer zu lesen.

Dieser Artikel erklärt zwei Kriterien zur Unterscheidung der Scope Functions und zeigt jede Funktion anhand konkreter Beispiele. Danach wirst du Tests schreiben, um ihr Verhalten zu überprüfen und häufige Fehler zu sehen. Danach wirst du für jede Situation die richtige Funktion wählen.

2. Zwei Kriterien zur Unterscheidung

Jede Scope Function nimmt einen Lambda und ruft diesen Lambda auf einem Context Object auf. Du musst nur zwei Fragen beantworten:

  1. Rufst du das Objekt in dem Lambda über this (Receiver) oder über it (Parameter) auf?
  2. Gibt die Funktion das Ergebnis des Lambda oder das Objekt selbst zurück?
Funktion Context Object Rückgabewert Ist Extension?
let it Lambda-Ergebnis Ja
run this Lambda-Ergebnis Ja (und eine Non-Extension-Version)
with this Lambda-Ergebnis Nein, Objekt ist Parameter
apply this Das Objekt selbst Ja
also it Das Objekt selbst Ja

Du kannst sie dir schnell als Paare merken:

  • apply und also geben das Objekt zurück, daher sind sie geeignet für die Konfiguration von Objekten oder um Nebenwirkungen in einer Kette von Aufrufen einzufügen.
  • let, run und with geben ein Ergebnis zurück, daher sind sie geeignet um ein Objekt in einen anderen Wert umzuwandeln.

Die folgenden Beispiele verwenden einige einfache Klassen:

data class Customer(val name: String, val email: String?)

class Order(val id: String) {
    val items = mutableListOf<Double>()
    var discountPercent = 0
}

class Invoice {
    var number: String = ""
    var amount: Double = 0.0
    var paid: Boolean = false
}

3. let: Transformieren und Verarbeiten von Nullable-Werten

let übergibt das Objekt an den Lambda unter dem Namen it und gibt das Ergebnis des Lambda zurück. Die häufigste Verwendung ist die Kombination mit dem Safe-Call-Operator ?. um Code nur auszuführen, wenn der Wert nicht null ist:

fun sendWelcome(customer: Customer) {
    customer.email?.let { email ->
        println("Willkomme-E-Mail an $email versenden")
    }
}

fun main() {
    sendWelcome(Customer("An", "an@example.com"))
    sendWelcome(Customer("Bình", null))
}
Willkomme-E-Mail an an@example.com versenden

Bei Kunde Bình ist email null. Der Ausdruck ?. stoppt dort und der Lambda wird nicht ausgeführt. Im Lambda hat email den Typ String und nicht String?, daher kannst du ihn verwenden, ohne weitere Überprüfungen zu benötigen. Hier benannten wir den Parameter email statt it, um den Code leichter lesbar zu machen.

let ist auch nützlich, wenn du einen Wert transformieren möchtest, ohne eine Zwischenvariable zu erstellen:

val domain = Customer("An", "an@example.com").email
    ?.let { it.substringAfter("@") }
    ?: "unbekannt"

println(domain) // example.com

4. run: Berechnungen basierend auf einem Objekt

run ist ähnlich wie let, aber das Objekt wird über this aufgerufen. Da this weggelassen werden kann, rufst du die Eigenschaften und Methoden des Objekts direkt auf:

val order = Order("ORD-42").apply {
    items += listOf(100_000.0, 50_000.0)
    discountPercent = 10
}

val total = order.run {
    val subtotal = items.sum()
    subtotal - subtotal * discountPercent / 100
}

println(total) // 135000.0

Im run-Block sind items und discountPercent Eigenschaften von order. Der zurückgegebene Wert ist der letzte Ausdruck, also der Betrag nach Rabatt.

run hat auch eine Non-Extension-Version. Du verwendest sie, um mehrere Anweisungen zu gruppieren, um einen Wert zu erstellen, ohne Hilfsvariablen in den äußeren Scope durchsickern zu lassen:

val connectionUrl = run {
    val host = System.getenv("DB_HOST") ?: "localhost"
    val port = System.getenv("DB_PORT") ?: "5432"
    "jdbc:postgresql://$host:$port/shop"
}

Nach diesem Block existieren host und port nicht mehr. Nur connectionUrl bleibt bestehen.

5. with: Mehrere Aufrufe auf demselben Objekt gruppieren

with funktioniert wie run, ist aber keine Extension. Das Objekt wird als erster Parameter übergeben:

val summary = with(order) {
    "Bestellung $id: ${items.size} Produkte, ${discountPercent}% Rabatt"
}

println(summary)
Bestellung ORD-42: 2 Produkte, 10% Rabatt

Semantisch liest sich with(order) { ... } wie „mit dieser Bestellung, tue…“. Diese Funktion ist geeignet, wenn das Objekt definitiv nicht null ist und du mehrere Mitglieder davon aufrufen musst. Wenn das Objekt null sein kann, solltest du stattdessen order?.run { ... } verwenden, da with nicht mit ?. kombiniert werden kann.

6. apply: Objekt konfigurieren

apply greift auf das Objekt über this zu und gibt das Objekt selbst zurück. Dies ist die idiomatische Art, ein Objekt zu initialisieren und zu konfigurieren in einem einzigen Ausdruck:

val invoice = Invoice().apply {
    number = "INV-001"
    amount = 250_000.0
    paid = false
}

println("${invoice.number} - ${invoice.amount}") // INV-001 - 250000.0

Das Ergebnis des Lambda wird ignoriert und apply gibt immer die gerade erstellte Invoice zurück. Du wirst apply häufig sehen, wenn du mit Java-Klassen arbeitest, die Setter verwenden, zum Beispiel beim Konfigurieren eines Beans in Spring:

@Bean
fun restTemplate(): RestTemplate = RestTemplate().apply {
    requestFactory = SimpleClientHttpRequestFactory().apply {
        setConnectTimeout(2_000)
        setReadTimeout(5_000)
    }
}

7. also: Nebenwirkungen hinzufügen, ohne die Kette zu unterbrechen

also greift auf das Objekt über it zu und gibt auch das Objekt selbst zurück. Diese Funktion ist geeignet für „beiläufige” Dinge wie Logging, Validierung oder Metriken, wenn diese Dinge nicht den Hauptfluss ändern:

fun createOrder(id: String): Order =
    Order(id).also { println("Bestellung ${it.id} erstellt") }

fun main() {
    val order = createOrder("ORD-7")
        .also { it.items += 99_000.0 }
        .also { println("Anzahl Produkte: ${it.items.size}") }
}
Bestellung ORD-7 erstellt
Anzahl Produkte: 1

Da das Objekt über it und nicht über this aufgerufen wird, kannst du immer noch this der äußeren Klasse verwenden, ohne verwirrt zu werden. Das ist auch der Grund, warum also leichter zu lesen ist als apply, wenn der Lambda das Objekt hauptsächlich verwendet statt es zu konfigurieren.

Ein kleiner Trick ist, also zu verwenden, um zwei Variablen auszutauschen:

var a = 1
var b = 2
a = b.also { b = a }
println("$a $b") // 2 1

Der Ausdruck b.also { ... } gibt den alten Wert von b (2) zurück, während der Lambda b = a (1) zuweist, bevor die Zuweisung zu a stattfindet.

8. Welche Funktion wählen?

Du kannst die folgenden Regeln verwenden:

  • Wenn du Code ausführen musst, wenn der Wert nicht null ist, oder wenn du den Wert transformieren musst: verwende ?.let.
  • Wenn du einen Wert aus mehreren Mitgliedern eines Objekts berechnen musst: verwende run oder with, wenn das Objekt definitiv nicht null ist.
  • Wenn du mehrere Anweisungen zu einem Ausdruck gruppieren musst: verwende run { } ohne Receiver.
  • Wenn du ein Objekt initialisieren oder konfigurieren musst: verwende apply.
  • Wenn du loggen, validieren oder Nebenwirkungen hinzufügen möchtest, während du das Objekt behältst: verwende also.

9. Verhalten testen

Um sicherzustellen, dass du die Rückgabewerte richtig verstehst, schreibe ein paar Tests. Zuerst füge die Abhängigkeiten zu pom.xml hinzu:

<dependencies>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
        <version>2.0.21</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Wenn das Projekt Spring Boot 3.x ist, enthält spring-boot-starter-test bereits JUnit 5 und AssertJ, daher musst du diese beiden Bibliotheken nicht separat deklarieren.

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

class ScopeFunctionsTest {

    @Test
    fun `apply und also geben das Objekt selbst zurück`() {
        val invoice = Invoice()

        val fromApply = invoice.apply { amount = 100.0 }
        val fromAlso = invoice.also { it.paid = true }

        assertThat(fromApply).isSameAs(invoice)
        assertThat(fromAlso).isSameAs(invoice)
        assertThat(invoice.amount).isEqualTo(100.0)
        assertThat(invoice.paid).isTrue()
    }

    @Test
    fun `let run with geben das Ergebnis des Lambda zurück`() {
        val order = Order("ORD-1").apply { items += listOf(10.0, 20.0) }

        val count: Int = order.let { it.items.size }
        val sum: Double = order.run { items.sum() }
        val label: String = with(order) { "Bestellung $id" }

        assertThat(count).isEqualTo(2)
        assertThat(sum).isEqualTo(30.0)
        assertThat(label).isEqualTo("Bestellung ORD-1")
    }

    @Test
    fun `let wird nicht ausgeführt, wenn der Wert null ist`() {
        var called = false
        Customer("Bình", null).email?.let { called = true }

        assertThat(called).isFalse()
    }
}

Der erste Test verwendet isSameAs, um zu überprüfen, dass apply und also die genaue ursprüngliche Referenz zurückgeben, nicht eine Kopie. Der zweite Test deklariert Typen explizit (Int, Double, String), um zu zeigen, dass jede Funktion das Ergebnis des Lambda zurückgibt. Der dritte Test bestätigt, dass ?.let einen null-Wert überspringt.

10. Häufige Fehler

10.1. this wird in apply verborgen

Innerhalb von apply wird der nächste Receiver priorisiert. Daher wird eine Eigenschaft mit demselben Namen in der äußeren Klasse verborgen:

class InvoiceFactory(private val amount: Double) {
    fun create() = Invoice().apply {
        amount = amount // weist Invoice.amount sich selbst zu, Ergebnis ist immer noch 0.0
    }
}

Es gibt zwei Möglichkeiten, das zu beheben. Die erste ist, den Receiver explizit mit this@InvoiceFactory.amount zu schreiben. Die zweite ist, zu also zu wechseln:

fun create() = Invoice().also { it.amount = amount }

10.2. Die Falle von ?.let { } ?: ...

Viele Leute schreiben x?.let { ... } ?: fallback mit der Bedeutung ähnlich wie if/else. Diese Art zu schreiben ist nicht äquivalent. Wenn der Lambda null zurückgibt, wird der ?:-Branch auch ausgeführt:

val cache = mapOf<String, String>()
val email: String? = "an@example.com"

email?.let { cache[it] } ?: println("Keine E-Mail")
Keine E-Mail

Hier ist email nicht null, aber cache[it] gibt null zurück, daher wird die Nachricht gedruckt, obwohl sie falsch ist. Wenn du eine klare Verzweigung brauchst, verwende if (email != null) ... else ....

10.3. Zu viele Verschachtelungsebenen

Wenn du Scope Functions verschachtelst, werden it und this auf verschiedene Objekte verweisen und der Leser muss zurückgehen, um zu sehen, was jeder ist:

// Schwer zu lesen
order.let { o -> o.items.firstOrNull()?.let { price -> price.run { this * 2 } } }

Hinweis: Du solltest nicht mehr als eine Ebene verschachteln. Wenn du verschachteln musst, gebe den Parametern explizite Namen statt it. Noch besser ist es, in lokale Variablen oder separate Funktionen zu teilen.

10.4. Scope Functions nur verwenden, weil du sie verwenden kannst

Die Zeile val name = customer.let { it.name } bringt nichts im Vergleich zu val name = customer.name. Scope Functions sind nützlich, wenn sie dir helfen, Hilfsvariablen zu vermeiden, null zu behandeln oder Konfigurationscode zu gruppieren. Wenn sie das nicht tun, verwende sie nicht.

11. Fazit

Die fünf Scope Functions von Kotlin unterscheiden sich nur in zwei Punkten: ob das Objekt über this oder it aufgerufen wird, und ob die Funktion das Objekt oder das Ergebnis des Lambda zurückgibt. Daher hast du entsprechende Verwendungen: apply zum Konfigurieren, also um Nebenwirkungen hinzuzufügen, let um Nullable-Werte zu behandeln und zu transformieren, sowie run und with um Berechnungen basierend auf dem Objekt durchzuführen. Du musst auch aufpassen vor this das verborgen wird, vor der ?.let ?:-Falle und davor, zu viele Verschachtelungsebenen zu haben. Letztendlich, denke daran, dass das Ziel von Scope Functions darin besteht, Code leichter lesbar zu machen, nicht kürzer um jeden Preis.

kotlinscope-functionsletapplyclean-code