Ségrégation des interfaces : codez uniquement le nécessaire.

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


Principe de ségrégation des interfaces : méthode permettant de séparer les responsabilités d’un module en plusieurs interfaces pour éviter les codebases désordonnées.


Problème

Imaginons vous vous êtes offert une toute nouvelle boite à outils magnifique, avec plein de compartiments.

Un objet absolument sublime que vous emmenez absolument partout avec vous : pour faire votre plomberie, réparer votre toiture, retaper votre porte ou aller boire une bière avec des potes.

Dans un langage orienté objet, votre boite ressemblerait à ça :

public class Toolbox
{
    public void Screw() 
    { 
        //...
    }

    public void Unscrew()
    { 
        //...
    }

    public void CutWood()
    { 
        //...
    }

    public void DriveNail()
    {
        //...
    }

    //...
}

Un module qui, ma foi, semble tout à fait légitime. Mais les plus malins d’entre vous auront surement repéré le problème.

C’est totalement une violation du SRP : le principe de responsabilité unique !

Exactement. Votre boite à outils est fourre-tout, et c’est un vrai bordel pour vous organiser :

  • Vous passez plus de temps à chercher votre clef de 12 au lieu de réellement travailler.
  • Votre boite est giga lourde. Vous avez besoin d’un bœuf pour l’amener sur votre lieu de bricolage.
  • Il y a de fortes chances que vous utilisiez le mauvais outil pour accomplir votre tâche. Difficile de choisir la bonne tête de tournevis lorsque vous en avez 40.

En programmation, c’est pareil. Une mauvaise séparation des responsabilités entraine systématiquement une perte de temps et d’énergie pour les développeurs.

Alors, comment s’en sortir ?

Facile, il suffit d’avoir une boite à outils adaptée à chaque besoin.

La réflexion est bonne. En séparent vos responsabilités dans plusieurs boites, tous les outils que vous aurez à disposition seront pertinents pour vos travaux.

Mais un autre problème vient rapidement pointer le bout de son nez : qu’est-ce qu’on fait lorsque plusieurs boites nécessitent le même outil ?

Prenez le fameux tournevis, un outil qui a bâti les civilisations depuis des millénaires. Quoi que vous fassiez, vous avez toujours besoin d’un tournevis.

public class DoorRepairToolbox
{
    public void Screw() 
    { 
        //...
    }

    public void Unscrew()
    { 
        //...
    }

    public void Grease()
    {
        //...
    }
}

public class WoodWorkToolbox
{
    public void Screw() 
    { 
        //...
    }

    public void Unscrew()
    { 
        //...
    }

    public void CutWood()
    { 
        //...
    }

    public void DriveNail()
    {
        //...
    }
}
On duplique salement les méthodes Screw() et Unscrew(), ce qui déroge à la règle de ne jamais se répéter (DRY).

Alors quoi ? On en achète un pour chaque boite ?

Mais non, il faut juste partager le même dans toutes les boites…
public class Toolbox
{
    public void Screw() 
    { 
        //...
    }

    public void Unscrew()
    { 
        //...
    }
}

public class DoorRepairToolbox : Toolbox
{
    public void Grease()
    {
        //...
    }
}

public class WoodWorkToolbox : Toolbox
{
    public void CutWood()
    { 
        //...
    }

    public void DriveNail()
    {
        //...
    }
}

Bien vu, mais sans contexte, c'est difficile de savoir ce que l’on est censé faire avec ce tournevis.

Ben, il suffit d’adapter son utilisation à la situation.

D’accord, mais dans ce cas, ça serait judicieux de forcer l’implémentation des outils dans les boites spécifiques, car leur utilisation est trop dépendante du contexte. On ne peut pas se contenter d’une boite générique qui fonctionne pour tout le monde.

Visser est une chose, mais en fonction du type ou de l’emplacement de la vis, ce n’est pas le même combat.

public abstract class Toolbox
{
    public abstract void Screw();

    public abstract void Unscrew();
}

public class DoorRepairToolbox : Toolbox
{
    public override void Screw()
    {
        // Nécessite un tournevis spécial
    }

    public override void Unscrew()
    {
        // Nécessite un tournevis spécial
    }

