Cours de Programmation avancée


Principes SOLID et patrons de conception

Principes SOLID

Introduction

Cinq principes pour un code maintenable

En programmation orientée objet, il existe cinq principes de conception destinés (regroupés sous l’acronyme SOLID) qui visent à produire des architectures logicielles plus compréhensibles, flexibles et maintenables. Ces principes sont un sous-ensemble de nombreux principes promus par l’ingénieur logiciel et instructeur américain Robert Cecil Martin (familièrement connu sous le nom Uncle Bob). Bien qu’ils s’appliquent à toute conception orientée objet, les principes SOLID peuvent également former une philosophie de base pour des méthodologies telles que le développement agile. La théorie des principes SOLID a été introduite par Martin dans son article Design Principles and Design Patterns de 2000, bien que l’acronyme SOLID ait été introduit plus tard par Michael Feathers.

Les cinq principes SOLID sont les suivants :

  • Single Responsibility Principle (SRP) : Une classe ne doit avoir qu’une seule responsabilité
  • Open/Closed Principle (OCP) : Programme ouvert pour l’extension, fermé à la modification
  • Liskov Substitution Principle (LSP) : Les sous-types doivent être substituables par leurs types de base
  • Interface Segregation Principle (ISP) : Éviter les interfaces qui contiennent beaucoup de méthodes
  • Dependency Inversion Principle (DIP) :
    • Les modules d’un programme doivent être indépendants
    • Les modules doivent dépendre d’abstractions

Le but de ces principes est donc de garantir la maintenabilité d’un programme, c’est-à-dire sa capacité à :

  • absorber les changements avec un minimum d’effort ;
  • implémenter les nouvelles fonctionnalités sans toucher aux anciennes ;
  • modifier les fonctionnalités existantes en modifiant localement le code.

L’application des principes SOLID a pour objectifs :

  • de limiter les modules impactés ;
  • de simplifier les tests ;
  • de rester conforme aux spécifications qui n’ont pas changé.

Approche qualité des 5S

Les principes SOLID sont donc ceux qui seront étudiés dans ce cours et constitueront son ossature. Dans la suite de ce cours, nous allons donc détailler ces cinq principes et expliquer pourquoi il est important de les respecter afin d’obtenir du code maintenable. Mais avant cela, il est utile de nous intéresser à une approche qualité pour la gestion de projet, venue du Japon au milieu du siècle dernier et qui peut s’appliquer à l’informatique. Cette approche ou philosophie des 5S peut être résumé par les cinq points suivants :

  • Seiri (“s’organiser”) : les différents éléments d’un code doivent être structurés et aisément identifiables. Ainsi une action aussi anodine que de nommer les identifiants, méthodes et classes ne l’est pas tant que cela et doit requérir toute votre attention ;
  • Seiton (“situer”) : un morceau de code doit se trouver là où l’on s’attend logiquement à ce qu’il se trouve. Si ce n’est pas le cas, cela veut dire qu’il n’est pas à sa place et que la structure du code n’a pas été pensée correctement ;
  • Seiso (“scintiller”) : l’espace de travail doit être propre ! Pensez à la cuisine d’un grand restaurant : on ne travaille pas sur un plan de travail comportant de la vaisselle sale, des ingrédients d’un autre plat, … Au niveau du code la présence de commentaires non informatifs ou de code ancien désactivé par une mise en commentaire constitue une pollution de l’espace de travail à laquelle il faut remédier ;
  • Seiketsu (“standardiser”) : dans un travail en équipe il faut que des conventions soient respectées, que chaque développeur suive les mêmes règles pour que le code conserve une homogénéité ;
  • Shutsuke (“suivi”) : suivre le travail des autres permet de s’interroger sur ses propres pratiques et d’évoluer positivement (en tout cas il faut l’espérer…).

On peut se poser la question de pourquoi s’imposer ces règles qui viennent s’ajouter à celles qu’il faut déjà suivre pour qu’un programme soit fonctionnel. En fait, le but de ces règles est de gagner du temps. Le temps de travail des personnes participant au projet est généralement la ressource qui est la plus coûteuse dans un projet. Cela peut paraître contre-intuitif, car appliquer les règles énoncées ci-dessus prend clairement du temps. Cependant, ce temps n’est pas perdu, car l’objectif est d’en gagner dans le futur. En effet, l’objectif est de garder un bon cadre de travail afin d’être efficace. C’est un peu le même principe qu’avoir une pièce bien rangée vous fait globalement gagner du temps, car cela vous permet d’accéder facilement à vos affaires, et ce même si ranger prend du temps.

Comme vous venez de le voir, en termes de bonnes pratiques on parle souvent de règles pour évoquer les principes recommandés par une approche ou l’autre. Il est important de comprendre qu’il s’agit bien de recommandations et non de lois immuables. Tout ce que nous allons voir dans la suite est à adapter au contexte dans lequel vous allez l’appliquer, il n’y a pas de loi universelle permettant d’obtenir à coup sûr un code propre et maintenable, seulement des indications de pratiques qui ont été reconnues profitables. De surcroît, il se peut que certaines considérations, notamment des contraintes de performances, puissent rendre difficile l’application de certaines bonnes pratiques de programmation dans des cas très spécifiques.

La vie d’un programme

On peut se demander pourquoi il est si important d’avoir une certaine méthodologie (et donc de se fixer des règles et d’appliquer des bonnes pratiques) lorsqu’on participe à un projet de développement logiciel d’envergure. Pour justifier cette approche, nous allons considérer l’historique de la vie d’un programme quelconque. On peut faire une analogie avec la vie humaine et découper la vie d’un projet en 5 phases :

  • La naissance : tout le monde vient s’extasier sur le beau bébé qui vient de naître. Les parents sont fiers de présenter leur rejeton. Le code est beau, pur, les développeurs ont porté une grande attention à sa création.
  • L’enfance : en commençant à marcher, courir, sauter, etc., l’enfant se blesse. Pour réparer un premier bug ou ajouter rapidement une nouvelle fonctionnalité les développeurs travaillent à la va-vite, ils ajoutent une rustine (c’est-à-dire une modification du code sommaire et temporaire visant à corriger rapidement un bug ou dysfonctionnement) au projet. Le code devient donc moins pur et moins beau.
  • L’adolescence : le moment de la rébellion. Il faut intervenir de plus en plus souvent, car les bugs se multiplient. Les développeurs multiplient les rustines et le code devient de moins en moins maintenable.
  • L’âge adulte : il faut avancer coûte que coûte. Les modifications de code précédentes sont un lourd héritage et toute amélioration ou correction prend énormément de temps. Parfois la correction d’un bug déclenche l’apparition de nombreux autres bugs. Mais il faut continuer à avancer : le programme est en production, il n’y a pas d’autre choix que de perdre un temps précieux dès qu’il faut modifier le code.
  • La vieillesse : certaines fonctionnalités sont défaillantes, mais on ne peut plus les réparer. À force d’ajouter des rustines le code n’est plus maintenable, plus aucun développeur ne peut effectuer la moindre modification sans tout casser. Il n’y a plus rien à faire qu’attendre une mort inexorable.

Respecter les bonnes pratiques de conception et de développement permet à un code de vieillir sereinement, de conserver ces fonctionnalités le plus longtemps possible. C’est comme pour un être humain : mener une vie d’excès ne permet pas d’envisager une longue vie en ayant la jouissance complète de ces capacités intellectuelles et physiques. Avec le code informatique, c’est encore plus important, car on développe rarement seul. Une mauvaise hygiène de vie aura des répercussions sur les développeurs faisant également partis du projet et fera nécessairement naître des tensions.

