TP3 – Qualité du code - Les principes SOLID SOLID, Qualité, Interface, Abstraction, Héritage, Composition, Injection de dépendances, Design Patterns

Attention, si vous aviez déjà commencé ce TP lors de votre dernière séance, il faut mettre à jour votre dépôt local avec les dernières modifications du dépôt d’origine ! :

# Depuis le dossier de votre dépôt, en local.
git remote add upstream git@gitlabinfo.iutmontp.univ-montp2.fr:qualite-de-developpement-semestre-3/tp3.git
git fetch upstream
git merge upstream/master master

Dans la première partie de cette ressource, nous avons parlé de conception logicielle et notamment comment modéliser cela à l’aide de diagrammes de classes de conception et de diagrammes de séquences des interactions.

Cependant, savoir modéliser la conception ne garantit en rien la qualité de celle-ci. Un plan de construction d’un bâtiment peut être tout à fait valide d’un point de vue technique, mais donnera potentiellement une bâtisse qui s’écroulera dans quelques années si elle a été mal pensée.

On a la même logique au niveau du développement logiciel. Un logiciel mal conçu peut répondre à un besoin et satisfaire le client et ses utilisateurs dans l’immédiat, mais il sera alors très difficile de le faire évoluer sur la durée.

De manière générale, les principes liés à la qualité du développement s’assurent que le logiciel que vous allez construire pourra évoluer facilement tout en satisfaisant les besoins actuels.

Tout cela est difficile à mettre en place au premier abord, car il peut parfois être bien plus rapide et facile de développer une solution peu qualitative, mais qui fonctionne. Néanmoins, le code produit ne tiendra pas au fur et à mesure que l’application va grossir ce qui conduira finalement à la réécriture d’une partie voir de la totalité du code ou pire, l’abandon du projet.

C’est un phénomène qui touche beaucoup d’entreprises du monde du développement. Face au besoin de délivrer une solution rapidement, l’aspect qualité est parfois négligé. Au bout de plusieurs années, il est très compliqué d’ajouter de nouvelles fonctionnalités et d’éviter les bugs. Une nouvelle personne rentrant dans le projet ne comprend rien au code. Le client n’est plus satisfait, car les nouvelles fonctionnalités sont délivrées moins fréquemment et de plus en plus de bugs apparaissent. C’est une barque qui prend l’eau sur laquelle on place du sparadrap pour boucher les trous. Mais à chaque fois qu’un trou est bouché, deux nouveaux apparaissent. Le client transfère le projet à une autre entreprise, qui ne comprend rien à ce qu’elle récupère… Plus formellement, on dit que la dette technique s’accumule.

Tout cela est aussi dur pour le développeur. Travailler dans un tel environnement est très désagréable et peut même dégoûter du développement. Jusqu’ici, vous avez travaillé sur des projets relativement petits (même pour vos SAEs) mais vous devriez être plus conscients de cette problématique après votre période de stage ou d’alternance.

L’ingénieur doit avoir une vision à long terme et prendre en compte les évolutions possibles de son programme. Pour l’aider dans cette tâche, il dispose de différents outils : les principes liés à la qualité du code, comme les principes SOLID et aussi les design patterns. La maîtrise de ces outils différencie un codeur (une IA ?) d’un ingénieur. Le codeur sait produire du code, l’ingénieur sait produire des logiciels durables.

Les frameworks sont des outils qui englobent différents design patterns et “forcent” le développeur (de par leur structure) à respecter un certain niveau de qualité dans la conception (parfois, sans qu’il s’en rende compte). Nous allons étudier plusieurs frameworks l’année prochaine.

Bref, dans ce cours, nous allons commencer par nous intéresser aux principes SOLID qui constituent la porte d’entrée vers un programme bien conçu. Nous allons également commencer à parler de design patterns (nous allons en introduire 2) mais nous les étudierons plus en détail dans les prochains TP.

Les principes SOLID

Les principes SOLID représentent un acronyme lié aux 5 principes clés pour obtenir un logiciel qualitatif :

Le respect de ces principes permet d’améliorer le principe de faible couplage et de forte cohésion des classes (que nous avons abordé précédemment), l’évolutivité du logiciel, la réduction du risque de bugs liés à l’architecture…

Les design patterns sont des solutions à des problèmes bien définis qui fournissent des modèles réutilisables et adaptables sur n’importe quel projet. Ces patterns permettent notamment de respecter les principes SOLID.

Dans un logiciel développé d’une telle manière, généralement, l’ajout d’une nouvelle fonctionnalité se résume à l’ajout de nouvelles classes ou de méthodes sans avoir besoin de modifier ou de récrire les classes existantes. Le programme est ouvert à l’extension, mais fermé aux modifications (qui pourraient entraîner elles-mêmes d’autres modifications…).

Dans ce TP, nous allons étudier chaque principe et nous allons vous fournir du code qui est (la plupart du temps) fonctionnel, mais développé sans respecter SOLID. Vous allez alors constater que :

Ensuite, nous allons voir comme un des principes SOLID permet de régler cela, et vous allez donc devoir refactorer les différentes applications. Refactorer du code (ou réusiner du code en français) signifie retravailler le code source du programme sans pour autant ajouter de nouvelles fonctionnalités à l’application. Il s’agit d’améliorer la qualité du code.

  1. Pour commencer, forkez le dépôt gitlab suivant en le plaçant dans le namespace qualite-de-developpement-semestre-3/etu/votrelogin.

  2. Clonez votre nouveau dépôt en local. Ouvre le projet avec IntelliJ et vérifiez qu’il n’y a pas d’erreur.

  3. Pendant ce TP, on vous demandera de créer des diagrammes UML de conception (classes, séquences). Vous déposerez vos diagrammes dans un dossier uml (en image ou bien avec le fichier du projet .mdj si vous utilisez StarUML) que vous devrez créer à la racine du dépôt.

  4. À la fin de chaque séance, n’oubliez pas de faire un push vers votre dépôt distant sur Gitlab.

Ce projet contient divers paquetages contenant le code de base pour chaque exercice que nous allons traiter.

Principe de responsabilité unique (Single Responsability)

Pour mener à bien le déroulement d’une fonctionnalité, le programme va faire appel à diverses classes qui vont interagir entre elles (comme nous l’avons vu avec le DSI). Ces classes vont traiter la demande. Chaque classe possède la responsabilité d’effecteur une partie de ce traitement.

Le principe de responsabilité unique indique qu’une classe ne doit pas posséder plus d’une responsabilité. Une responsabilité concerne des opérations (traitement, méthodes) de même nature. Nous avions déjà abordé cela plus tôt dans le cours sur les diagrammes de séquences en parlant d’architecture centralisée et distribuée. Nous avions vu qu’une distribution du traitement (et donc des responsabilités) était plus conseillée. C’est un peu le même principe ici.

Robert C. Martin dit : “Si une classe a plus d’une responsabilité, alors ces responsabilités deviennent couplées. Des modifications apportées à l’une des responsabilités peuvent porter atteinte ou inhiber la capacité de la classe de remplir les autres. Ce genre de couplage amène à des architectures fragiles qui dysfonctionnent de façon inattendue lorsqu’elles sont modifiées.

En bref, une classe ne doit changer que pour une seule raison. Si diverses raisons liées à des responsabilités différentes impliquent de modifier la classe, le principe de responsabilité unique n’est donc pas respecté.

Par exemple, considérons le code suivant :

class Email {

   private String sujet;

   private String[] destinataires;

   private String contenu;

   public Email(String sujet, String[] destinataires, String contenu) {
      this.sujet = sujet;
      this.destinataires = destinataires;
      this.contenu = contenu;
   }

   public String getSujet() {
      return sujet;
   }

   public String[] getDestinataires() {
      return destinataires;
   }

   public void envoyer() {
      //Code complexe pour envoyer un mail...
   }

}

class Main {

   public static void main(String[]args) {
      Email m = new Email("Hello", new String[]{"test@example.com"}, "Hello world!");
      m.envoyer();
   }

}

Ici, la classe Email a deux responsabilités clairement identifiables : stocker les informations d’un mail et l’envoyer. Le principe de responsabilité unique n’est donc pas respecté. Pour régler cela, il faudrait donc mettre en place une nouvelle classe qui se charge de l’envoi d’un mail :

