Implémenter des Mixins (ou traits) en kotlin grâce à la délégation

30/09/2024 — 7 minutes de lecture (1560 mots)

En programmation orientée objet, un Mixin est une façon d'ajouter des fonctionnalités prédéfinies et autonomes à une classe. Certains langages fournissent cette possibilité directement, d'autres langages nécessitent un peu plus de travail et de compromis pour coder des mixins. Je vous propose une solution pour coder des mixins en Kotlin à base de délégation.

Contents

Objectif

Définition du pattern « mixins »

Le pattern mixin n’est pas aussi précisément défini que d’autres design patterns tels que Singleton ou Proxy. Selon le contexte, il peut y avoir de légères différences dans ce qui se cache derrière le terme.

Ce pattern peut aussi se rapprocher des « Traits » présents dans d’autres langages (Rust par exemple), mais de la même manière le terme « Trait » ne signifie pas forcément la même chose selon le langage utilisé1.

Ceci étant dit, voici une définition tirée de Wikipédia (en anglais) traduite par mes soins :

En programmation orientée objet, un mixin (ou mix-in) est une classe qui contient des méthodes utilisées par d’autres classes sans avoir besoin d’être la classe parente de ces autres classes. La façon dont ces autres classes accèdent aux méthodes du mixin dépend du langage. Les mixins sont parfois décrits comme étant “inclus” plutôt que “hérités”.

On peut aussi trouver des définitions dans différents articles sur le sujet de la programmation utilisant les mixins (2, 3, 4). Ces définitions apportent également cette notion d’extension de classes sans la relation parent-enfant (ou is-a) qu’apporte l’héritage classique. Elles font de plus le lien avec l’héritage multiple qui n’est pas possible en Kotlin (ni en Java) mais qui est présenté comme l’un des intérêts de l’utilisation des mixins.

Caractéristiques et contraintes

Une implémentation du pattern qui se rapprocherait au maximum de ces définitions doit satisfaire les contraintes suivantes :

Implémentation

Approche naïve par composition

La manière la plus triviale d’ajouter des fonctionnalités à une classe est d’utiliser une autre classe en tant qu’attribut. Les fonctionnalités du mixin sont alors accessibles en appelant des méthodes de cet attribut.

class MyClass {
    private val mixin = Counter()
    
    fun myFunction() {
        mixin.increment()
        
        // ...
    }
}

Cette façon de faire n’apporte aucune information au système de types de kotlin. Impossible par exemple d’avoir une liste d’objets qui utilisent Counter. Prendre en paramètre un objet de type Counter n’a pas d’intérêt, car ce type représente uniquement le mixin et donc un objet probablement inutile au reste de l’application.

Un autre problème de cette implémentation est que les fonctionnalités du mixin ne sont pas accessibles de l’extérieur de la classe sans modifier cette classe ou rendre le mixin public.

Utilisation de l’héritage

Pour que les mixins définissent également un type utilisable dans l’application, nous allons devoir hériter d’une classe abstraite ou implémenter une interface.

La classe abstraite pour définir un mixin est éliminée d’office, car cela ne nous permettrait pas d’utiliser plusieurs mixins sur une seule classe (il est impossible d’hériter de plusieurs classes en kotlin).

Un mixin va donc être créé avec une interface.

interface Counter {
    var count: Int
    fun increment() {
        println("Mixin does its job")
    }
    fun get(): Int = count
}

class MyClass: Counter {
    override var count: Int = 0 // Nous sommes forcés d'ajouter l'état du mixin dans la classe qui l'utilise
    
    fun hello() {
        println("Class does something")
    }
}

Cette approche est plus satisfaisante que la précédente pour plusieurs raisons :

Cependant, il reste une importante limitation à cette implémentation : les mixins ne peuvent pas contenir d’état. En effet, si les interfaces en Kotlin peuvent définir des propriétés, elles ne peuvent pas les initialiser directement. Chaque classe qui utilise le mixin doit donc définir toutes les propriétés nécessaires au fonctionnement du mixin et on ne respecte pas la contrainte selon laquelle on ne veut pas que l’utilisation d’un mixin nous force à ajouter des propriétés ou des méthodes à la classe qui l’utilise.

