Inversion des dépendances : la pièce manquante du puzzle.

Cet article fait partie d'une série sur SOLID : les 5 principes fondamentaux de la programmation. Vous pouvez retrouver tous les autres ici.


Je vais passer pour un vieux croûton qui radote, mais en programmation, il est extrêmement important d’abstraire.

Ça permet de créer des programmes robustes, flexibles (principe ouvert/fermé) et stables (principe de substitution de Liskov), et ainsi de limiter la vilaine dette technique.

Mais en y réfléchissant un peu, ou pourrait se demander : est-ce que les bienfaits de ces principes ne sont pas trop surestimés ?

Car ils s'appliquent principalement au sous-typage (héritage, interface...), mais au final, c’est loin d’être le type de relation que vous allez utiliser le plus souvent.

Dans la grande majorité des cas, vos objets auront plutôt une relation de conteneur/contenu, ce que l’on appelle la composition.

Ouais, mais c’est fastoche ça.

Et bien détrompez-vous !

Car même si le concept de composition semble simpliste à première vue, je vous garantis qu’il n’est pas DU TOUT épargné par les mauvaises pratiques.

Tellement pas que beaucoup de développeurs ont rendu leur codebase totalement impossible à modifier sans tout casser, juste pour avoir négligé les bonnes pratiques liées à la composition.

What !? Comment c’est possible ?

Gardez votre calme, je vais tout vous expliquer et je vous garantis qu’une fois cet article finis, vous aurez toutes les clefs en main pour ne jamais vous faire avoir.


Problème

Les fêtes de Noël approchent, donc imaginons une situation parfaitement raisonnable.

Vous avez commandé tous les composants nécessaires pour monter le PC de vos rêves : processeur de fou furieux, de la RAM à en faire rougir les serveurs de Google et une carte graphique pour un résultat plus beau que le réel.

Traduit en POO, votre super PC ressemble à ça.

public class PersonalComputer
{
    public Intel64 Processor { get; set; }
    public NvidiaGtx GraphicCard { get; set; }
    public RAM Memory { get; set; }
}

Quelques années plus tard, les tendances changent, et l’envie vous prend de changer de processeur, et de passer d’une architecture Intel x64 à ARM. Après tout, pourquoi se priver ?

public class PersonalComputer
{
    //public Intel64 Processor { get; set; }
    public ARM Processor { get; set; }
    public NvidiaGtx GraphicCard { get; set; }
    public RAM Memory { get; set; }
}

À priori, aucun souci, n’est-ce pas ? ERREUR !

Car si vous avez bien suivi mes précédents articles, vous remarquerez une belle violation du principe ouvert/fermé (OCP), qui dit qu'il ne faut jamais modifier un module, mais seulement l’étendre.

Or là, en changeant d’architecture de processeur, vous allez avoir de gros soucis de compatibilité avec tous les logiciels qui fonctionnaient uniquement sur une architecture x64.

CallProcessorInstruction(Intel64 processor)
{ 
    //...
}
Avec ce genre de fonction, votre programme ne compilera même plus.

Donc en modifiant un composant de votre ordinateur, vous avez ruiné toutes ses dépendances. Je ne vous félicite pas !

…Ouais, mais attends, comment j’aurais pu faire du coup ?

L’OCP nous dit qu’un module doit toujours être modifié de l’extérieur, et jamais de l’intérieur.

Mais ici, vous ne pouvez rien faire, car votre ordinateur et vos programmes dépendent directement de l’architecture Intel x64. Vous n'allez pas le scotcher au processeur ARM non plus.

Le seul moyen de vous en sortir, c’est de modifier touuuut les programmes pour qu’ils s’adaptent à votre nouveau processeur.

S’il y en a 3 ou 4, bon, en serrant les fesses vous en avez pour 20 minutes. Mais s’il y en a 150, vous avez intérêt à avoir une (voir plusieurs) bonne Thermos de café sous le bras.

CallProcessorInstruction(ARM processor)
{ 
    //...
}
Une de fait, plus que 1789.

D’autant plus que si vous décidez de changer une fois de plus pour passer sur du i386 (drôle d’idée), alors rebelote, vous êtes parti pour modifier les 150 dépendances une fois de plus.

C’est comme pour le principe de substitution de Liskov (LSP).

