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:
- Rufst du das Objekt in dem Lambda über
this(Receiver) oder überit(Parameter) auf? - 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:
applyundalsogeben 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,runundwithgeben 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
nullist, oder wenn du den Wert transformieren musst: verwende?.let. - Wenn du einen Wert aus mehreren Mitgliedern eines Objekts berechnen musst: verwende
runoderwith, wenn das Objekt definitiv nichtnullist. - 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.