Arrêtez de développer n'importe comment ! - Le principe d'inversion des dépendances

En programmation informatique, SOLID est un acronyme représentant cinq
principes de base pour la programmation orientée objet. Ces cinq principes sont censés apporter une ligne directrice permettant le développement de logiciels plus fiables, plus robustes, plus maintenables, plus extensibles et plus testables.

Pour en savoir plus sur les principes SOLID nous vous invitons à lire ce document en français ici.

Nous allons nous intéresser au dernier de ces 5 principes, nous nous intéressons au «D» de SOLID :

  • Dependency Inversion Principle ou « principe d'inversion des dépendances » stipule que les objets doivent dépendre d'abstractions plutôt que d'implémentations. Cela signifie qu'il est préférable de typer des arguments avec des types abstraits (classes concrètes ou interfaces) plutôt que des types concrets (classes concrètes) afin de diminuer les couplages et favoriser d'autres implémentations.

Dans la vie réelle cela se traduit surtout par le fait d'éviter qu'une nouvelle fonctionnalité entraine la réécriture de code existant. Je vous propose donc de voir comment mettre tout ça concrètement en application.

Mais avant-tout, attardons-nous sur la notion importante de ce principe : Inversion.

Le principe de DIP stipule que les modules de haut niveau ne doivent pas dépendre de modules de plus bas niveau. Mais pour quelle raison ?

Pour répondre à cette question, prenons la définition à l’envers : les modules de haut niveau dépendent de modules de bas niveau.

En règle générale les modules de haut niveau contiennent le cœur des applications.
Lorsque ces modules dépendent de modules de plus bas niveau, les modifications effectuées dans les modules « bas niveau » peuvent avoir des répercussions sur les modules « haut niveau » et les « forcer » à appliquer des changements.

Exercice

Tous les exercices sont faisables entièrement en ligne depuis site de Kotlin :

Kotlin Playground: Edit, Run, Share Kotlin Code Online

🏠 Pour illustrer ce principe, nous allons prendre l'exemple d'une maison connectée. Nous allons devoir créer les différents équipements qui composent cette maison.

  • Prenons une lampe 🛋 :
// Tous les exemples sont en Kotlin.
class Lamp {
    fun stateOn() {
        println("Jour")
    }

    fun stateOff() {
        println("Nuit")
    }
}
  • Puis une classe interrupteur qui permet d'allumer la lampe :
class LampButton (private val lamp: Lamp) {

    private var state = false
    
    //Toggle Lamp on / off
    fun toggle() {
        state = !state
        if (state) {
            lamp.stateOn()
        } else {
            lamp.stateOff()
        }
    }
}

📝 Complétez le code puis ajoutez une classe "fan" qui pourra être allumé par un bouton. De façon à correspondre à ce diagramme :


Hosted on Sketchviz

Faites fonctionner le ventilateur et la lampe :

val lamp = Lamp()
val lampButton = LampButton(lamp)

val fan = Fan()
val fanButton = FanButton(fan)

lampButton.toggle()
fanButton.toggle()

👉 Question 1

  • Peut-on utiliser la classe Lamp séparément ?
  • A-t-on besoins de quelque chose (de paramètres) pour l'instancier ?

Qu’est-ce qu’une instance ?

Avant tout, pour simplifier notons qu’un “objet de classe” et une “instance de classe” sont une seule est même chose. De plus pour comprendre une instance, il faut savoir ce qu’est une classe.

Les classes permettent en quelque sorte de décrire un concept. Les classes, c’est le “plan” selon lequel on va construire un objet. C’est aussi un peu comme le dictionnaire, la définition d’un mot représente la classe et son utilisation concrète dans une phrase, c’est son instance.

Le programme ne manipule pas des classes, mais des objets. Je pourrais par exemple avoir plusieurs objets lampe : lampeDeBureau et lampeDeChevet.
Ces deux lampes correspondent à la définition de la classe Lamp.