En pensant programmer correctement, on s’est mis dans une situation où on ne peut rien faire sans déroger à l’OCP, et donc sans prendre le risque de casser tout le programme.

La différence, c’est qu’avec le LSP, il s’agissait d’une erreur de logique vis-à-vis du sous-typage. Mais ici, il n’y a pas d’erreur ! C’est juste de la composition bête et méchante.

Alors, où est-ce que ça a dérapé ?


Solution

Le problème vient du fait que la cohésion entre le type PersonalComputer et Intel64 est trop forte.

Le PC a été construit pour accueillir un processeur x64, et rien d’autre. Dans ces circonstances, on dit que PersonalComputer et Intel64 sont fortement couplés.

Changer cela remettrait en question la manière dont le PC a été imaginé. Et en programmation, c’est rarement une bonne idée.

C’est comme revoir les plans d’une maison une fois qu’elle est entièrement construite.

Genre là, c'est un peu trop tard.

Pour se sortir de cette situation, il faut affaiblir ce couplage afin que PersonalComputer ne dépendent plus directement d’Intel64, mais qu’il puisse quand même l’utiliser.

Et pour faire ça, on va passer par une inversion des dépendances.

Il s’agit d’un principe qui dit que deux modules ne doivent pas avoir de dépendance directe, mais plutôt passer par une couche d’abstraction.

Cela signifie que pour réduire le couplage entre PersonalComputer et Intel64, il faut ajouter un module entre les deux.

Ainsi, l’ordinateur n’aura plus besoin de savoir de quel processeur il s’agit, car toutes les instructions passeront par cette couche d’abstraction. Que vous ayez un processeur ARM, x64 ou i386, vos programmes fonctionneront toujours de la même manière.

C’est un principe tout simple, mais diablement efficace, car il va rendre vos modules :

  • Flexibles, car vous pouvez étendre cette couche d’abstraction sans jamais toucher aux modules en question, et donc sans briser l’OCP.
  • Moins contextuels, car en limitant le couplage des modules, ils deviennent plus indépendants, et donc facilement réutilisables.

Et en plus, ça ne mange pas votre pain, et ça ne vend pas vos données personnelles non plus. C'est tout bénef !

Mais pour bien saisir ce principe, il faut d'abord comprendre les autres principes SOLID. Si ce n'est pas le cas, allez lire le bonus de cet article.


Explication

Le principe d’inversion des dépendances part de deux postulats très simples.

Les modules de haut-niveau ne doivent pas dépendre des modules de bas-niveau. Les deux doivent dépendre d’abstraction.

En gros, cela revient à ajouter une pièce entre les deux pour les faire communiquer tout en préservant leur indépendance.

Les modules de bas niveau (Intel64) vont implémenter cette pièce, tandis que les modules de haut niveau (PersonalComputer) vont l’utiliser via de la composition.

Là où c’est très fort, c’est que l’abstraction peut avoir plusieurs implémentations. Ainsi, tout module utilisant cette couche peut bénéficier de toutes ses implémentations.

Il ne reste plus qu’à choisir la bonne à l’initialisation.

public class PersonalComputer
{
    public ICPU Processor { get; set; }

    public PersonalComputer(ICPU processor)
    {
        Processor = processor;
    }
}

public interface ICPU
{
    //...
}

public class Intel64 : ICPU 
{
    public Intel64() { }
}

public class ARM : ICPU 
{
    public ARM() { }
}
static void Main(string[] args)
{
    PersonalComputer pc = new PersonalComputer(new ARM()); 
}
Program.cs

Les abstractions ne doivent pas dépendre de leurs détails (implémentations), uniquement l’inverse.

C’est une règle d’or qui s’applique partout et tout le temps : classes abstraites, interfaces, types génériques, fonction d’ordre supérieur, et j’en passe.


Une minute ! Mais c’est juste du sous-typage en fait ?

Et oui, c’est EXACTEMENT la même chose que ce qu’on a vu dans les articles précédents, ce qui veut dire que les mêmes règles s’appliquent :

  • SRP : il faut que le module abstrait ne s’occupe que d’une seule chose.
  • OCP : on ne modifie jamais le module abstrait, on ne fait que l’étendre.
  • LSP : on s’assure que toutes les implémentations de ce module effectuent les mêmes changements d’état et les mêmes effets secondaires.