Le zen du développement

En Python, l’un des documents qui définit le langage est une ôde aux bonnes pratiques. Il s’agit du PEP 20 (Python Enhancement Proposal), intitulé le Zen of Python :

En français cela donne :

Dans cette introduction, nous avons pu observer qu’il n’y avait pas que les principes SOLID qui existent en termes de bonnes pratiques de programmation. D’ailleurs nous avions même évoqué d’autres bonnes pratiques dans le premier cours portant sur la gestion de version et les tests. Toutes ces recommandations ont vu le jour à peu près à la même période. Comme on vient de l’expliquer elles ont toutes le même objectif : coder proprement de manière à conserver un code maintenable le plus longtemps possible. Finalement, à y regarder un peu plus en détail, que ce soit la philosophie des 5S, le Zen de Python ou les principes SOLID, tous reprennent plus ou moins les mêmes idées… il y a donc sans doute un enseignement intéressant à en tirer !

Principe de responsabilité unique

Le “S” de SOLID signifie Single Responsability Principle, également généralement noté SRP. Robert Cecil Martin dans son livre “Agile Software Development, Principles, Patterns, and Practices” définit ce principe de la manière suivante :

Single Responsability Principle : A class should have only one reason to change.

En français, cela donne :

Principe de responsabilité unique : une classe ne doit avoir qu’une seule raison de changer.

Les classes (et les méthodes) ne devraient avoir qu’une seule fonctionnalité.

Considérons une implémentation d’un jeu dans lequel il a des tours de jeu et un comptage de points comme le bowling. On pourrait imaginer qu’un tel jeu aura une classe Game qui aura la responsabilité de se souvenir du numéro du tour en cours (à quel carreau on est dans le cas du bowling) ainsi que du calcul du score (quel est le nombre de points obtenu par chacun des joueurs). Pour respecter le principe SRP, il faudrait séparer ces deux fonctionnalités en deux classes de sorte que chaque classe n’ait qu’une seule responsabilité. La classe Game garderait la responsabilité du suivi des tours alors qu’une nouvelle classe Scorer aurait la responsabilité de calculer le score.

On peut se poser la question de savoir pourquoi il est important de séparer ces deux responsabilités dans des classes distinctes. La raison en est que chaque responsabilité correspond à une direction dans lequel on peut faire un changement dans la classe. Si une classe possède plus d’une responsabilité, elle aura donc plus d’une raison de changer. Si une classe a plusieurs responsabilités, elles sont couplées. Dans ce cas, la modification d’une des responsabilités nécessite de :

  • tester à nouveau l’implémentation des autres responsabilités ;
  • modifier potentiellement les autres responsabilités (les modifications apportées à une responsabilité pouvant compromettre la capacité de la classe à assurer ses autres responsabilités) ;
  • déployer à nouveau les autres responsabilités.

Ce type de couplage conduit à des conceptions fragiles qui se brisent de manière inattendue lorsqu’elles ont besoin d’être modifiées. Une modification dans les spécifications d’une des responsabilités d’une classe peut entraîner l’introduction de bugs et donc une perte de temps.

Séparer les responsabilités et donc respecter SRP a de nombreux avantages :

  • Diminution de la complexité du code
  • Amélioration de la lisibilité du code
  • Meilleure organisation du code
  • Modification locale lors des évolutions
  • Augmentation de la fiabilité
  • Classes davantage réutilisables

Afin d’illustrer ce principe, nous allons décrire un exemple plus concret. On va considérer une classe Rectangle qui a deux méthodes : une méthode draw() permettant de dessiner le rectangle et une méthode area() calculant l’aire de celui-ci. Deux classes différentes utilisent la classe Rectangle : GeometricApplication et GraphicalApplication. La classe GeometricApplication utilise Rectangle pour faire des calculs mathématiques sur des formes géométriques (pour faire simple des calculs d’aires), mais ne dessine jamais de rectangle à l’écran. La classe GraphicalApplication est de nature graphique et peut également faire de la géométrie informatique, mais elle dessine des rectangles à l’écran en utilisant une classe GraphicalInterface. Le diagramme de classe ci-dessous illustre cette architecture.

Cette conception est contraire à SRP. En effet, la classe Rectangle a deux responsabilités. La première consiste à fournir un modèle mathématique de la géométrie d’un rectangle. La seconde est de dessiner le rectangle pour une interface graphique en utilisant une interface graphique nommée GraphicalInterface.

La violation de SRP entraîne plusieurs problèmes. Tout d’abord, nous devons inclure l’interface graphique GraphicalInterface dans l’application de géométrie informatique GeometricApplication car Rectangle a besoin de cette classe. Cela signifie que GraphicalInterface devra être construit et déployé avec l’application de géométrie GeometricApplication. Deuxièmement, si une modification de l’application graphique GraphicalApplication entraîne une modification de la classe Rectangle pour une raison quelconque, cette modification peut nous obliger à reconstruire, retester et redéployer l’application de géométrie informatique GeometricApplication. Si nous oublions de le faire, cette application peut dysfonctionner de manière imprévisible.

Une meilleure conception consiste à séparer les deux responsabilités dans deux classes complètement différentes, comme le montre la figure ci-dessous. Cette conception déplace les aspects de calcul géométrique de Rectangle dans la classe GeometricRectangle et de garder les fonctionnalités de rendu graphique dans GraphicalRectangle. Désormais, les modifications apportées à la manière dont les rectangles sont rendus ne peuvent pas affecter GeometricApplication.

Principe d’ouvert/fermé

Le “O” de SOLID signifie Open-Closed Principle, également noté OCP. Robert Cecil Martin dans son livre “Agile Software Development, Principles, Patterns, and Practices” définit ce principe de la manière suivante :

The Open/Closed Principle (OCP) : Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

En français, cela donne :

Les entités logicielles (classes, modules, fonctions, etc.) doivent être ouvertes à l’extension, mais fermées à la modification.

Si on souhaite respecter OCP pour les classes, il doit donc être possible de rajouter une nouvelle fonctionnalité :

  • en ajoutant des classes (ouvert pour l’extension)
  • sans modifier le code existant d’une classe (fermé à la modification)

Cela signifie que par exemple pour une classe donnée, on doit pouvoir l’étendre, c’est-à-dire l’utiliser pour créer une nouvelle classe, mais pas la modifier pour y intégrer le comportement de la nouvelle classe. Les Avantages sont nombreux :

  • Le code existant n’est pas modifié et on a donc pas besoin de le rester ou de le reconstruire ce qui nous permet de gagner en fiabilité.
  • Les classes ont plus de chance d’être réutilisables.
  • Simplification de l’ajout de nouvelles fonctionnalités.

Afin d’illustrer les problèmes du non-respect d’OCP, on va considérer un exemple pour lequel on a déjà deux classes représentant des formes géométriques : Rectangle et Circle ayant le code suivant :

public class Rectangle {
  public Point point1, point2;

  public Rectangle(Point point1, Point point2) {
    this.point1 = point1;
    this.point2 = point2;
  }
}
public class Circle {
  public Point center;
  public int radius;
  
  public Circle(Point center, int radius) {
    this.center = center;
    this.radius = radius;
  }
}

