Propriétés déléguées en Kotlin : lazy et observable

1. Aperçu

Kotlin vous permet de déléguer la lecture et l’écriture d’une propriété à un autre objet via le mot-clé by. Ce mécanisme s’appelle propriétés déléguées. La bibliothèque standard fournit plusieurs delegates très couramment utilisés. Les plus populaires sont lazy pour l’initialisation paresseuse, observable pour surveiller les changements et vetoable pour bloquer les changements invalides.

Après avoir lu cet article, vous saurez comment utiliser chaque delegate et choisir le bon mode de thread-safety pour lazy. Vous saurez aussi comment écrire des tests pour eux et les situations à éviter.

2. Configuration

Les delegates présentés dans cet article se trouvent déjà dans kotlin-stdlib, donc vous n’avez pas besoin d’ajouter de bibliothèque supplémentaire. Pour écrire des tests, ajoutez JUnit 5 et AssertJ :

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

Si vous utilisez Spring Boot 3.x, spring-boot-starter-test inclut déjà ces deux bibliothèques.

3. Comment fonctionnent les propriétés déléguées

Quand vous écrivez val x: T by someDelegate, le compilateur génère un champ masqué pour contenir le delegate. Le getter de x appelle someDelegate.getValue(...). Si x est un var, le setter appelle setValue(...).

Vous pouvez écrire un delegate simple pour comprendre le mécanisme :

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}]")
}

Explication point par point :

  • TrimmedString implémente ReadWriteProperty, composée de deux fonctions getValue et setValue.
  • Chaque fois que vous assignez customer.name, Kotlin appelle setValue, et la chaîne est trim() avant d’être enregistrée.
  • Le paramètre property vous indique le nom de la propriété en cours d’accès. observable utilise ce paramètre pour vous informer quelle propriété vient de changer.
[Nguyễn Văn An]

Vous devrez rarement écrire un delegate vous-même, car la bibliothèque standard dispose de ceux qui sont les plus couramment utilisés.

4. Initialisation paresseuse avec by lazy

4.1. Utilisation basique

lazy accepte une lambda. La lambda ne s’exécute que la première fois que vous lisez la propriété. Le résultat est mis en cache pour toutes les lectures ultérieures.

class ReportService {
    val exchangeRates: Map<String, Double> by lazy {
        println("Chargement des taux de change...")
        mapOf("USD" to 25_400.0, "EUR" to 27_300.0)
    }
}

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

Résultat :

Service créé
Chargement des taux de change...
25400.0
27300.0

Il y a trois points à noter :

  • Créer ReportService ne déclenche pas le chargement des taux de change.
  • La ligne “Chargement des taux de change…” n’est imprimée qu’une seule fois, bien que la propriété soit lue deux fois.
  • lazy ne peut être utilisé qu’avec val. Écrire var x by lazy { ... } provoquera une erreur de compilation, car Lazy n’a pas de setValue.

lazy convient aux valeurs coûteuses à créer mais qui ne sont pas toujours nécessaires. Par exemple : une regex complexe, une configuration lue depuis un fichier, ou un client appelant un service externe.

4.2. Modes de thread-safety

Par défaut, lazy utilise LazyThreadSafetyMode.SYNCHRONIZED. Ce mode garantit que la lambda ne s’exécute qu’une seule fois, même si plusieurs threads y accèdent. Vous pouvez changer le mode si nécessaire :

class PriceCalculator {
    // Un seul thread exécute l'initializer, les autres attendent
    val taxTable: Map<String, Double> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
        mapOf("VN" to 0.1, "SG" to 0.09)
    }

    // Plusieurs threads peuvent exécuter l'initializer ensemble, la première valeur est conservée
    val discountRules: List<String> by lazy(LazyThreadSafetyMode.PUBLICATION) {
        listOf("BLACK_FRIDAY", "TET_SALE")
    }

    // Pas de synchronisation, à utiliser uniquement si la propriété est accédée à partir d'un seul thread
    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

Vous pouvez choisir le mode selon le tableau suivant :

Mode Exécution d’Initializer Quand l’utiliser
SYNCHRONIZED Une seule fois Par défaut, sûr pour la plupart des cas
PUBLICATION Peut s’exécuter plusieurs fois, la première valeur est conservée L’initializer est peu coûteux, pas d’effets secondaires, pour éviter les verrous
NONE Aucune garantie Code exécuté sur un seul thread (UI thread, boucle interne)

Remarque : NONE est un peu plus rapide car il n’y a pas de verrou. Si vous l’utilisez dans un bean singleton Spring accédé par plusieurs requêtes en parallèle, l’initializer peut s’exécuter plusieurs fois. Dans ce cas, les threads peuvent voir des instances différentes. Ne changez pas le mode juste pour « optimiser » sans mesurer.

4.3. Quand l’initializer lève une exception

Si la lambda lève une exception, la valeur n’est pas mise en cache. La lecture suivante exécutera à nouveau la lambda :

class ConfigLoader {
    var attempts = 0

    val apiKey: String by lazy {
        attempts++
        if (attempts < 2) error("Impossible de lire le fichier de configuration")
        "secret-key-123"
    }
}

fun main() {
    val loader = ConfigLoader()
    runCatching { loader.apiKey }.onFailure { println("Erreur : ${it.message}") }
    println(loader.apiKey)
    println("Nombre de tentatives : ${loader.attempts}")
}
Erreur : Impossible de lire le fichier de configuration
secret-key-123
Nombre de tentatives : 2

Ce comportement est avantageux quand l’erreur est temporaire. Mais si l’initializer est très coûteux et que l’erreur se répète, chaque accès entraînera à nouveau ce coût.

4.4. Vérifier si l’initialisation a eu lieu

