Pourquoi toujours faire UNE SEULE chose ?

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


Dans les bonnes pratiques informatiques, il y a une phrase légendaire qui dit : “Fais une seule chose, mais fais-la bien”.

Cela signifie que chaque module d’un programme (type, fonction, classe…) ne doit s’occuper que d’une seule tâche. Autrement dit, une seule responsabilité.

C’est logique. Quand on achète un four, on s’attend à s’en servir pour cuire des aliments, et non pas pour faire des milk-shakes.

Après, ça pourrait être pratique d’avoir un four multifonction, alors pourquoi pas des modules tout terrains ?

Grave erreur ! Même si c’est tentant au premier abord, il s’agit d’une mauvaise pratique qui peut totalement ruiner un projet, comme le premier clou dans le cercueil de votre codebase. Ça devrait être illégal !

Mais alors, pourquoi c’est aussi problématique ? Et comment faire pour y remédier ? Je vous explique tout dans cet article.

Besoin d'un résumé des principes SOLID et leur application concrète ? Allez lire le bonus de cet article.

Problème

Prenons la question à l’envers : pourquoi est-ce qu’on irait faire des modules à plusieurs responsabilités alors que c’est une mauvaise pratique ?

Parce que c’est facile de programmer de la sorte.

Ajouter des responsabilités à un module est généralement la manière la plus simple et rapide de résoudre un problème ou d’ajouter une fonctionnalité sans avoir se casser la tête.

Après tout, si c’était possible de faire des codebases propres en mettant tout le code dans la fonction main(), on le ferait tous. Manque de bol, aux dernières nouvelles, ça n’est pas le cas.

C’est récurent chez les personnes qui codent à l’instinct, en prenant tous les raccourcis possibles sans réfléchir à l’impact de leur code dégueulasse sur le long terme.

Mais ne vous y méprenez pas, même les développeurs expérimentés se font avoir tellement c’est tentant de choisir la solution de facilité.

C’est une erreur que l’on est tous à même de faire en période de stress (la fameuse deadline qui approche), de fatigue, ou quand on n’est pas concentré.

Sauf qu’en programmation, un raccourci tout bête prit en mai peut vous faire perdre des heures (jours?) de travail en novembre.


Bah, t’exagères, quelques modules avec 2 ou 3 responsabilités, ce n’est pas la mort, tant que ce n’est pas systématique.

Et bien si, justement !

Car il y a un effet pervers lorsque l’on déroge à ce principe : plus un module possède de responsabilités, moins il est compatible avec les autres.

Du coup, impossible de l’associer avec d’autres modules, et le seul moyen de l’étendre, c’est de le modifier, et donc de lui ajouter encore plus de responsabilités, ce qui le rend encore moins compatible, etc. Un bon cercle vicieux des familles.

Ainsi, en partant d’un simple four à milk-shakes, vous pouvez vous retrouver avec un four/micro-ondes/frigo qui fait du café, des milk-shakes, des cupcakes, récite des berceuses et mine du bitcoin.

Dans l’idée, pourquoi pas. Mais dans les faits, ça va rendre le code ultra-lourd, totalement impossible à tester, un enfer sur terre à débugger, avec une lisibilité proche de celle des hiéroglyphes.


Pour avoir déjà travaillé sur des vieux logiciels hérités, j’ai connu cette situation un paquet de fois. Voici une des plus croustillantes.

Je devais ajouter une fonctionnalité permettant d’imprimer un document. Pour cela, il fallait sélectionner le type de document à imprimer dans une liste déroulante et cliquer sur le bouton “Imprimer”. Facile.

Sauf que la personne qui avait fait la génération de tous les autres types de document s’était contenté de mettre tout le code de tous les documents dans l’événement “click” du bouton.

