Object, companion object et singleton en Kotlin

1. Aperçu

Kotlin n’a pas de mot-clé static. À la place, le langage fournit le mot-clé object avec trois usages : object declaration, object expression et companion object.

Cet article explique chaque usage, comment Kotlin les compile en bytecode JVM, comment les appeler depuis Java, et quand ne pas les utiliser. Après la lecture, tu sauras créer un singleton thread-safe, écrire une factory method élégante et éviter les erreurs courantes en test.

2. Configuration

Les exemples utilisent Kotlin 2.x sur JVM 21. Pour exécuter les tests, tu ajoutes JUnit 5 et AssertJ dans pom.xml :

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

Le projet doit avoir le plugin kotlin-maven-plugin pour compiler le code Kotlin.

3. Object declaration – singleton intégré au langage

Quand tu déclares object au niveau top-level ou imbriqué dans une classe, tu obtiens un singleton. Kotlin crée automatiquement une seule instance. Tu n’as pas besoin de constructor private, pas besoin de getInstance() et pas besoin de double-checked locking.

import java.util.concurrent.atomic.AtomicLong

object OrderIdGenerator {
    private val counter = AtomicLong(0)

    fun next(): String = "ORD-${counter.incrementAndGet()}"
}

fun main() {
    println(OrderIdGenerator.next())
    println(OrderIdGenerator.next())
}

Tu appelles la fonction directement par le nom de l’objet : OrderIdGenerator.next(). Pas de mot-clé new et pas d’appel au constructeur.

La variable counter utilise AtomicLong car plusieurs threads peuvent appeler next() simultanément. Le singleton garantit seulement une seule instance. Il ne rend pas automatiquement l’état interne thread-safe.

ORD-1
ORD-2

3.1. Initialisation lazy et thread-safe

L’objet est initialisé au premier accès, pas au démarrage du programme :

object AppConfig {
    init {
        println("AppConfig est initialisé")
    }

    val currency = "VND"
}

fun main() {
    println("Démarrage")
    println(AppConfig.currency)
    println(AppConfig.currency)
}
Démarrage
AppConfig est initialisé
VND
VND

Le bloc init s’exécute une seule fois, juste avant la première lecture de currency. Au niveau du bytecode, Kotlin compile AppConfig en une classe final avec un champ public static final AppConfig INSTANCE. Ce champ est assigné dans le static initializer.

La JVM garantit que le static initializer d’une classe s’exécute une seule fois et de manière thread-safe quand plusieurs threads y accèdent. Ainsi, le singleton de Kotlin est à la fois lazy et thread-safe sans que tu aies à écrire une ligne supplémentaire.

3.2. Un object peut hériter et implémenter une interface

Un object est un type réel. Il peut implémenter une interface et être passé comme n’importe quelle valeur :

interface DiscountPolicy {
    fun apply(amount: Long): Long
}

object NoDiscount : DiscountPolicy {
    override fun apply(amount: Long): Long = amount
}

class TenPercentOff : DiscountPolicy {
    override fun apply(amount: Long): Long = amount * 90 / 100
}

fun checkout(total: Long, policy: DiscountPolicy): Long = policy.apply(total)

fun main() {
    println(checkout(200_000, NoDiscount))
    println(checkout(200_000, TenPercentOff()))
}
200000
180000

NoDiscount n’a pas d’état, donc créer plusieurs instances serait du gaspillage. Le déclarer comme object montre clairement l’intention : il faut une seule instance.

4. Object expression – objet anonyme

Quand tu places object à la position d’une expression, tu crées un objet anonyme. Cet usage est similaire aux anonymous classes en Java :

data class Order(val id: String, val total: Long)

fun main() {
    val byTotalDesc = object : Comparator<Order> {
        override fun compare(a: Order, b: Order): Int = b.total.compareTo(a.total)
    }

    val orders = listOf(Order("A", 50), Order("B", 300), Order("C", 120))
    println(orders.sortedWith(byTotalDesc).map { it.id })
}
[B, C, A]

Différence importante : object expression n’est pas un singleton. Chaque fois que l’expression s’exécute, une nouvelle instance est créée. De plus, object expression est initialisé immédiatement, pas de manière lazy comme object declaration.

Tu peux aussi créer un object sans supertype pour regrouper temporairement quelques valeurs dans une portée locale :

fun main() {
    val stats = object {
        val count = 3
        val sum = 470L
    }
    println("Moyenne : ${stats.sum / stats.count}")
}

Le type de stats n’est accessible que dans la fonction ou dans une déclaration private. Si tu le retournes au-delà d’une fonction publique, le type sera converti en Any et tu perdras l’accès aux propriétés. Dans ce cas, utilise data class.

