S'améliorer sur sa pratique des langages objet

Les Principes SOLID

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.

Dependency Inversion Principle

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.


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 pour l'instancier ?

Qu’est-ce qu’une instance ?

Avant tout, pour simplifier notons qu’un “objet de classe” est une “instance de classe”. 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 pour l'instancier ?

👉 Question 3

  • Peut-on utiliser les classes Button séparément ?
  • A-t-on besoins de quelque chose pour l'instancier ?

👉 Question 4

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

👉 Question 5

  • Quel est le problème ?
  • Quels autres solutions peut-on envisager ?

👉 Question 7

  • 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 8

  • À 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 9
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 10

  • Et si finalement je voulais ajouter un autre type de bouton ? Par exemple un interrupteur à réglage variable.
  • Quel impact cela aurait dans le code ?

👉 Question 11
Admettons que par la suite, en plus d'avoir ce nouveau type de bouton, je dois modifier la classe IEquipement car finalement je veux ajouter un autre type de comportement.

  • Quel sont les impacts dans le code ?
  • Quel est le problème ?

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 12

  • À 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 13

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

Commentaires

Connectez vous ou devenez un membre de Async pour rejoindre la conversation.
Entrez un mail ici pour recevoir un lien de connexion, super simple ⚡️