public void GenerateDocumentsButton_ClickEvent(object sender, EventArgs e)
{
    // Initialision des trucs
    string serialNumber = GetSerialNumber();
    object stuff = GetStuffFromDatabase();
    object otherStuff = GetOtherStuffFromDatabase();
    object evenMoreStuff = GetEvenMoreStuffFromDatabase();
    //...

    // Generation des documents
    switch(DocumentCombobox.Text) 
    {
        case "RapportDeMachin":
            // Génération
        case "DossierDeTrucs":
            // Géneration
        case "ManuelDeBidule":
            //Géneration
        //...
    }
}
Version simplifiée. En réalité, le code faisait 1200 lignes, utilisait très peu de fonctions et beaucoup de duplications.

Le résultat ? Une belle merde d’un millier de lignes de code complètement illisibles.

En plus, l’optimisation était pourrie, car toutes les données de tous les documents étaient récupérées et initialisées à chaque appel, mais utilisées que sur certains d’entre eux.

Naïvement, je me suis dit que j’allais juste me contenter de faire mon petit module à coté, et le relier à cette aberration. Mais n’oubliez pas, plus un module possède de responsabilités, moins il devient compatible avec les autres.

Et comme j’étais déjà en retard à cause de la quantité obscène de dette technique sur ce projet, je me suis contenté de rentrer dans le moule, et j’ai ajouté 100 pathétiques lignes à cette fonction.

Autant vous dire que j’ai passé un excellent moment, sans exagérer, ça m’a dégoutté de la programmation pendant des jours (avec d’autres trucs).


Solution

Pour éviter ce genre d’abomination, la solution est aussi simple que radicale : faire UNIQUEMENT des modules avec une seule responsabilité, et les faire communiquer pour créer des comportements complexes.

C’est ce que l’on appelle le principe de responsabilité unique : une véritable règle d’or qui ne doit jamais faire office de dérogation.

J’en avais déjà parlé dans mon article sur la séparation UI/Rules dans un contexte plus précis. Mais c’est un principe qui s’applique dans absolument n’importe quelle circonstance, sans exception.

Contre-exemple :

public void InsertOrUpdateProduct(Product product)
{
    int productCountById = FetchData($"SELECT COUNT(Id) FROM Product WHERE Id = {product.Id}");
    if(productCountById >= 1) 
    {
        // Modification de l'enregistrement
    }
    else 
    {
        // Insertion de l'enregistrement
    }
}
Tout est mis dans la même fonction, ce qui va rendre le résultat final très lourd et peu extensible.

Le même code, mais en applicant le principe de responsabilité unique :

public bool ProductExists(int id)
{
    return FetchData($"SELECT COUNT(Id) FROM Product WHERE Id = {id}") >= 1;
}

public Product InsertProduct(Product product)
{
    try
    {
        // Insertion de l'enregistrement
        // Retourne le produit inséré
    }
    catch (Exception e)
    {
        // Traitements correctifs
        throw e;
    }
}

public Product UpdateProduct(Product product)
{
    try
    {
        // Modification de l'enregistrement
        // Retourne le produit inséré
    }
    catch (Exception e)
    {
        // Traitements correctifs
        throw e;
    }
}

public void InsertOrUpdateProduct(Product product)
{
    if(ProductExists(product.Id))
    {
        UpdateProduct(product);
    }
    else
    {
        InsertProduct(product);
    }
}
Comme chaque fonction est plus légère, j’ai facilement pu améliorer le code de base en ajoutant un retour de l’enregistrement modifié et un meilleur traitement des erreurs.
Tu sur-vends un peu le truc, ce n’est pas non plus un remède à tous les maux...

Bien sûr, le mauvais code ne va pas disparaître comme par magie grâce à une seule règle (si seulement…).

Cependant, ce principe va corriger tellement de problèmes que vous pouvez sincèrement le considérer comme un remède miracle.

Appliqué correctement, il permettra de rendre votre codebase :


Plus compréhensible

Chaque responsabilité est rangée à sa place et se distingue des autres. Vous allez trouver immédiatement les modules que vous cherchez sans même avoir à lire le code. Et comme ils sont plus légers, ils sont plus faciles à lire dans tous les cas.