Afin de pouvoir dessiner ces deux types de formes géométriques, on a aussi le code d’une classe GraphicTools qui permet de dessiner une liste d’objets qui représente des formes géométriques.

public class GraphicTools {
  static void drawShapes(Graphics graphics, List<Object> shapes) {
    for (Object shape : shapes) {
      if (shape instanceof Rectangle) {
        Rectangle rectangle = (Rectangle) shape;
        int x = Math.min(rectangle.point1.x, rectangle.point2.x);
        int y = Math.min(rectangle.point1.y, rectangle.point2.y);
        int width = Math.abs(rectangle.point1.x - rectangle.point2.x);
        int height = Math.abs(rectangle.point1.y - rectangle.point2.y);
        graphics.drawRect(x, y, width, height);
      } else if (shape instanceof Circle) {
        Circle circle = (Circle) shape;
        int x = circle.center.x - circle.radius;
        int y = circle.center.y - circle.radius;
        int width = circle.radius * 2;
        int height = circle.radius * 2;
        graphics.drawOval(x, y, width, height);
      }
    }
  }
}

Parce qu’elle n’est fermée à de nouveaux types de formes, la méthode drawShapes n’est pas conforme à OCP. Si on voulait étendre cette fonction pour pouvoir dessiner une liste de formes comprenant des triangles, on devrait modifier la fonction. En fait, on devrait modifier la fonction pour tout nouveau type de forme qu’on aurait besoin de dessiner.

On va donc modifier le code afin de respecter OCP. La première étape va consister à simplifier le code de la méthode drawShapes en extrayant dans des méthodes distinctes les fonctionnalités de dessin des rectangles et des cercles.

public class GraphicTools {
  static void draw(Graphics graphics, List<Object> objects) {
    for (Object object : objects) {
      if (object instanceof Rectangle) {
        Rectangle rectangle = (Rectangle)object; 
        drawRectangle(graphics, rectangle);
      } else if (object instanceof Circle) {
        Circle circle = (Circle)object; 
        drawCircle(graphics, circle);
      }
    }
  }
  
  static void drawRectangle(Graphics graphics, Rectangle rectangle) {  
    int x = Math.min(rectangle.point1.x, rectangle.point2.x);
    int y = Math.min(rectangle.point1.y, rectangle.point2.y);
    int width = Math.abs(rectangle.point1.x - rectangle.point2.x);
    int height = Math.abs(rectangle.point1.y - rectangle.point2.y);
    graphics.drawRect(x, y, width, height);
  }
  
  static void drawCircle(Graphics graphics, Circle circle) { 
    int x = circle.center.x - circle.radius;
    int y = circle.center.y - circle.radius;
    int width = circle.radius * 2;
    int height = circle.radius * 2;
    graphics.drawOval(x, y, width, height);
  }
}

La deuxième étape de la modification du code consiste à créer une interface Drawable qui va contenir une unique méthode draw et qui sera implémentée par Circle et Rectangle. Cela nous donne le schéma ci-dessous.

Le code du diagramme est le suivant.

public interface Drawable {
  void draw(Graphics graphics);
}
public class GraphicTools {
  static void draw(Graphics graphics, List<Drawable> drawables) {
    for (Drawable drawable : drawables)
      drawable.draw(graphics);
  }
}
public class Circle implements Drawable {
  public Point center;
  public int radius;

  public Circle(Point center, int radius) {
    this.center = center;
    this.radius = radius;
  }

  public void draw(Graphics graphics) {
    int x = center.x - radius;
    int y = center.y - radius;
    int width = radius * 2;
    int height = radius * 2;
    graphics.drawOval(x, y, width, height);
  }
}
public class Rectangle implements Drawable {
  public Point point1, point2;

  public Rectangle(Point point1, Point point2) {
    this.point1 = point1;
    this.point2 = point2;
  }

  public void draw(Graphics graphics) {
    int x = Math.min(point1.x, point2.x);
    int y = Math.min(point1.y, point2.y);
    int width = Math.abs(point1.x - point2.x);
    int height = Math.abs(point1.y - point2.y);
    graphics.drawRect(x, y, width, height);
  }
}

Cette solution n’est pas totalement satisfaisante, car les classes Rectangle et Circle ont deux responsabilités distinctes : le stockage des données géométriques des formes (comment on sauvegarde la position des formes géométriques) et la fonctionnalité de rendu graphique. La classe Rectangle a donc deux raisons de changer : un changement si on souhaite sauvegarder le rectangle avec un coin, sa largeur et sa hauteur plutôt que les deux coins opposés et un autre changement dû à un choix différent de bibliothèque graphique qui changera la méthode draw. Nous verrons par la suite comment obtenir une meilleure solution avec l’application du patron de conception visiteur.

Principe de substitution de Liskov

Un des concepts les plus importants afin de pouvoir respecter OCP est l’extension de classe aussi appelé héritage de code. C’est grâce à l’héritage qu’il est possible de créer des classes dérivées qui implémentent de nouvelles méthodes dans les classes de base. C’est un des outils qu’on peut utiliser pour ajouter des services à une classe sans modifier le code de la classe elle-même. On peut donc se poser la question de savoir quelles sont les règles de conception qui régissent cette utilisation de l’héritage. Comment peut-on reconnaître une bonne hiérarchie d’héritage d’une mauvaise ? Quels sont les pièges qui nous amèneront à créer des hiérarchies non conformes à OCP ? Ce sont les questions qui sont répondues par le principe de substitution de Liskov qui correspond au “L” de SOLID pour Liskov Substitution Principle, également noté LSP. Robert Cecil Martin dans son livre “Agile Software Development, Principles, Patterns, and Practices” définit ce principe de la manière suivante :

The Liskov Substitution Principle : Subtypes must be substitutable for their base types.

En français, cela donne :

Le principe de substitution de Liskov : les sous-types doivent être substituables à leurs types de base.

Barbara Liskov a définit ce principe en 1988 de la manière suivante :

S est un sous-type (extension) correct de T Si pour chaque objet o1 de type S il existe un objet o2 de type T tel que pour tous les programmes P définis en termes de T, le comportement de P est inchangé lorsque o1 est remplacé par o2.

Dit autrement, si une classe D étend une classe B (ou implémente une interface B) alors un programme P écrit pour manipuler des instances de type B doit avoir le même comportement s’il manipule des instances de la classe D. L’importance de ce principe devient évidente lorsque l’on considère les conséquences de sa violation. Supposons que nous ayons une fonction f qui prend comme argument un objet de type B. Supposons également que, lorsqu’on passe à f un objet de type D, on obtient un comportement incorrect de f. Dans ce cas, la classe D viole LSP. C’est ce genre de problème que l’on souhaite éviter en respectant LSP.

Afin d’illustrer les problèmes du non-respect de LSP, on va considérer un exemple pour lequel on a déjà deux classes représentant des formes géométriques. On considère tout d’abord une classe Rectangle ayant le code suivant :

public class Rectangle {
  private double width;
  private double height;
    
  public void setWidth(double width) { 
    this.width = width; 
  }

  public void setHeight(double height) { 
    this.height = height; 
  }

  public double getWidth() { 
    return width; 
  }

  public double getHeight() { 
    return height; 
  }

  public double getArea() {
    return width*height; 
  }
}

Un carré étant un rectangle, on souhaite définir une classe Square qui est une extension de Rectangle de la façon suivante :

public class Square extends Rectangle {

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

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

Maintenant, supposons que nous testons la méthode area de rectangle avec le code suivant :

