Delegierte Properties in Kotlin: lazy und observable

1. Überblick

Kotlin ermöglicht es dir, das Lesen und Schreiben einer Property an ein anderes Objekt zu delegieren, indem du das Schlüsselwort by verwendest. Dieser Mechanismus wird als delegierte Properties bezeichnet. Die Standardbibliothek enthält bereits mehrere häufig verwendete Delegate. Die verbreitetsten sind lazy für verzögerte Initialisierung, observable um Änderungen zu beobachten und vetoable um ungültige Änderungen zu blockieren.

Nach dem Lesen dieses Artikels wirst du wissen, wie du jeden Delegate verwendest und welchen Thread-Safety-Modus du für lazy wählst. Du wirst auch lernen, wie du Tests dafür schreibst und welche Situationen du vermeiden solltest.

2. Einrichtung

Die Delegate in diesem Artikel sind bereits in kotlin-stdlib enthalten, daher benötigst du keine zusätzlichen Bibliotheken. Zum Schreiben von Tests füge JUnit 5 und AssertJ hinzu:

<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>

Wenn du Spring Boot 3.x verwendest, enthält spring-boot-starter-test bereits beide Bibliotheken.

3. Wie delegierte Properties funktionieren

Wenn du val x: T by someDelegate schreibst, erzeugt der Compiler ein verstecktes Feld, um den Delegate zu halten. Der Getter von x ruft someDelegate.getValue(...) auf. Wenn x ein var ist, ruft der Setter setValue(...) auf.

Du kannst einen einfachen Delegate schreiben, um den Mechanismus deutlich zu sehen:

import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

class TrimmedString(private var value: String = "") : ReadWriteProperty<Any?, String> {
    override fun getValue(thisRef: Any?, property: KProperty<*>): String = value

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        this.value = value.trim()
    }
}

class Customer {
    var name: String by TrimmedString()
}

fun main() {
    val customer = Customer()
    customer.name = "   Nguyễn Văn An  "
    println("[${customer.name}]")
}

Erklärung einzelner Teile:

  • TrimmedString implementiert ReadWriteProperty mit zwei Funktionen getValue und setValue.
  • Jedes Mal wenn du customer.name zuweist, ruft Kotlin setValue auf und der String wird vor dem Speichern mit trim() zugeschnitten.
  • Der Parameter property teilt dir mit, welche Property gerade zugegriffen wird. observable nutzt genau diesen Parameter um dir zu sagen, welche Property sich gerade geändert hat.
[Nguyễn Văn An]

Du wirst selten einen Delegate selbst schreiben müssen, da die Standardbibliothek bereits die am häufigsten verwendeten zur Verfügung stellt.

4. Verzögerte Initialisierung mit by lazy

4.1. Grundlegende Verwendung

lazy nimmt ein Lambda an. Das Lambda wird nur beim ersten Zugriff auf die Property ausgeführt. Das Ergebnis wird für alle nachfolgenden Zugriffe gecacht.

class ReportService {
    val exchangeRates: Map<String, Double> by lazy {
        println("Wechselkurse werden geladen...")
        mapOf("USD" to 25_400.0, "EUR" to 27_300.0)
    }
}

fun main() {
    val service = ReportService()
    println("Service erstellt")
    println(service.exchangeRates["USD"])
    println(service.exchangeRates["EUR"])
}

Ausgabe:

Service erstellt
Wechselkurse werden geladen...
25400.0
27300.0

Es gibt drei wichtige Punkte zu beachten:

  • Das Erstellen von ReportService triggert nicht das Laden der Wechselkurse.
  • Die Zeile “Wechselkurse werden geladen…” wird nur einmal ausgegeben, obwohl die Property zweimal gelesen wurde.
  • lazy kann nur mit val verwendet werden. var x by lazy { ... } führt zu einem Compilerfehler, da Lazy kein setValue hat.

lazy eignet sich gut für Werte, die aufwändig zu erstellen sind, aber nicht immer benötigt werden. Beispiele: ein komplexer regulärer Ausdruck, eine aus einer Datei gelesene Konfiguration oder ein Client für einen externen Service.