    public void Grease()
    {
        //...
    }
}

public class BeerToolbox : Toolbox
{
    public override void Screw()
    {
        // Nécessite un tournevis spécial
    }

    public override void Unscrew()
    {
        // Nécessite un tournevis spécial
    }

    public void Grease()
    {
        //...
    }
}

Cependant, cela pose un nouveau problème : comment fait-on concernant les boites à outils qui n’ont PAS besoin de tournevis ?

Avec cette solution, on est obligé d’implémenter la méthode Srew() dans toutes nos boites à outils, même quand il s’agit d’une boite qui ne sert qu’à entreposer des bières.

Ce phénomène s’appelle la pollution d’interface. Nos modules n’ont pas besoin de ces API, mais sont contraints de les implémenter quand même.

C’est aussi pertinent que de prendre une douche juste avant d’aller courir un marathon.

Bah, ce n’est pas grave, ça ne va pas nous boucher le trou du…

Hop hop hop, pas de grossièretés, bordel.

À priori, ce n’est qu’une petite méthode à ajouter. Il y a juste serrer les fesses et se mettre au travail.

Mais en réalité, c’est tout à fait le genre de petit problème bénin qui finit par pourrir la vie des développeurs.


Perte de temps

Implémenter ce genre de méthode n’est ni plus ni moins qu’une immonde perte de temps. Dans un business, chaque seconde passée sur cette connerie, c’est de l’argent qui ne rentre pas en caisse.

Mais ce n’est pas tout. Il faut aussi compter le temps que perdent les développeurs en essayant d’utiliser cette méthode, avant de se rendre compte qu’elle n’est, au final, pas implémentée.

public class BeerToolbox : Toolbox
{
    public override void Screw()
    {
        throw new NotImplementedException();
    }

    public override void Unscrew()
    {
        throw new NotImplementedException();
    }
}
static void Main(string[] args)
{
    BeerToolbox toolbox = new BeerToolbox();
    toolbox.Screw(); // balance NotImplementedException
}
Même si ces méthodes ne font que balancer une exception, elles sont quand même utilisables. Donc il y a de fortes chances qu’un développeur se fasse avoir par mégarde.

On a tendance à l’oublier, mais le temps de modification, de compilation, de tests, de réalisation, de recherche du problème et de correction, même si ça dure 2 minutes, c’est toujours ça de perdu.


Perte de confort

Les modules deviennent inutilement lourds, les développeurs n’y comprennent rien et ne savent donc jamais quelles méthodes utiliser.

public class BeerToolbox : Toolbox
{
    public void DrinkBeer()
    {
        //...
    }

    public void WatchFootball()
    {
        //...
    }

    public override void Screw()
    {
        throw new NotImplementedException();
    }

    public override void Unscrew()
    {
        throw new NotImplementedException();
    }

    public override void DriveNail()
    {
        throw new NotImplementedException();
    }

    public override void CookPizza()
    {
        throw new NotImplementedException();
    }
}
Les deux seules méthodes requises sont DrinkBeer() et WatchFootball(). Le reste n’a rien à foutre là.

Vous reconnaissez les symptômes ? On viole le SRP une fois de plus, c’est-à-dire le problème que l’on souhaitait résoudre au départ. Ça valait bien le coup de se casser la tête.


Perte de stabilité

Souvenez-vous du principe de substitution de Liskov : « toutes les implémentations d’une même méthode doivent avoir les mêmes effets secondaires ».

Mais ici, c’est impossible de respecter ce principe, puisqu’il n’y a pas le contexte nécessaire pour que les effets secondaires soient les mêmes. Un tournevis ne peut rien dévisser s’il n’y a pas de vis.

Donc on se retrouve systématiquement des modules qui violent le LSP. Ça commence à faire beaucoup…


Pour résumer :

  • Si l’on met tous nos outils dans une seule boite, elle devient trop lourde et compliquée à utiliser (violation du SRP).
  • Si l’on se fait une boite par tâche isolée de toutes les autres, on fait beaucoup de duplication et l’on se retrouve avec 3 fois plus d’outils qu’à l’origine (violation du DRY).
  • Si l’on fait des boites spécifiques en gardant certains outils en communs, il faudra faire face aux cas où ces outils ne sont pas nécessaires (violation du SRP et du LSP).