  void testRectangleArea(Rectangle r){
    r.setWidth(3);
    r.setHeight(2);
    assertThat(r.area()).isEqualTo(3*2);
  }

Si on appelle la méthode avec un objet de type Rectangle, le code s’exécute sans problème, car l’assertion est vraie. Si on appelle la méthode avec un objet de type Square, l’assertion est fausse, car la largeur est fixée à 2 par l’appel r.setHeight(2) et l’aire est donc de 4. En fait la bonne question à se poser n’est pas de savoir si un carré est-il un rectangle, mais plutôt de déterminer si un carré a le même comportement qu’un rectangle. Puisque la réponse à cette deuxième question est négative, ce n’est pas une bonne idée de définir la classe des carrés comme un sous-type de la classe des rectangles.

Pour corriger le problème, une solution consiste à définir une classe abstraite RectangularShape qui contiendra les parties communes de Rectangle et Square, c’est-à-dire le calcul de l’aire et la définition des signatures des accesseurs (getters). Cette classe sera étendue par Rectangle et Square. Cela nous donne le diagramme suivant :

Cela nous donne le code suivant :

public abstract class RectangularShape {
  public abstract double getWidth();
  public abstract double getHeight();
  
  public double getArea() { 
    return getWidth() * getHeight(); 
  }
}

public class Rectangle extends RectangularShape {
  private double width;
  private double height;
    
  public void setWidth(double width) { 
    this.width = width; 
  }

  public void setHeight(double height) { 
    this.h = height; 
  }
  
  public double getWidth() { 
    return width;
  }
  
  public double getHeight() { 
    return height; 
  }
}

class Square extends RectangularShape {
  private double sideLength;

  public void setSideLength(double sideLength) { 
    this.sideLength = sideLength;
  }

  public double getWidth() { 
    return sideLength;
  }

  public double getHeight() { 
    return sideLength;
  }
}

Principe de ségrégation des interfaces

Le “I” de SOLID signifie Interface Segregation Principle, également noté ISP. Robert Cecil Martin dans son livre “Agile Software Development, Principles, Patterns, and Practices” définit ce principe de la manière suivante :

The Interface Segregation Principle (ISP) : Clients should not be forced to depend upon interfaces that they do not use.

En français, cela donne :

Les clients ne doivent pas être obligés de dépendre d’interfaces qu’ils n’utilisent pas.

Il faut donc éviter qu’un client ne voit une interface qu’il n’utilise pas. Dans la plupart des cas, appliquer ce principe revient à éviter les interfaces qui contiennent beaucoup de méthodes :

  • Découper les interfaces en responsabilités distinctes (SRP)
  • Quand une interface grossit, se poser la question du rôle de l’interface
  • Éviter de devoir implémenter des services qui n’ont pas à être proposés par la classe qui implémente l’interface
  • Limiter les modifications lors de la modification de l’interface

Les avantages sont nombreux :

  • Le code existant est moins modifié et on a donc une augmentation de la fiabilité
  • Les classes ont plus de chance d’être réutilisables
  • Simplification de l’ajout de nouvelles fonctionnalités

Afin d’illustrer les raisons de ce principe, on va considérer un exemple dans lequel on considère une interface Element pour les éléments d’une interface graphique. Cette interface sera implémentée par une classe Label qui permet de représenter des étiquettes de texte. On considère donc l’architecture décrite par le diagramme suivant :

Cela nous donne le code suivant :

public interface Element {
  public void setText(String text);
  public String getText(String text);
  public void setPosition(int x, int y);
  public void draw(Graphics graphics);
}

public class Label implements Element {
  private int x,y;
  private String text;
  
  public Label(String text, int x, int y) {
    this.text = text; this.x = x; this.y = y;
  }
  
  public void setText(String text)   { 
    this.text = text; 
  }

  public String getText(String text) { 
    return text; 
  }

  public void setPosition(int x, int y) { 
    this.x = x; this.y = y; 
  }
  
  public void draw(Graphics graphics) { 
    graphics.drawString(text, x, y);
  }
}

Supposons maintenant que nous souhaitions ajouter une classe image qui est aussi un élément graphique et qui implémenterait donc Element. Cela nous donne le diagramme suivant :

Nous avons un problème, car une image n’a pas de texte. On ne sait donc pas que faire dans les méthodes setText et getText. La solution est de découper l’interface Element en deux en séparant les fonctionnalités liées au texte des fonctionnalités liées au rendu graphique de l’élément. On obtient donc le diagramme suivant :

Principe d’inversion des dépendances

Le “D” de SOLID signifie Dependency Inversion Principle, également noté DIP. Robert Cecil Martin dans son livre “Agile Software Development, Principles, Patterns, and Practices” définit ce principe de la manière suivante :

High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g. interfaces). Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

En français, cela donne :

Les modules de haut niveau ne doivent pas dépendre de ceux de bas niveau. Les deux doivent dépendre d’abstractions (par exemple les interfaces). Les abstractions, elles, ne doivent pas dépendre des détails. Les détails (implémentations concrètes) doivent dépendre des abstractions.

Ainsi, si une classe A utilise une classe B, il doit être possible de remplacer B par une autre classe C. B et C sont alors des implémentations concrètes d’une classe abstraite (ou d’une interface) qui sera utilisée par A.

Les modules d’un programme doivent donc être indépendants et doivent dépendre d’abstractions. Pour respecter DIP, il faut donc :

  • découpler le plus possible les différents modules de votre programme ;
  • les lier quand c’est nécessaire en utilisant des interfaces ;
  • spécifier correctement le comportement de chaque module.

Les avantages de l’application de DIP :

  • Permet de remplacer un module par un autre module plus facilement.
  • Les modules sont plus facilement réutilisables.
  • Simplification de l’ajout de nouvelles fonctionnalités.
  • L’intégration est rendue plus facile.

Afin d’illustrer l’application de DIP, nous allons considérer un exemple assez simple. Considérons le logiciel qui pourrait contrôler le régulateur d’un four. Le logiciel peut lire la température actuelle à partir d’un canal d’entrée/sortie et demander au four de s’allumer ou de s’éteindre en écrivant des commandes à un autre canal d’entrée/sortie. La structure du programme pourrait ressembler au code suivant :

public class Thermostat {
  enum IOChannel{
    THERMOMETER, FURNACE
  }
  enum Action{
    ENGAGE, DISENGAGE
  }

  int read(IOChannel channel){
    // TODO : add code for reading on a channel
    return 0;
  }

  void write(IOChannel channel, Action action){
    // TODO : add code for writing on a channel
  }

  void Regulate(double minTemp, double maxTemp)
    throws InterruptedException{
    for(;;) {
      while (read(IOChannel.THERMOMETER) > minTemp)
        wait(1);
      write(IOChannel.FURNACE, Action.ENGAGE);
      while (read(IOChannel.THERMOMETER) < maxTemp)
        wait(1);
      write(IOChannel.FURNACE, Action.DISENGAGE);
    }
  }
}

On comprend assez facilement l’intuition derrière l’algorithme. Néanmoins, un certain nombre de détails techniques liés à des aspects bas niveau (lecture et écriture dans des canaux d’entrées/ sorties) rend le code un peu moins lisible. Ce code ne pourra jamais être réutilisé avec un matériel de contrôle différent qui n’utiliserait pas le même protocole de communication (utilisant des canaux). Ce n’est peut-être pas une grande perte, car le code est très petit. Mais même dans ce cas, il est dommage que l’algorithme ne soit pas réutilisable. Il est préférable inverser les dépendances et de concevoir une architecture comme celle décrite par le diagramme ci-dessous.

