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 :
TrimmedStringimplémenteReadWriteProperty, composée de deux fonctionsgetValueetsetValue.- Chaque fois que vous assignez
customer.name, Kotlin appellesetValue, et la chaîne esttrim()avant d’être enregistrée. - Le paramètre
propertyvous indique le nom de la propriété en cours d’accès.observableutilise 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
ReportServicene 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.
lazyne peut être utilisé qu’avecval. Écrirevar x by lazy { ... }provoquera une erreur de compilation, carLazyn’a pas desetValue.
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 :
NONEest 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é avecval. La valeur est calculée par la propriété elle-même, lors de la première lecture.lateinit: utilisé avecvaret types non-nullable, non applicable aux primitives commeInt. La valeur est assignée de l’extérieur après, par exemple via dependency injection ou la fonction@BeforeEachdans 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.NEWest 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) etnew(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
vetoablene 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é avecrequire(...)pour que l’erreur soit évidente.vetoableconvient 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.