Certaines implémentations de modules ne fonctionnent pas aussi bien qu’on le voudrait.

Donc à priori, on est coincé.

Mais attendez avant de mettre votre boite à outils sur Leboncoin, car il reste un tour dans mon sac qui va totalement débloquer cette situation.


Solution

Séparer les boites tout en ayant des outils en commun est de loin la meilleure idée, mais il manque une pièce au puzzle.

Le vrai problème, c’est que la cohésion entre les modules parents et enfants est trop forte.

Chaque boite héritant de Toolbox va assimiler toutes ses API, même lorsqu’elle n’en a pas besoin. On dit que les deux modules sont fortement couplés.

Une meilleure solution serait que chaque boite hérite des API dont elle a besoin, et laisse les autres de côté. Cela permettrait d’avoir une boite à outils toujours parfaitement adaptée à chaque situation, sans pour autant dupliquer son contenu.

Et c’est pour cela que l’on a besoin du principe de ségrégation des interfaces.

Au lieu de mettre toutes les API dans un seul module, on va les répartir dans différentes interfaces qui vont ensuite être implémentées par les modules d’en dessous.

Ces interfaces font office de pont entre un type et ses sous-types pour réduire le couplage entre les deux.

Ainsi, les sous-types peuvent littéralement choisir les API qu’ils vont utiliser, en laissant les autres sur le banc de touche.

C’est un principe qui est ridiculement simple à comprendre et à implémenter, mais qui apporte très gros à une codebase :

  • Le code est plus léger, ne se répète pas et possède une meilleure séparation des responsabilités, ce qui améliore grandement le confort de développement.
  • Cela limite le nombre d’implémentations d’une même interface, et donc limite le risque de violer le LSP. Et oui, vous avez moins de chances d’avoir un accident de voiture si vous ne prenez jamais la voiture.
  • Vous pouvez étendre vos modules sans même les modifier, car vous avez juste à ajouter de nouvelles interfaces.

Autrement dit, le principe de ségrégation des interfaces est le bout de colle qui raccorde tous les principes SOLID entre eux. Rien que pour ça, j’achète !

Mais pour que ça soit pertinent, encore faut-il maitriser les autres principes. Pour ça, allez lire mon résumé des principes SOLID où je détaille leur fonctionnement et leur utilisation en situation réelle.


Explication

Le principe de ségrégation des interfaces dit qu’un client (=module) ne devrait pas être forcé de dépendre de méthodes (=API) qu’il n’utilise pas.

La philosophie de ce principe est donc de limiter le nombre d’API d’un module à son strict minimum. Il ne doit pas faire plus que ce qu’il a à faire.

C’est-à-dire qu’il faut mettre en place un système d’API à la demande où chaque module pourra utiliser ce qu’il souhaite.

Cela revient purement et simplement à choisir ses bons outils avant de partir en mission bricolage.

Et coup de chance, les interfaces sont parfaites pour cela.

public abstract class Toolbox { }

public interface IScrewdriver
{
    void Screw();
    void Unscrew();
}

public interface IHammer
{
    void DriveNail();
}

public interface IOil
{
    void Grease();
}

public interface ISaw
{
    void CutWood();
}

public class DoorRepairToolbox : IScrewdriver, IOil
{
    public void Screw() { }
    public void Unscrew() { }
    public void Grease() { }
}

public class WoodWorkToolbox : IScrewdriver, IHammer, ISaw
{
    public void Screw() { }
    public void Unscrew() { }
    public void CutWood() { }
    public void DriveNail() { }
}
Mais pourquoi des interfaces ? Pourquoi pas de l’héritage traditionnel ?

Parce que dans la plupart des langages de programmation orientée objet, l’héritage multiple n’existe pas. Une classe peut avoir plusieurs enfants, mais qu’un seul parent.

La raison, c’est que si un module hérite deux fois de la même API, le compilateur ne saura pas quelle implémentation choisir. Les développeurs ont appelé ça le diamant mortel de la mort.