Patrons de conception

Introduction

Les design patterns (patrons de conception) sont des recettes de conception orientées objet. Ce sont des solutions classiques à des problèmes récurrents de la conception de logiciels. Ils consistent en des plans ou des schémas que l’on peut personnaliser afin de résoudre un problème récurrent dans notre code. Ils furent introduits par quatre développeurs (connus sous le nom de gang of four) : Gamma, Helm, Johnson et Vlissides en 1995. Ces développeurs sont partis de l’idée qu’il existe des recettes de conception permettant de répondre de manière similaire à des problèmes semblables. En 1995 donc, ces quatre auteurs publient leur livre “Design Patterns : Elements of Reusable Object-Oriented Software” qui détaille vingt-trois solutions répondant à des problèmes récurrents en développement logiciel. Ces recettes, patrons ou design patterns peuvent être classés en trois catégories :

— création : instanciation et configuration des classes et des objets ; — structure : organisation des classes d’un programme dans une structure plus importante ; — comportement : organisation des objets en vue de leur collaboration.

Les patrons de conception sont décrits de manière générique afin de pouvoir être adaptés aux différents problèmes à résoudre : ils ne peuvent donc pas s’appliquer directement à toutes les situations sans adaptation. Vous ne pouvez donc pas vous contenter de trouver un patron et de le recopier dans votre programme. Un patron, ce n’est pas un bout de code spécifique, mais plutôt un concept général pour résoudre un problème précis. Il faut donc toujours réfléchir à leur utilisation dans un contexte donné. Néanmoins, ils permettent de ne pas réinventer la roue, car ils couvrent la plupart des situations aux problématiques auxquelles un développeur est confrontées.

Patrons de création

Les patrons de création fournissent des mécanismes de création d’objets qui permettent d’augmenter la flexibilité et la réutilisation du code. Les principaux patrons de conceptions sont les suivants :

  • Factory Method (fabrique) : définit une interface pour la création d’objets dans une classe mère, mais délègue aux sous-classes le choix des types d’objets à créer.
  • Abstract Factory (Fabrique abstraite) : permet de créer des familles d’objets apparentés sans préciser leur classe concrète.
  • Builder (monteur) : permet de construire des objets complexes étape par étape. Ce patron permet de construire différentes variations ou représentations d’un objet en utilisant le même code de construction.
  • Prototype (prototype) : permet de créer de nouveaux objets à partir d’objets existants sans rendre le code dépendant de leur classe.
  • Singleton (Singleton) : permet de garantir que l’instance d’une classe n’existe qu’en un seul exemplaire, tout en fournissant un point d’accès global à cette instance.

Par la suite, nous allons détailler quelques patrons de création.

Singleton (singleton)

Singleton est un patron de conception de création qui garantit que l’instance d’une classe n’existe qu’en un seul exemplaire, tout en fournissant un point d’accès global à cette instance. Le singleton règle deux problèmes à la fois :

  • Il garantit l’unicité d’une instance pour une classe. Pour quelle raison voudrait-on maîtriser le nombre d’instances d’une classe ? En général, cette situation se présente lorsque l’on veut contrôler l’accès à une ressource partagée — une base de données ou un fichier par exemple.
  • Il fournit un point d’accès global à cette instance, protège cette instance en l’empêchant d’être modifiée.

La solution consiste à rendre le constructeur privé (pour empêcher la création d’une instance sans contrôle), et à créer une méthode statique pour contrôler l’instanciation.

Le code associé est le suivant :

public class Singleton
{
  private static Singleton instance = null;
  /**
   * Private constructor to prevent construction outside the class.
   */
  private Singleton()
  {
    // Code of the constructor
  }
  /**
   * Keyword synchronized is used to allow only one thread to execute 
   * the method at any given time (preventing concurrent access).
   * 
   * @return the instance of the unique instance of the class.
   */
  public synchronized static Singleton getInstance()
  {
    if (instance == null)
    {
      instance = new Singleton();
    }
    return instance;
  }
}

Factory Method (fabrique)

Fabrique est un patron de conception de création qui définit une interface pour créer des objets dans une classe mère ou interface, mais délègue le choix des types d’objets à créer aux sous-classes. La fabrique permet donc à une classe de déléguer l’instanciation à des sous-classes.

Supposons que nous disposions de l’interface suivante :

public interface Button {
  public void draw();
}

Il existe une première implémentation de cette interface :

public class SimpleButton implements Button  {
  public void draw() {
    System.out.println("Simple button.");
  }
}

La classe SimpleButton est instanciée dans de nombreuses autres classes et donc son constructeur apparaît à de nombreux endroits. Supposons que nous fassions une autre implémentation de Button :

public class ModernButton implements Button  {
  public void draw() {
    System.out.println("Modern button.");
  }
}

Afin d’utiliser ce nouveau bouton, nous devons modifier toutes les instanciations (appels au constructeur) présentes dans notre code. Il y a donc une violation d’OCP. Afin d’éviter cela, on va donc appliquer le patron de conception fabrique. Une fabrique consiste à isoler la création des objets de leurs utilisations :

public class ButtonFactory {
  public Button createButton() {
    return new SimpleButton();
  }
}

Toutes les instanciations doivent se faire via cette classe. Les modifications nécessaires à l’utilisation de la classe ModernButton sont isolées :

public class ButtonFactory {
  public Button createButton() {
    return new ModernButton();
  }  
}

De manière générale le patron de conception a la forme suivante dans laquelle a une classe abstraite de création d’objets qui peut être étendue de plusieurs manières différentes.

Abstract factory (fabrique abstraite)

Fabrique abstraite est un patron de conception qui permet de créer des familles d’objets apparentés sans préciser leur classe concrète. On va définir une interface contenant plusieurs méthodes de création d’objets et on implémentera cette interface avec des classes qui garantiront une cohérence entre les différents objets créés.

Pour illustrer ce principe, nous allons considérer la création des éléments d’une interface utilisateur pouvant fonctionner sur plusieurs systèmes : mac ou windows. Pour simplifier notre exemple, nous allons considérer qu’il n’y a que deux types d’éléments dans l’interface utilisateur : des boutons (interface Button) et des coches (interface CheckMark). Dans notre cas, il faut donc pouvoir créer ces deux types d’objets. On définit donc une interface GUIFactory contenant deux méthodes de création d’objet (une pour créer des Button et l’autre pour créer des CheckMark). Cette interface aura deux implémentations WinFactory qui crée des éléments pour windows et MacFactory qui crée des éléments pour mac. L’application graphique peut donc utiliser une instance d’une de ces deux classes (suivant le système visé) afin de créer les éléments de l’interface utilisateur. Vous trouverez ci-dessous le diagramme de classes correspondant à cette organisation du code.

L’utilisation du patron fabrique abstraite a ici deux avantages :

  • Cela permet de cacher les classes concrètes à l’application et donc d’éviter des dépendances à des classes concrètes (respect de DIP).
  • Cela permet de garantir la cohérence entre les objets construits par une même fabrique. Ces objets partagent une propriété commune (dans notre exemple un système d’exploitation). Cela permet d’éviter certaines incohérences comme dans notre exemple, des éléments pour des systèmes différents.