Note : Pour une interface avec une seule méthode abstraite, tu dois utiliser une lambda (SAM conversion) plutôt qu’une object expression, par exemple Comparator<Order> { a, b -> b.total.compareTo(a.total) }. N’utilise object expression que quand tu dois surcharger plusieurs méthodes ou maintenir un état.

5. Companion object – remplacer static

Parfois, tu as besoin d’une fonction attachée à une classe plutôt qu’à une instance, par exemple une factory method. En Java, tu utiliserais static. En Kotlin, tu utilises companion object :

class Customer private constructor(val email: String, val name: String) {

    companion object {
        private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")

        fun of(email: String, name: String): Customer {
            require(EMAIL_REGEX.matches(email)) { "Email invalide : $email" }
            return Customer(email.trim().lowercase(), name.trim())
        }
    }
}

fun main() {
    val customer = Customer.of("An.Nguyen@Example.com", "  Nguyễn An ")
    println("${customer.email} | ${customer.name}")
}
an.nguyen@example.com | Nguyễn An

Le code fonctionne ainsi :

  • Le constructeur est private, donc de l’extérieur tu es obligé de créer Customer via Customer.of(...).
  • Le companion object peut accéder au constructeur private de la classe qui le contient.
  • EMAIL_REGEX est compilé une seule fois et partagé par tous les appels.

Le companion object est toujours un objet réel : un singleton imbriqué dans la classe, nommé Companion par défaut. Il est initialisé quand la classe qui le contient est chargée, équivalent au static initializer en Java. Chaque classe peut avoir au maximum un companion object.

5.1. Nommer et implémenter une interface

Comme c’est un object, le companion peut avoir un nom propre et implémenter une interface. C’est quelque chose que static en Java ne peut pas faire :

interface Parser<T> {
    fun parse(raw: String): T
}

data class Money(val amount: Long, val currency: String) {
    companion object Factory : Parser<Money> {
        override fun parse(raw: String): Money {
            val (amount, currency) = raw.trim().split(" ")
            return Money(amount.toLong(), currency)
        }
    }
}

fun <T> parseAll(parser: Parser<T>, lines: List<String>): List<T> = lines.map(parser::parse)

fun main() {
    println(parseAll(Money, listOf("150000 VND", "20 USD")))
}
[Money(amount=150000, currency=VND), Money(amount=20, currency=USD)]

Note l’appel parseAll(Money, ...). Quand tu utilises le nom de classe Money comme une valeur, Kotlin comprend que c’est son companion object. Grâce à cela, la classe Money elle-même joue le rôle d’un Parser<Money>.

5.2. Extension function sur le companion

Tu peux ajouter une fonction “de type static” à une classe sans modifier son code source, tant que la classe a déclaré un companion :

fun Money.Factory.zero(currency: String): Money = Money(0, currency)

fun main() {
    println(Money.zero("VND"))
}
Money(amount=0, currency=VND)

Si le companion n’a pas de nom, tu écris fun Money.Companion.zero(...).

6. Appeler object et companion depuis Java

Par défaut, Java voit object et companion comme des instances ordinaires :

String id = OrderIdGenerator.INSTANCE.next();
Customer c = Customer.Companion.of("a@b.com", "An");

Cette façon d’appeler est verbeuse et difficile à lire. Kotlin fournit trois outils pour que le code Java soit plus naturel :

class PaymentService {
    companion object {
        const val MAX_RETRY = 3

        @JvmField
        val SUPPORTED_CURRENCIES = listOf("VND", "USD")

        @JvmStatic
        fun create(): PaymentService = PaymentService()

        fun legacyCreate(): PaymentService = PaymentService()
    }
}

Depuis Java, tu appelles comme ceci :

int retry = PaymentService.MAX_RETRY;
List<String> currencies = PaymentService.SUPPORTED_CURRENCIES;
PaymentService a = PaymentService.create();
PaymentService b = PaymentService.Companion.legacyCreate();

Voici ce que chaque outil fait :

  • const val ne peut être utilisé qu’avec les types primitifs et String dont la valeur est connue au moment de la compilation. La constante est inlinée là où elle est utilisée et devient un champ public static final.
  • @JvmField génère un champ static public sans génération de getter. Cette annotation convient aux valeurs qui ne sont pas des constantes compile-time, comme List.
  • @JvmStatic génère une méthode static supplémentaire sur la classe PaymentService. Les fonctions sans cette annotation doivent toujours être appelées via Companion.

Si ton projet n’a que du code Kotlin, tu n’as pas besoin de ces annotations. L’exception est const val : il reste utile car la valeur peut être utilisée dans une annotation, par exemple @Retry(maxAttempts = MAX_RETRY).

