• 4 heures
  • Facile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_video

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 04/08/2023

« O » pour le principe ouvert/fermé

J’ai travaillé sur un site de petites annonces entre particuliers, ciblant des marchés de niche et permettant d’acheter et de vendre des articles ayant des caractéristiques assez techniques.  À chaque fois que nous ajoutions une nouvelle famille d’articles, une grosse partie du code devait être modifiée. Approche peu concluante lorsque nous voulions ajouter de nouvelles familles d'articles.

Une classe devait être ouverte pour l'extension, mais fermée pour la modification.

Le système devait pouvoir s'ouvrir pour l'extension (nous pouvions ajouter de nouvelles fonctionnalités) et devait rester fermé à la modification (l'ancien code n'avait pas nécessairement besoin d'être modifié lorsque nous ajoutions ces nouvelles fonctionnalités). En d'autres termes, les nouvelles fonctionnalités ne nécessitaient aucune réécriture du code existant. 

Hola hola, mais comment ? Je pensais que vous deviez modifier le code à chaque nouvelle famille d’articles !

C'est ce que nous faisions, mais il n'était pas nécessaire de procéder ainsi. J'ai pu isoler l'essentiel de l'ancienne implémentation. Ensuite, je me suis concentrée sur les nouvelles données à gérer (les nouvelles familles d’articles). J'ai pu rendre le processus plus générique. À partir de là, lorsque nous ajoutions une nouvelle famille d’articles, nous ne modifiions rien (ou presque…) dans le code existant.

Pourquoi utiliser le principe ouvert/fermé ? 

Si vous ne modifiez pas le code existant, vous savez que vous ne risquez pas de l’endommager. Toutes les nouvelles fonctionnalités sont contenues dans la ou les classe(s) nouvellement ajoutée(s) et le risque de régressions est moindre.

L'aspect le plus complexe de ce principe est de reconnaître quand il peut être appliqué avant de commencer le codage. Voici quelques exemples pour vous aider à reconnaître à quel moment le principe ouvert/fermé peut s’appliquer.

  • Lorsque vous disposez d'algorithmes qui effectuent un calcul (coûts, taxes, scores de jeu, etc.) : il est probable que ces algorithmes changeront au fil du temps. Commencez par créer une interface, puis effectuez des implémentations spécifiques dans les classes, en sélectionnant la classe lors de l'exécution.

  • Lorsque vous avez des données qui entrent ou sortent du système : le point final (fichier, base de données, autre système) est susceptible de changer. Il en va de même pour le format des données. À nouveau, commencez par définir une interface, puis une implémentation spécifique pour récupérer ou enregistrer les données selon les besoins.

Appliquez le principe ouvert/fermé à votre code

Le calcul du gagnant est une étape pouvant faire l'objet de modifications. Dans le dernier chapitre, nous avons extrait une classe GameEvaluator. Et si nous voulions modifier le jeu de manière à ce que la carte la moins forte gagne ? Nous pourrions ajouter un paramètre booléen indiquant quel type de calcul réaliser :

evaluateWinner(List<Player> players, bool findHighCardWinner) {

    if (findHighCardWinner) { /* chercher le gagnant */ }
    
    else { /* chercher l'autre */ }

};

Cependant, la méthode fait maintenant deux choses. Ce n'est pas une bonne idée.

Est-ce un si gros problème ?

Que se passerait-il si l'évaluation du gagnant s'effectuait en ajoutant encore plus de règles ? Cette méthode deviendrait difficile à comprendre, à tester et à gérer.

Dans ce cas, que devrions-nous faire ?

Nous devons convertir GameEvaluator en une interface. De cette façon, différentes règles peuvent être facilement créées sous forme d'implémentations spécifiques. Toutes les implémentations de GameEvaluator disposeront d'une méthode evaluateWinner avec une liste de joueurs en paramètre. Elles pourront ainsi effectuer toutes les vérifications souhaitées pour les mains des joueurs, et appliquer tous les algorithmes nécessaires pour calculer le gagnant.

Dans notre cas, nous créerons un GameEvaluator basé sur la victoire de la carte la plus forte ou la plus faible, en fonction de la partie choisie.

Faisons cela ensemble !

Nous allons utiliser le concept de l'injection de dépendance. L'injection est le principe selon lequel un objet se voit attribuer (ou « injecter ») un objet à utiliser, au lieu d’instancier l'objet lui-même. Vous disposez de l'objet par injection, via une méthode ayant pour paramètre une interface. Et ailleurs dans le code, vous instanciez une implémentation de l'interface, puis utilisez cet objet pour l'injecter.

Étape 1 : l'injection de dépendance

Dans notre jeu, nous « injecterons » le GameEvaluator de notre choix au lieu de laisser la classe contrôleur instancier l'une des deux implémentations de GameEvaluator. Nous ajoutons un paramètre de type GameEvaluator au constructeur du contrôleur. Ensuite, nous pouvons transmettre l'implémentation de notre choix, en paramètre du constructeur.

public GameController (GameEvaluator _gameEvaluator) {

    gameEvaluator = _gameEvaluator;

}

Bien bien bien, le GameEvaluator est désormais injecté dans le GameController.

Voyons comment rendre la partie GameEvaluator "ouverte".

Étape 2 : l'extraction de l'interface

public interface GameEvaluator {
    public Player evaluateWinner(List<Player> players);
}
    
public class HighCardGameEvaluator implements GameEvaluator {
}
    
public class LowCardGameEvaluator implements GameEvaluator {
}

Le plus difficile est de savoir quelles sont les parties ouvertes et fermées. C'est ici que les interfaces sont utiles :

  • La classe fermée (notre contrôleur) est celle qui ne change pas. Elle parcourt systématiquement l'enchaînement des événements, pour finalement calculer un gagnant.

  • La partie ouverte (interface GameEvaluator) offre une implémentation souple, de sorte que nous pouvons facilement modifier les règles du jeu.

Cela est possible, car la classe fermée utilise la classe ouverte seulement via une interface. Pas mal, non ?

Faites un essai :

Extrayez l'interface GameEvaluator, et implémentez les deux évaluateurs spécialisés (carte forte et carte faible).

Voici les classes que vous pourrez obtenir :

public interface GameEvaluator {
    public Player evaluateWinner(List<Player> players);
}
    
public class HighCardGameEvaluator implements GameEvaluator {
}
    
public class LowCardGameEvaluator implements GameEvaluator {
}

Si vous avez besoin d'aide, consultez la solution fournie ci-dessous dans la vidéo :

En résumé

  • Le principe ouvert/fermé indique que les classes doivent être ouvertes à l'extension, mais fermées à la modification.

  • Dans des systèmes existants, il peut être nécessaire de retravailler le code pour tirer parti du principe ouvert/fermé. 

Dans le chapitre suivant, nous allons voir comment éviter un mauvais usage de l'héritage, grâce au principe de substitution de Liskov.

Exemple de certificat de réussite
Exemple de certificat de réussite