Comme on vient de le voir, la Fabrique Abstraite est un patron de conception de création qui permet de créer des familles d’objets apparentés sans préciser leur classe concrète. Une famille d’objets est un ensemble de classes ayant une certaine cohérence. Par exemple, si on considère des triplets : (Transport, Moteur, Contrôles), plusieurs variantes peuvent exister :

  • (Voiture, MoteurCombustion, Volant)
  • (Avion, MoteurRéaction, Manche)

Si votre programme fonctionne sans famille de produits, vous n’avez pas besoin d’une fabrique abstraite et une fabrique simple (patron vu précédemment) peut suffire.

Patrons structurels

Les patrons structurels servent à guider l’assemblage des objets et des classes afin d’obtenir des structures plus grandes tout en gardant celles-ci flexibles et efficaces. Les principaux patrons de conception structurels sont les suivants :

  • Adapter (adaptateur) : permet de faire collaborer des objets ayant des interfaces normalement incompatibles.
  • Bridge (pont) : permet de séparer une grosse classe ou un ensemble de classes connexes en deux hiérarchies — abstraction et implémentation — qui peuvent évoluer indépendamment l’une de l’autre.
  • Composite (composite) : permet d’agencer les objets dans des arborescences afin de pouvoir traiter celles-ci comme des objets individuels.
  • Decorator (décorateur) : permet d’affecter dynamiquement de nouveaux comportements à des objets en les plaçant dans des emballeurs qui implémentent ces comportements.
  • Facade (façade) : procure une interface qui offre un accès simplifié à une librairie, un framework ou à n’importe quel ensemble complexe de classes.
  • Flyweight (poids mouche) :Permet de stocker plus d’objets dans la RAM en partageant les états similaires entre de multiples objets, plutôt que de stocker les données dans chaque objet.
  • Proxy (procuration) : permet de fournir un substitut d’un objet. Une procuration donne le contrôle sur l’objet original, vous permettant d’effectuer des manipulations avant ou après que la demande ne lui parvienne.

Nous allons détailler le principe de certains patrons structurels.

Adapter (adaptateur)

L’Adaptateur est un patron de conception structurel qui permet de faire collaborer des objets ayant des interfaces normalement incompatibles. Supposons que la classe suivante existe :

public class Drawer {
  public void draw(Pencil pencil) {
    pencil.drawLine(0,0,10,10); 
    pencil.drawCircle(5,5,5); 
    pencil.drawLine(10,0,0,10); 
  }
}

Nous avons également l’interface suivante qui permet de dessiner des segments et des cercles à partir des coordonnées des points et du rayon du cercle :

public interface Pencil {
  public void drawLine(int x1, int y1, int x2, int y2);
  public void drawCircle(int x, int y, int radius);
}

Cette classe Drawer et cette interface Pencil ne peuvent pas être modifiées. Nous avons également la classe PointPencil suivante qui permet de dessiner des segments et des cercles à partir d’objets Point2D :

public class PointPencil {
  public void drawLineWithPoints(Point2D p1, Point2D p2) {
    /* ... */
  }

  public void drawCircleWithPoint(Point2D center, int radius) {
    /* ... */
  }
}

Nous souhaitons utiliser la classe PointPencil comme un Pencil pour qu’elle puisse être utilisée par la classe Drawer. Pour ce faire, nous définissons l’adaptateur suivant :

public class PointPencilAdapter implements Pencil {
  
  private PointPencil pointPencil;
  
  public CrayonAdapter(PointPencil pointPencil) { 
    this.pointPencil = pointPencil; 
  }

  public void drawLine(int x1, int y1, int x2, int y2) {
    pointPencil.drawLineWithPoints(new Point2D(x1, y1), 
                                   new Point2D(x2, y2));
  }

  public void drawCircle(int x, int y, int radius) {
    pointPencil.drawCircleWithPoint(new Point(x, y), radius);
  }
}

Cette classe est utilisable de la façon suivante :

Pencil pencil = new PointPencilAdapter(new PointPencil());
Drawer drawer = new Drawer();
drawer.draw(pencil);

De manière générale, le patron a le format suivant :

Decorator (décorateur)

Décorateur est un patron de conception structurel qui permet d’affecter dynamiquement de nouveaux comportements à des objets en les plaçant dans des emballeurs qui implémentent ces comportements. Supposons que nous avons la classe suivante :

public class ArrayStack {
  private int[] stack = new int[10]; 
  private int size = 0;

  public void push(int value) { 
    list[size] = value; 
    size++; 
  }
  
  public int pop() { 
    size--; 
    return list[size]; 
  }
}

Nous souhaitons ajouter des logs pour débugger notre programme :

public class ArrayStack {
  private int[] stack = new int[10]; 
  private int size = 0;

  public void push(int value) { 
    System.out.println("push("+value+")");
    list[size] = value; 
    size++; 
  }  
  
  public int pop() { 
     System.out.println("pop()");
     size--; 
     return list[size]; 
  }
}

Cette modification a été réalisée en modifiant une classe existante. De plus, une nouvelle modification est nécessaire pour retirer les logs. Nous avons donc un non-respect d’OCP.

Afin de résoudre cette difficulté, nous allons utiliser le patron décorateur. On commence par définir l’interface suivante :

public interface Stack {
  public void push(int value);
  public int pop();
}

On peut facilement faire en sorte que ArrayStack implémente cette interface (en utilisant le mot-clé implements) car la classe ArrayStack définit déjà les méthodes de l’interface.

public class ArrayStack implements Stack {
  /* ... */
}

Maintenant, il suffit de définir un décorateur :

public class VerboseStack implements Stack {
  private Stack stack;

  public VerboseStack(Stack stack) { this.stack = stack; }

  public void push(int value) { 
    System.out.println("push("+value+")");
    stack.push(value);
  }  
  
  public int pop() { 
     System.out.println("pop()");
     return stack.pop();
  } 
}

Supposons que nous ayons le code suivant :

Stack stack = new ArrayStack(10);
stack.push(2);
stack.pop();

Il est très facile d’introduire le décorateur :

Stack stack = new ArrayStack(10);
stack = new VerboseStack(stack);
stack.push(2);
stack.pop();

Ce code produit la sortie suivante :

push(2);
pop();

Nous pouvons aussi définir un nouveau décorateur :

public class CounterStack implements Stack {
  private Stack stack;
  private int size;

  public CounterStack(Stack stack) { 
    this.stack = stack; 
    size = 0; 
  }

  public void push(int value) { 
    size++; 
    stack.push(value); 
  }    
  
  public int pop() { 
    size--; 
    return stack.pop(); 
  }

  public int getSize() { 
    return size; 
  }
}

Un exemple d’utilisateur du décorateur précédent :

Stack stack = new ArrayStack(10);
CounterStack counterStack = new CounterStack(stack);
stack = counterStack;
stack.push(2); 
stack.push(3);
stack.pop();
System.out.println(counterStack.getSize());

Ce code produit la sortie suivante :

1

Il est possible d’utiliser plusieurs décorateurs simultanément :

Stack stack = new ArrayStack(10);
Stack verboseStack = new VerboseStack(stack);
CounterStack counterStack = new CounterStack(verboseStack);
stack = counterStack;
stack.push(2); 
stack.push(3);
stack.pop();
System.out.println(counterStack.getSize()); 

Ce code produit la sortie suivante :

push(2);
push(3);
pop();
1