4.2. Thread-Safety-Modi

Standardmäßig verwendet lazy den Modus LazyThreadSafetyMode.SYNCHRONIZED. Dieser Modus stellt sicher, dass das Lambda genau einmal ausgeführt wird, auch wenn mehrere Threads gleichzeitig zugreifen. Du kannst den Modus bei Bedarf ändern:

class PriceCalculator {
    // Nur ein Thread führt den Initializer aus, andere Threads warten
    val taxTable: Map<String, Double> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
        mapOf("VN" to 0.1, "SG" to 0.09)
    }

    // Mehrere Threads können gleichzeitig den Initializer ausführen, der erste Wert wird behalten
    val discountRules: List<String> by lazy(LazyThreadSafetyMode.PUBLICATION) {
        listOf("BLACK_FRIDAY", "TET_SALE")
    }

    // Keine Synchronisation, nutze nur wenn sicher, dass die Property von einem Thread zugegriffen wird
    val formatter: java.text.DecimalFormat by lazy(LazyThreadSafetyMode.NONE) {
        java.text.DecimalFormat("#,###")
    }
}

fun main() {
    val calculator = PriceCalculator()
    println(calculator.taxTable["VN"])
    println(calculator.discountRules.first())
    println(calculator.formatter.format(1_250_000))
}
0.1
BLACK_FRIDAY
1,250,000

Du kannst den Modus nach dieser Tabelle auswählen:

Modus Initializer läuft Wann man es nutzt
SYNCHRONIZED Genau 1x Standard, sicher für die meisten Fälle
PUBLICATION Möglicherweise mehrfach, das erste Ergebnis wird behalten Initializer ist günstig, hat keine Seiteneffekte, man möchte Lock vermeiden
NONE Nicht garantiert Code läuft nur auf einem Thread (UI-Thread, innere Schleife)

Hinweis: NONE ist etwas schneller, da es kein Lock gibt. Wenn du es in einem Spring-Bean-Singleton verwendest, der von vielen Requests parallel zugegriffen wird, könnte der Initializer mehrmals laufen. In diesem Fall könnten verschiedene Threads unterschiedliche Instanzen sehen. Ändere den Modus nicht nur um zu “optimieren” ohne Messungen.

4.3. Wenn der Initializer eine Exception wirft

Wenn das Lambda eine Exception wirft, wird der Wert nicht gecacht. Der nächste Zugriff wird das Lambda erneut ausführen:

class ConfigLoader {
    var attempts = 0

    val apiKey: String by lazy {
        attempts++
        if (attempts < 2) error("Konfigurationsdatei konnte nicht gelesen werden")
        "secret-key-123"
    }
}

fun main() {
    val loader = ConfigLoader()
    runCatching { loader.apiKey }.onFailure { println("Fehler: ${it.message}") }
    println(loader.apiKey)
    println("Anzahl der Versuche: ${loader.attempts}")
}
Fehler: Konfigurationsdatei konnte nicht gelesen werden
secret-key-123
Anzahl der Versuche: 2

Dieses Verhalten ist hilfreich, wenn der Fehler vorübergehend ist. Aber wenn der Initializer sehr teuer ist und der Fehler sich wiederholt, wird jeder Zugriff wieder diese Kosten verursachen.

4.4. Prüfe ob initialisiert wurde

Wenn du wissen musst, ob ein Wert bereits erstellt wurde, halte das Lazy-Objekt in einer separaten Variable und delegiere daran:

class CacheHolder {
    private val productCacheDelegate = lazy { mutableMapOf<String, Int>() }
    val productCache: MutableMap<String, Int> by productCacheDelegate

    fun isCacheReady(): Boolean = productCacheDelegate.isInitialized()
}

fun main() {
    val holder = CacheHolder()
    println(holder.isCacheReady())
    holder.productCache["SKU-1"] = 10
    println(holder.isCacheReady())
}
false
true

Dieses Muster ist hilfreich, wenn du Ressourcen beim Herunterfahren freigeben möchtest, aber nur wenn die Ressource wirklich erstellt wurde.

4.5. lazy oder lateinit?