class Email {

   private String sujet;

   private String[] destinataires;

   private String contenu;

   public Email(String sujet, String[]destinataires, String contenu) {
      this.sujet = sujet;
      this.destinataires = destinataires;
      this.contenu = contenu;
   }

   public String getSujet() {return sujet;}

   public String[] getDestinataires() {return destinataires;}

}

class ServeurMail {

  public void envoyerMail(Email mail) {
      //Code complexe pour envoyer un mail...
  }

}

class Main {

   public static void main(String[]args) {
      Email e = new Email("Hello", new String[]{"test@example.com"}, "Hello world!");
      ServeurMail serveur = new ServeurMail();
      serveur.envoyerMail(e);
   }

}

Ici, chaque classe à une responsabilité unique : si la logique pour envoyer un mail change, la classe Email n’est pas impactée.

Le principe de responsabilité unique s’applique également aux paquetages : chaque paquetage est lié à une responsabilité du programme. Par exemple, sur le TP JDBC que vous faites en bases de données, chaque paquetage à un rôle (et donc une responsabilité) précis : IHM, controllers, services, stockage… (et on pourrait (même devrait) aller plus en détail).

Ce principe semble assez facile à mettre en place, mais dans la réalité, on retrouve malheureusement des classes (et des paquetages) “fourre-tout” qui deviennent illisibles au fur et à mesure de l’évolution du programme. Si votre classe a trop de méthode, c’est peut-être qu’elle possède plus d’une responsabilité et que celles-ci pourraient être mieux réparties.

  1. Ouvrez le paquetage srp1. Examinez le code. Il s’agit d’un programme qui permet de faire un simple calcul (pour le moment, une addition). Actuellement, la classe Client possède trois responsabilités (certaines sont très simples et tiennent sur une ligne). Identifiez-les.

  2. Refactorez le code pour répartir les responsabilités de Client en trois classes.

  3. Assurez-vous qu’en effectuant les changements suivants, vous ne modifiez jamais la même classe deux fois :

    • On veut que le calcul effectué soit une soustraction.

    • On veut que l’affichage final soit “Résultat : valeur”.

    • Pour la saisie, on veut plutôt utiliser un Scanner et la méthode nextInt au lieu d’un BufferedReader (il faudra enlever le catch de IOException et les parseInt). Pour rappel, pour définir un Scanner :

     Scanner scanner = new Scanner(System.in);
    

Bien sûr, ce premier exercice est très simpliste, mais il faut vous imaginer que les différentes responsabilités sont généralement des traitements plus longs et/ou plus complexes !

Voyons maintenant un autre exemple.

  1. Ouvrez le paquetage srp2. Il contient une classe Rectangle qui permet de créer des rectangles et de calculer leur aire.

  2. Une application graphique souhaite pouvoir afficher les rectangles. Une première idée est d’utiliser la classe JFrame (de la librairie Java Swing), ce qui permettra de l’afficher :

    • Faites étendre la classe JFrame à Rectangle.

    • Dans le constructeur, ajoutez le code suivant qui permettra de configurer la fenêtre affichant le rectangle :

      setSize(largeur * 2, hauteur * 2);
      setTitle("Rectangle");
      setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
      
    • Redéfinissez la méthode paint(Graphics g) de la classe JFrame de la façon suivante :

      @Override
      public void paint(Graphics g) {
          g.drawRect(largeur/2, hauteur/2, largeur, hauteur);
      }
      
    • Pour afficher votre rectangle dans le main, utilisez simplement la méthode suivante :

      //Affiche une fenêtre contenant le rectangle.
      rectangle.setVisible(true);
      
    • Testez.

  3. Certaines applications aimeraient aussi utiliser simplement la classe Rectangle pour faire des calculs géométriques sans pour autant avoir besoin de l’afficher. À ce stade, vous avez sans doute clairement identifié que la classe Rectangle possède trop de responsabilités : la gestion de la forme géométrique et de ses propriétés (aire, peut-être plus tard périmètre, etc.) et l’affichage graphique. La classe peut changer si on ajoute de nouvelles opérations ou si l’affichage graphique change. Le principe de responsabilité unique n’est pas respecté.

  4. L’idée est d’avoir deux classes : une première, gérant la forme rectangle et l’autre permettant de l’afficher. Mais pour autant, il ne faut pas dupliquer de code ! Refactorez votre code en conséquence afin de mettre en place cette nouvelle conception.

  5. Assurez-vous que tout fonctionne (il faudra sans doute adapter la main) et qu’il est bien possible d’avoir de créer des rectangles ne contenant aucune logique d’affichage et des rectangles qu’il est possible d’afficher.

Principe Ouvert/Fermé (Open/Close)

Après cette mise en bouche, il est temps d’attaquer de sérieux problèmes de conception en développant le principe ouvert/fermé. Ce principe est défini comme suit :

Les entités d’un logiciel (classes, modules, fonctions) doivent être ouverts aux extensions, mais fermés aux modifications.” (Bertand Meyer).

En d’autres termes, il doit être possible d’étendre les fonctionnalités/le comportement d’une entité comme une classe sans pour autant avoir besoin de modifier son code source.

Ce principe est un pilier fondamental de qualité de code. Avec ce principe, même une classe ou une librairie compilée (non modifiable) autorise le développeur à étendre les fonctionnalités proposées.

Malheureusement, dans de nombreux projets, on rencontre fréquemment des classes violant ce principe, car mal conçues. Les symptômes sont généralement :

Pour vous mettre dans le bain et vous montrer la problématique derrière tout cela, vous allez ajouter de nouvelles fonctionnalités à des projets existants qui ne respectent pas le principe ouvert/fermé.

  1. Ouvrez le paquetage ocp1. Dans ce projet, une classe Animal permet de gérer différents types d’animaux et leur cri, selon l’attribut type de l’objet.

  2. On aimerait prendre en charge les chiens et les poules. Modifiez la classe Animal en conséquence et testez dans le Main.

  3. Que se passe-t-il si vous essayez de créer un animal qui n’existe pas et d’afficher son cri ? Pour régler cela, ajoutez une clause default dans le switch affichant simplement “Animal inconnu”.

Votre programme fonctionne ? C’est super ! Mais votre code serait-il encore lisible s’il y avait une centaine d’animaux ?

Continuons maintenant avec de petits monstres bien populaires : les pokémons.

Le programme sur lequel nous allons travailler fait combattre des pokémons dans un simulateur. Il y a différents types de pokémons (feu, eau, plante…) et chaque type de pokémon possède une attaque (le type feu attaque toujours avec lance-flamme). Bien sûr, cette analyse est un peu bizarre et ne correspond pas à la réalité du jeu (où chaque pokémon possède plusieurs attaques, et pas une attaque précise selon le type) mais nous allons l’utiliser ainsi pour ne pas plus compliquer l’exercice.

En plus de l’attaque, le type de pokémon est aussi lié à un nombre de dégâts. Par exemple, le type feu fait toujours 60 dégâts et le type plante entre 40 et 80 dégâts.

Pour modéliser tout cela, le développeur a choisi de mettre en place un héritage pour gérer les différents types. Pour savoir quelle attaque annoncer dans le simulateur et quels dégâts infliger, il a besoin de vérifier quel est le pokémon qui attaque.

De même, dans la classe Pokemon, pour que le pokémon puisse se présenter avec son type (dans la méthode toString), la classe vérifie son type concret.

  1. Ouvrez le paquetage ocp2. Explorez les classes. Étudiez notamment la classe SimulateurCombat.

  2. On souhaite ajouter un nouveau type de pokémon : le pokémon type électricité. Son attaque est Eclair et il fait entre 20 et 100 dégâts. Faites en sorte de prendre en charge ce nouveau type, sans modifier la structure du code existant.

  3. On souhaite ajouter un nouveau type de pokémon : le pokémon type psy. Son attaque est Choc mental et il fait précisément 60 dégâts. Faites en sorte de prendre en charge ce nouveau type, sans modifier la structure du code existant.

  4. Modifiez le Main pour faire combattre un pokémon possédant le type électrique contre un pokémon possédant le type psy. Attention, il ne faut pas modifier les autres classes créées auparavant !

  5. Ajoutez encore un nouveau pokémon (avec le type, l’attaque et les dégâts de votre choix…)