La structure générale du patron décorateur est la suivante :

Patrons comportementaux

Les comportementaux qui permettent de gérer les algorithmes et la répartition des responsabilités entre les objets. Les principaux patrons comportementaux sont les suivants :

  • Chain of Responsibility (chaîne de responsabilité) : permet de faire circuler une demande dans une chaîne de handlers. Lorsqu’un handler reçoit une demande, il décide de la traiter ou de l’envoyer au handler suivant de la chaîne.
  • Command (commande) : Prend une action à effectuer et la transforme en un objet autonome qui contient tous les détails de cette action. Cette transformation permet de paramétrer des méthodes avec différentes actions, planifier leur exécution, les mettre dans une file d’attente ou d’annuler des opérations effectuées.
  • Iterator (itérateur) : permet de parcourir les éléments d’une collection sans révéler sa représentation interne (liste, pile, arbre, etc.).
  • Mediator (médiateur) : Permet de diminuer les dépendances chaotiques entre les objets. Ce patron restreint les communications directes entre les objets et les force à collaborer uniquement via un objet médiateur.
  • Memento (Mémento) : Permet de sauvegarder et de rétablir l’état précédent d’un objet sans révéler les détails de son implémentation.
  • Observer (observateur) : Permet de mettre en place un mécanisme de souscription pour envoyer des notifications à plusieurs objets, au sujet d’événements concernant les objets qu’ils observent.
  • State (état) : Modifie le comportement d’un objet lorsque son état interne change. L’objet donne l’impression qu’il change de classe.
  • Strategy (stratégie) : Permet de définir une famille d’algorithmes, de les mettre dans des classes séparées et de rendre leurs objets interchangeables.
  • Template Method (patron de méthode) : Permet de mettre le squelette d’un algorithme dans la classe mère, mais laisse les sous-classes redéfinir certaines étapes de l’algorithme sans changer sa structure.
  • Visitor (Patron de méthode) : Permet de séparer les algorithmes et les objets sur lesquels ils opèrent.

Iterator (itérateur)

Itérateur est un patron de conception comportemental qui permet de parcourir les éléments d’une collection sans révéler sa représentation interne (liste, pile, arbre, …).

Les collections font partie des types de données les plus usitées en programmation. Elles servent de conteneur pour un groupe d’objets généralement en utilisant une structure linéaire : listes, tableaux … Néanmoins, certaines d’entre elles sont basées sur structures plus complexes comme des arbres, des graphes ou d’autres structures complexes de données.


Quelle que soit sa structure, une collection doit fournir un moyen d’accéder à ses éléments pour permettre au code de les utiliser. Elle doit donner la possibilité de parcourir tous ses éléments sans passer plusieurs fois par les mêmes. Le but du patron de conception itérateur est d’extraire le comportement qui permet de parcourir une collection et de le mettre dans un objet que l’on nomme itérateur.

En java (c’est le cas aussi pour d’autres langages), le patron de conception itérateur est défini dans le langage et la bibliothèque standard. La première chose à définir pour le patron de conception itérateur est l’interface itérable qui indique que l’on peut itérer (parcourir les éléments) sur la collection. En Java, l’interface est la suivante (on a volontairement omis certaines méthodes) :

public interface Iterable<T> {
    /**
     * Returns an iterator over elements of type {@code T}.
     *
     * @return an Iterator.
     */
    Iterator<T> iterator();
}

L’interface Iterable est d’ailleurs étendue par l’interface Collection de Java. Elle ne contient qu’une méthode qui retourne un objet pour itérer sur la collection.

L’interface Iterator donne les méthodes à implémenter pour un objet permettant de parcourir les éléments d’une collection :

public interface Iterator<E> {
    /**
     * Returns {@code true} if the iteration has more elements.
     * (In other words, returns {@code true} if {@link #next} would
     * return an element rather than throwing an exception.)
     *
     * @return {@code true} if the iteration has more elements
     */
    boolean hasNext();

    /**
     * Returns the next element in the iteration.
     *
     * @return the next element in the iteration
     * @throws NoSuchElementException if the iteration has no more elements
     */
    E next();
}

On a deux méthodes à implémenter hasNext qui permet de tester s’il reste des éléments sur lesquels on peut itérer et next qui renvoie le prochain élément dans l’ordre d’itération et met à jour l’itérateur pour que le prochain appel de next renvoie l’élément après et ainsi de suite. Pour itérer sur un tableau, on peut écrire le code suivant :

public class IteratorArray {
  Object[] array;
  int position = 0;
  IteratorArray(Object[] array){
    this.array = array;
  }
    
  boolean hasNext(){
    return (position<array.length);
  }
  
  Object next(){
    position++;
    return array[position-1];
  }
}

Pour illustrer l’utilisation du patron de conception itérateur, on considère la classe suivante qui permet de stocker des éléments dans une grille avec des lignes et des colonnes.

public class GridContainer<E> implements Iterable<E> {
  private final int numberOfRows;
  private final int numberOfColumns;
  private final Object[][] elements;
  public GridContainer(int numberOfRows, int numberOfColumns) {
    if(numberOfRows <= 0)
      throw new IllegalArgumentException("The number of rows must be positive and not equal to " + numberOfRows);
    if(numberOfColumns <= 0)
      throw new IllegalArgumentException("The number of columns must be positive and not equal to " + numberOfColumns);
    this.numberOfRows = numberOfRows;
    this.numberOfColumns = numberOfColumns;
    elements = new Object[numberOfRows][numberOfColumns];
  }
  @SuppressWarnings("unchecked")
  public E getElement(int rowIndex, int columnIndex) {
    return (E) elements[rowIndex][columnIndex];
  }
  public void setElement(int rowIndex, int columnIndex, E value) {
    elements[rowIndex][columnIndex] = value;
  }

  public int getNumberOfRows() {
    return numberOfRows;
  }

  public int getNumberOfColumns() {
    return numberOfColumns;
  }

  @Override
  public Iterator<E> iterator() {
    return new GridIterator<>(this);
  }
}

L’itérateur permettant de visiter les éléments de la grille ligne par ligne a le code suivant :

public class GridContainer<E> implements Iterable<E> {
  private final int numberOfRows;
  private final int numberOfColumns;
  private final Object[][] elements;
  public GridContainer(int numberOfRows, int numberOfColumns) {
    if(numberOfRows <= 0)
      throw new IllegalArgumentException("The number of rows must be positive and not equal to " + numberOfRows);
    if(numberOfColumns <= 0)
      throw new IllegalArgumentException("The number of columns must be positive and not equal to " + numberOfColumns);
    this.numberOfRows = numberOfRows;
    this.numberOfColumns = numberOfColumns;
    elements = new Object[numberOfRows][numberOfColumns];
  }
  @SuppressWarnings("unchecked")
  public E getElement(int rowIndex, int columnIndex) {
    return (E) elements[rowIndex][columnIndex];
  }
  public void setElement(int rowIndex, int columnIndex, E value) {
    elements[rowIndex][columnIndex] = value;
  }

  public int getNumberOfRows() {
    return numberOfRows;
  }

  public int getNumberOfColumns() {
    return numberOfColumns;
  }

  @Override
  public Iterator<E> iterator() {
    return new GridIterator<>(this);
  }
}

Puisque GridContainer implémente Iterable, on peut l’utiliser dans une boucle for each (aussi appelé enhanced for) qui permet directement de parcourir les éléments de GridContainer dans l’ordre défini par l’itérateur. Si on considére le code suivant :