Si vous devez savoir si la valeur a déjà été créée, conservez l’objet Lazy dans une variable séparée et déléguez-y :

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

Cette approche est utile quand vous voulez libérer les ressources lors de l’arrêt, mais uniquement si la ressource a effectivement été créée.

4.5. lazy ou lateinit ?

Ces deux approches sont souvent confondues :

  • lazy: utilisé avec val. La valeur est calculée par la propriété elle-même, lors de la première lecture.
  • lateinit: utilisé avec var et types non-nullable, non applicable aux primitives comme Int. La valeur est assignée de l’extérieur après, par exemple via dependency injection ou la fonction @BeforeEach dans les tests.

Si vous savez comment calculer la valeur directement dans la classe, utilisez lazy. Si la valeur est « injectée » de l’extérieur, utilisez lateinit.

5. Surveiller les changements avec Delegates.observable

Delegates.observable accepte une valeur initiale et un callback. Le callback s’exécute après que la nouvelle valeur ait été assignée.

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

Explication :

  • OrderStatus.NEW est la valeur initiale. L’assignation de cette valeur n’active pas le callback.
  • Le callback reçoit trois paramètres : property (métadonnées), old (ancienne valeur) et new (nouvelle valeur).
  • La troisième ligne montre que le callback s’exécute toujours quand vous réassignez la même valeur.

Si vous ne voulez réagir que quand la valeur change réellement, filtrez dans le callback :

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

observable est généralement utilisé pour enregistrer l’audit, marquer un objet comme « sale » pour être sauvegardé, ou mettre à jour une valeur dérivée.

6. Bloquer les valeurs invalides avec Delegates.vetoable

vetoable ressemble à observable, mais le callback s’exécute avant l’assignation et retourne un Boolean. Si le callback retourne false, l’ancienne valeur est conservée.

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

Les assignations 0 et 150 sont refusées silencieusement, et quantity reste à 5.

Remarque : Puisque vetoable ne lève pas d’exception, le code appelant ne saura pas que l’assignation a échoué. Pour les données métier critiques, comme un montant de paiement, vous devez utiliser un setter personnalisé avec require(...) pour que l’erreur soit évidente. vetoable convient aux cas où ignorer une valeur incorrecte est le comportement souhaité, comme limiter la valeur d’une barre de défilement dans une UI.

7. Écrire des tests pour les delegates

La section de test ci-dessous vérifie trois comportements clés : lazy n’initialise qu’une seule fois, observable enregistre l’historique et vetoable refuse les valeurs invalides.

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 n'exécute l'initializer qu'une seule fois même avec plusieurs threads accédant simultanément`() {
        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 enregistre chaque changement d'état`() {
        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 conserve l'ancienne valeur quand elle est refusée`() {
        val item = CartItem("SKU-7")

        item.quantity = 10
        item.quantity = -3

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

Le premier test utilise 8 threads lisant value simultanément. Avec le mode par défaut SYNCHRONIZED, counter est toujours égal à 1. Si vous passez à LazyThreadSafetyMode.NONE et relancez plusieurs fois, le test échouera parfois. C’est un moyen rapide de vérifier par vous-même la différence entre les modes.

8. Erreurs courantes et quand ne pas les utiliser

Utiliser des delegates dans les entités JPA. Le vrai champ généré par le compilateur est status$delegate, de type ObservableProperty, pas OrderStatus. Hibernate ne peut pas mapper ce type de champ naturellement. Avec les entités, utilisez des champs ordinaires avec un setter personnalisé ou un entity listener.

La lambda de lazy maintient une référence. La lambda est conservée jusqu’à son exécution. Si elle capture un grand objet et que la propriété n’est jamais lue, cet objet reste en mémoire.

Lire lazy dans le bloc init trop tôt. Si la lambda dépend d’une propriété déclarée plus bas, et que vous lisez la propriété lazy directement dans init, vous pouvez obtenir une valeur non initialisée, comme null ou 0.

Remplacer le mécanisme du framework. Dans Spring, n’utilisez pas by lazy pour créer vous-même des beans ou gérer le cycle de vie d’un pool de connexions. Utilisez @Lazy ou ObjectProvider, pour que le container puisse toujours gérer la fermeture des ressources.

Effets secondaires lourds dans observable. Le callback s’exécute de manière synchrone dans le setter. Appeler HTTP ou écrire une base de données dans le callback rendra une simple assignation lente et sujette aux erreurs. Émettez plutôt un événement au lieu de faire un travail lourd sur place.

Confondre observable avec thread-safe. observable et vetoable n’ont aucun mécanisme de synchronisation. Si plusieurs threads assignent simultanément, le callback peut recevoir une valeur old incorrecte.

9. Conclusion

Les propriétés déléguées vous aident à séparer la logique d’accès aux propriétés de la classe, ce qui rend le code plus compact et lisible. Utilisez by lazy pour les valeurs coûteuses à créer, et gardez le mode SYNCHRONIZED par défaut sauf s’il y a une raison claire de le changer. Utilisez Delegates.observable quand vous devez réagir à chaque changement, et rappelez-vous que le callback s’exécute même quand la valeur ne change pas. Utilisez Delegates.vetoable quand vous voulez ignorer silencieusement les valeurs invalides, mais passez à require dans le setter quand vous devez signaler une erreur clairement.

Une fois que vous maîtrisez getValue et setValue, vous pouvez tout à fait écrire vos propres delegates pour des besoins personnalisés comme normaliser une chaîne, lire une valeur depuis une Map ou enregistrer un accès. Vous devez seulement éviter de les utiliser où le framework a besoin de voir le vrai champ, comme dans les entités JPA.

kotlinpropriétés déléguéeslazyobservabledelegates