Ce dernier point est extrêmement important, car toutes les opérations nécessitant le type en question (le processeur) passeront par cette abstraction.

Ainsi, toutes les précautions liées au polymorphisme doivent être prises lorsque l’on fait de l’inversion de dépendance. D’où l’intérêt de tester son code à mort, une fois de plus !

public interface ICPU
{
    void ExecuteSomeInstruction();
}

public class Intel64 : ICPU 
{
    public void ExecuteSomeInstruction()
    {
        // Do stuff
    }
}
public class ARM : ICPU 
{
    public void ExecuteSomeInstruction()
    {
        throw new NotImplementedException();
    }
}
Problème, car une des implémentations de ICPU fait planter le programme alors que l’autre fonctionne correctement. Les effets secondaires ne sont pas les mêmes.

Donc non, les principes SOLID sont loin d'être surestimés. C'est plutôt l'inverse en fait.


Bon après, c’est uniquement s’il est pertinent d’en faire.

C’est vrai. Mais souvenez-vous de l’OCP : un module doit toujours être ouvert à l’extension.

Si vous ne faites pas d’inversion des dépendances, vous fermez votre programme à l’extension en forçant le couplage entre les deux modules.

Ce n’est pas forcement faux. Parfois, ce couplage est totalement légitime.

public class PersonalComputer
{
    public Electricity Energy { get; set; }
}

Mais en réalité, cette situation est plutôt rare. La plupart du temps, vous ne pouvez pas être certain qu’une extension n’aura jamais lieu, même quand ça parait logique.

public class PersonalComputer
{
    public SwitchedPowerSupply PSU { get; set; }
}
Erreur ! Car il y a plusieurs types d’alimentation possibles, même si on n'y pense pas forcement lorsqu’on monte un PC.

Donc dans le doute, inversez toujours vos dépendances.

Ouah, c’est méga-chiant ! Ça veut dire que pour chaque composition, je dois créer au moins un module en plus, alors que je suis à peu près sûr de ne jamais m’en servir ?

Ouais, c’est ça.

Mais déjà, souvenez-vous que SI JAMAIS vous avez, au final, réellement besoin de ce module, c’est game over, bonne chance pour changer ça sans rien casser !

Ça va foutre un merdier pas possible dans vos dépendances, et vous êtes bon pour passer votre après-midi à tout débugger. Ça, c’est vraiment méga-chiant.

Ça revient à retirer un bout de bois à la base.

Et ensuite, ce n’est pas forcement chiant à faire, dans le sens ou ces couches d’abstraction peuvent très bien être réutilisées pour autre chose.

Par exemple, vous pouvez recycler ICPU pour faire un inventaire de tous les processeurs que vous avez en rab.

public interface ICPU
{
    string FullName { get; }
    int CoreNumber { get; }
}
ICPU.cs
static void Main(string[] args)
{
    List<ICPU> processors = new List<ICPU>()
    {
        new ARM("Cortex-A78", 4),
        new Intel64("Intel Core i9-9900K", 8),
        new Intel64("Intel Core i7-10700K", 8)
    };

    foreach(ICPU processor in processors)
    {
        Console.Write(processor.FullName + ", ");
    }

    // Output : Cortex-A78, Intel Core i9-9900K, Intel Core i7-10700K
}
J'en connais un qui avait de l'argent à perdre.

De la même manière, vous n’avez pas forcément besoin de la créer de toutes pièces. Parfois, la couche d’abstraction parfaite existe déjà dans votre codebase.

Par exemple, si vous êtes amené à construire un serveur qui possède aussi un processeur, pas besoin d’une nouvelle couche, autant réutiliser ICPU !

public class Server
{
    public ICPU Processor { get; set; }
} 

public class PersonalComputer
{
    public ICPU Processor { get; set; }
}

Et même sans ça, si vous avez déjà une hiérarchie de types existante, vous pouvez très bien la réutiliser pour inverser vos dépendances.

public class Server
{
    public CPU Processor { get; set; }
} 

public class PersonalComputer
{
    public CPU Processor { get; set; }
} 


public abstract class CPU { }

public class Intel64 : CPU { }
public class ARM : CPU { }
public class i386 : CPU { }

Tant que vous pouvez étendre facilement la couche d’abstraction, toutes les solutions sont bonnes.