GridContainer<String> grid = new GridContainer<>(2,3);
for(int row = 0; row<grid.getNumberOfRows(); row++)
  for (int column = 0; column<grid.getNumberOfColumns(); column++)
    grid.setElement(row,column,"(" + row + ", " + column + ")");
for (String element : grid)
  System.out.println(element);

Ce code produit la sortie suivante :

(0, 0)
(0, 1)
(0, 2)
(1, 0)
(1, 1)
(1, 2)

Visitor (visiteur)

Visiteur est un patron de conception comportemental qui vous permet de séparer les implémentations de méthodes et les objets sur lesquels ils opèrent.

Dans une section précédente, nous avons défini l’interface suivante :

public interface Shape {
  void draw(GraphicsContext context);
  float area();
}

Dans cette interface, sont regroupés deux services assez différents (calcul géométrique et dessin dans un contexte graphique) ce qui constitue une violation de SRP.

Cette interface est implémentée par la classe suivante :

public class Rectangle implements Shape {
    public float x, y, w, h;

    public Rectangle(float x, float y, float w, float h) {
        this.x = x; 
        this.y = y; 
        this.w = w; 
        this.h = h;
    }

    public void draw(GraphicsContext context) {
        context.strokeRect(x, y, h, w);
    }

    public float area() { 
      return w * h; 
    }
}

Dans cette classe, il y a l’implémentation de deux services assez différents. On peut imaginer des raisons différentes pour lesquelles le code des deux méthodes pourrait changer (changement de bibliothèque graphique pour draw et passage à un type de retour double pour area afin d’éviter des problèmes de représentation d’aires trop grande pour être stocké dans un float). La classe ne respecte donc pas SRP dans cette optique.

On peut représenter les correspondances entre classes et méthodes dans un tableau :


Dans le tableau ci-dessus, on a :

  • Une classe par colonne
  • Une méthode par ligne
  • SRP violé, car plusieurs responsabilités dans chaque classe
  • OCP violé, car le nombre de lignes peut augmenter

Une solution est de définir une classe par ligne en utilisant le patron de conception visiteur. On commence par créer une interface Shape qui va accepter des visiteurs.

public interface Shape {
  <R> R accept(ShapeVisitor<R> visitor);
}

Ensuite, on définit une interface pour les visiteurs avec une méthode de visite par classe à visiter. L’interface est paramétrée par le type de retour de la méthode de visite. Cela permet d’avoir par exemple un visiteur pour le calcul des aires qui renvoie un Float et un visiteur de dessin qui ne renvoie rien avec Void.

public interface ShapeVisitor<R> {
  R visit(Rectangle rectangle);
  R visit(Circle circle);
}

Dans les classes Circle et Rectangle, qui implémentent chacune l’interface Shape, n’ont plus qu’une seule méthode accept et un constructeur :

public class Circle implements Shape {
  public final float x, y;
  public final float radius;

  public Circle(float x, float y, float radius) {
    this.x = x;
    this.y = y;
    this.radius = radius;
  }

  @Override
  public <R> R accept(ShapeVisitor<R> visitor) {
    return visitor.visit(this);
  }
}
public class Rectangle implements Shape {
  public float x, y, w, h;

  public Rectangle(float x, float y, float w, float h) {
    this.x = x; this.y = y; this.w = w; this.h = h;
  }

  @Override
  public <R> R accept(ShapeVisitor<R> visitor) {
    return visitor.visit(this);
  }
}

La méthode accept se contente d’appeler la méthode visit du visiteur sur l’objet courant et de renvoyer le résultat. Toute l’implémentation de la méthode sera donc dans le visiteur. On peut remarquer que bien que le nom de fonction appelée soit le même (visit), les méthodes appelées sont différentes. En effet, dans Circle c’est la méthode R visit(Circle circle) qui est appelée, car this est de type Circle alors que dans Rectangle, c’est la méthode R visit(Rectangle rectangle) qui est appelée.

Maintenant, on doit fournir des implémentations de l’interface Visitor qui permettent le dessin et le calcul de l’aire. On commence donc par l’implémentation du visiteur par une classe DrawerVisitor :

public class DrawerVisitor implements ShapeVisitor<Void> { 

  private GraphicsContext context;
  
  public DrawerVisitor(GraphicsContext context) {
    this.context = context;
  }
  
  public void draw(List<Shape> shapes) {
    for (Shape shape : shapes)
      shape.accept(this);
  }

  @Override
  public Void visit(Rectangle rectangle) {  
    context.strokeRect(rectangle.x, rectangle.y, 
                       rectangle.h, rectangle.w);
    return null;
  }

  @Override    
  public Void visit(Circle circle) { 
    context.strokeOval(circle.x - circle.radius, 
                       circle.y - circle.radius,
                       circle.radius*2, circle.radius*2);
    return null;
  }
}

Ici, on a une petite particularité du fait que le type de retour est Void qui représente le type void dans le cas d’un type générique. Pour ce type de retour, la seule valeur acceptée est null d’où les return null dans les méthodes de visite.

On peut continuer avec l’implémentation du visiteur AreaVisitor :

public class AreaVisitor implements ShapeVisitor<Float> { 
  
  public float sumOfArea(List<Shape> shapes) {
    float sum = 0;
    for (Shape shape : shapes)
      sum += shape.accept(this);
    return sum;
  }


  @Override
  public Float visit(Rectangle rectangle) {
    return rectangle.w * rectangle.h;
  }

  @Override
  public Float visit(Circle circle) {
    return Math.pow(circle.radius,2) * Math.PI;
  }
}

De manière générale, le patron de conception visiteur a la structure suivante :

Conclusion

Nous avons vu que 23 patrons de conceptions existent et nous n’en avons étudié que certains. Si ce domaine vous intéresse je vous recommande l’ouvrage de référence, écrit par les quatre concepteurs initiaux des patrons de conceptions : “Design patterns. Catalogue des modèles de conception réutilisables” par Gamma, Helm, Johnson et Vlissides chez Vuibert Informatique. Il faut connaître les patrons de conceptions pour pouvoir les utiliser dans des cas précis, tout en faisant attention de ne pas en abuser. Ils permettent de résoudre beaucoup de problèmes, mais ne sont pas toujours optimaux et peuvent parfois introduire une complexité excessive. De même, il peut être très difficile de respecter tous les principes SOLID. En fait, ces principes s’appliquent dans le cadre d’un code qui évolue. Par exemple, en ce qui concerne SRP, si votre code n’évolue pas d’une manière telle que deux responsabilités présentes au sein d’une même classe changent à des moments différents, il n’est pas nécessaire de les séparer. En effet, les séparer serait une source de complexité inutile. Il y a un corollaire à cela. Un axe de changement n’est un axe de changement que si les changements se produisent. Il n’est pas judicieux d’appliquer SRP, ou tout autre principe, d’ailleurs, s’il n’y a pas de symptôme.

Ce cours finit la partie sur la Conception Orientée Objet. Vous devez maintenant posséder l’essentiel des connaissances vous permettant de concevoir des logiciels avec cette approche. De même, en fonction de vos affinités ou de vos obligations, vous allez développer cette compétence dans un langage particulier. Dans le cadre de ce cours, on a utilisé exclusivement le langage Java, mais rien ne vous empêche d’utiliser d’autres langages permettant le paradigme de programmation orienté objet comme Python, C++, …