Si ce n’était peut-être pas encore assez évident avec les animaux, à ce stade, vous devez vous rendre compte qu’ajouter un nouveau type de pokémon est fastidieux :

De plus, ici, seules deux fonctions ont vraiment besoin de connaître les détails des pokémons. Mais que se passerait-il s’il y avait beaucoup plus de méthodes et de classes qui en dépendent ? À chaque ajout d’un nouveau type, il faudrait modifier l’ensemble de ces classes et de ces méthodes ! Dans un gros projet, cela deviendrait vite un calvaire. De plus, il est facile d’oublier de mettre à jour une classe, ce qui ne provoque pas d’erreur de compilation, mais un bug lors de l’exécution (comme avec les animaux inconnus). Donc, la dette technique va croître beaucoup trop vite.

Le respect du principe ouvert/fermé va permettre de se limiter aux deux premières étapes : création d’une classe et implémentation des méthodes nécessaires dans cette même classe.

Considérons l’exemple suivant, similaire à ce que vous venez de faire :


class FigureGeometrique {

   private String typeFigure;

   public FigureGeometrique(String typeFigure) {
      this.typeFigure = typeFigure;
   }

   public void dessiner() {
      if(typeFigure == "rectangle") {
         dessinerRectangle();
      }
      else if(typeFigure == "triangle") {
         dessinerTriangle();
      }
   }

   private void dessinerRectangle() {
      //Code pour dessiner un rectangle...
   }

   private void dessinerTriangle() {
      //Code pour dessiner un triangle...
   }

}

L’ajout d’une nouvelle figure géométrique (par exemple, un carré) nécessite la modification de la classe FigureGeometrique et de la méthode dessiner. Le principe ouvert/fermé n’est pas respecté. De plus, un bug (à l’exécution) se produira si on essaie de traiter une figure géométrique qui n’existe pas !

Pour régler ce problème, l’idée est de se reposer sur un système d’héritage et d’abstraction :


abstract class FigureGeometrique {

  public abstract void dessiner();

}

class Rectangle extends FigureGeometrique {

  public void dessiner() {
    //Code pour dessiner un rectangle...
  }
}

class Triangle extends FigureGeometrique {

  public void dessiner() {
    //Code pour dessiner un triangle...
  }
}

Open-close 1

L’ajout d’une nouvelle figure nécessite donc simplement l’ajout d’une nouvelle classe correspondant à la figure et l’implémentation des méthodes requises (ici, dessiner la figure). Aucune autre classe n’est modifiée. L’extension est possible (ajout de nouvelles figures) sans modification du code source déjà présent. Par ailleurs, il est maintenant impossible de créer des figures géométriques qui n’existent pas au préalable dans notre programme (c’est une bonne chose !).

Comme FigureGeometrique ne contient aucun attribut (qui pourraient être communs à toutes les figures) et définit simplement des méthodes abstraites, il serait plus judicieux d’utiliser une interface ! Par contre, si la classe abstraite possède des attributs et/ou définit un bout de comportement commun à toutes les sous-classes, on utilisera bien une classe abstraite.

Ceci devrait vous permettre de refactorer le code du paquetage ocp1 (animaux). Pour ocp2 (pokémons) cela peut sembler un peu plus dur (car il y a déjà un système d’héritage), mais cela ne devrait pas être trop dur à adapter.

  1. Refactorez le code du paquetage ocp1 afin de respecter le principe ouvert/fermé. Adaptez le Main en conséquence et vérifiez que tout fonctionne. Il ne doit plus être possible de gérer des animaux inconnus, et c’est bien normal !

  2. Réaliser un diagramme de classes de conception (hors Main) de l’application du paquetage ocp2 (avant refactoring). Indiquez bien les dépendances.

  3. Refactorez le code du paquetage ocp2 afin de respecter le principe ouvert/fermé. Adaptez le Main en conséquence et vérifiez que tout fonctionne.

  4. Réaliser un diagramme de classes de conception (hors Main) de l’application du paquetage ocp2 (après refactoring). Indiquez bien les dépendances.

En comparant vos deux diagrammes de classes, on peut facilement voir ce qui différencie la “mauvaise” conception de la “bonne” : dans votre premier diagramme, les classes SimulateurCombat et Pokemon ont autant de dépendances qu’il y a de sous-classes de type de pokémon. S’il y a 30 types de pokémons, SimulateurCombat et Pokemon auront chacune 30 dépendances. Après refactoring, ces dépendances disparaissent. SimulateurCombat est seulement dépendant de Pokemon.

Dans un code de qualité les abstractions ne dépendent pas des implémentations. En d’autres termes, une superclasse ne devrait pas dépendre de ses sous-classes. Seules les sous-classes peuvent dépendre de leurs parents (et on verra que parfois, là aussi, il faut faire attention lorsqu’on utilise l’héritage.). Sur votre premier diagramme, il est clair que ce principe n’est pas respecté, car Pokemon dépendait de ses différentes sous-classes, ce qui n’est plus les cas sur le deuxième diagramme.

Nous allons mettre à l’épreuve votre compréhension des deux principes (S et O) avec un nouvel exercice un peu différent de ce que vous venez de voir, dans sa forme.

  1. Ouvrez le paquetage ocp3. Ce projet permet de gérer un paquet de 52 cartes, de le trier, de le mélanger… Exécutez le programme pour observer le rendu. Deux méthodes de tri sont possibles. On définit la méthode de tri utilisée quand on construit l’objet et on peut la changer à tout moment avec un setter.

  2. On aimerait ajouter une nouvelle méthode de tri par bulles. Voici cet algorithme illustré sur un simple tableau d’entiers :

     //Soit t, un tableau contenant des entiers
     for(int i=t.length-1;i>=0;i--) {
       for(int j=0; j < i; j++) {
         if(t[j] > t[j+1]) {
           int temp = t[j];
           t[j] = t[j+1];
           t[j+1] = temp;
         }
       }
     }
    

    Faites en sorte que la classe Paquet puisse effectuer un tri à bulles.

    Pour vous aider, vous pourrez utiliser la méthode compareTo qui permet de comparer deux cartes. Cette méthode renvoie un nombre négatif si la première carte est strictement inférieure à la seconde, 0 si elles sont égales et un nombre positif si la première carte est strictement supérieure à la seconde.

  3. Testez que votre algorithme fonctionne bien en changeant la méthode de tri du paquet dans le Main.

  4. À votre avis, en quoi les principes de responsabilité unique et ouvert/fermé ne sont pas respectés ?

Comme vous l’avez sans doute déduit, dans un premier temps, le principe ouvert/fermé n’est pas respecté : ajouter un nouveau tri demande de modifier le code source de la classe Paquet et notamment la méthode trier.

Dans un second temps, on remarque aussi que la classe Paquet a peut-être un peu trop de responsabilités : cela ne devrait pas être à elle de trier les cartes ! On pourrait aussi dire la même chose pour le mélange, et peut-être même pour l’affichage ! La vérification peut se faire facilement :

Le principe de responsabilités unique n’est pas respecté. Pour le toString, cela peut encore se discuter, mais cela est clair pour le tri et le mélange.

  1. Refactorez le code afin de respecter le principe ouvert/fermé au niveau des tris. On souhaite tout de même garder la possibilité de changer de tri avec un setter et aussi de définir le tri utilisé via le constructeur. Quelques indices :

    • Isolez le code de chaque méthode de tri dans des classes dédiées.

    • Généralisez le tout avec une interface.

    • Utilisez cette interface dans la classe Paquet, à la place de typeTri.

  2. Refactorez le code concernant le mélange afin de respecter le principe de responsabilité unique. Prévoyez aussi le cas où d’autres méthodes de mélanges pourraient être ajoutées. Comme pour le tri, on souhaite pouvoir définir la méthode de mélange dans le constructeur et la modifier via un setter.

  3. Testez que tout fonctionne, essayez plusieurs méthodes de tri sur le paquet, notamment. Normalement, vous ne devez jamais éditer la classe Paquet si vous changez le tri utilisé.

  4. Réalisez un diagramme de classes de conception votre application (sans la classe Main).