Vous pourrez facilement parcourir votre code avec ce genre d'outils.

Imaginez à quoi pourrait ressembler l’interface du four multifonction (brrr…). Pour un four normal, 2 ou 3 boutons suffisent, et vous pouvez l’utiliser facilement sans même lire le manuel (=la documentation).


Plus robuste

En isolant un maximum vos responsabilités, vous vous assurez qu’elles ne vont pas aller foutre le boston chez les autres à chaque modification.

Il n’y a rien de plus frustrant que de corriger un bug pour en voir apparaître un autre à l’autre bout de l’application.

Plus les modules sont séparés, moins ils ont de chance d'être impactés par des changements.

Je vous laisse imaginer le bordel si votre four multifonction commençait à vous sortir des milk-shakes au ketchup, alors que vous avez simplement augmenté sa température de cuisson maximale...


Plus maintenable

Déjà, parce que votre code sera plus lisible et moins susceptible de bugger. Mais en plus, cela vous permet de tracer les bugs et de remonter vers le module défectueux beaucoup plus facilement, sans passer par un labyrinthe de fonctions moisies.

Bon exemple.
TRÈS mauvais exemple.

Si vous utilisez une cafetière et un four séparément, vous pouvez être certain que si elle se bouche, le problème ne vient pas du four.


Plus séparée du reste

Cela permet d’étendre plus facilement vos modules existants.

Si vous voulez faire des jus de fruits, il est plus pratique (et logique) d’acheter un extracteur de jus à côté, plutôt que de monter un nouveau module sur votre four multifonction.


Plus facile à tester

Quand un module ne sort qu’un seul résultat, il devient très facile à tester unitairement, car vous avez juste à passer une valeur d’entrée et comparer la valeur de sortie avec ce que vous avez prévu.

Mais quand un module fait plusieurs choses, le nombre de tests augmente de manière exponentielle si on veut couvrir tous les cas possible. Donc plus de temps perdu sur les tests unitaires, quand ceux-ci ne sont pas totalement discrédités.

Tests = Cas de test^Responsabilites

Par exemple, il y a des chances que le frigo intégré au four multifonction fausse les résultats du réchauffement des aliments.


Plus facile à documenter

Chaque module doit pouvoir être résumé de la manière suivante : prends tels paramètres d’entrée, fait tel traitement et sort tel résultat.

/// <summary>
/// Cuit un plat.
/// </summary>
/// <param name="dish">Le plat à cuire</param>
/// <return></return>
public void CookDish(Dish dish) 
{ 
    dish.IsCooked = true;
}

Si vous regardez le manuel d’un four, cela devrait être assez être relativement simple à avaler. Maintenant, si vous voulez lire le manuel du four multifonction, autant se taper l’intégrale du Seigneur des Anneaux...


Voilà pourquoi ce principe est indispensable : parce qu’il va considérablement augmenter la qualité d’une codebase, tout en étant utilisable partout, et sans complexifier énormément la tâche du développeur. C’est free-win.

Maintenant, il ne reste plus qu’à l’appliquer !

Je peux voir la goutte de sueur sur le front de certains, ne vous inquiétez pas, c’est relativement facile une fois que l’on a le principe en tête.


Application

Créer les modules

Oui, je sais, merci Captain Obvious. Mais en vrai, ce n’est que du bon sens.

Pour chaque module, il suffit de se poser une question toute simple : qu’est-ce que ça fait ?

L’astuce, c’est d’arriver à résumer la réponse en une phrase, sans utiliser les mots et, ou, les actions simultanées ou les listes de traitements…

Quelques contre-exemples et leur solution :

  • Calculer la somme des nombres d’un tableau et l’afficher sur une interface graphique. ➔ On récupère le résultat dans une autre fonction qui s’occupera de l’affichage dans l’UI.
private int[] Sales;

public int Sum(params int[] numbers)
{
    int res = 0;
    foreach(int number in numbers)
    {
        res += number;
    }
    return res;
}

