Implémenter des Mixins (ou traits) en kotlin grâce à la délégation
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 :
- On peut ajouter plusieurs mixins à une classe. Nous sommes dans un contexte orienté objet, et si cette contrainte n’était pas respectée le pattern n’aurait que peu d’intérêt par rapport à d’autres possibilités de conception comme l’héritage.
- La fonctionnalité d’un mixin peut être utilisée depuis l’extérieur de la classe. De la même manière, si nous oublions cette contrainte le pattern n’apportera rien qu’on ne pourrait accomplir avec une simple composition.
- Ajouter un mixin à une classe ne nous force pas à ajouter des attributs et méthodes dans la définition de la classe. Sans cette contrainte, le mixin ne pourrait plus être vu comme une fonctionnalité « boite noire ». On ne pourrait pas uniquement se baser sur le contrat d’interface du mixin pour l’ajouter à une classe, mais il faudrait comprendre son fonctionnement (par exemple via une documentation). Je veux pouvoir utiliser un mixin comme j’utilise une classe ou une fonction.
- Un mixin peut avoir un état. Certains mixins peuvent avoir besoin de stocker des données pour leur fonctionnalité.
- Les mixins peuvent être utilisés en tant que type. Par exemple, je peux avoir une fonction qui prend en paramètre n’importe quel objet tant qu’il a un mixin donné.
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 :
- La classe qui utilise le mixin n’a pas besoin d’implémenter le comportement du mixin grâce aux méthodes par défaut
- une classe peut utiliser plusieurs mixins car Kotlin permet à une classe d’implémenter plusieurs interfaces.
- Chaque mixin crée un type qui peut être utilisé pour manipuler des objets selon les mixins inclus par leur classe.
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 :
- Toutes les méthodes d’un mixin doivent être publiques. Certains mixins contiennent des méthodes qui ont vocation à être utilisée depuis la classe qui utilise le mixin et d’autres qui ont plus de sens si elles sont appelées depuis l’extérieur. Comme le mixin définit ses méthodes sur une interface, il n’est pas possible de forcer le compilateur à vérifier ces contraintes. On doit alors se rabattre sur de la documentation ou des outils d’analyse statique de code.
- Les méthodes du mixin n’ont pas accès à l’instance de la classe qui utilise le mixin. Au moment où la délégation est déclarée, l’instance n’est pas initialisée et on ne peut pas la passer au mixin.
Si vous utilisezclass MyClass: MyMixin by MyMixin(this) {} // Erreur de compilation : `this` is not defined in this context
this
à l’intérieur du mixin, vous vous référez à l’instance de la classe Holder.
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.
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.
-
Fun fact : Kotlin possède un mot clé
trait
, mais il est déprécié et a été remplacé parinterface
(cf. https://blog.jetbrains.com/kotlin/2015/05/kotlin-m12-is-out/#traits-are-now-interfaces) ↩