Les principes SOLID se combinent naturellement entre eux. D’ailleurs, si vous avez refactoré proprement le dernier exercice, vous avez même déjà utilisé le principe d’inversion des dépendances dont nous parlerons plus tard !

Encore mieux, vous venez d’utiliser votre premier design pattern au niveau des tris : Stratégie. Ce pattern permet d’injecter un comportement spécifique dans une classe sans en modifier le code source (et éventuellement, le modifier plus tard). Ce pattern s’appuie sur ouvert/fermé, l’inversion des dépendances et aide à renforcer responsabilité unique. C’est exactement ce que vous venez de faire : la méthode de tri du paquet est modulable et on peut même en ajouter des nouvelles dans le futur ! Et tout cela, sans modifier Paquet.

À partir du diagramme de classes de conception que vous venez de réaliser, vous devriez être en mesure de généraliser le pattern stratégie à tout type de problème.

Héritage, composition et principe ouvert-fermé

Certains développeurs abusent de l’héritage par facilité au lieu d’utiliser d’autres solutions comme la composition d’objets. Un “mauvais” héritage est un héritage où il n’existe pas vraiment de relation de spécialisation entre la superclasse et la sous-classe. La sous-classe ne représente alors pas le même concept que sa classe mère, ce n’est pas vraiment une spécialisation.

Tout cela occasionne des bugs parfois inattendus.

  1. Ouvrez le paquetage heritage. Dans ce programme, on gère des comptes bancaires. La première classe CompteBancaire permet d’effectuer des opérations et de gérer un solde. La deuxième classe CompteBancaireAvecHistorique permet de gérer l’historique de toutes les opérations réalisées. Il semble s’agir d’un compte bancaire spécialisé, on lui fait donc étendre la classe CompteBancaire.

  2. Une classe de test unitaire est présente dans test/java/heritage. Lisez les tests et exécutez-les. Tout devrait bien se passer.

  3. Le développeur à l’origine de la classe CompteBancaire souhaite revoir sa classe et résoudre la duplication de code. Pour cela, il va légèrement réfactorer afin d’éviter la duplication de code. Dans la méthode effectuerTransactions de CompteBancaire, il souhaite donc plutôt appeler la méthode effectuerTransaction. Faites cette modification.

  4. Relancez les tests unitaires… Le second ne passe plus. Pourquoi ?

  5. Selon l’implémentation interne de CompteBancaire, l’appel de effectuerTransactions sur une classe fille provoque un bug (historique des opérations enregistrées en double). C’est là que vous pouvez remarquer qu’avoir un héritage impose une vigilance accrue : en refactorisant correctement le code de la classe mère, il se peut qu’on ait altéré le bon fonctionnement d’une de ses classes filles. Prudence donc… Heureusement, dans notre exemple le problème peut être facilement résolu en supprimant la duplication de code dans la classe CompteBancaireAvecHistorique. Faites-le !

Nous venons de voir que l’héritage impose une vigilance accrue lors du refactoring.

Maintenant, voyons une nouvelle situation, où les choses risquent d’être plus compliquées notamment vis-à-vis du respect du principe ouvert/fermé !

  1. Ouvrez le paquetage ocp4. Dans ce projet, il y a une classe Produit permettant de gérer des produits et leurs prix. Ensuite, on a souhaité définir une classe ProduitAvecReduction, car certains produits proposent des réductions. Tout fonctionne pour le moment, comme vous pouvez le constater dans le Main.

  2. On souhaite maintenant ajouter un nouveau type de produit : les produits avec une date de péremption proche. Sur un tel produit, le prix est calculé en faisant une réduction de 50% sur le prix d’origine. Implémentez donc une classe ProduitAvecDatePeremptionProche héritant de Produit et réécrivez la méthode getPrix. Testez que votre nouveau type de produit a bien le comportement attendu en testant dans le Main (ou encore mieux, avec des tests unitaires !)

  3. Maintenant, nous voulons qu’un produit puisse à la fois être un produit qui périme bientôt et un produit avec une réduction. Est-il possible de créer une telle classe ou un tel comportement ?

Si vous vous êtes contenté uniquement de faire hériter ProduitAvecDatePeremptionProche de Produit, vous remarquerez qu’il est impossible d’avoir un même produit possédant ces deux fonctionnalités à la fois. Avec un héritage multiple, il y aurait pu y avoir une solution (moche), mais nous avons vu qu’il est déconseillé de faire cela et de toute façon, dans beaucoup de langages (dont Java), ce n’est pas possible.

Tout en respectant le principe ouvert/fermé, il existe une méthode pour construire un Produit possédant autant de comportements mixtes que nous souhaitons. On peut le régler en favorisant la composition au lieu d’un héritage. L’idée est que la classe “fille” n’hérite pas de la classe mère en redéfinissant simplement les fonctions, mais possède à la place un attribut stockant une instance de cette classe et l’utilise. Si en plus, on a besoin que les deux classes soient du même type, alors on utilise une interface.

Illustrons le problème et sa solution :

class Salarie {

   private double salaire;

   public Salarie(double salaire) {
      this.salaire = salaire;
   }

   public double getSalaire() {
      return salaire;
   }

}

class ChefProjet extends Salarie {

   private int nombreProjetsGeres;

   public ChefProjet(double salaire, int nombreProjetsGeres) {
      super(salaire);
      this.nombreProjetsGeres = nombreProjetsGeres;
   }

   @Override
   public double getSalaire() {
      return super.getSalaire() + 100 * (nombreProjetsGeres);
   }

}

class ResponsableDeStagiaires extends Salarie {

   private int nombreStagiairesGeres;

   public ResponsableDeStagiaires(double salaire, int nombreStagiairesGeres) {
      super(salaire);
      this.nombreStagiairesGeres = nombreStagiairesGeres;
   }

   @Override
   public double getSalaire() {
      return super.getSalaire() + 50 * nombreStagiairesGeres;
   }
}

Ici, même problème que pour les produits, si je veux un salarié qui est à la fois chef de projet et responsable de stagiaire, cela est impossible !

Comme souvent, l’héritage est le problème ici. Au lieu de faire un simple héritage entre les classes, nous pourrions plutôt utiliser des compositions sur les sous-types et créer ainsi des salariés incluant des salariés, incluant des salariés… ce qui permet de combiner la logique de chaque type !

interface I_Salarie {
  double getSalaire();
}


class Salarie implements I_Salarie {

  private double salaire;

  public Salarie(double salaire) {
    this.salaire = salaire;
  }

  public double getSalaire() {
    return salaire;
  }

}

class ChefProjet implements I_Salarie {

  private I_Salarie salarie;

  private int nombreProjetsGeres;

  public ChefProjet(I_Salarie salarie, int nombreProjetsGeres) {
    this.salarie = salarie;
    this.nombreProjetsGeres = nombreProjetsGeres;
  }

  @Override
  public double getSalaire() {
    return salarie.getSalaire() + 100 * (nombreProjetsGeres);
  }

}

class ResponsableDeStagiaires implements I_Salarie {

  private I_Salarie salarie;

  private int nombreStagiairesGeres;

  public ResponsableDeStagiaires(I_Salarie salarie, int nombreStagiairesGeres) {
    this.salarie = salarie;
    this.nombreStagiairesGeres = nombreStagiairesGeres;
  }

  @Override
  public double getSalaire() {
    return salarie.getSalaire() + 50 * nombreStagiairesGeres;
  }
}

Open close 2

Avec cette nouvelle architecture, nous pouvons créer des salariés qui sont chefs de projet et responsables de stagiaires :

//Salarié qui est chef de projet gérant 3 projets et aussi responsable de stagiaires gérant 5 stagiaires
I_Salarie salarie = new ResponsableStagiaires(new ChefProjet(new Salarie(2000), 3), 5);
salarie.getSalaire(); //Renvoie 2550

Il est important de noter que la classe composée est I_Salarie et non pas Salarie! Sinon, on ne pourrait pas combiner ChefProjet avec ResponsableStagiaires.