public void WindowInit()
{
    TotalSales_TextBox.Text = Sum(Sales).ToString();
}
  • Créer une nouvelle sauvegarde. En cas de problème, afficher un message d’erreur. ➔ On balance une exception qui sera gérée dans une autre fonction.
public void WriteDataInSaveFile()
{
    try 
    {
        // Sauvegarde de données dans un fichier
    }
    catch(Exception e)
    {
        throw e;
    }
}

public void Save()
{
    try 
    {
        WriteDataInSaveFile();
    }
    catch (Exception e)
    {
        DisplayErrorMessageBox(e);
    }
}
  • Insérer ou mettre un jour un produit dans la base de données ➔ On fait deux fonctions, et on prend la décision dans une troisième.
public bool ProductExists(int id)
{
    return FetchData($"SELECT COUNT(Id) FROM Product WHERE Id = {id}") >= 1;
}

public Product InsertProduct(Product product)
{
    try
    {
        // Insertion de l'enregistrement
        // Retourne le produit inséré

    }
    catch (Exception e)
    {
        // Traitements correctifs
        throw e;
    }
}

public Product UpdateProduct(Product product)
{
    try
    {
        // Modification de l'enregistrement
        // Retourne le produit inséré

    }
    catch (Exception e)
    {
        // Traitements correctifs
        throw e;
    }
}

public void InsertOrUpdateProduct(Product product)
{
    if(ProductExists(product.Id))
    {
        UpdateProduct(product);
    }
    else
    {
        InsertProduct(product);
    }
}
  • Chauffer un plat tout en minant du bitcoin. ➔ Dégage de là.

Je ne vous cache pas que dans certaines situations, il va falloir se creuser les méninges. Heureusement, il existe des stratégies qui vont permettre de vous simplifier la vie tout en écrivant de l’excellent code.

Le Test Driven Development (TDD) est de loin la plus efficace. Il s’agit d’une méthode consistant à écrire les tests unitaires d’un module avant même de le créer.

C’est une manière très bizarre de programmer, mais elle est extrêmement efficace et utilisée par beaucoup de professionnels.

Grâce à ça, vous vous assurez que le module ne s’occupera que d’une seule chose : celle qui lui permettra de valider le test. Par conséquent, vous forcez le principe de responsabilité unique.

C’est loin d’être le seul avantage du TDD, mais on aura l’occasion d’en reparler dans un futur article.

public void CookDish_Test()
{
    Dish dish = new Dish();
    Oven oven = new Oven();

    dish = oven.CookDish(dish);

    Assert.AreEqual(true, dish.IsCooked);
}
Première étape : écrire un test unitaire qui échoue (forcement).
class Oven 
{
    public void CookDish(Dish dish)
    {
        dish.IsCooked = true;
    }
}
Seconde étape : implémenter le module.

Dans une moindre mesure, vous pouvez utiliser des commentaires pour arriver au même résultat.

Vous commencez par poser un commentaire détaillant le comportement du module : paramètres, traitement et valeur de retour. Et seulement après, vous le créez.

Contrairement au TDD, vous ne forcez pas le principe de responsabilité unique, mais cela vous donne une ligne directrice solide qui vous permettra de ne pas partir dans tous les sens durant l’implémentation.

/// <summary>
/// Cuit un plat.
/// </summary>
/// <param name="dish">Le plat à cuire</param>
/// <return></return>
Première étape : écrire un commentaire dans le vide.
/// <summary>
/// Cuit un plat.
/// </summary>
/// <param name="dish">Le plat à cuire</param>
/// <return></return>
public void CookDish(Dish dish) 
{ 
    dish.IsCooked = true;
}
Seconde étape : implémenter le module.

Créer une cohésion entre les modules

Créer des p’tit modules dans son coin, c’est mignon, mais ça de produit rien de concret. Pour réaliser des comportements complexes, il va falloir qu’ils communiquent entre eux.

