Clean Code : partie 1

Clean Code: A Handbook of Agile Software Craftsmanship, par Robert C. Martin (aka Uncle Bob), est un ouvrage de référence dans le domaine du Software Craftsmanship.

Pour ces auteurs, le Clean Code c’est :

  • Bjarne Stroustrup (inventeur du C++) : le code clean doit être élégant et efficace, et la gestion des erreurs doit être totale.
  • Grady Booch (co-créateur d’UML) : le code clean se caractérise par sa lisibilité.
  • Dave Thomas (fondateur d’OTI) : le code clean peut être lu et amélioré par d’autres développeurs.
  • Ron Jeffries (co-fondateur de l’eXtreme Programming) : le code clean est du code simple, expressif, sans duplication et avec peu d’abstraction.
  • Ward Cunningham (inventeur du Wiki) : le code est clean quand il correspond à tout ce qu’on peut en attendre.

Règle du Boy Scout

Une règle d’or des boy-scouts consiste à laisser le lieu de campement plus propre qu’à l’arrivée. Si le campement est sale en arrivant, il faut le nettoyer, indépendamment de qui aurait pu le salir. En procédant ainsi, on laisse un meilleur environnement pour le prochain groupe de campeurs. En retour, on peut espérer trouver des campements plus propres en partant du principe que d’autres boy-scouts appliquent cette règle.

Cette règle peut s’appliquer au développement logiciel: « toujours publier du code plus propre que celui qu’on a récupéré initialement ».

Si tout les développeurs appliquaient cette règle, les systèmes logiciels s’amélioreraient progressivement au fil des évolutions. Cela permettraient à chacun de s’approprier une plus grande portion de la base de code.

Cette règle doit être appliquée à bon escient : il ne faut pas s’astreindre à tout réécrire en essayant d’atteindre la perfection, mais procéder par petites touches : toute amélioration du code, même minime, est une victoire en soi.

Principes SOLID

SOLID est un acronyme permettant de représenter les 5 principes de base suivants de la programmation objet :

  • Single Responsibility Principle : une classe devrait gérer une seule responsabilité.
  • Open-Closed Principle : une classe doit être à la fois ouverte (à l’extension) et fermée (à la modification) ; une fois qu’une classe a été approuvée et validée, elle ne doit plus être modifiée mais seulement étendue.
  • Liskov Substitution Principle : tout usage d’une classe A peut être remplacé par l’usage d’un sous-classe A’sans que la classe utilisatrice ne s’en rende compte.
  • Interface Segregation Principle : découpage des interfaces de telle façon que le client de l’interface ne se préoccupe que des méthodes qui l’intéressent.
  • Dependency Inversion Principle : les dépendances doivent porter sur les abstractions, pas sur les implémentations.

 

SOLID_6EC97F9C SingleResponsibilityPrinciple2_71060858 OpenClosedPrinciple2_2C596E17
LiskovSubtitutionPrinciple_52BB5162 InterfaceSegregationPrinciple_60216468 DependencyInversionPrinciple_0278F9E2

 

Single Responsibility Principle

Le Single Responsibility Principle (SRP) repose sur la constatation suivante : si on a besoin de modifier une classe (ou un module) pour plus d’une raison, c’est qu’elle fait plus d’une chose, en d’autre termes qu’elle gère plus d’une responsabilité.

Dans l’exemple suivant, proposé par Uncle Bob, la classe Rectangle gère 2 responsabilités : le calcul de sa surface, et son affichage à l’écran.

srp1

Le SRP est rétabli en séparant la classe Rectangle en 2 classes : la première pour gérer le modèle géométrique du rectangle, la seconde pour gérer l’affichage :

srp2

Les bénéfices sont multiples :

  • code mieux organisé,
  • code moins fragile,
  • moins de couplage et de dépendances,
  • refactoring facilité,
  • maintenabilité,
  • testabilité : les scénarios de tests unitaires sont beaucoup moins complexes.