Application

Pas mal. Et du coup, comment on les fait, ces couches d’abstraction ? Il y a une méthode particulière ?

Pas vraiment. C’est juste du sous-typage classique, et 99% du temps, vous allez vous contenter des deux méthodes suivantes :


Interfaces

Les modules de bas niveaux implémentent les API d’une interface. Les modules de haut niveau utilisent une instance implémentant cette interface.

C’est probablement la manière la plus facile et directe de faire, car votre couche fait simplement office de pont, sans comportement particulier.

public class PersonalComputer
{
    public ICPU Processor { get; set; }

    public PersonalComputer(ICPU processor)
    {
        Processor = processor;
    }
}

public interface ICPU
{
    //...
}

public class Intel64 : ICPU 
{
    public Intel64() { }
}

public class ARM : ICPU 
{
    public ARM() { }
}

Classes abstraites

Les modules de bas-niveau héritent des API d’une classe abstraite. Les modules de haut-niveau utilisent une instance héritant de cette classe abstraite.

Elle complexifie un peu plus le programme dans le sens où vos implémentations sont séparées dans plusieurs modules.

Mais à part ça, c’est une solution parfaitement viable, en particulier si la classe est déjà présente, ou si les implémentations de vos API sont toujours les mêmes (DRY).

public class PersonalComputer
{
    public CPU Processor { get; set; }

    public PersonalComputer(CPU processor)
    {
        Processor = processor;
    }
}

public abstract class CPU
{
    //...
}

public class Intel64 : CPU 
{
    public Intel64() { }
}

public class ARM : CPU 
{
    public ARM() { }
}

Donc maintenant, mon PC utilise un processeur abstrait. Mais à partir de là, comment je fais pour lui dire d’utiliser mon processeur ARM ?

Facile, il suffit d’instancier votre objet dans type de que vous souhaitez en utilisant le polymorphisme.

static void Main(string[] args)
{
    ICPU myProcessor = new Intel64();
}

Dans cette situation, la technique miracle, c’est l’injection de dépendances : vous passez une instance toute faite directement au constructeur.

De cette manière, vous rendez PersonalComputer totalement indépendant du type de processeur qu’il utilise, car le choix se fait à l’extérieur de la classe.

public class PersonalComputer
{
    public ICPU Processor { get; set; }

    public PersonalComputer(ICPU processor)
    {
        Processor = processor;
    }
}
PersonalComputer.cs
static void Main(string[] args)
{
    PersonalComputer myPc = new PersonalComputer(new ARM()); 
    PersonalComputer myGrandmasPc = new PersonalComputer(new I386()); 
}
Program.cs

Voilà pour le principe d’inversion des dépendances. Pas compliqué, n’est-ce pas ?

Et pourtant, il s’agit du principe SOLID avec le meilleur retour sur investissement.

Déjà, parce qu’il est simple à appliquer, mais aussi parce qu’il augmente considérablement la flexibilité d’une codebase et limite drastiquement la dette technique.

Et en plus de ça, il donne une utilité à ces interfaces ~qui ne servent à rien~, que demande le peuple ! ^^

Mais pour l’appliquer correctement, encore faut-il comprendre les autres principes SOLID. Si ce n’est pas encore le cas, allez lire le bonus de cet article.

J’y explique le fonctionnement et l’intérêt de tous ces concepts afin d'écrire des programmes stables, robustes et flexibles, mêmes des années après !


Le principe d’inversion des dépendances (Dependency Inversion Principle) correspond au D de SOLID : un ensemble de 5 concepts fondamentaux pour construire des applications maintenables, pérennes et évolutives.

Vous pouvez retrouver tous mes autres articles sur SOLID ici. Ils ont tous pour but de vous faire découvrir ces principes et leur importance primordiale.

Et pour les gens qui raffolent d’architecture logicielle et de bonnes pratiques, voici d’autres articles qui vont vous intéresser :

Quant à moi, je vous laisse, il faut que j’aille cuisiner un pot-au-feu pour l’anniversaire de mon arrière-grand-mère.

Je vous donne rendez-vous la semaine prochaine pour un nouvel article sur le blog des développeurs ultra-efficaces. Tchao !


Accéder au bonus : Résumé et application des principes SOLID.