Aussi, le salarie n’est pas instancié dans la classe, il est injecté (autrement, cela ne fonctionnerait pas), comme ce que vous avez fait, par exemple, avec l’exercice sur le paquet de carte et les différentes méthodes de tri. Sur un diagramme de classes de conception, cela pourrait être représenté par une agrégation blanche.

  1. Refactorez votre code afin de pouvoir créer un produit qui possède une réduction et qui a aussi une date de péremption proche.

  2. Créez un Twix avec pour prix de base 3€, qui périme bientôt et qui a une réduction de 50 centimes. Testez que la valeur obtenue pour le prix est bien la bonne.

  3. Inversez l’ordre de création du produit (le produit avec réduction est composé d’un produit avec une date de péremption proche ou l’inverse selon ce que vous avez fait à la question précédente). Est-ce que le prix obtenu est le même qu’à l’étape précédente ?

Attention, même si nous pouvons maintenant rajouter un nouveau type de produit et le combiner aux autres pour calculer le prix adéquat, quand on instancie l’objet, il faut faire attention à l’ordre de combinaison des objets.

Bon, tout fonctionne bien, mais le code est encore un peu redondant : A priori, tous nos produits “dérivés” vont posséder un objet I_Produit. Il est alors possible de factoriser cela avec une classe abstraite dont vont hériter tous les sous-produits.

Par exemple, pour l’exemple des salariés :

//Nous reviendrons sur le nom "decorator" plus tard.
abstract class SalarieDecorator implements I_Salarie {
   protected I_Salarie salarie;

   public SalarieDecorator(I_Salarie salarie) {
      this.salarie = salarie;
   }

   public double getSalaire() {
      return salarie.getSalaire();
   }
}

class ResponsableDeStagiaires extends SalarieDecorator {

  private int nombreStagiairesGeres;

  public ResponsableDeStagiaires(I_Salarie salarie, int nombreStagiairesGeres) {
    super(salarie);
    this.nombreStagiairesGeres = nombreStagiairesGeres;
  }

  @Override
  public double getSalaire() {
    return super.getSalaire() + 50 * nombreStagiairesGeres;
  }

}

Open close 3

  1. Réfactorez votre code pour introduire une classe abstraite et réduire la redondance et la répétition de code au niveau des sous-classes (ProduitAvecReduction et ProduitAvecDatePeremptionProche).

  2. Testez que tout fonctionne comme auparavant.

  3. Dessinez le diagramme de classes de conception (hors Main) de cette application.

Dans le futur, si nous ajoutons un nouveau type de produit, il suffira alors de rajouter une nouvelle classe et lui faire étendre votre classe abstraite. Avec une telle architecture, le principe ouvert/fermé est respecté et nous avons tiré profit de manière avantageuse du mécanisme de composition au lieu de se reposer sur l’héritage qui ne nous permettait pas d’arriver à nos fins.

En fait, ce modèle est réutilisable et adaptable à d’autres situations (nous l’avons vu avec les salariés). C’est en fait un autre design pattern nommé décorateur d’où le nom de la classe abstraite dans l’exemple.

À partir du diagramme de classes que vous avez réalisé, vous devriez être capable de produire un modèle général fonctionnant pour vous.

Principe de substitution de Liskov (Liskov substitution)

Le principe de substitution de Liskov a été introduit par Barbara Liskov et énonce qu’un objet d’une superclasse donnée doit pouvoir être remplacée par une de ses sous-classes sans casser le fonctionnement du programme. Une méthode provenant à l’origine d’une superclasse et appelée sur la sous-classe devrait produire le même résultat que si elle avait été appelée sur la superclasse.

L’utilisation inappropriée de l’héritage peut amener au non-respect de ce principe. Voici un scénario illustrant le problème de non-respect du principe de substitution de Liskov : on possède une classe Rectangle et on souhaite modéliser une classe Carre. D’ailleurs, en géométrie, un carré est une sorte de rectangle…

  1. Ouvrez le paquetage lsp1 et observez les classes mises à disposition. Dans cette application, nous avons considéré qu’un Carre est une spécialisation d’un Rectangle. Cependant, un carré possède une caractéristique particulière : sa hauteur et sa largeur sont toujours identiques. Bref, si on change la hauteur d’un carré, sa largeur change aussi. Ou plutôt, on ne devrait pas pouvoir changer la largeur d’un rectangle indépendamment de sa hauteur et vice-versa.

  2. Dans le paquetage lsp1 dans src/test/java vous trouverez deux classes de tests unitaires pour tester le rectangle et le carré. Lancez-les. Essayez de comprendre pourquoi les tests ne passent pas.

  3. Les tests écrits sont bien valides. Si on change la hauteur d’un carré, alors sa largeur doit aussi changer. Une première solution peut donc être de récrire les méthodes setLargeur et setHauteur dans la classe Carre afin que quand on modifie la largeur, la hauteur soit mise à la même valeur et inversement. Faites cette modification (les attributs hauteur et largeur sont accessibles dans Carre car en protected dans la classe Rectangle).

  4. Relancez les tests unitaires. Un autre test ne passe plus ! Essayez de comprendre pourquoi.

Avec cette modification, le principe de substitution de Liskov n’est toujours pas respecté ! En effet, si on utilise Carre comme un Rectangle, des bugs étranges surviennent quand on utilise une méthode prévue pour un Rectangle (ici, la méthode agrandirRectangle). On ne peut pas substituer le rectangle par un carré sans produire de bugs logiques.

Un autre “patch” possible serait d’ajouter une fonction setTailleCote dans Carre et redéfinition des fonctions setHauteur et setLargeur dans Rectangle de façon à ce qu’elles ne fassent rien :

class Carre extends Rectangle {

  public Carre(int tailleCote) {
    super(tailleCote, tailleCote);
  }

  public void setTailleCote(int tailleCote) {
    this.hauteur = tailleCote;
    this.largeur = tailleCote;
  }

  @Override
  public void setHauteur(int hauteur) {}

  @Override
  public void setLargeur(int largeur) {}
  
}

Mais dans ce cas, on ne peut plus utiliser Carre comme un Rectangle et en plus :

Bref, cet héritage est une très mauvaise idée ! En fait, conceptuellement, en programmation, un carré n’est pas un rectangle spécialisé, car les règles pour la hauteur et la largeur sont différentes…cela peut être un peu dur à accepter.

Si on souhaite quand même utiliser un rectangle dans un carré (pour ne pas dupliquer le calcul de l’aire ou des autres méthodes, par exemple) on peut éventuellement utiliser une composition comme nous l’avons vu dans l’exercice sur les comptes bancaires, en interdisant à un Carre de redéfinir sa hauteur et sa largeur.

  1. Créez l’interface FigureRectangulaire suivante :

     public interface FigureRectangulaire {
       int getHauteur();
       int getLargeur();
       int aire();
     }
    
  2. Modifiez la classe Carre afin qu’elle n’étende plus la classe Rectangle mais implémente l’interface FigureRectangulaire et utilise plutôt une composition avec un objet de type Rectangle (en attribut de la classe) :

    • On utilise une composition avec un rectangle, donc il n’y a pas besoin d’attribut tailleCote dans la classe. Cela est stocké au niveau du rectangle.

    • La signature du constructeur ne change pas. L’attribut de type Rectangle est directement initialisé dans le constructeur (agrégation noire sur un diagramme de classes).

    • On n’a plus besoin des méthodes setHauteur et setLargeur (on ne modifie pas indépendamment la largeur et la hauteur d’un carré).

    • Les méthodes getLargeur et getHauteuret aire sont déléguées au Rectangle.

    • Une nouvelle méthode setTailleCote permet de changer la taille du côté du carré (en utilisant le rectangle).

  3. Faites aussi implémenter l’interface FigureRectangulaire à Rectangle.

  4. Modifiez les tests unitaires portants sur Carre :

    • Les méthodes setLargeur et setHauteur n’existent plus. On utilise à la place setTailleCote.

    • Le test testAireAgrandirCarre n’a plus lieu d’être, car la méthode agrandirRectangle porte sur un Rectangle et dorénavant, un Carre n’est plus un Rectangle. Supprimez donc ce test.

  5. Lancez les tests, vérifiez que tout fonctionne.

Après refactoring, le diagramme de classes de votre application devrait donner quelque-chose comme ça :

Liskov substitution 2

La composition forte entre Carre et Rectangle (initialisé dans Carre et n’en sort pas) est en opposition avec la composition faible, qui indique aussi une composition, mais dans laquelle la dépendance est injectée (comme avec les décorateurs dans l’exercice des produits).