Il va donc falloir trouver une solution pour que les mixins puissent avoir un état, tout en gardant l’interface qui est la seule façon d’avoir à la fois un type et la possibilité d’utiliser plusieurs mixins.

La délégation pour contenir l’état du mixin

Cette solution est légèrement plus complexe pour définir un mixin, en revanche, elle n’a aucun impact sur la classe qui l’utilise. L’astuce est d’associer à chaque mixin un objet pour contenir l’état dont pourrait avoir besoin le mixin. Nous utiliserons cet objet en l’associant à la fonctionnalité de délégation proposée par Kotlin de manière à créer cet objet pour chaque utilisation du mixin.

Voilà la solution basique, mais qui répond néanmoins à toutes les contraintes :

interface Counter {
    fun increment()
    fun get(): Int
}

class CounterHolder: Counter {
    var count: Int = 0
    override fun increment() {
        count++
    }
    override fun get(): Int = count
}


class MyClass: Counter by CounterHolder() {
    fun hello() {
        increment()
        // Le reste de la méthode...
    }
}

Implémentation finale

Nous pouvons encore améliorer l’implémentation : la classe CounterHolder est un détail d’implémentation et il serait intéressant de ne pas avoir besoin de connaitre son nom.

Pour arriver à cet objectif nous allons utiliser un companion object sur l’interface de mixin et le pattern « Factory Method » pour créer l’objet qui contient l’état du mixin. On utilisera également un peu de magie noire Kotlin pour ne pas avoir besoin de connaitre le nom de cette méthode :

interface Counter {
    // Les fonctions et propriétés définies ici constituent le « contrat
    // d'interface » du mixin. C'est ce qui pourra être utilisé par la
    // classe qui utilise le mixin, ou depuis l'extérieur de celle-ci.
    fun increment()
    fun get(): Int

    companion object {
        private class MixinStateHolder : Counter {
            // L'état du mixin peut être défini ici, et il est privé s'il
            // n'est pas également défini dans l'interface
            var count: Int = 0

            override fun increment() {
                count++
            }
            override fun get(): Int = count
        }
        
        // Utiliser l'opérateur invoke dans un companion object permet de
        // faire comme si l'interface avait un constructeur. Normalement
        // je déconseille ce genre de magie noire, mais ici je trouve que
        // c'est l'un des rares cas qui se justifie. Si ça ne vous plait
        // pas, renommez simplement cette fonction en utilisant un nom
        // standard commun à tous les mixins comme `init` ou `create`
        operator fun invoke(): Counter {
            return MixinStateHolder()
        } 
    }
}

class MyClass: Counter by Counter() {
    fun myFunction() {
        this.increment()
        // Le reste de la méthode...
    }
}

Limites

Cette implémentation des mixins n’est pas parfaite (aucune ne pourrait l’être sans être supportée au niveau du langage à mon avis). En particulier, elle présente les inconvénients suivants :

Exemples

Pour améliorer la compréhension du pattern que je vous propose dans cet article, voilà quelques exemples réalistes de mixins.

Auditable

Ce mixin permet à une classe d’« enregistrer » les actions qui sont effectuées sur une instance de cette classe. Le mixin propose une autre méthode qui permet de récupérer les dernières actions.

import java.time.Instant

data class TimestampedEvent(
    val timestamp: Instant,
    val event: String
)

interface Auditable {
    fun auditEvent(event: String)
    fun getLatestEvents(n: Int): List<TimestampedEvent>

    companion object {
        private class Holder : Auditable {
            private val events = mutableListOf<TimestampedEvent>()
            override fun auditEvent(event: String) {
                events.add(TimestampedEvent(Instant.now(), event))
            }
            override fun getLatestEvents(n: Int): List<TimestampedEvent> {
                return events.sortedByDescending(TimestampedEvent::timestamp).takeLast(n)
            }
        }
        
        operator fun invoke(): Auditable = Holder()
    }
}

class BankAccount: Auditable by Auditable() {
    private var balance = 0
    fun deposit(amount: Int) {
        auditEvent("deposit $amount")
        balance += amount
    }

    fun withdraw(amount: Int) {
        auditEvent("withdraw $amount")
        balance -= amount 
    }
    
    fun getBalance() = balance
}