👉 Question 2

  • Peut-on utiliser la classe Fan séparément ?
  • A-t-on besoins de quelque chose  (de paramètres) pour l'instancier ?

👉 Question 3

  • Peut-on utiliser les classes Button séparément (sans la modifier) ?
  • A-t-on besoins de quelque chose (de paramètres) pour l'instancier ?

💡 A ce stade vous avez donc 2 classes FanButton et LampButton en plus de vos classes Fan et Lamp

👉 Question 4

  • Que ce passe-t-il si un nouveau modèle de lampe sort tout juste sur le marché ?
  • Imaginons que désormais une nouvelle lampe sorte sur le marché, cette lampe on l'appellera NeoLamp que faudrait-il faire pour lui ajouter un interrupteur ? (Sans utiliser l'héritage)

👉 Question 5

  • Quel est le problème ?

👉 Question 6

  • Si nous utilisons de l'héritage, en quoi dans l'avenir, et sur un code plus complexe, cela peut-il poser des problèmes ? Ici nous pouvons raisonner en termes de nombre de classes modifiées et importance/complexité des classes.

L'exercice ici est d'acquérir et de démontrer une vision d'ensemble et d'anticipation des futurs points bloquants d'un projet.

Appliquons le principe d'inversion des dépendances

La première étape et de faire de l’abstraction. Et généralement quand on parle d'abstraction on parle d'interface.

Donc a des fins d'« inversion », nous allons utiliser une interface :


Hosted on Sketchviz

💡 Notez une chose intéressante à propos des dépendances par rapport à la lampe et au ventilateur : Nous les avons inversés (Le mot est lâché) !
À présent les flèches partent de (et non plus vers) Lamp et Fan. Les trois classes se rejoignent sur IEquipment.


👉 Question 7

  • À quoi peut ressembler l'interface IEquipment ?

Qu’est-ce qu’une interface ?

Les interfaces servent à définir des contrats. La notion d'interface est utilisée pour représenter des propriétés transverses de classes. Une interface nous dit juste que telle classe possède telle propriété, indépendamment de ce qu'elle représente. Par exemple, nous pourrions ajouter l'interface Inflammable à nos lampe et ventilateur pour définir si oui ou non ces objets sont inflammables.

Attention les interfaces ne doivent pas être confondues avec l'héritage : l'héritage représente un sous-ensemble (exemple : un magicien est un sous-ensemble d'un personnage). Ainsi, une voiture et un personnage n'ont aucune raison d'hériter d'une même classe. Par contre, une voiture et un personnage peuvent tous les deux se déplacer, donc une interface représentant ce point commun pourra être créée.

Correction si vous n'y arrivez pas :

interface IEquipment {
    fun stateOn()
    fun stateOff()
}

👉 Question 8
Implémentez l'interface dans les classes correspondantes.
Dans le constructeur de la classe Button faites en sorte de dépendre d'abstraction (l'interface) plutôt que de l'implémentation (les classes concrètes).

Correction si vous n'y arrivez pas

class Fan : IEquipment {
    override fun stateOn() {
        println("Pfffffffffff")
    }

    override fun stateOff() {
        println("....")
    }
}
class Lamp : IEquipment {
    override fun stateOn() {
        println("Jour")
    }

    override fun stateOff() {
        println("Nuit")
    }
}
class Button(private val equipment: IEquipment) {
    private var state = false
    fun toggle() {
        state = !state
        if (state) {
            equipment.stateOn()
        } else {
            equipment.stateOff()
        }
    }
}

À l’utilisation, voici ce que ça donne :

val lamp = Lamp()
val lampButton = Button(lamp)

val fan = Fan()
val fanButton = Button(fan)

lampButton.toggle()
fanButton.toggle()

L’essentiel est donc de ne pas dépendre de l’implémentation, mais d’une abstraction. Grâce à cela, l’implémentation (les classes Lamp et Fan) peut être remplacée ou si votre projet évolue nous pouvons en ajouter.
Libre à vous de créer des RollerShutter, Hoover, etc tant que ceux-ci implémentent l’interface IEquipment.