Bien sûr, nous aurions pu quand même conserver la logique d’agrandissement d’une FigureRectangulaire. À ce moment-là, il faudrait rajouter une méthode agrandir(int facteur) dans l’interface FigureRectangulaire et implémenter les méthodes dans Rectangle et Carre.

Mettons vos nouvelles connaissances en pratique avec un autre exercice.

  1. Ouvrez le paquetage lsp2. On souhaite implémenter le fonctionnement d’une Pile. Une Pile est une structure de données LIFO (last in, first out) qui fonctionne comme une pile d’assiette. On peut globalement réaliser quatre opérations sur une pile :

    • Vérifier si elle est vide.

    • Obtenir la valeur au sommet de la pile.

    • Empiler un élément (l’ajouter au sommet de la pile)

    • Dépiler un élément (le retirer du sommet de la pile et le renvoyer).

    La classe Pile que nous allons implémenter possède un paramètre de type générique. Cela permet de créer des piles contenant un type de données paramétré, comme quand vous créez un objet de type List<Double> par exemple.

    Afin de ne pas avoir à définir la structure stockant les données nous même, nous allons étendre la classe Vector qui permet de gérer une structure de données ordonnée. Diverses méthodes de la super-classe vont vous être utiles :

    • isEmpty → permet de vérifier si la structure est vide.

    • get(index) → permet de récupérer la valeur située à la position ciblée par l’index.

    • remove(index) → permet de supprimer la valeur située à la position ciblée par l’index.

    • add(index, valeur) → permet d’insérer une valeur à la position ciblée par l’index.

  2. Ouvrez la classe de tests unitaires placée dans test/java/lsp2. Exécutez les tests. Rien ne passe, c’est normal ! Vous n’avez pas encore implémenté le code de la classe Pile qui contient du code par défaut… Vous êtes donc en mode TDD (test driven development).

  3. Implémentez les méthodes de la classe Pile afin que les tests passent.

  4. Ajoutez le test unitaire suivant et exécutez-le :

     @Test
     public void testSupprimerIndex() {
         Pile<Integer> pile = new Pile<>();
         pile.empiler(5);
         pile.empiler(9);
         pile.empiler(10);
         pile.remove(1);
         pile.depiler();
         assertEquals(9, pile.sommetPile());
     }
    

Le dernier test n’est pas mal rédigé, car, selon le contrat de Pile, seules les opérations estVide, empiler, depiler et sommetPile doivent produire un effet. Or, comme Pile hérite de Vector, on a accès à toutes les opérations réalisables sur une liste classique… Donc, dans la logique, même s’il est possible d’appeler remove sur notre Pile, cela ne doit produire aucun effet ! Or, ce n’est pas le cas ici.

On pourrait redéfinir la méthode remove (et toutes les méthodes de Vector !) pour qu’elles ne fassent rien, mais le principe de substitution de Liskov ne serait alors plus respecté ! On ne pourrait pas substituer un Vector par une Pile.

Bref, conceptuellement, une Pile n’est pas un Vector spécial, mais bien une structure indépendante… Néanmoins, il est possible d’utiliser une composition pour utiliser un Vector comme attribut, dans notre classe Pile.

  1. Refactorez le code de la classe Pile en enlevant l’héritage et en utilisant une composition avec un Vector à la place.

  2. Le dernier test ajouté ne compile plus, c’est normal ! La pile n’est pas un Vector, on ne peut pas appeler remove dessus (et c’est tant mieux). Supprimez donc ce test.

  3. Relancez les tests et vérifiez que tout passe.

  4. Quelque part dans votre code, définissez une variable de type Stack qui est une classe de Java permettant de gérer une pile. Allez observer le code source de cette classe (CTRL+B sur IntelliJ en cliquant sur le nom de la classe). Que remarquez-vous au niveau de sa déclaration ? Cette classe est aujourd’hui dépréciée, pourquoi ?

Eh oui, même les concepteurs de Java ont fait quelques bêtises lors du développement du langage. Et il n’est plus possible de supprimer cette classe après coup pour ne pas causer de problèmes de compatibilité. La seule chose à faire est de déprécier cette classe et de conseiller une nouvelle solution mieux conçue. D’où l’importance de bien penser sa conception !

Principe de ségrégation des interfaces (Interface segregation)

Le quatrième principe SOLID est le principe de ségrégation des interfaces.

Un objet ne doit pas être forcé de dépendre de méthodes qu’il n’utilise pas. Globalement, il ne faut pas qu’une interface définisse dans son contrat des méthodes qui ne seront voir ne pourront pas être implémentées par la classe implémentant l’interface.

Nous en avons déjà parlé, mais une interface est un contrat. Une classe qui implémente une interface est forcé d’implémenter toutes les méthodes de l’interface. Mais une classe ne devrait pas être forcée à implémenter un bout de contrat qu’elle ne peut pas remplir.

Voyons comment ne pas respecter ce principe peut devenir très fastidieux au fur et à mesure que le projet grossit.

  1. Ouvrez le paquetage isp. Ce projet modélise un système de jeu où certaines créatures sont des montures toutes les montures du même type ont les mêmes caractéristiques type (vitesse, endurance…). Au début, le développeur a modélisé une créature Cheval. Pour cette monture, on veut connaître :

    • Le nom de la monture.

    • La vitesse.

    • L’endurance.

    Pour prévoir le futur dans le cas où il y aurait besoin d’ajouter d’autres montures, le développeur a ajouté une interface Monture.

    Il a ensuite ajouté un autre type de monture, le Tigre.

  2. Ajoutez une classe Rhinoceros implémentant la classe Monture. Pour un Rhinoceros on a une vitesse de 30 et une endurance de 100.

  3. Ajoutez une classe Dauphin étendant Creature et implémentant l’interface Monture. Cette monture a une vitesse de 45. Cependant, contrairement aux autres montures, cette monture est une monture aquatique. Elle ne possède pas d’endurance et on veut connaître son temps de respiration sous l’eau qui est de 15.

    Concernant l’endurance, que faut-il renvoyer ? On ne peut pas renvoyer 0 ou un nombre négatif, cela n’aurait pas beaucoup de sens. À la place, on peut lever une erreur :

     public double getEnduranceMonture() {
         throw new Error("Une monture aquatique n'a pas d'endurance!");
     }
    
  4. Dans le cas où il y aurait besoin d’ajouter d’autres montures aquatiques dans le futur, ajoutez la méthode permettant d’obtenir le temps de respiration dans l’interface Monture.

  5. Les classes Cheval et Tigre ne compilent plus ! C’est parce qu’il faut implémenter la méthode permettant d’obtenir le temps de respiration sous l’eau… Or, ces montures n’ont pas de temps de respiration ! On va donc procéder comme pour Dauphin en soulevant une erreur :

     throw new Error("Cette monture ne peut pas respirer sous l'eau!");
    
  6. Ajoutez une classe Griffon étendant Creature et implémentant l’interface Monture. Cette monture a une vitesse de 300. Cependant, contrairement aux autres montures, cette monture est une monture volante. Elle ne possède pas d’endurance et ne respire pas sous l’eau non plus. Par contre, on veut connaître son temps maximum de vol qui est de 40. Adaptez votre classe en conséquence.

  7. Mettez à jour l’interface Monture avec la méthode pour le temps de vol au cas où il y ait d’autres types de montures volantes ajoutées et corrigez les erreurs de compilation.

  8. Ajoutez une classe Dragon étendant Creature et implémentant l’interface Monture. Cette monture a une vitesse de 400. Elle possède aussi un temps de vol maximum de 120 (car c’est une monture volante). Elle ne possède pas d’endurance et ne respire pas sous l’eau non plus. Par contre, on veut connaître sa puissance de feu qui est de 200. Adaptez votre classe en conséquence.

  9. Mettez à jour l’interface Monture avec la méthode pour la puissance de feu au cas où il y ait d’autres types de dragons ajoutés dans le futur et corrigez les erreurs de compilation.

  10. Enfin, ajoutez une classe LicorneAilee étendant Creature et implémentant l’interface Monture. Cette monture a une vitesse de 75. Elle possède aussi un temps de vol maximum de 20 (car c’est une monture volante) et une endurance de 50 (car c’est aussi une monture terrestre !). Par contre, elle ne respire pas sous l’eau et n’a pas de puissance de feu non plus.