Le SRP s’applique aux différents niveaux du code : module, package, classe, méthode.

Les symptômes suivants sont caractéristiques d’une rupture du SRP :

  • une classe est difficile à tester :  les scénarios des tests unitaires sont trop complexes ou trop nombreux.
  • une classe a trop de dépendances : le nombre de paramètres du constructeur est un bon indicateur, de même que la difficulté à configurer ou substituer les dépendances dans un test unitaire.
  • une méthode a trop de paramètres : même chose que dans le cas de la classe. Les paramètres peuvent être considérés comme des dépendances.
  • la longueur d’une classe ou d’une méthode est trop élevée : c’est un indicateur évident que le code fait trop de choses.
  • un nom de classe ou de méthode est trop long : ici encore, c’est que le code doit faire trop de choses.
  • effet bulldozer : un changement dans une classe entraîne de lourds changements ailleurs dans le code ; pire encore, le moindre changement ou refactoring fait échouer des tests sans lien apparent.

Voici quelques pistes pour développer conformément au SRP :

  • rester vigilant : le développeur se doit de faire attention et détecter le plus tôt possible si une classe ou une méthode gère trop de choses.
  • garder le code testable : le code doit être rédigé de façon à ce que tout puisse être testé. L’approche TDD est un excellent moyen d’y parvenir.
  • pratiquer le refactoring : ne pas lésiner sur l’extraction de méthodes ou de classe ou de classe, ou le déplacement de méthodes. Le raccourci Ctrl+Alt+M (Eclipse) / Ctrl+R,M (IntelliJ) doit devenir un réflexe naturel.
  • appliquer des design patterns : par exemple, on peut éviter des branchements dans le code en employant le design pattern Strategy.

Open-Closed Principle

Prenons l’exemple suivant, proposé par Joel Abrahamsson, qui consiste à calculer la somme des surfaces d’un ensemble de formes géométriques. Dans un premier temps on gère seulement les rectangles :

public class AreaCalculator
{
     public static double getArea(List shapes)
    {
        double area = 0d;
        for (Rectangle shape : shapes)
        {
            area += shape.getWidth()*shape.getHeight();
        }

        return area;
    }
}

Il est nécessaire ensuite de gérer les cercles, ce qui va entraîner une violation du Open-Closed Principle (OCP) :

public class AreaCalculator {
    public static double getArea(List<Object> shapes)
    {
        double area = 0d;
        for (Object shape : shapes)
        {
            if (shape instanceof Rectangle) {
                Rectangle rectangle = (Rectangle) shape;
                area += rectangle.getWidth() * rectangle.getHeight();
            }
            if (shape instanceof Circle) {
                Circle circle = (Circle) shape;
                area += circle.getRadius() * circle.getRadius() * Math.PI;
            }
        }

        return area;
    }
}

En effet tout ajout d’une nouveau type de forme implique de modifier le code qui calcule la surface de l’ensemble. Le code est de plus inélégant parce qu’il oblige à tester sur chaque type de forme.

La solution proposée consiste à laisser à chaque forme la responsabilité de calculer sa surface. Ainsi le support d’un nouveau type de forme n’entraîne pas de modification des classes existantes :

public abstract class Shape {
    public abstract double getArea();
}

public class Rectangle extends Shape {

    private double width;
    private double height;

    public double getWidth() {
        return width;
    }

    public double getHeight() {
        return height;
    }

    @Override
    public double getArea() {
        return getWidth() * getHeight();
    }
}

public class Circle extends Shape {

    private double radius;

    public double getRadius() {
        return radius;
    }

    @Override
    public double getArea() {
        return getRadius() * getRadius()  * Math.PI;
    }
}

public class AreaCalculator {
    public static double getArea(List<Shape> shapes)
    {
        double area = 0d;
        for (Shape shape : shapes)
        {
            area += shape.getArea();
        }

        return area;
    }
}

 

Liskov Substitution Principle

Ce principe est respect si tout code qui référence une classe peut en utiliser n’importe quelle sous-classe sans que ça ne pose de problème.