Une manière de faire très classique est la composition : écrire des modules qui vont appeler d’autres modules. C’est un principe universel, applicable en POO comme en PF.

class Pizza
{
    public Dough PizzaDough { get; set; }
    public Sauce Base { get; set; }
    public List<Ingredient> Ingredients { get; set; }
}
Composition de types.
public Dish MakeDish()
{
    return PrepareTools()
        .AddIngredients()
        .Mix()
        .Cook()
        .Wait()
        .Present()
    ;
}
Composition de fonctions.
Mais du coup, il y a une incohérence. Ces modules-là, ils ont bien plusieurs responsabilités, non ?

Pas tout à fait.

Reprenons l’exemple du four. On veut faire une méthode qui permet de cuire un aliment.

C’est une seule responsabilité, on est d’accord. Mais celle-ci est le résultat de la composition de plusieurs autres responsabilités, telles que :

  • Ouvrir la porte.
  • Allumer la lumière.
  • Activer le ventilateur.
  • Faire tourner la plaque.
  • Faire chauffer les résistances.
  • Faire un ding quand c’est terminé.

Chacune de ces responsabilités va avoir le droit à sa propre fonction, et la fonction finale va venir les assembler en un tout-en-un.

C’est aussi ce qu’il se passe avec les classes, qui sont des modules composés d’autres modules (propriétés et méthodes).

class Oven 
{
    public Dish CurrentCookingDish { get; set; };

    public void PutDishIntoOven(Dish dish) { 
        CurrentCookingDish = dish;
        // Autres trucs...
    }

    public void OpenDoor() { /*...*/ }
    public void LightUp() { /*...*/ }
    public void StartBlower() { /*...*/ }
    public void StartPlateRotation(int speed) { /*...*/ }
    public void HeatResisors(int maxTemperature) { /*...*/ }
    public async Task<T> HeatUpForAWhile(int time) { /*...*/ }
    public void NotifieWithSoundWhenFinished() { /*...*/ }

    public void CookDish(Dish dish, int plateRotationSpeed, int maxTemperature, int heatingTime) 
    {
        // Préparation
        PutDishIntoOven(dish);
        .LightUp()
        .StartBlower()
        .StartPlateRotation(plateRotationSpeed)
        .HeatResisors(maxTemperature);

        // Chauffage
        HeatUpForAWhile(heatingTime)
        .ContinueWith(
            // Une fois que le chauffage est terminé
            (task, result) => NotifieWithSoundWhenFinished()
        )
    }
}
Et encore, ici, j'aurais pu mettre toute la préparation dans une autre fonction.

Cependant, il est vrai qu’il est facile de donner plusieurs responsabilités à un module en pensant n’en donner qu’une seule (et j’en suis le premier coupable). D’où l’intérêt de bien designer son application, de faire du réusinage et des revues de code régulièrement.

Et même si ça parait long et rébarbatif, ça n’est rien à côté du temps passé à la maintenance d’une application complètement ruinées avec des fonctions à 3000 lignes de code.


En conclusion, je n’ai pas grand-chose à vous dire à part de vous rappeler de respecter ce principe partout et tout le temps.

Si vous avez peu d’expérience, ça peut sembler intimident et peu utile, surtout si vous travaillez uniquement sur des petits projets de moins de 2 mois à temps plein. Mais plus on pratique, plus ça devient naturel, et plus on en apprécie la saveur.

C’est un incontournable, une règle gravée dans le marbre qui était vraie en 1965, qui l’est toujours aujourd’hui, et qui le sera encore dans 50 ans.

Le principe de substitution de Liskov (Liskov Substitution Principle) correspond au L 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.

Mais pour résumé et une application concrète des principes SOLID dans un vrai programme, allez lire le bonus de cet article.

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 stopper une invasion de zombies au Guatemala. Je vous donne rendez-vous la semaine prochaine pour un nouvel article sur le blog des développeurs ultra-efficaces !


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