C’était pénible, n’est-ce pas ? C’est normal, cette solution est très mauvaise et fastidieuse. À chaque nouvel ajout de type de monture avec ses spécificités, on doit modifier tous les autres types et les forcer à implémenter des méthodes qui ne les concernent pas… Le fait de lever tant d’erreurs indique une très mauvaise conception.

Si le développeur avait bien raison de vouloir faire une interface lors de la création de la première monture, il a voulu regrouper trop de chose dans une seule et même interface : les méthodes communes à toutes les montures (nom, vitesse) et la méthode concernant l’endurance, spécifique aux montures terrestres. Par la suite, on a continué dans cette mauvaise logique. Il aurait dû dès le départ diviser cela en deux interfaces et on aurait dû créer plus d’interfaces à chaque nouveau type de monture ayant ses spécificités.

On rappelle qu’une interface peut hériter d’une autre interface ! Et qu’une classe peut implémenter autant d’interfaces qu’elle le désire.

Imaginons l’exemple suivant :

interface I_Exemple {
  void operationGlobale();
  void operationA();
  void operationB();
  void operationC();
}

class A implements I_Exemple {
  public void operationGlobale() {
    //Code...
  }

  public void operationA() {
    //Code...
  }

  public void operationB() {
    throw new Error("Impossible d'implémenter cette méthode.");
  }

  public void operationC() {
    throw new Error("Impossible d'implémenter cette méthode.");
  }
}

class B implements I_Exemple {
  public void operationGlobale() {
    //Code...
  }

  public void operationA() {
    throw new Error("Impossible d'implémenter cette méthode.");
  }

  public void operationB() {
    //Code
  }

  public void operationC() {
    throw new Error("Impossible d'implémenter cette méthode.");
  }
}

class C implements I_Exemple {
  public void operationGlobale() {
    //Code...
  }

  public void operationA() {
    throw new Error("Impossible d'implémenter cette méthode.");
  }

  public void operationB() {
    throw new Error("Impossible d'implémenter cette méthode.");
  }

  public void operationC() {
    //Code
  }
}

class D implements I_Exemple {
  public void operationGlobale() {
    //Code...
  }

  public void operationA() {
    throw new Error("Impossible d'implémenter cette méthode.");
  }

  public void operationB() {
    //Code
  }

  public void operationC() {
    //Code
  }
}

Interface segregation 1

Tout cela peut être refactoré bien plus élégamment ainsi :

interface I_Exemple {
  void operationGlobale();
}

interface I_A extends I_Exemple {
  void operationA();
}

interface I_B extends I_Exemple {
  void operationB();
}

interface I_C extends I_Exemple {
  void operationC();
}

class A implements I_A {
  public void operationGlobale() {
    //Code...
  }

  public void operationA() {
    //Code...
  }
}

class B implements I_B {
  public void operationGlobale() {
    //Code...
  }

  public void operationB() {
    //Code...
  }
}

class C implements I_C {
  public void operationGlobale() {
    //Code...
  }

  public void operationC() {
    //Code...
  }
}

class D implements I_B, I_C {
  public void operationGlobale() {
    //Code...
  }

  public void operationB() {
    //Code
  }

  public void operationC() {
    //Code
  }  
}

Interface segregation 2

Comme vous le constatez, plus aucune classe n’est forcée à implémenter des méthodes qu’elle ne peut pas définir, tout en conservant ses spécificités. Plus aucune erreur n’a besoin d’être levée.

Bref, cela est en partie un mix entre le principe de responsabilité unique et une visualisation hiérarchique du problème. On note quand même le cas intéressant de la classe D qui permet à la fois d’avoir le type I_Exemple, I_B et I_C !

Refactorez votre code pour respecter le principe de ségrégation des interfaces. On veut garder les spécificités de chaque type de monture, mais éliminer toutes les levées d’erreurs. Soyez vigilant pour le dragon et la licorne ailée.

Principe d’inversion des dépendances (Dependency inversion)

Enfin, il reste le principe d’inversion des dépendances. Ce principe dit que :

Ce principe permet d’obtenir une très forte modularité du programme. Si on couple cela avec la technique d’injection des dépendances et des design patterns créateurs, tels que des fabriques abstraites, ou bien des conteneurs de dépendances, on obtient un logiciel dont on peut moduler le fonctionnement sans toucher au code source, simplement en ajoutant de nouvelles classes et/ou en éditant des fichiers de configuration. Les différents frameworks mettent en place une architecture favorisant l’inversion des dépendances.

En fait, ce principe découle de la bonne application des autres principes et notamment du principe ouvert/fermé et de la substitution de Liskov.

Nous verrons aussi qu’il est essentiel de bien respecter ce principe quand on réalise des tests unitaires.

Tout d’abord, illustrons ce principe avec un exemple.

  1. Ouvrez le paquetage dip1. Dans ce projet, il y a une classe Etudiant et une classe CompteUniversitaire. Un compte universitaire est détenu par un étudiant. On utilise son nom et son prénom pour générer un login.

  2. Ajoutez une classe Enseignant qui possède un nom, un prénom et définit des getters pour ces deux attributs.

  3. La logique pour créer un compte universitaire pour un enseignant est la même que pour un étudiant. On souhaite donc logiquement réutiliser la classe CompteUniversitaire. Mais ce n’est pas possible, car un enseignant n’est pas un étudiant !

Le problème souligné ici est qu’on a utilisé une classe concrète (Etudiant) à la place d’une classe abstraite ou d’une interface, ce qui empêche son utilisation pour d’autres types de classes (ici, Enseignant).

Illustrons ce problème avec un exemple :

class A {

  public void methodeA() {
    //Code...
  }

}

class B {

  public void methodeB() {
    //Code
  }

}

class Service {

  private A dependance;

  public Service() {
    dependance = new A();
  }

  public void action() {
    a.methodeA();
  }
}

Ici, nous avons un problème similaire : la classe Service dépend directement d’une implémentation concrète A ce qui le rend peu modulable. Si je veux utiliser B à la place de A, je dois récrire le code source de Service.

Pour régler cela, nous allons tout d’abord commencer par définir et utiliser une interface :

interface I_Exemple {
  void methodeExemple();
}

class A implements I_Exemple {

  public void methodeA() {
    //Code...
  }

  @Override
  public void methodeExemple() {
    methodeA();
  }

}

class B implements I_Exemple {

  public void methodeB() {
    //Code
  }

  @Override
  public void methodeExemple() {
    methodeB();
  }
}

Bien, je peux maintenant utiliser un I_Exemple dans Service au lieu de A ou B… Mais il reste un problème ! Dans l’exemple d’origine, A était instancié dans le constructeur. Hors, on ne peut pas instancier une interface ou une classe abstraite (seulement une classe concrète) :

class Service {

  private I_Exemple dependance;

  public Service() {
    dependance = new ???;
  }

  public void action() {
    dependance.methodeExemple();
  }
}

Pour palier à ce problème, on utilise l’injection de dépendance. La classe concrète est injectée via le constructeur, au moment de l’instanciation de l’objet, mais la classe ne connait que le type abstrait. Cela permet une modularité de la classe qui peut alors être utilisée avec n’importe quel service concret dérivé du type abstrait. Et on peut en ajouter dans le futur. C’est exactement ce que nous avions fait avec le paquet de cartes et les tris avec le pattern stratégie, mais également avec le décorateur dans l’exercice avec les produits. L’injection de dépendance est partout !

class Service {

  private I_Exemple dependance;

  public Service(I_Exemple dependance) {
    this.dependance = dependance;
  }

  public void action() {
    dependance.methodeExemple();
  }
}

class Main {

  public static void main(String[] args) {
    //Utilise "methodeA" dans "action"
    Service s1 = new Service(new A());

    //Utilise "methodeB" dans "action"
    Service s2 = new Service(new B());
  }

}

Dependency inversion 1