fun main() {
    val myAccount = BankAccount()
    
    // This function will call deposit and withdraw many times but we don't know exactly when and how
    giveToComplexSystem(myAccount)
    
    // We can query the balance of the account
    myAccount.getBalance()
    
    // Thanks to the mixin, we can also know the operations that have been performed on the account.
    myAccount.getLatestEvents(10)
}

Observable

le design pattern Observable peut être implémenté facilement en utilisant un mixin. De cette manière, les classes observables n’ont plus besoin de définir la logique de souscription et notification et elles n’ont plus besoin de maintenir elles-mêmes la listes des observers.

interface Observable<T> {
    fun subscribe(observer: (T) -> Unit)
    fun notifyObservers(event: T)

    companion object {
        private class Holder<T> : Observable<T> {
            private val observers = mutableListOf<(T) -> Unit>()
            override fun subscribe(observer: (T) -> Unit) {
                observers.add(observer)
            }

            override fun notifyObservers(event: T) {
                observers.forEach { it(event) }
            }
        }

        operator fun <T> invoke(): Observable<T> = Holder()
    }
}

sealed interface CatalogEvent
class PriceUpdated(val product: String, val price: Int): CatalogEvent

class Catalog(): Observable<CatalogEvent> by Observable() {
    val products = mutableMapOf<String, Int>()

    fun updatePrice(product: String, price: Int) {
        products[product] = price
        notifyObservers(PriceUpdated(product, price))
    }
}

fun main() {
    val catalog = Catalog()

    catalog.subscribe { println(it) }

    catalog.updatePrice("lamp", 10)
}

Il y a un inconvénient dans ce cas précis cependant : la méthode notifyObservers est accessible depuis l’extérieur de la classe Catalog alors qu’on aurait sans doute préféré la garder privée. Mais toutes les méthodes d’un mixin doivent être publiques pour être utilisée depuis la classe qui utilise le mixin (car on n’utilise pas d’héritage, mais de la composition, même si la syntaxe simplifiée proposée par Kotlin fait que cela ressemble à de l’héritage)

Entity / Identity

Si votre projet gère des données métier persistantes et/ou que vous pratiquez, au moins en partie, le DDD, votre application contient probablement des entités. Une entité est une classe qui possède une identité, souvent implémentée sous forme d’un ID numérique ou d’un UUID. Cette caractéristique se prête bien à l’utilisation d’un mixin, et je vous en propose ici un exemple.

interface Entity {
    val id: UUID

    // Surcharger equals et hashCode dans un mixin n'est peut-être pas 
    // toujours une bonne idée, mais ça me semble intéressant pour l'exemple. 
    override fun equals(other: Any?): Boolean
    override fun hashCode(): Int
}

class IdentityHolder(
    override val id: UUID
): Entity {
    // Deux entités sont égales si leurs ids le sont.
    override fun equals(other: Any?): Boolean {
        if(other is Entity) {
            return this.id == other.id
        }

        return false
    }

    override fun hashCode(): Int {
        return id.hashCode()
    }
}

class Customer(
    id: UUID,
    val firstName: String,
    val lastName: String,
) : Entity by IdentityHolder(id)

val id = UUID.randomUUID()
val c1 = Customer(id, "John", "Smith")
val c2 = Customer(id, "John", "Doe")

c1 == c2 // true

Cet exemple est un peu différent : on voit que rien ne nous empêche de nommer la classe Holder différemment, et que rien ne nous empêche non plus de lui passer des paramètres au moment de l’instanciation.

He's mixing in code!

Conclusion

La technique des mixins permet d’enrichir les classes en y ajoutant des comportements souvent transverses et réutilisables, sans avoir à modifier ces classes pour accueillir ces fonctionnalités. Malgré quelques limitations, les mixins permettent de faciliter la réutilisation de code et d’isoler certaines fonctionnalités communes à plusieurs classes dans l’application.

Les mixins sont un outil intéressant dans la boîte à outils d’un développeur Kotlin et je vous encourage à explorer cette méthode dans votre propre code, tout en restant conscient des contraintes et des alternatives.


  1. Fun fact : Kotlin possède un mot clé trait, mais il est déprécié et a été remplacé par interface (cf. https://blog.jetbrains.com/kotlin/2015/05/kotlin-m12-is-out/#traits-are-now-interfaces)

  2. Mixin Based Inheritance

  3. Classes and Mixins

  4. Object-Oriented Programming with Flavors