Finalement, peu importe vos équipements, le code ne bougera pas.


👉 Question 9

  • Et si finalement je voulais ajouter un autre type de bouton ? Par exemple un qui permet de changer de couleur. Il faudra donc modifier l'interface IEquipement avec la nouvelle méthode changeColor().
  • Sans coder, quel impact cela aurait dans le code pour les lampes ? Et pour le ventilateur ?

Nous n’avons toujours pas entièrement répondu au principe d’inversion des dépendances.

Quel est la définition exacte du principe d’inversion des dépendances ?

La définition est la suivante : Les modules de haut niveau ne devraient pas dépendre des modules de bas niveau. Les 2 devraient dépendre d’abstraction.

  • Les abstractions ne devraient pas dépendre de détails (= de l’implémentation).
  • Les détails (= les implémentations) devraient dépendre d’abstractions.

Ce qui se traduit en code par :

interface IButtonAdapter {
    fun turnOn()
    fun turnOff()
}
class Button(private val adapter: IButtonAdapter) {

    private var state = false
    
    fun toggle() {
        state = !state
        if (state) {
            adapter.turnOn()
        } else {
            adapter.turnOff()
        }
    }
}

Nous avons donc :


Hosted on Sketchviz

Les 2 modules sont maintenant indépendants, les interfaces sont bien définies (et différentes), les tests unitaires sont probablement encore plus simples (on ne mock que le nécessaire).

Cependant, quelque chose ne va pas : ils sont tellement indépendants qu’ils ne savent plus communiquer ensemble ! Nous avons un petit problème 🙂

Il existe une solution pour faire communiquer 2 modules qui « ne parlent pas la même langue ». Cette solution, c'est le pattern Adapter.

Je vous invite à faire une recherche de ce design pattern sur Google.


👉 Question 10

  • À quoi sert ce Design pattern ?
  • En quoi peut-il être utile dans notre cas ?

Ça peut paraitre compliqué, mais voici son code, il fait vraiment office de passerelle entre les 2 modules en implémentant une interface et en utilisant l’autre :

//Implémentation de l'interface 
class ButtonAdapter(private val equipment: IEquipment) : IButtonAdapter {

    override fun turnOn() {
        equipment.stateOn()
    }

    override fun turnOff() {
        equipment.stateOff()
    }
}

À l’utilisation, voici ce que ça donne :

val lampButtonAdapter = ButtonAdapter(Lamp())
val fanButtonAdapter = ButtonAdapter(Fan())

val lampButton = Button(lampButtonAdapter)
val fanButton = Button(fanButtonAdapter)

lampButton.toggle()
fanButton.toggle()

Voilà à quoi cela va ressembler en UML. Vous remarquerez que les flèches partent de ButtonAdapter, les 2 modules ne se connaissent toujours pas :


Hosted on Sketchviz

👉 Question 11

  • En guise de conclusion, pour vous quels sont les avantages de cette façon de faire ?
  • Dans quel cas cela peut-il être utile ?

👉 Bonus

Nous décidons d'ajouter (pour de vrai cette fois) un tout nouveau type d'ampoule et d'interrupteur. Cette lampe sera capable de changer de couleur et s'appellera HueLamp elle sera accompagné d'un nouveau type d'interrupteur ButtonColor. Faites les modifications nécessaires pour que ce code fonctionne :

val hueLamp = HueLamp()

val hueLampButtonColorAdapter = ButtonColorAdapter(hueLamp)
val hueLampButtonColor = ButtonColor(hueLampButtonColorAdapter)
hueLampButtonColor.changeColor("Rouge")
hueLampButtonColor.toggle()

val hueLampButtonAdapter = ButtonAdapter(hueLamp)
val hueLampButton = Button(hueLampButtonAdapter)
hueLampButton.toggle()
hueLampButton.toggle()

Attention ! Votre ancien bouton Button doit lui aussi fonctionner avec la nouvelle lampe.