De cette manière, l’inversion des dépendances est respectée. La classe Service ne dépend plus d’aucun service concret, mais d’abstractions.

  1. Réfactorez votre code pour pouvoir utiliser CompteUniversitaire aussi bien avec un objet de type Etudiant qu’un objet de type Enseignant. Il faudra normalement adapter le code dans le Main.

  2. Testez de créer un compte universitaire pour l’enseignante “Bricot Judas”.

  3. On souhaite pouvoir utiliser différentes méthodes pour générer le login dans CompteUniversitaire. La première méthode est celle déjà présente qui combine le nom + la première lettre du prénom. On souhaite aussi pouvoir disposer d’une méthode qui mélange les caractères du nom avec cet algorithme :

     List<String> nomList = Arrays.asList(nom.split(""));
     Collections.shuffle(nomList);
     StringBuilder builder = new StringBuilder();
     for(String letter : nomList) {
       builder.append(letter);
     }
     String login = builder.toString();
    
  4. En utilisant votre connaissance du pattern stratégie et de l’injection de dépendances faites en sorte qu’on puisse choisir si CompteUniversitaire utilise la génération de login “simple” (nom + première lettre prénom) ou par mélange.

  5. Le compte de “Tarembois Guy” doit être généré en utilisant le générateur de login simple et celui de “Bricot Judas” avec le générateur par mélange. Testez.

Pour finir, nous allons travailler sur un exercice un poil plus conséquent divisé en différentes couches (cf. partie architecture logicielle du cours sur les DSI).

Vous allez voir qu’en plus de rendre notre projet modulable, utiliser l’inversion de dépendances et globalement ne pas dépendre d’implémentations concrètes est plus qu’important, car une architecture ne respectant pas ce principe peut occasionner des effets de bords indésirables lors des tests unitaires.

  1. Ouvrez le paquetage dip2. Cette application permet de créer des utilisateurs, de hacher leur mot de passe, de se connecter… Il y a aussi un système de gestion de diverses erreurs. Prenez le temps d’examiner l’architecture, la répartition des classes. Exécutez le programme avec le Main.

  2. Réalisez un diagramme de classes de conception de l’application (hors Main). Cela nous permettra de faire une comparaison après refactoring.

  3. Une classe contenant des tests unitaires est présente dans src/test/java/dip2/service/utilisateur. Lancez les tests deux fois, tout devrait bien se passer.

  4. On aimerait effectuer quelques changements dans le programme, notamment au niveau de ServiceUtilisateur :

    • Hasher le mot de passe avec SHA256 au lieu de MD5.

    • Utiliser la classe StockageUtilisateurFichier pour gérer le stockage des utilisateurs. Contrairement à StockageUtilisateurMemoire qui stocke les données de manière volatile, ici, les utilisateurs seront stockés dans un fichier de manière persistante (que sera généré à la racine du projet).

    Effectuez ces changements, relancez le programme (Main) pour vérifier que tout fonctionne.

  5. Lancez maintenant les tests unitaires 2 fois de suite. Il y a une erreur ! Trouvez la raison de cette erreur.

Comme dans l’exercice précédent, l’architecture proposée ne respecte pas le principe d’inversion des dépendances, car la classe ServiceUtilisateur possède des dépendances vers des classes concrètes qui, de plus, ne sont pas injectées.

On se rend compte que cela pose un véritable problème au niveau des tests unitaire. Un test unitaire, comme son nom l’indique, teste le fonctionnement d’une classe, une unité. Or, quand on exécute les tests sur ServiceUtilisateur, les méthodes des dépendances concrètes utilisées sont aussi appelées ! Ce qui déclenche donc réellement l’enregistrement de l’utilisateur créé pour les tests dans la base de donnée, alors qu’on souhaitait simplement vérifier la méthode creerUtilisateur. Quand les tests sont exécutés une seconde fois, une erreur est détectée, car l’utilisateur existe déjà.

Imaginez-vous dans un contexte plus concret, par exemple, dans un projet web : avec une telle conception, vos tests unitaires déclencheraient l’enregistrement d’utilisateurs de test sur votre base de données réelle ! Ce n’est pas envisageable.

Les tests unitaires ne doivent pas dépendre de l’environnement de production. Ils doivent pouvoir être lancé seulement à partir du code de la classe testée, sans dépendre de rien d’autre.

Vous avez sans doute constaté des messages mail envoyé lors de l’exécution des tests unitaires. Bien sûr, cela n’est pas vraiment le cas, mais de même, dans un cas concret, avec la conception actuelle, des mails seraient véritablement envoyés lors du test du programme (après création d’un utilisateur). C’est problématique.

Pour palier à cela, les testeurs mettent en place des stubs. Il s’agit de classes bouchons qui ne réalisent pas vraiment l’action demandée, ou alors pas de manière persistante. Aucun effet de bord est produit.

Plus tard, dans l’année, vous découvrirez les mocks qui permettent de créer de “fausses” classes destinées aux tests dont on peut facilement éditer les méthodes.

Ici, la classe StockageUtilisateurMemoire agit comme un stub qu’on peut utiliser pour les tests sans risque. Dans un environnement réel, on utiliserait un stockage avec une base de données dédiée aux tests, comme SQLite, qu’on viderait ensuite.

Cependant, comment faire pour garder le stockage avec fichier pur l’exécution “normale” du programme, et le stockage en mémoire pour les tests ? Devons-nous éditer le code source de la classe ServiceUtilisateur avant les tests, puis la remettre en ordre ensuite ? Bien sûr que non ! Il suffit de mettre en pratique le principe d’inversion de dépendance que vous connaissez.

  1. Refactorez les classes du paquetage storage puis le code de ServiceUtilisateur afin de respecter le principe d’inversion des dépendances en utilisant des abstractions et surtout l’injection de dépendances.

  2. Mettez à jour le code de ControllerUtilisateur afin que celui-ci utilise ServiceUtilisateur avec StockageUtilisateurFichier.

  3. Mettez à jour le code de vos tests unitaires pour utiliser que l’objet de type ServiceUtilisateur utilisé dans les tests utilise StockageUtilisateurMemoire.

  4. Vérifiez que votre programme fonctionne toujours correctement et vérifiez que vos tests unitaires passent plusieurs fois sans problème.

Il reste maintenant le problème des “mails envoyés” quand on exécute les tests. On aimerait également que plusieurs autres dépendances soient modulables dans le programme :

  1. Refactorez votre code pour appliquer l’inversion de dépendances par rapport aux modules cités (mails, hasher, service utilisateur…). Il faudra créer de nouvelles interfaces et modifier des classes existantes (les hashers, le mailer…) et aussi créer deux classes de stub pour les tests : FakeMailer et FakeHasher.

  2. Adaptez l’instanciation de ServiceUtilisateur dans vos tests unitaires et vérifiez qu’ils passent toujours. Vous ne devez plus constater aucun message “mail envoyé”.

  3. Vérifiez que le programme fonctionne toujours comme attendu.

  4. Réalisez un diagramme de classes de conception de l’application (après refactoring donc) et comparez-le avec le premier diagramme que vous aviez réalisé.

  5. Réalisez un diagramme de séquence des interactions du scénario nominal (le scénario où tout se passe bien et il n’y a pas d’erreur) du cas d’utilisation “Créer un nouvel utilisateur”.

Conclusion

Voilà, maintenant, vous savez tout des principes SOLID ! Vous êtes donc plus proche d’un ingénieur logiciel qu’un codeur. Il existe un acronyme opposé : les principes STUPID qui sont 6 pratiques qui rendent le code très peu qualitatif, untestable, non évolutif et qu’il faut donc absolument éviter ! Bref, des mauvaises pratiques qui sont souvent observées. Vous pouvez consulter de la documentation à ce propos sur cette page.

Dorénavant, pour vos futurs projets (ou ceux actuels, comme la SAE) il faut systématiquement vous poser et réfléchir à la conception de votre programme à long terme. Il n’est jamais trop tard pour faire du refactoring, mais ne pas avoir besoin d’en faire en respectant une certaine qualité logicielle d’entrée de jeu est encore mieux.

Dans les prochains TPs, nous allons donc nous concentrer sur les design patterns (nous en avons déjà vu deux dans ce TP) qui permettront de vous donner des outils pour résoudre des problèmes de conception connus tout en mettant en facilitant le respect et la mise en ouvre les principes SOLID.