En revanche, avec les interfaces, c’est possible, car elles n’ont pas d’implémentation, tout ce passe dans le module qui l’implémente. C’est pourquoi elles sont au centre de ce principe.

Par exemple : si vous devez changer une chambre à air de vélo, vous allez avoir une boite à outils dans ce style.

public class AirChamberToolbox : ITireLever, IPump, IAirChamber
{
    //...
}
Au final, ça ressemble énormément au principe d’inversion des dépendances (DIP).

C’est normal, car dans les deux cas, on réduit le couplage entre deux modules en plaçant une interface au milieu.

La grande différence, c’est que le DIP s’applique dans un contexte de composition, tandis que le principe de ségrégation des interfaces s’applique dans un contexte de sous-typage.

Mais ça ne veut pas dire que les deux ne sont pas complémentaires ! Regardez plutôt.

public interface IScrewdriver
{
    void Screw();
    void Unscrew();
}

public class WoodWorkToolbox : IScrewdriver, IHammer, ISaw
{
    public void Screw() { }
    public void Unscrew() { }
    public void CutWood() { }
    public void DriveNail() { }
}

public class BobTheBuilder
{
    public IScrewdriver SuperScrewdriver { get; set; }

    public BobTheBuilder(IScrewdriver srewdriver)
    {
        SuperScrewdriver = srewdriver;
    }
}
static void Main(string[] args)
{
    BobTheBuilder bob = new BobTheBuilder(
        new WoodWorkToolbox()
    );
}
C’est comme si Bob prenait le tournevis de la boite de menuisier.

Quand je vous disais que ce principe permet de renforcer tous les autres, ce n’était pas des conneries !

Mais du coup, est-ce qu’il faut faire ça pour chaque sous-type ? Ça va vite être relou.

Je vais moins sévère que je l’ai été dans mon article sur le DIP, car à la différence de celui-ci, une violation du principe de ségrégation des interfaces ne va pas systématiquement ruiner votre codebase.

CEPENDANT !

Il ne faut pas oublier que coupler vos modules est une mauvaise pratique, car cela entraine toujours une perte de flexibilité de la codebase.

Si vraiment vous hésitez, posez-vous les questions suivantes :

  • Est-ce que mon module a besoin de toutes les méthodes qu’il implémente ? Sinon, une ségrégation des interfaces est obligatoire.
  • Est-ce que mon module hérite de méthodes virtuelles ? Si oui, danger ! C’est généralement un signe avant-coureur qu’une ségrégation des interfaces est nécessaire.
  • Est-ce que mon module implémente beaucoup d’API ? Si c’est le cas, considérez une ségrégation des interfaces aussi, car ce n’est qu’une question de temps avant que les problèmes pointent le bout de leur nez.

De plus, vous faites une pierre à deux coups, car le principe de ségrégation des interfaces permet de mieux implémenter les autres principes SOLID, notamment l’OCP qui est de loin le plus difficile à appliquer à 100%.

Donc il n’est pas du tout choquant d’en abuser, bien au contraire !


Application

Comme pour le DIP, il n’y a pas de stratégie particulière pour implémenter le principe de ségrégation des interfaces. Le processus est assez basique, et c’est le même à chaque fois.

La première chose à faire, c’est bien sûr de définir toutes les API qui vont être transmises aux enfants, pour ensuite pouvoir mieux les grouper dans les interfaces.

Pour faire ça, ma technique secrète est de séparer les API par responsabilités. En pensant les interfaces de cette manière, les modules finaux ne sont plus que des éléments pouvant assurer ces responsabilités.

Par exemple : de quoi a besoin une boite à outils de menuisier ?

  • D’une scie pour couper du bois.
  • D’un marteau avec des clous.
  • D’un tournevis avec des vis.

Une fois que l’on a mené cette réflexion à bien, il devient très facile de savoir quelles API sont nécessaires pour chaque module, et ce dont ils n’ont pas besoin. C’est aussi simple que ça.

public class WoodWorkToolbox : IScrewdriver, IHammer, ISaw
{
    public void Screw() { }
    public void Unscrew() { }
    public void CutWood() { }
    public void DriveNail() { }
}
Aiya ! N’est-ce pas là une violation du SRP ?

