Le principe "ouvert/fermé" | 1 vote(s)
Par Emmanuel Deloget, jeudi 21 septembre 2006 à 12:23 :: Architecture Orientée Objet :: permalien #15
Tags: OCP, principe POO
Le principe "ouvert-fermé" (Open Closed Principle, ou OCP) est probablement l'un des principe de programmation les plus important. L'expérience montre qu'une simple entorse à ce principe introduit dans une architecture un point de faiblesse par lequel l'architecture peut se corroder lentement.
Appliquer le principe OCP, s'est s'assurer que l'architecture d'un module logiciel répond à ces deux impératifs:
- le module est ouvert à l'extension, ce qui signifie qu'il peut être étendu pour ajouter une nouvelle fonctionnalité.
- pour intégrer une nouvelle fonctionnalité, le code source existant n'est pas modifié
D'où le nom de ce principe, énonce par Bertrand Meyer[1].
Pourquoi ?
La première réponse, naturelle, est que la modification d'un code existant peut ne pas être possible - par exemple, votre client peut vouloir étendre un framework sans en posséder le code source.
Une seconde réponse, tout aussi valide, est que la modification d'un code existant peut provoquer l'apparition de bugs dans cette partie du code. Il faut alors sortir tout l'arsenal des tests de non régression, ce qui peut entraîner des surcoûts non négligeables.
Une troisième réponse est que la modification d'un code existant peut provoquer des problèmes bien plus grave qu'un bug en terme de maintenance: le code est sensiblement plus fragile, plus visqueux, plus rigide ou plus immobile. Chaque nouvelle modification modifie la façon dont la base de code va réagir à la prochaine modification. Au final, il devient prohibitif, dangereux voire même impossible d'effectuer une quelconque modification sur la base de code.
Viscosité ? Rigidité ?
Ces grandeurs, expliquées par Robert Martin[2], sont les symptômes d'une architecture pourrissante. Pour simplifier, en voici une explication succincte:
- Rigidité : un logiciel est rigide quand un petit changement induit une grande quantité de petit changements à d'autres endroits dans le logiciel
- Fragilité : un logiciel est fragile lorsqu'un changement mineur provoque des bugs dans d'autres endroits du logiciel (cette caractéristique est proche de la rigidité)
- Immobilité : l'immobilité dénote l'impossibilité de réutiliser une partie du logiciel pour effectuer d'autre tache dans le logiciel ou à l'extérieur de celui-ci.
- Viscosité : un logiciel devient visqueux lorsqu'il est moins coûteux d'introduire du code de mauvaise qualité plutôt que du code respectant l'architecture d'origine. La viscosité peut alors entraîner des modifications augmentant la fragilité, la rigidité ou l'immobilité du code.
Retour sur le principe
Le principe OCP est difficile à appliquer. La plupart du temps, on parle d'architecture évolutive pour désigner ces architecture logicielles tournées vers l'avenir. Toutefois, même si une architecture est globalement tournée vers l'avenir, il est nécessaire de s'assurer que cette ouverture est aussi vraie à l'échelle locale.
Considérons un cas simple: celui de la partie "gestion de fichier (lecture/écriture) d'un éditeur de texte. Le système est prévu pour pouvoir lire plusieurs formats de fichiers, et la conception facilite l'ajout d'un nouveau format. Le code originel est ainsi écrit:
if (file_extension == "doc") {
pDoc = new CWordDocument(filename);
} else if (file_extension == "wrt") {
pDoc = new CWordPadDocument(filename);
} else if (file_extension == "abi") {
pDoc = new CAbiwordDocument(filename);
}
L'ajout de la gestion d'un nouveau type de fichier (par exemple, le nouveau format XML de MS Word) implique la modification du code précédent, ainsi que l'ajout des nouvelles classes dans l'arbre des sources.
Comment corriger ce problème ? Une solution typique fait intervenir le patron de conception abstract factory[3]: une méthode virtuelle de cette "factory" permet de créer un nouveau document en fonction d'un paramètre connu (par exemple, l'extension ou le nom du fichier). Cette solution permet ensuite de dériver une nouvelle ’’factory’’ qui peut créer un nouveau type de document qui n'était pas connu au moment de la programmation de la première version de l'application.
Comment faire ?
Isoler les parties qui risquent d'être modifiées par la suite peut s'avérer une tâche longue et difficile. De plus, toutes les parties modifiables ne seront peut être pas modifiées, et prévoir des modifications qui ne se feront jamais est une perte de temps non souhaitable.
On peut toutefois citer des comportements qui facilitent l'application du principe OCP.
Premièrement, il est interdit d'effectuer un choix en se basant sur le type d'une donnée - si la liste des types de donnée dérivés augmente, alors le choix devra être modifié. Un exemple classique consiste à implémenter un mécanisme similaire à la phrase suivante: "si mon objet est un cercle, alors je dois faire A; si mon objet est un carré alors je dois faire B". La plupart du temps, on corrige le problème en utilisant les possibilités de polymorphisme d'héritage. De manière similaire, l'utilisation d'un système de RTTI est dangereuse - puisque son principe de base est l'identification dynamique d'un type de donnée.
Deuxièmement, il faut veiller à ce qu'il ne soit pas possible de donner un autre sens à l'interface d'une classe. Deux classes dérivant d'une même classe doivent pouvoir être utilisées de la même façon. Bien évidemment, les comportement différents peuvent avoir une incidence sur le résultat de cette utilisation, mais il n'en reste pas moins que l'utilisation d'une classe ne doit pas dépendre ce cette classe mais de son interface. Dans le cas contraire, l'utilisateur peut être forcé de vérifier le type réel de l'objet - on retombe ainsi dans le premier cas. Ce second point touche à un principe corollaire de l'OCP: le principe de substitution de Liskov, qui sera détaillé dans un autre billet.
A noter que ces deux règles peuvent forcer des choix d'architecture qui peuvent sembler illogiques: ainsi, d'un point de vue logiciel, il est très dangereux de considérer qu'un carré est un rectangle particulier. La preuve ? Si la classe Square dérive de Rectangle, que doit faire la méthode setDimension() ? Dans un cas, elle ne modifie qu'une seule grandeur, dans l'autre elle modifie les deux dimensions. Considérons alors le cas suivant:
void setRectangleDimension(Rectangle *r)
{
r->setExtent(100, 200);
}
Cette fonction a une exécution très différente selon qu'elle traite un carré ou un rectangle, et ce n'est pas souhaitable - dans notre cas, il est difficile de comprendre ce qui doit se passer si on lui transmet un carré en paramètre (bien sur, la solution dans ce cas est de casser le lien d'héritage entre les classes Square et Rectangle: d'un point de vue logiciel, un carré n'est pas un rectangle).
Troisième point : faire attention à l'héritage. En héritant d'une classe, on intègre l'ensemble de ses comportements. L'extension du comportement de cette classe de base n'a d'effets sur ses classes filles que si on modifie directement la classe de base - et cette modification, à cause des particularités de l'héritage, rend le code plus fragile/rigide. Encore une fois, ce point touche à une autre grande constante de l'architecture orientée objet : favoriser la composition par rapport à l'héritage. Cet autre principe sera discuté dans un autre billet.
Mais la principale réussite d'une application correcte du principe OCP tient en un seul mot : abstraction. Seule l'abstraction permet de définir une interface connue regroupant un nombre infini de comportements. Un module composé d'implémentation d'une abstraction clairement définie est une application du principe OCP dans le sens ou l'implémentation ne change pas (module fermé à la modification), et qu'il est possible d'augmenter l'ensemble des comportements en définissant une nouvelle implémentation (module ouvert à l'extension).
Dans la pratique
Bien évidemment, il est impossible de créer des abstractions pour tout - l'architecture, composée de centaines de stratégies et de classes abstraites sans aucune sémantique, ne serait pas non plus viable. De fait, cela introduit un problème qu'il faut s'attacher à résoudre: quelque soit l'envie qu'on a de créer une architecture répondant au principe OCP, il existe au moins un cas où je vais devoir modifier le logiciel si je veut étendre ses fonctionnalité.
Prenons le cas déjà énoncés des rectangles et des carrés. Les deux classes Rectangle et Square dérivent d'une classe abstraite Shape et se dessinent via une méthode draw() virtuelle. Le dessin est effectué par une fonction drawAllShapes():
void drawAllShapes(const std::vector<Shape*>& shapes)
{
for (size_t i=0; i<shapes.size(); i++) {
shapes[i]->draw();
}
}
Que faire maintenant si je souhaite dessiner les carrés avant les rectangles ? Evidemment, je vais devoir modifier mon code source - et mon design - de manière cohérente afin de régler ce problème.
Le fait est qu'il est impossible de prévoir tous les cas où je vais devoir modifier mon code source, et qu'il est impossible de créer une architecture qui réponde complètement au principe OCP. Néanmoins, je peux toujours effectuer une analyse qui me permettra d'ouvrir mon logiciel de manière ’’stratégique’’, à défaut de pouvoir le faire complètement.
Cette ouverture stratégique, seule une connaissance métier peut me permettre de l'intégrer dans mon architecture. La connaissance métier permet de prévoir les possibles évolutions - évolutions qu'il est alors possible d'intégrer dans l'architecture.
Conclusion
Le principe ouvert-fermé, ou OCP, est l'un des principe fondateur de l'architecture orientée objet. Son application a pour principe l'écriture d'un code source évolutif, dont les extensions subséquentes ne touchent pas (ou peu) le code source déjà existant. On évite ainsi le pourrissement de l'architecture en garantissant que celle ci ne sera pas rigidifiée/fragilisée/rendue visqueuse ou immobile par l'intégration d'une nouvelle fonctionnalité.
Un module logiciel doit être ouvert à l'extension et fermé à la modification
Notes
[1] Object Oriented Software Construction, Bertrand Meyer, 1988, Prentice Hall
[2] Designing Object Oriented Applications using UML, Robert C. Martin, 1999, Prentice Hall
[3] Stéphane Morat m'a fait remarquer très justement que dans le cadre de cette exemple précis c'est le patron de conception factory method (FM) que j'utilise et non pas abstract factory (AF). AF est souvent implémenté en utilisant plusieurs FM, mais possède la caractéristique supplémentaire de pouvoir créer des groupes d'objets, souvent liés sémantiquement, et non pas un objet unique.
Commentaires
Aucun commentaire pour le moment.
:: Fil rss des commentaires de ce billet ::
Ajouter un commentaire