Un cas typique de violation de ce principe est le fait de tester le type de l’objet en fonction des sous-classes définies et d’utiliser des casts explicites (c’est le cas dans l’exemple donné violant également l’Open-Closed Principle).

Une mauvaise interprétation de la relation conceptuelle is a sous la forme d’un héritage constitue un cas plus subtil de violation du LSP (Liskov Substitution Principle). En modélisation objet, il est courant de dire que lorsque B est un (is a) A, alors B hérite de A. Seulement, ce n’est pas vrai dans tous les cas.

Le contre-exemple classique est celui du rectangle (défini par une largeur et une hauteur) et du carré (Square). Mathématiquement parlant, le carré est un rectangle particulier. On peut donc avoir tendance à faire hériter naturellement Square de Rectangle.

Le premier problème qui se pose concerne l’attribut hauteur, qui bien qu’hérité par Square, ne lui est d’aucune utilité. Au delà du fait de gaspiller de l’espace mémoire, c’est aussi un problème conceptuel : la classe Square hérite des accesseurs associés (getHeight() et setHeight()). Rien n’empêche d’appeler successivement setWidth() et set Height() avec des valeurs différentes.

On pourrait contourner le problème de la façon suivante :

public class Square extends Rectangle {

    @Override
    public void setWidth(double width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(double height) {
        super.setHeight(height);
        super.setWidth(height);
    }
}

Cette astuce permet de maintenir la même valeur pour la largeur et la hauteur d’un carré. Le code suivant montre néanmoins qu’elle est bancale :

Square square = new Square();
square.setWidth(150);
square.setHeight(480);

Pire encore, testons le calcul de surface d’un rectangle :

import org.junit.Assert;
import org.junit.Test;
public class LiskovTest {

    private static Rectangle getNewRectangle() {
            return new Square();
    }

    @Test
    public void shouldComputeArea() {
        Rectangle r = LiskovTest.getNewRectangle();

        r.setWidth(5);
        r.setHeight(10);

        Assert.assertEquals (50, r.getArea(), 0.1);
    }        
}

Utilisé avec une instance de Square, le test échoue :

liskov_test

Le test est certes un peu biaisé (on injecte explicitement une instance de Square), mais cela montre qu’il ne fonctionne pas avec n’importe quel type de Rectangle.

Mais imaginons la définition d’une méthode grow() permettant d’agrandir un Rectangle en largeur et en hauteur :

public class ShapeHelper {

    public static void grow(Rectangle rectangle, int widthFactor, int heightFactor) {
        rectangle.setWidth(rectangle.getWidth() * widthFactor);
        rectangle.setHeight(rectangle.getHeight() * heightFactor);
    }
}

Un certain nombre de problèmes se posent avec un Square passé en argument :

  • vu de l’extérieur, compréhension de l’utilisation de grow() avec un Square : si je passe des facteurs différents, mon carré ne risque-t-il pas de devenir un rectangle ?
  • l’implémentation montre que chaque facteur est appliqué successivement sur chaque côté : si j’ai un carré de 10 de côté, si j’applique grow(square, 2, 4) alors j’obtiens un carré de 80 de côté.

Il existe malgré tout une meilleure solution à ce problème : rendre les classes Rectangle et Square immutables.

public class Rectangle {
    int width;
    int height;

    public Rectangle(int w, int h) {
        width = w;
        height = h;
    }

