Data Class et Copy en Kotlin
1. Aperçu
data class est l’une des fonctionnalités qui rend Kotlin nettement plus concis que Java lorsqu’on travaille avec des classes contenant uniquement des données (DTO, model, value object). Cet article vous aide à comprendre ce que Kotlin génère pour une data class, comment utiliser la fonction copy() pour créer une copie modifiée sur quelques champs, et les pièges courants comme les comparaisons incorrectes, l’utilisation de var dans une data class, ou l’héritage de data class.
2. Qu’est-ce qu’une Data Class ?
Lorsque vous déclarez une classe avec le mot-clé data, le compilateur Kotlin génère automatiquement plusieurs fonctions basées sur les propriétés déclarées dans le constructeur principal (primary constructor).
data class Customer(
val id: Long,
val name: String,
val email: String
)
Avec seulement ces 4 lignes, Kotlin génère automatiquement :
equals()/hashCode()basés surid,name,email.toString()sous la formeCustomer(id=1, name=..., email=...).copy()pour créer une copie avec possibilité de modifier quelques champs.componentN()pour supporter les destructuring declarations.
Par rapport à Java, vous devriez écrire une classe avec un constructeur, des getters, equals, hashCode, toString — environ 40-50 lignes pour la même fonctionnalité.
2.1. toString() par Défaut
fun main() {
val customer = Customer(1, "An Nguyen", "an@nonladev.com")
println(customer)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)
toString() généré automatiquement est très pratique pour logger ou déboguer, vous n’avez pas besoin de l’écrire manuellement.
3. equals() et hashCode() Par Valeur
La plus grande différence entre une data class et une classe ordinaire est que equals() compare par valeur (structural equality) plutôt que par référence.
fun main() {
val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
val c2 = Customer(1, "An Nguyen", "an@nonladev.com")
println(c1 == c2) // compare les valeurs
println(c1 === c2) // compare les références
}
true
false
==appelleequals(), deux objets avec les mêmes champs sont égaux même s’ils ont des références différentes.===vérifie si deux variables pointent vers le même objet en mémoire.
Remarque : Avec une classe ordinaire (pas une
data class),==compare par défaut la référence comme===, sauf si vous surchargezequals().
Puisque hashCode() est aussi généré basé sur les propriétés, vous pouvez utiliser data class comme clé dans une HashMap ou élément dans un HashSet en toute sécurité.
fun main() {
val set = hashSetOf(
Customer(1, "An Nguyen", "an@nonladev.com"),
Customer(1, "An Nguyen", "an@nonladev.com")
)
println(set.size)
}
1
Deux objets avec des valeurs identiques sont considérés comme le même, HashSet conserve seulement 1 élément.
4. La Fonction copy() — Créer une Copie Immuable
Lorsque vous utilisez val pour les propriétés, un Customer est immuable — vous ne pouvez pas modifier les champs directement après sa création. Pour « modifier » quelques valeurs, Kotlin génère une fonction copy() qui permet de créer un nouvel objet en gardant les champs non fournis.
fun main() {
val original = Customer(1, "An Nguyen", "an@nonladev.com")
val updated = original.copy(email = "an.new@nonladev.com")
println(original)
println(updated)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)
Customer(id=1, name=An Nguyen, email=an.new@nonladev.com)
originaln’est pas modifié du tout — c’est l’essence de l’immuabilité.updatedest un nouvel objet, différent uniquement paremail, les autres champs sont copiés tel quel.
C’est un pattern très courant lorsqu’on travaille avec l’état en UI (Compose, MVI) ou lors de la mise à jour d’une entité en logique métier sans vouloir modifier l’objet original.
4.1. copy() Avec des Named Arguments
Vous pouvez passer plusieurs paramètres à la fois en utilisant les named arguments pour éviter les confusions sur l’ordre :
val promoted = original.copy(name = "An Nguyen (VIP)", email = "vip@nonladev.com")
Si vous n’utilisez pas les named arguments mais l’ordre positionnel, le code risque de créer de la confusion lorsque la classe a de nombreux champs du même type String.
5. Shallow Copy — Piège Avec les Objets Mutables Imbriqués
copy() effectue seulement une shallow copy : il copie les références des champs, pas une copie profonde (deep copy) des objets complexes imbriqués.
data class Order(
val id: Long,
val items: MutableList<String>
)
fun main() {
val order1 = Order(1, mutableListOf("Book"))
val order2 = order1.copy()
order2.items.add("Pen")
println(order1.items) // affecté !
println(order2.items)
}
[Book, Pen]
[Book, Pen]
order2 = order1.copy()crée seulement un nouvel objetOrder, mais le champitemspointe toujours vers la mêmeMutableList.- Modifier
order2.itemsmodifie aussiorder1.items— c’est une erreur très facile à faire lorsqu’on utilise une collection mutable dans une data class.
Correction : utilisez une List immuable (toList()) ou copiez manuellement la collection dans le constructeur.
data class Order(
val id: Long,
val items: List<String>
)
fun main() {
val order1 = Order(1, listOf("Book"))
val order2 = order1.copy(items = order1.items + "Pen")
println(order1.items)
println(order2.items)
}
[Book]
[Book, Pen]
Utilisez une List<String> immuable et l’opérateur + pour créer une nouvelle liste au lieu de la modifier, en respectant l’esprit de l’immuabilité d’une data class.
Remarque : Si vous avez besoin d’une véritable deep copy (data class imbriquée contenant d’autres data classes), vous devez écrire vous-même la logique de copie récursive, Kotlin ne génère pas de deep copy automatiquement.
6. Destructuring Declaration
Grâce à componentN() généré automatiquement, vous pouvez « destructurer » une data class en plusieurs variables distinctes :
fun main() {
val customer = Customer(1, "An Nguyen", "an@nonladev.com")
val (id, name, email) = customer
println("$id - $name - $email")
}
1 - An Nguyen - an@nonladev.com
L’ordre des variables correspond à l’ordre des propriétés dans le constructeur principal, pas au nom. Changer l’ordre de déclaration des propriétés causera une destructuring incorrecte — soyez prudent lors de refactorisation.
Remarque : Ne destructurez que les data classes avec peu de champs (2-3). Pour une classe avec beaucoup de champs, utilisez directement les noms des propriétés pour une meilleure lisibilité.
7. Limitations de Data Class
7.1. Impossible d’Hériter
Une data class ne peut pas hériter d’une autre data class (vous ne pouvez pas utiliser open avec data de manière à hériter automatiquement les champs) :
// Erreur de compilation si vous essayez :
// data class VipCustomer(val tier: String) : Customer(...)
Kotlin ne permet qu’à une data class d’implémenter une interface, ou d’hériter d’une classe ordinaire (non-data) sans propriété dans le constructeur principal. Raison : les fonctions equals()/copy() générées seraient très difficiles à définir correctement avec l’héritage multi-niveaux.
Solution courante : composition plutôt qu’héritage.
data class VipCustomer(
val customer: Customer,
val tier: String
)
7.2. Le Constructeur Principal Doit Avoir Au Moins 1 Propriété
// Erreur : une data class doit avoir au moins 1 paramètre dans le constructeur principal
// data class Empty
7.3. Seules les Propriétés du Constructeur Principal Sont Comptabilisées
data class Product(val name: String) {
var discount: Int = 0
}
fun main() {
val p1 = Product("Laptop").apply { discount = 10 }
val p2 = Product("Laptop").apply { discount = 20 }
println(p1 == p2) // true, discount ne participe pas à equals()
}
true
discount est déclaré dans le corps, pas dans le constructeur principal, donc ne participe pas à equals(), hashCode(), toString(), copy(). C’est une source de confusion courante — si un champ est important pour la comparaison, vous devez le mettre dans le constructeur principal.
8. Test Avec JUnit 5 et AssertJ
Dépendances nécessaires (Gradle Kotlin DSL) :
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.assertj:assertj-core:3.26.0")
Écrivez des tests pour vérifier le comportement de copy() et equals() :
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class CustomerTest {
@Test
fun `copy devrait conserver les champs inchangés et mettre à jour ceux spécifiés`() {
val original = Customer(1, "An Nguyen", "an@nonladev.com")
val updated = original.copy(email = "an.new@nonladev.com")
assertThat(updated.id).isEqualTo(original.id)
assertThat(updated.name).isEqualTo(original.name)
assertThat(updated.email).isEqualTo("an.new@nonladev.com")
assertThat(original.email).isEqualTo("an@nonladev.com")
}
@Test
fun `deux clients avec les mêmes champs devraient être égaux`() {
val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
val c2 = Customer(1, "An Nguyen", "an@nonladev.com")
assertThat(c1).isEqualTo(c2)
assertThat(c1).isNotSameAs(c2)
}
}
isEqualTovérifie l’égalité par valeur, c’est correct avec la nature deequals()d’unedata class.isNotSameAsconfirme quec1etc2sont deux objets différents en mémoire, bien qu’égaux en valeur.- Le premier test confirme que
copy()fonctionne correctement : les champs non spécifiés gardent leur valeur, les champs spécifiés sont mis à jour, l’objet d’origine ne change pas.
9. Quand Ne Pas Utiliser Data Class
- Entité JPA/Hibernate :
equals()/hashCode()selon tous les champs (y compris les champs générés commeidavant la persistance) cause facilement des erreurs lorsqu’utilisé dansHashSetou pour comparer des entités persistées/non-persistées en BD. Écrivez manuellementequals()/hashCode()selonidpour les entités. - Classe avec logique métier complexe :
data classest destinée aux conteneurs de données, pas appropriée lorsqu’une classe a besoin de beaucoup de comportements, d’héritage multi-niveaux. - Classe optimisant la mémoire avec plusieurs champs mutables imbriqués : considérez le builder pattern ou une classe ordinaire si la shallow copy présente un risque difficile à contrôler.
10. Conclusion
data class vous aide à éliminer le code verbeux lors du travail avec DTO/model en Kotlin : génère automatiquement equals(), hashCode(), toString(), copy() et supporte la destructuring. La fonction copy() est un outil puissant pour maintenir l’immuabilité, mais vous devez vous souvenir qu’elle ne fait qu’une shallow copy — attention aux champs contenant des collections ou des objets mutables imbriqués. Évitez d’utiliser data class pour les entités JPA ou les classes avec une logique métier complexe, et mettez toujours les champs importants dans le constructeur principal pour que equals()/copy() fonctionnent comme prévu.