Pas spécialement. Une boite à outils de menuisier a la responsabilité de travailler avec du bois. Et pour cela, elle va utiliser d’autres responsabilités qui sont définies dans les interfaces.

Les définitions sont séparées, et les implémentations sont cohérentes entre elles. Le SRP est donc préservé, petit malin 😜

Si vous raisonnez tout le temps de la sorte, vous devriez n’avoir absolument aucun problème pour créer vos interfaces.

Attention cependant à trouver le bon angle d’attaque, car si vous séparez vos outils en fonction de leur poids ou de leur couleur, ça ne va pas le faire.

public interface IHeavyTool 
{ 
    // Pelle
    void Dig();

    // Pioche
    void BreakStone();

    // Hache
    void CutWood();
}

public class LumberjackToolbox : IHeavyTool
{
    public void CutWood()
    {
        //...
    }

    public void Dig()
    {
        throw new NotImplementedException();
    }

    public void BreakStone()
    {
        throw new NotImplementedException();
    }
}
L’angle d’approche n’est pas bon, car le module final possède des méthodes dont il n’a pas besoin. Il aurait été plus intelligent de faire 3 interfaces IAxe, IPickaxe et IShovel.

C’est comme choisir une voiture pour sa vitesse au lieu de la choisir pour sa fiabilité ou sa sécurité (sans offense 😛).

Ouais, mais attends. Si l’on veut appliquer ce principe à fond, il faudrait faire une interface par méthode en fait…

Il est vrai que certaines de vos interfaces ne vont contenir qu’une seule méthode, car elle est en mesure de définir une responsabilité complète à elle toute seule. C’est tout à fait normal et rationnel.

Cependant, pousser le vice au point de séparer chaque méthode est une mauvaise idée, car certaines n’ont de sens que lorsqu’elles sont ensemble.

Par exemple : les méthodes Screw() et Unscrew() décrivent la responsabilité d’un tournevis, donc il n’est pas aberrant de les mettre dans la même interface.

Ouais, mais si le type a uniquement besoin de visser, on implémente la méthode Unscrew() pour rien, non ?

Je comprends bien votre logique, cependant, il ne faut pas pousser mémé dans les orties non plus.

Dans ce cas précis, il est tout de même cohérent de les mettre dans la même interface, car :

  • Leur implémentation est très similaire. Si vous avez un BAC+5 en vissage, vous savez aussi ~théoriquement~ aussi dévisser.
  • Ces deux actions sont tellement corrélées qu’il est juste impensable qu’elles soient séparées.

C’est typiquement le genre de situation où il ne faut pas se contenter de l’instant présent et penser au futur, car il y a fort à parier que le mec finira, un jour ou l’autre, par dévisser quelque chose.

En revanche, il est ~relativement~ improbable qu’il se fasse cuire une pizza en pleine session bricolage, donc pas besoin d’implémenter cette méthode-là.

Image de freepik.com, juste pour vous ouvrir l’appétit 😉

L’architecture logicielle n’est pas une science exacte, et le bon sens sera votre meilleur ami dans cette aventure.

Mais honnêtement, ce n’est pas très compliqué. Si vous suivez les conseils de cet article et que vous prenez le temps d’y réfléchir sérieusement, vous ne devriez jamais avoir de problème.


Le principe de ségrégation des interfaces fait partie de ces principes qui, comme le DIP, sont simples à mettre en place, mais augmentent considérablement la flexibilité et la robustesse d’une codebase.

Il est d’autant plus intéressant qu’il vient consolider tous les autres principes SOLID :

  • SRP : séparation des responsabilités spécifiques.
  • OCP : permets l’ajout d’API sans modifier de modules existants.
  • LSP : moins de méthodes à implémenter, donc moins de risque de causer des instabilités à cause de mauvaises implémentations (CQFD).
  • DIP : les interfaces peuvent être réutilisées pour composer d’autres classes.

Mais pour vraiment comprendre la relation entre tous ces principes, allez lire le bonus de cet article qui résume tous les points clefs de chacun et leur complémentarité.


Le principe de ségrégation des interfaces (Interface Segregation Principle) correspond au I 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, j’ai un délicieux plat de fugu qui m’attend à Kyoto.

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.