Les principes SOLID sont incontournables si on souhaite fournir un code qui soit lisible, robuste et maintenable. Aujourd'hui nous nous intéressons au principe "Open-Closed" et plus particulièrement, à sa présentation faite par l'Oncle Bob dans un article écrit en 1996 https://www.cs.utexas.edu/~downing/papers/OCP-1996.pdf
Les exemples cités ici afin de guider l'explication reprennent ceux du document, mais les adaptent au langage Java à des fins de clarté.
Les postulats de base de l'OCP
- Tous les champs d'une classe doivent être privés.
- Ne jamais utiliser de variables globales.
- le RunTime Type Information (RTTI) doit être évité.
Afin de répondre à ces postulats, Bertrand Meyer a énoncé la définition de l'OCP de la manière suivante :
"Les entités logicielles (classes, modules, fonctions, etc.) doivent être ouvertes à l'extension mais fermées à la modification."
Bertrand Meyer
Pourquoi de tels postulats ? Sont-ils toujours vrais ? C'est ce à quoi tente de répondre Robert C. Martin dans son papier.
Problématique traitée par l'OCP
Qui n'est jamais arrivé sur un code existant, avec toutes les briques entremêlées, à devoir implémenter une nouvelle demande ? Je peux voir d'ici la petite goutte de sueur perler lors d'une modification, qui risque de tout détruire tel un château de cartes. La moindre modification peut apporter son lot de régressions, qui seront heureusement détectées par la couverture de tests induite par le TDD, mais qui n'en restent pas moins des régressions…
L'OCP a pour principal objectif de traiter cette fragilité.
Alors comment le mettre en place ?
Des modules ouverts à l'extension...
Si le besoin évolue, il faut que le module puisse être étendu. Cela signifie que comme dans un jeu de LEGO®, on doit pouvoir rajouter des briques. Il faut pouvoir ajouter des éléments qui correspondent aux nouveaux besoins. Un exemple concret : Si la batterie de votre téléphone tombe en panne, vous devez pouvoir la remplacer directement avec une nouvelle batterie, voire greffer une batterie secondaire sur votre téléphone.
Mais fermés à la modification !
En revanche, intégrer un nouveau besoin ne doit pas impliquer la modification du code existant. En effet, si le code existant fonctionne, alors pourquoi le modifier ?
Si pour changer une batterie, il faut venir bidouiller le téléphone au risque de casser l'écran, le Bluetooth, ou tout autre composant, alors vous admettrez que ce n'est pas très rassurant…
Vous avez dit contradiction ?
Tout ceci peut sembler contradictoire : Comment faire pour rendre un module évolutif si on ne peut pas le modifier ? Si un nouveau besoin apparait, il suffit de venir modifier le code source pour le faire évoluer… non ?
La clé est l'abstraction. Dans la programmation orientée objets (POO), il est possible de créer des abstractions qui seront des entités "fixées", qui ne bougeront plus. Ces abstractions sont finalement une sorte de contrat. Elles ne définissent pas le comportement (le "comment"), mais simplement le service rendu (le "quoi"). Ainsi, les implémentations possibles sont indépendantes de leur utilisation.
Mise en pratique, l'exemple de la forme
Faisons la revue du code suivant.
Lorsque l'OCP n'est pas respecté
abstract class Shape{
Color color;
}
class Circle extends Shape{
double radius;
Point center;
}
class Square extends Shape{
double side;
Point topLeft;
}
static void drawSquare(Square square){
// some stuff drawing square
}
static void drawCircle(Circle circle){
// some method drawing circle
}
static void drawAllShapes(List<Shape> shapes){
shapes.forEach(shape -> {
switch(shape){
case Circle circle -> drawCircle(circle);
case Square square -> drawSquare(square);
default -> throw new IllegalStateException("Unexpected value: " + shape);
}
});
}
La méthode "drawAllShapes" n'est pas fermée à la modification. En effet, si une nouvelle forme doit être implémentée, alors il faudra nécessairement ajouter une branche dans le switch case afin de la prendre en compte. Supposons que cette conception particulière se répète à plusieurs endroits du code, alors l'ajout d'une forme engendrera des modifications multiples dans le code existant.
Par ailleurs, ce code reste simple, mais imaginons que les conditions soient plus complexes (selon le type, la couleur, le point d'accroche, etc...), le code deviendra alors rapidement illisible et souffrira de la précédence des conditions.
Lorsque l'OCP est respecté
Le code suivant propose une solution pour que la méthode "drawAllShapes" soit fermée à la modification, en utilisant l'abstraction.
abstract class Shape{
Color color;
abstract void draw();
}
class Circle extends Shape{
double radius;
Point center;
@Override
void draw() {
// some stuff drawing circle
}
}
class Square extends Shape{
double side;
Point topLeft;
@Override
void draw() {
// some stuff drawing square
}
}
static void drawAllShapes(List<Shape> shapes){
shapes.forEach(Shape::draw);
}
Désormais, si une nouvelle forme est ajoutée, alors cela n'impactera pas l'implémentation de "drawAllShapes" qui est donc correctement fermée et l'implémentation est ouverte à l'extension.
L'OCP est respecté ? Finalement cela dépend…
L'oncle Bob nous a eu sur ce coup-là ! En effet, on peut croire que l'OCP est respecté car le code est fermé à la modification. Mais dans l'absolu, aucun code n'est totalement fermé à la modification. Supposons que le besoin évolue : l'affichage doit afficher en priorité les cercles. Nous allons devoir modifier la méthode "drawAllShapes" pour que les cercles soient affichés avant les carrés.
abstract class Shape implements Comparable<Shape> {
Color color;
abstract void draw();
}
class Circle extends Shape {
double radius;
Point center;
@Override
void draw() {
// some stuff drawing circle
}
@Override
public int compareTo(Shape otherShape) {
return otherShape instanceof Circle ? 0 : -1;
}
}
class Square extends Shape {
double side;
Point topLeft;
@Override
void draw() {
// some stuff drawing square
}
@Override
public int compareTo(Shape otherShape) {
return otherShape instanceof Square ? 0 : 1;
}
}
static void drawAllShapes(List<Shape> shapes) {
shapes.stream().sorted().forEach(Shape::draw);
}
Comme expliqué précédemment, les méthodes "compare" ne sont pas fermées à la modification en cas d'ajout de nouvelle forme, et de nouvelle règle de tri.
Une autre approche serait de stocker (par exemple dans une base de donnée), une table de précédence. Par défaut, tous les types auraient le même niveau de priorité d'affichage (s'ils ne figurent pas dans la table), et sinon la table donnerait la priorité.
Le code pourrait alors ressembler à celui-ci
abstract class Shape implements Comparable<Shape> {
Color color;
abstract void draw();
@Override
public int compareTo(Shape otherShape) {
return priority(this)-priority(otherShape);
}
}
class Circle extends Shape {
double radius;
Point center;
@Override
void draw() {
// some stuff drawing circle
}
}
class Square extends Shape {
double side;
Point topLeft;
@Override
void draw() {
// some stuff drawing square
}
}
static void drawAllShapes(List<Shape> shapes) {
shapes.stream().sorted().forEach(Shape::draw);
}
Finalement, aucun code n'est fermé dans l'absolu. La fermeture d'un code doit être faite en fonction de l'évolution possible du besoin métier. Dans notre exemple, s'il s'avère peu probable que le métier demande un tri en fonction du type de forme, alors il n'est pas nécessaire de fermer notre code à une modification sur ce critère.
Et quid du SRP ?
Le lien avec le principe de responsabilité unique n'est pas évoqué dans l'article de Robert C Martin, cependant voici ce qu'on peut en dire.
D'un côté on a l'OCP qui dit que le code doit être fermé à la modification. De manière directe, cela signifie qu'il faut éviter de modifier le code existant, et préférer l'extension.
D'un autre côté on a le SRP, qui dit que chaque entité du code ne doit avoir qu'une seule raison de changer.
Donc on modifie ou pas finalement ? N'est-ce pas contradictoire ?
Avec ce qu'on vient de voir cela ne devrait plus sembler contradictoire. L'OCP doit fermer le code à la modification en fonction du besoin métier. Le SRP traite de la séparation de ces besoins. Si un besoin évolue au point d'invalider le besoin initial qui devient alors faux, cela signifie évidemment que le code devra être modifié (car invalide). Le SRP nous permet de modifier le code en un unique endroit en cas de changement ou invalidation du besoin métier. L'OCP nous permet de ne pas modifier le code existant en cas de nouveau besoin.
Ces deux principes ont le même objectif au final : rendre le code plus lisible, plus maintenable, plus robuste.
Explication des postulats
Tous les champs doivent être privés
Si on permet à un champs d'être accessible directement par des entités clientes alors aucune de ces entités ne sera fermée à la modification en cas de modification du champs.
Remarque : les méthode contenues dans une classe peuvent rester ouvertes à la modification suite aux modifications des champs de cette classe. En revanche, la modification d'un champs d'une classe ne doit pas influer en cascade sur les méthodes des autres classes car c'est justement ce que cherche à éviter l'OCP. C'est la définition du principe d'encapsulation des champs.
Ok, mais si un champ, d'un point de vue métier, n'a plus jamais vocation à changer, pourquoi respecter l'encapsulation, si de toute façon je ne devrai jamais faire de modifications sur ce champ (et donc pas de cascade) ? Pourquoi ne pas le mettre publique, plutôt que de créer des "get" et des "set" sur ce champ ?
Réponse : il n'est pas possible de contrôler la manière dont ce champ est écrit. Supposons par exemple qu'il soit nécessaire de mettre en place des validations pour être certain que ce champ possède toujours une valeur cohérente. S'il est publique, alors il ne sera pas possible de mettre en place ces validations en amont de l'écriture. De plus, les autres clients peuvent être victimes de ces écritures inconsidérées.
D'accord mais si je rends mon champ constant ("final" en java), il ne sera écrit qu'une seule fois, son écriture sera bien contrôlée, et dans ce cas, les clients auront accès en lecture à ce champ publique. Dans de telles conditions, pourquoi ne pas rendre ces champs publiques ?
Réponse : Dans l'absolu on pourrait. Il s'agit désormais plutôt d'une question de style, de convention adoptée par la communauté. En terme de code, il est quasiment immédiat de créer une méthode de lecture du champ (avec lombok en java, une simple annotation @GET fait l'affaire). La manière d'accéder au champ reste homogène peu importe s'il est constant ou non.
Ne jamais utiliser de variables globales
On parle ici des variables statiques et non constantes. Cette règle ne traite donc pas des champs de classe et la cible diffère de celle de la règle précédente.
Cependant, les raisons sont les mêmes : Si une variable globale est modifiée, le code client le sera également. De fait, aucun code utilisant cette variable ne sera fermé à la modification.
Je comprends, mais et si on parle justement des constantes statiques, pourquoi ici on ne fait pas une méthode ?
Réponse : Par convention également. De telles variables sont en général reconnaissables par la manière de les nommer afin d'éviter toute confusion. En java, par exemple, une constante statique est écrite en majuscules. Ces constantes n'ont généralement pas vocation à changer, elles font partie du contexte du programme. Par exemple, si mon programme calcule le périmètre d'un cercle de rayon r, j'aurais probablement une constante PI=3.14159. Cette constante ne serait jamais modifiée et ferait partie du contexte de mon programme.
Attention au RTTI (RunTime Type Information)
Le RTTI est le fait d'avoir besoin de connaître le type d'une variable au runtime et de s'en servir pour un traitement spécifique.
Exemple :
static void drawAllShapes(List<Shape> shapes){
shapes.forEach(shape -> {
switch(shape){
case Circle circle -> drawCircle(circle);
case Square square -> drawSquare(square);
default -> throw new IllegalStateException("Unexpected value: " + shape);
}
});
}
On l'appelle aussi transtypage (ou cast) dynamique.
Pourquoi est-il dangereux de l'utiliser ?
Réponse : Le code client dépend des détails d'implémentation. Il n'est donc pas fermé à la modification. Supposons que le métier décide que finalement il n'y a pas de raison de traiter des carrés spécifiquement, mais des rectangles de manière générale, la classe "Square" sera sans doute supprimée et remplacée par la classe "Rectangle". La méthode "drawAllShapes" ne fonctionnera plus et il faudra alors la modifier.
Conclusion
Nous avons pu mettre en lumière les principaux aspects de l'OCP au travers d'exemples écrits en JAVA et en nous basant sur le papier de Robert C Martin : https://www.cs.utexas.edu/~downing/papers/OCP-1996.pdf
À retenir :
- L'OCP est l'un des principes SOLID.
- L'objectif de l'OCP est d'éviter les modifications en cascade afin d'améliorer la maintenance, la robustesse et la lisibilité de notre application.
- Respecter l'OCP dans l'absolu n'a pas de sens. On le respecte relativement à des modifications potentielles engagées par le métier.
- L'OCP et le SRP sont complémentaires.
- De manière générale, tous les champs doivent être privés, aucune variable globale pouvant être écrite ne doit être publique et il faut éviter le transtypage dynamique.