Diese beiden werden oft verwechselt:

  • lazy: Wird mit val verwendet. Der Wert wird von der Property selbst berechnet, beim ersten Zugriff.
  • lateinit: Wird mit var und Nicht-Nullable-Typen verwendet, gilt nicht für Primitive wie Int. Der Wert wird von außen eingeflüchtet, zum Beispiel durch Dependency Injection oder eine @BeforeEach-Funktion in Tests.

Wenn du weißt, wie der Wert berechnet werden soll, verwende lazy. Wenn der Wert von außen “geliefert” wird, verwende lateinit.

5. Änderungen nachverfolgen mit Delegates.observable

Delegates.observable nimmt einen Anfangswert und einen Callback an. Der Callback wird nach dem Zuweisen des neuen Wertes ausgeführt.

import kotlin.properties.Delegates

enum class OrderStatus { NEW, PAID, SHIPPED, DELIVERED }

class Order(val id: String) {
    val history = mutableListOf<String>()

    var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { property, old, new ->
        history += "${property.name}: $old -> $new"
    }
}

fun main() {
    val order = Order("ORD-001")
    order.status = OrderStatus.PAID
    order.status = OrderStatus.SHIPPED
    order.status = OrderStatus.SHIPPED
    order.history.forEach(::println)
}
status: NEW -> PAID
status: PAID -> SHIPPED
status: SHIPPED -> SHIPPED

Erklärung:

  • OrderStatus.NEW ist der Anfangswert. Das Zuweisen dieses Wertes triggert den Callback nicht.
  • Der Callback empfängt drei Parameter: property (Metadaten), old (alter Wert) und new (neuer Wert).
  • Die dritte Zeile zeigt, dass der Callback auch ausgeführt wird wenn du denselben Wert erneut zuweist.

Wenn du nur reagieren möchtest wenn sich der Wert tatsächlich ändert, filtere im Callback:

var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { _, old, new ->
    if (old != new) history += "$old -> $new"
}

observable wird häufig für Audit-Logging verwendet, um ein Objekt als “dirty” zu markieren dass es gespeichert werden muss, oder um einen abgeleiteten Wert zu aktualisieren.

6. Ungültige Werte blockieren mit Delegates.vetoable

vetoable ist ähnlich wie observable, aber der Callback wird vor dem Zuweisen ausgeführt und gibt ein Boolean zurück. Wenn der Callback false zurückgibt, wird der alte Wert beibehalten.

import kotlin.properties.Delegates

class CartItem(val sku: String) {
    var quantity: Int by Delegates.vetoable(1) { _, _, new ->
        new in 1..99
    }
}

fun main() {
    val item = CartItem("SKU-42")
    item.quantity = 5
    println(item.quantity)
    item.quantity = 0
    println(item.quantity)
    item.quantity = 150
    println(item.quantity)
}
5
5
5

Die Zuweisungen von 0 und 150 werden stillschweigend abgelehnt und quantity bleibt 5.

Hinweis: Da vetoable keine Exception wirft, weiß der aufrufende Code nicht, dass die Zuweisung fehlgeschlagen ist. Bei wichtigen Geschäftsdaten wie Zahlungsbeträgen solltest du einen benutzerdefinierten Setter mit require(...) verwenden um den Fehler deutlich zu machen. vetoable eignet sich für Situationen, in denen das Ignorieren ungültiger Werte das gewünschte Verhalten ist, wie z.B. bei Schiebereglern auf der UI.

7. Tests für Delegates schreiben

Die folgenden Tests überprüfen drei Hauptverhaltensweisen: lazy initialisiert nur einmal, observable protokolliert die Historie und vetoable lehnt ungültige Werte ab.

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger

class DelegatedPropertiesTest {

    class ExpensiveHolder(private val counter: AtomicInteger) {
        val value: String by lazy {
            counter.incrementAndGet()
            Thread.sleep(50)
            "ready"
        }
    }