7. data object pour les états sans données

À partir de Kotlin 1.9, tu peux déclarer data object. Cet usage se voit souvent dans une sealed hierarchy :

sealed interface PaymentResult {
    data class Success(val transactionId: String) : PaymentResult
    data class Failed(val reason: String) : PaymentResult
    data object Pending : PaymentResult
}

fun main() {
    println(PaymentResult.Pending)
    println(PaymentResult.Success("TX-42"))
}
Pending
Success(transactionId=TX-42)

Si tu omets data, toString() affichera quelque chose comme PaymentResult$Pending@1b6d3586, très difficile à lire dans les logs. data object génère aussi equals/hashCode cohérents. C’est important quand l’objet est désérialisé en une instance différente.

8. Écrire des tests

Test pour une factory method dans un companion object :

import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test

class CustomerTest {

    @Test
    fun `of normalise email et nom`() {
        val customer = Customer.of("An.Nguyen@Example.com", "  Nguyễn An ")

        assertThat(customer.email).isEqualTo("an.nguyen@example.com")
        assertThat(customer.name).isEqualTo("Nguyễn An")
    }

    @Test
    fun `of lève une exception quand l'email est invalide`() {
        assertThatThrownBy { Customer.of("khong-phai-email", "An") }
            .isInstanceOf(IllegalArgumentException::class.java)
            .hasMessageContaining("khong-phai-email")
    }

    @Test
    fun `companion parse plusieurs lignes`() {
        val result = parseAll(Money, listOf("100 VND"))

        assertThat(result).containsExactly(Money(100, "VND"))
    }

    @Test
    fun `object declaration retourne toujours la même instance`() {
        val first: DiscountPolicy = NoDiscount
        val second: DiscountPolicy = NoDiscount

        assertThat(first).isSameAs(second)
    }
}

Ces tests s’exécutent indépendamment car Customer.of et Money.parse ne maintiennent pas d’état. Avec OrderIdGenerator, c’est différent : le compteur vit pendant toute la durée de vie de la JVM. Un test qui vérifie next() == "ORD-1" sera juste ou faux selon l’ordre d’exécution des tests. C’est exactement la première erreur dans la section suivante.

9. Erreurs courantes et quand ne pas les utiliser

Object contient un état mutable. L’état global rend les tests dépendants les uns des autres et crée des difficultés lors de l’exécution parallèle. Si un object a besoin d’état, extrayez-le dans une class et passez l’instance où elle est nécessaire (dependency injection).

Note : Object declaration n’a pas de constructeur avec paramètres. Si le singleton dépend d’une configuration ou d’un autre service, comme DataSource ou l’URL d’une gateway de paiement, alors object n’est pas le bon choix.

Utiliser object dans une application Spring. Les beans dans Spring sont par défaut des singletons dans la portée de ApplicationContext. Écrire object PaymentService te fera perdre l’injection de constructeur, rendra le mock difficile en test et créera deux mécanismes de gestion du cycle de vie en parallèle. Dans ce cas, utilise @Service class PaymentService(...). N’utilise object que pour la partie pure, sans dépendances : constantes, fonctions utilitaires, policies sans état.

Confondre companion object avec static. Le companion est une instance réelle. Appeler Customer.of(...) c’est vraiment appeler Customer.Companion.of(...). Le coût de ceci est négligeable, mais cela explique pourquoi Java a besoin de @JvmStatic.

Fourrer toutes les fonctions utilitaires dans le companion. Pour les fonctions ne relatant pas directement à la classe, une top-level function comme fun formatVnd(amount: Long): String est plus concise et plus facile à trouver.

Dépendance circulaire lors de l’initialisation. Si init de l’objet A lit l’objet B, tandis que B lit A, tu pourrais obtenir null pour une propriété déclarée non-null. Garde le bloc d’initialisation de l’objet vraiment simple.

10. Conclusion

Le mot-clé object en Kotlin a trois rôles :

  • Object declaration crée un singleton lazy et thread-safe grâce au mécanisme d’initialisation des classes de la JVM.
  • Object expression crée un objet anonyme. Chaque exécution de l’expression crée une nouvelle instance.
  • Companion object remplace static. Il peut être nommé, implémenter une interface et recevoir une extension function.

Quand tu travailles avec Java, tu utilises const val, @JvmField et @JvmStatic pour que l’API soit naturelle. La règle la plus importante est de garder les objects sans état mutable. Quand tu as besoin de dépendances ou de mock en test, une class ordinaire, ou un bean Spring, est toujours le meilleur choix.

kotlinsingletoncompanion objectobjectjvm