    //getters, but no setters.
}


public Square extends Rectangle {
    public Square(int side) {
        super(side, side);
    }
}

 

Conceptuellement parlant, le vrai problème est le suivant : bien que mathématiquement parlant un carré soit un rectangle, du point de vue du comportement objet un Square n’est pas un Rectangle. Il faut garder à l’esprit que le développement logiciel se rapporte avant tout au comportement. Et se demander si il est pertinent de définir une classe Square, ce apparaît comme une forme de surconceptualisation. Peut être est-il finalement suffisant d’avoir les méthodes suivantes dans la classe Rectangle pour travailler avec des carrés :

public boolean isSquare() {
    return getWidth() == getHeight();
}

public static Rectancle createSquare(int side) {
    return new Rectangle(side, side);
}

 

Interface Segregation Principle

L’Interface Segregation Principle (ISP) se rapporte aux inconvénients des interfaces massives proposant de nombreuses méthodes. Lorsque c’est le cas, il doit être possible de regrouper les méthodes en différents groupes, qui en principe vont être destinés à différents clients.

Le premier problème est que chaque client connaît les méthodes destinés aux autres clients, mais qui ne lui servent jamais. Des clients distincts devraient utiliser des interfaces distinctes.

Le second problème est que chaque classe implémentant une telle interface doit implémenter toutes ces méthodes, dont certaines ne lui servent pas : on parle alors de pollution d’interface.

On trouve un exemple simple sur oodesign.com : des employés partagent une interface commune permettant de travailler (work()) et de se restaurer (eat()). La technologie évoluant, on fait intervenir des travailleurs robots, mais ces derniers n’ont pas besoin de manger. La définition d’interface pose alors problème :

// interface segregation principle - bad example
interface IWorker {
  public void work();
  public void eat();
}

class Worker implements IWorker{
  public void work() {
    // ....working
  }
  public void eat() {
    // ...... eating in launch break
  }
}

class SuperWorker implements IWorker{
  public void work() {
    //.... working much more
  }

  public void eat() {
    //.... eating in launch break
  }
}

class Manager {
  IWorker worker;

  public void setWorker(IWorker w) {
    worker=w;
  }

  public void manage() {
    worker.work();
  }
}

En appliquant l’ISP, on va séparer l’interface IWorker en deux interfaces : IWorkable et IFeedable :

// interface segregation principle - good example
interface IWorker extends Feedable, Workable {
}

interface IWorkable {
  public void work();
}

interface IFeedable{
  public void eat();
}

class Worker implements IWorkable, IFeedable{
  public void work() {
    // ....working
  }

  public void eat() {
    //.... eating in launch break
  }
}

class Robot implements IWorkable{
  public void work() {
    // ....working
  }
}

class SuperWorker implements IWorkable, IFeedable{
  public void work() {
    //.... working much more
  }

  public void eat() {
    //.... eating in launch break
  }
}

class Manager {
  Workable worker;

  public void setWorker(IWorkable w) {
    worker=w;
  }

  public void manage() {
    worker.work();
  }
}

On pourrait envisager une fonctionnalité de redémarrage des robots avec une interface IBootable.

Dependency Inversion Principle

Selon ce principe, la relation de dépendance conventionnelle que les modules de haut niveau ont, par rapport aux modules de bas niveau, est inversée dans le but de rendre les premiers indépendants des seconds.

Les deux assertions de ce principe sont :

  1. Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d’abstractions.
  2. Les abstractions ne doivent pas dépendre des détails. Les détails doivent dépendre des abstractions.

Pour y parvenir, le module de haut niveau ne connaît pas l’implémentation du module de bas niveau et n’est pas responsable de son instanciation. L’instanciation se fait par un composant extérieur par un mécanisme nommée injection de dépendance, selon le principe de l’IoC (Inversion of Control).

Exemple de code sans inversion de contrôle :

class Service {
    Database database;
    init() {
        database = ServiceHelper.getService("database");
    }
}

Avec une inversion de contrôle (effectuée depuis le main()) :

class Service {
    Database database;
    init(database) {
        this.database = database;
    } 
}
public static void main(String[] args) {
  Database database = ServiceHelper.getService("database");
  Service service = new Service();
  service.init(database);
}

La classe Service est rendue nettement plus configurable, ce n’est plus elle qui a le contrôle sur la récupération d’une Database, mais directement les objets utilisateurs de la classe Service.

La classe Service sera plus aisément testable dans la mesure où on dispose de davantage de possibilités de substitution de son objet Database par un test double.

 

Laisser un commentaire