    @Test
    fun `lazy führt den Initializer nur einmal aus auch wenn mehrere Threads gleichzeitig zugreifen`() {
        val counter = AtomicInteger()
        val holder = ExpensiveHolder(counter)
        val executor = Executors.newFixedThreadPool(8)

        repeat(8) { executor.submit { holder.value } }
        executor.shutdown()
        executor.awaitTermination(5, TimeUnit.SECONDS)

        assertThat(holder.value).isEqualTo("ready")
        assertThat(counter.get()).isEqualTo(1)
    }

    @Test
    fun `observable protokolliert jeden Statuswechsel`() {
        val order = Order("ORD-002")

        order.status = OrderStatus.PAID
        order.status = OrderStatus.DELIVERED

        assertThat(order.history).containsExactly(
            "status: NEW -> PAID",
            "status: PAID -> DELIVERED"
        )
    }

    @Test
    fun `vetoable behält den alten Wert bei wenn er abgelehnt wird`() {
        val item = CartItem("SKU-7")

        item.quantity = 10
        item.quantity = -3

        assertThat(item.quantity).isEqualTo(10)
    }
}

Der erste Test verwendet 8 Threads um value gleichzeitig zu lesen. Im Standardmodus SYNCHRONIZED ist counter immer 1. Wenn du es zum Modus LazyThreadSafetyMode.NONE änderst und mehrmals ausführst, wird der Test manchmal fehlschlagen. Das ist eine schnelle Möglichkeit um die Unterschiede zwischen den Modi selbst zu überprüfen.

8. Häufige Fehler und Wann man Delegates nicht verwenden sollte

Delegate in JPA-Entities verwenden. Das echte Feld das der Compiler erzeugt heißt status$delegate, hat den Typ ObservableProperty anstatt OrderStatus. Hibernate kann diesen Feldtyp nicht von selbst mappen. Bei Entities verwende normale Felder mit benutzerdefinierten Settern oder Entity-Listenern.

Lambda von lazy hält Referenzen. Das Lambda wird bis zur Ausführung behalten. Wenn es ein großes Objekt erfasst das die Property nie liest, wird dieses Objekt trotzdem im Speicher gehalten.

lazy in init-Block zu früh lesen. Wenn das Lambda auf eine Property verweist die darunter deklariert ist, und du liest die lazy-Property sofort im init, bekommst du möglicherweise einen nicht initialisierten Wert wie null oder 0.

Den Mechanismus eines Frameworks ersetzen. In Spring verwende nicht by lazy um selbst Beans zu erzeugen oder die Lebensdauer eines Connection-Pools selbst zu verwalten. Verwende @Lazy oder ObjectProvider damit der Container Ressourcen noch verwalten kann.

Schwere Side Effects in observable. Der Callback läuft synchron direkt im Setter. HTTP-Aufrufe oder Datenbankzugriffe im Callback machen eine einfache Zuweisung langsam und fehleranfällig. Sende stattdessen ein Event anstatt schwere Arbeit direkt durchzuführen.

Verwechseln dass observable threadsicher ist. observable und vetoable haben keinen Synchronisationsmechanismus. Wenn mehrere Threads gleichzeitig zuweisen, könnte der Callback old nicht korrekt erhalten.

9. Fazit

Delegierte Properties helfen dir dabei, die Logik des Property-Zugriffs aus der Klasse zu trennen, wodurch der Code prägnanter und lesbarer wird. Du verwendest by lazy für Werte die aufwändig zu erstellen sind und behältst den Standardmodus SYNCHRONIZED bei, es sei denn du hast einen klaren Grund ihn zu ändern. Du verwendest Delegates.observable wenn du auf jede Änderung reagieren musst und denke daran dass der Callback auch ausgeführt wird wenn sich der Wert nicht ändert. Du verwendest Delegates.vetoable wenn du ungültige Werte stillschweigend ignorieren möchtest, und wenn du einen Fehler deutlich kommunizieren musst, wechsle zu require im Setter.

Sobald du getValue und setValue verstehst kannst du vollständig deinen eigenen Delegate schreiben für deine Anforderungen wie String-Normalisierung, Lesen von Werten aus Map oder Protokollieren von Zugriff. Du musst sie nur dort vermeiden wo das Framework das echte Feld sehen muss, wie in JPA-Entities.

kotlindelegated propertieslazyobservabledelegates