Principe ouvert-fermé

Bonjour, Habr! Voici une traduction d'un article de Robert Martin du principe Open-Closed qu'il a publié en janvier 1996. L'article, pour le moins, n'est pas le dernier. Mais dans RuNet, les articles d'oncle Bob sur SOLID ne sont racontés que sous une forme tronquée, j'ai donc pensé qu'une traduction complète ne serait pas superflue.



J'ai décidé de commencer par la lettre O, car le principe d'ouverture-fermeture, en fait, est central. Entre autres choses, il existe de nombreuses subtilités importantes qui méritent une attention particulière:


  • Aucun programme ne peut être "fermé" à 100%.
  • La programmation orientée objet (POO) ne fonctionne pas avec des objets physiques du monde réel, mais avec des concepts - par exemple, le concept de "commande".

Ceci est le premier article de ma colonne Notes d'ingénieur pour le rapport C ++ . Les articles publiés dans cette colonne se concentreront sur l'utilisation de C ++ et OOP et aborderont les difficultés du développement logiciel. Je vais essayer de rendre les matériaux pragmatiques et utiles pour les ingénieurs en exercice. Pour la documentation de la conception orientée objet dans ces articles, j'utiliserai la notation de Buch.


Il existe de nombreuses heuristiques associées à la programmation orientée objet. Par exemple, «toutes les variables membres doivent être privées», ou «les variables globales doivent être évitées» ou «la détermination du type lors de l'exécution est dangereuse». Quelle est la raison de ces heuristiques? Pourquoi sont-ils vrais? Sont-ils toujours vrais? Cette colonne explore le principe de conception qui sous-tend ces heuristiques - le principe d'ouverture-fermeture.
Ivar Jacobson a déclaré: «Tous les systèmes changent au cours du cycle de vie. Cela doit être pris en compte lors de la conception d'un système pour lequel plusieurs versions sont attendues. » Comment concevoir un système pour qu'il soit stable face au changement et que plus d'une version soit attendue? Bertrand Meyer nous en a parlé en 1988, lorsque le désormais célèbre principe d'ouverture-proximité a été formulé:


Les entités de programme (classes, modules, fonctions, etc.) doivent être ouvertes pour l'expansion et fermées pour les modifications.


Si un changement dans le programme entraîne une cascade de changements dans les modules dépendants, alors le programme affiche des signes indésirables d'une «mauvaise» conception.


Le programme devient fragile, inflexible, imprévisible et inutilisé. Le principe d'ouverture-ouverture résout ces problèmes de manière très simple. Il dit qu'il faut concevoir des modules qui ne changent jamais. Lorsque les exigences changent, vous devez étendre le comportement de ces modules en ajoutant du nouveau code, plutôt qu'en changeant l'ancien code déjà fonctionnel.


La description


Les modules qui répondent au principe d'ouverture-proximité ont deux caractéristiques principales:


  1. Ouvert pour expansion. Cela signifie que le comportement du module peut être étendu. Autrement dit, nous pouvons ajouter un nouveau comportement au module en fonction des exigences changeantes de l'application ou pour répondre aux besoins de nouvelles applications.
  2. Fermé pour changement. Le code source d'un tel module est intouchable. Personne n'a le droit d'y apporter des modifications.

Il semble que ces deux signes ne s'emboîtent pas. La manière standard d'étendre le comportement d'un module consiste à y apporter des modifications. Un module qui ne peut pas être modifié est généralement considéré comme un module à comportement fixe. Comment ces deux conditions opposées peuvent-elles être remplies?


La clé de la solution est l'abstraction.


En C ++, en utilisant les principes de la conception orientée objet, il est possible de créer des abstractions fixes qui peuvent représenter un ensemble illimité de comportements possibles.


Les abstractions sont des classes de base abstraites et un ensemble illimité de comportements possibles est représenté par toutes les classes successives possibles. Un module peut manipuler l'abstraction. Un tel module est fermé pour modifications, car il dépend d'une abstraction fixe. De plus, le comportement du module peut être étendu en créant de nouveaux descendants d'abstraction.


Le diagramme ci-dessous montre une option de conception simple qui ne répond pas au principe d'ouverture-proximité. Les deux classes, Client et Server , ne sont pas abstraites. Il n'y a aucune garantie que les fonctions qui sont membres de la classe Server sont virtuelles. La classe Client utilise la classe Server . Si nous voulons que l'objet de classe Client utilise un autre objet serveur, nous devons changer la classe Client pour faire référence à la nouvelle classe de serveur.


image
Client fermé


Et le diagramme suivant montre l'option de conception correspondante, qui répond au principe d'ouverture-proximité. Dans ce cas, la classe AbstractServer est une classe abstraite, dont toutes les fonctions membres sont virtuelles. La classe Client utilise l'abstraction. Cependant, les objets de la classe Client utiliseront des objets de la classe successeur Server . Si nous voulons que les objets de la classe Client utilisent une classe de serveur différente, nous introduirons un nouveau descendant de la classe AbstractServer . La classe Client restera inchangée.


image
Client ouvert


Shape abstraite


Prenons une application qui devrait dessiner des cercles et des carrés dans une interface graphique standard. Les cercles et les carrés doivent être dessinés dans un ordre spécifique. Dans l'ordre correspondant, une liste de cercles et de carrés sera compilée, le programme devrait parcourir cette liste dans l'ordre et dessiner chaque cercle ou carré.


En C, en utilisant des techniques de programmation procédurale qui ne respectent pas le principe d'ouverture-fermeture, nous pourrions résoudre ce problème comme le montre le listing 1. Ici, nous voyons de nombreuses structures de données avec le même premier élément. Cet élément est un code de type qui identifie la structure de données comme un cercle ou un carré. La fonction DrawAllShapes passe par un tableau de pointeurs vers ces structures de données, reconnaissant le code de type et appelant ensuite la fonction correspondante ( DrawCircle ou DrawSquare ).


 // 1 //  /    enum ShapeType {circle, square} struct Shape { ShapeType itsType; }; struct Circle { ShapeType itsType; double itsRadius; Point itsCenter; }; struct Square { ShapeType itsType; double itsSide; Point itsTopLeft; }; // //     // void DrawSquare(struct Square*) void DrawCircle(struct Circle*); typedef struct Shape *ShapePointer; void DrawAllShapes(ShapePointer list[], int n) { int i; for (i=0; i<n; i++) { struct Shape* s = list[i]; switch (s->itsType) { case square: DrawSquare((struct Square*)s); break; case circle: DrawCircle((struct Circle*)s); break; } } } 

La fonction DrawAllShapes répond pas au principe d'ouverture-fermeture, car elle ne peut pas être «fermée» à partir de nouveaux types de formes. Si je voulais étendre cette fonction avec la possibilité de dessiner des formes à partir d'une liste qui comprend des triangles, alors je devrais changer la fonction. En fait, je dois changer la fonction pour chaque nouveau type de forme que je dois dessiner.


Bien sûr, ce programme n'est qu'un exemple. Dans la vie réelle, l'opérateur de switch de la fonction DrawAllShapes serait répété maintes et maintes fois dans diverses fonctions de l'application, et chacun ferait quelque chose de différent. Ajouter de nouvelles formes à une telle application signifie trouver tous les endroits où de telles switch (ou chaînes if/else ) sont utilisées, et ajouter une nouvelle forme à chacune d'elles. De plus, il est très peu probable que toutes les switch et les chaînes if/else soient aussi bien structurées que dans DrawAllShapes . Il est beaucoup plus probable que les prédicats dans le if soient combinées avec des opérateurs logiques, ou que les blocs de switch soient combinés de manière à «simplifier» une place particulière dans le code. Par conséquent, le problème de trouver et de comprendre tous les endroits où vous devez ajouter une nouvelle figure peut être non trivial.


Dans le Listing 2, je montrerai du code qui illustre une solution carrée / circulaire qui répond au principe d'ouverture-fermeture. Une classe Shape abstraite est introduite. Cette classe abstraite contient une fonction de Draw virtuel pur. Les classes Circle et Square sont des descendants de la classe Shape .


 // 2 //  /  - class Shape { public: virtual void Draw() const = 0; }; class Square : public Shape { public: virtual void Draw() const; }; class Circle : public Shape { public: virtual void Draw() const; }; void DrawAllShapes(Set<Shape*>& list) { for (Iterator<Shape*>i(list); i; i++) (*i)->Draw(); } 

Notez que si nous voulons étendre le comportement de la fonction DrawAllShapes du Listing 2 pour dessiner un nouveau type de forme, tout ce que nous devons faire est d'ajouter un nouveau descendant de la classe Shape . Pas besoin de changer la fonction DrawAllShapes . Par conséquent, DrawAllShapes répond au principe d'ouverture-proximité. Son comportement peut être étendu sans changer la fonction elle-même.


Dans le monde réel, la classe Shape contiendrait de nombreuses autres méthodes. Et pourtant, ajouter une nouvelle forme à l'application est toujours très simple, car il vous suffit de saisir un nouvel héritier et de mettre en œuvre ces fonctions. Pas besoin de parcourir toute l'application à la recherche de lieux à changer.


Par conséquent, les programmes qui respectent le principe d'ouverture-proximité sont modifiés en ajoutant un nouveau code, et non en changeant le code existant; ils ne répercutent pas en cascade les changements caractéristiques des programmes qui ne correspondent pas à ce principe.


Stratégie d'entrée fermée


De toute évidence, aucun programme ne peut être fermé à 100%. Par exemple, DrawAllShapes il de la fonction DrawAllShapes dans le listing 2 si nous décidons que les cercles puis les carrés doivent être dessinés en premier? La fonction DrawAllShapes pas fermée de ce type de modification. En général, peu importe à quel point le module est «fermé», il y a toujours un certain type de changement dont il n'est pas fermé.


La fermeture ne pouvant être complète, elle doit être introduite de manière stratégique. Autrement dit, le concepteur doit choisir les types de modifications à partir desquelles le programme sera fermé. Cela nécessite une certaine expérience. Un développeur expérimenté connaît suffisamment les utilisateurs et l'industrie pour calculer la probabilité de divers changements. Il s'assure ensuite que le principe d'ouverture-proximité est respecté pour les changements les plus probables.


Utilisation de l'abstraction pour obtenir une proximité supplémentaire


Comment fermer la fonction DrawAllShapes des modifications de l'ordre de dessin? N'oubliez pas que la fermeture est basée sur l'abstraction. Par conséquent, pour fermer DrawAllShapes de la commande, nous avons besoin d'une sorte d'abstraction de commande. Un cas particulier de commande, présenté ci-dessus, est le dessin de figures d'un type devant des figures d'un autre type.


La politique de commande implique qu'avec deux objets, vous pouvez déterminer lequel doit être dessiné en premier. Par conséquent, nous pouvons définir une méthode pour la classe Shape appelée Precedes , qui prend un autre objet Shape comme argument et renvoie une valeur booléenne true si l'objet Shape qui a reçu ce message doit être trié avant l'objet Shape qui était passé en argument.


En C ++, cette fonction peut être représentée comme une surcharge de l'opérateur "<". Le listing 3 montre la classe Shape avec des méthodes de tri.


Maintenant que nous avons un moyen de déterminer l'ordre des objets de la classe Shape , nous pouvons les trier puis les dessiner. Le listing 4 montre le code C ++ correspondant. Il utilise les classes Set , OrderedSet et Iterator de la catégorie Components développée dans mon livre (Designing Object Oriented C ++ Applications using the Booch Method, Robert C. Martin, Prentice Hall, 1995).


Nous avons donc implémenté l'ordre des objets de la classe Shape et les dessiner dans l'ordre approprié. Mais nous n'avons toujours pas de mise en œuvre de l'abstraction de la commande. De toute évidence, chaque objet Shape doit remplacer la méthode Precedes pour déterminer l'ordre. Comment cela peut-il fonctionner? Quel code doit être écrit dans Circle::Precedes pour que les cercles soient dessinés en carrés? Faites attention à la liste 5.


 // 3 //  Shape    . class Shape { public: virtual void Draw() const = 0; virtual bool Precedes(const Shape&) const = 0; bool operator<(const Shape& s) {return Precedes(s);} }; 

 // 4 // DrawAllShapes   void DrawAllShapes(Set<Shape*>& list) { //    OrderedSet  . OrderedSet<Shape*> orderedList = list; orderedList.Sort(); for (Iterator<Shape*> i(orderedList); i; i++) (*i)->Draw(); } 

 // 5 //    bool Circle::Precedes(const Shape& s) const { if (dynamic_cast<Square*>(s)) return true; else return false; } 

Il est clair que cette fonction ne répond pas au principe d'ouverture-proximité. Il n'y a aucun moyen de le fermer aux nouveaux descendants de la classe Shape . Chaque fois qu'un nouveau descendant de la classe Shape apparaît, cette fonction doit être modifiée.


Utilisation d'une approche basée sur les données pour réaliser la fermeture


La proximité des héritiers de la classe Shape peut être obtenue en utilisant une approche tabulaire qui ne provoque pas de changements dans chaque classe héritée. Un exemple de cette approche est illustré dans le Listing 6.


En utilisant cette approche, nous avons réussi à fermer la fonction DrawAllShapes des changements liés à l'ordre et à chaque descendant de la classe Shape - de l'introduction d'un nouveau descendant ou d'un changement dans la politique de classement des objets de la classe Shape fonction de leur type (par exemple, de sorte que les objets de la classe Squares doivent être tiré en premier).


 // 6 //     #include <typeinfo.h> #include <string.h> enum {false, true}; typedef int bool; class Shape { public: virtual void Draw() const = 0; virtual bool Precedes(const Shape&) const; bool operator<(const Shape& s) const {return Precedes(s);} private: static char* typeOrderTable[]; }; char* Shape::typeOrderTable[] = { "Circle", "Square", 0 }; //      . //   ,    //  . ,    , //      bool Shape::Precedes(const Shape& s) const { const char* thisType = typeid(*this).name(); const char* argType = typeid(s).name(); bool done = false; int thisOrd = -1; int argOrd = -1; for (int i=0; !done; i++) { const char* tableEntry = typeOrderTable[i]; if (tableEntry != 0) { if (strcmp(tableEntry, thisType) == 0) thisOrd = i; if (strcmp(tableEntry, argType) == 0) argOrd = i; if ((argOrd > 0) && (thisOrd > 0)) done = true; } else // table entry == 0 done = true; } return thisOrd < argOrd; } 

Le seul élément qui n'est pas empêché de changer l'ordre des formes de dessin est un tableau. La table peut être placée dans un module séparé, séparé de tous les autres modules, et donc ses modifications n'affecteront pas les autres modules.


Fermeture supplémentaire


Ce n'est pas la fin de l'histoire. Nous avons fermé la hiérarchie de la classe Shape et de la fonction DrawAllShapes de la modification de la stratégie de DrawAllShapes fonction du type de formes. Cependant, les descendants de la classe Shape ne sont pas fermés aux règles de classement qui ne sont pas associées aux types de Shape . Il semble que nous devons organiser le dessin des formes selon une structure de niveau supérieur. Une étude complète de ces problèmes dépasse le cadre de cet article; cependant, un lecteur intéressé pourrait penser comment résoudre ce problème en utilisant la classe abstraite OrderedObject contenue dans la classe OrderedShape , qui hérite des OrderedObject Shape et OrderedObject .


Heuristique et conventions


Comme mentionné au début de l'article, le principe d'ouverture-proximité est la motivation clé derrière de nombreuses heuristiques et conventions qui ont émergé au cours des années de développement du paradigme OOP. Voici les plus importants.


Rendre toutes les variables membres privées


Il s'agit de l'une des conventions les plus durables de l'OLP. Les variables membres ne doivent être connues que des méthodes de la classe dans laquelle elles sont définies. Les membres variables ne doivent être connus d'aucune autre classe, y compris les classes dérivées. Par conséquent, ils doivent être déclarés avec un modificateur d'accès private , non public ou protected .
À la lumière du principe d'ouverture-proximité, la raison d'une telle convention est compréhensible. Lorsque des variables de membre de classe changent, chaque fonction qui en dépend doit changer. Autrement dit, la fonction n'est pas fermée aux modifications de ces variables.


En POO, nous nous attendons à ce que les méthodes d'une classe ne soient pas fermées aux modifications des variables qui sont membres de cette classe. Cependant, nous nous attendons à ce que toute autre classe, y compris les sous-classes, soit fermée aux modifications de ces variables. C'est ce qu'on appelle l'encapsulation.


Mais que faire si vous avez une variable dont vous êtes sûr qu'elle ne changera jamais? Est-il judicieux de le rendre private ? Par exemple, le Listing 7 montre la classe Device qui contient le bool status membre variable. Il stocke l'état de la dernière opération. Si l'opération a réussi, la valeur de la variable d' status sera true , sinon false .


 // 7 //   class Device { public: bool status; }; 

Nous savons que le type ou la signification de cette variable ne changera jamais. Alors pourquoi ne pas le rendre public et lui donner un accès direct? Si la variable ne change jamais vraiment, si tous les clients suivent les règles et ne lisent qu'à partir de cette variable, alors il n'y a rien de mal à ce que la variable soit publique. Cependant, réfléchissez à ce qui se passera si l'un des clients saisit l'occasion d'écrire dans cette variable et de modifier sa valeur.


Du coup, ce client peut affecter le fonctionnement de tout autre client de la classe Device . Cela signifie qu'il est impossible de fermer les clients de la classe Device des modifications apportées à ce module incorrect. C'est trop de risque.


Par contre, supposons que nous ayons la classe Time , montrée dans l'extrait 8. Quel est le danger de la publicité des variables qui sont membres de cette classe? Il est très peu probable qu'ils changent. De plus, peu importe que les modules clients modifient ou non les valeurs de ces variables, car un changement dans ces variables est supposé. Il est également très peu probable que les classes héritées puissent dépendre de la valeur d'une variable membre particulière. Y a-t-il donc un problème?


 // 8 class Time { public: int hours, minutes, seconds; Time& operator-=(int seconds); Time& operator+=(int seconds); bool operator< (const Time&); bool operator> (const Time&); bool operator==(const Time&); bool operator!=(const Time&); }; 

La seule plainte que je pourrais faire au code du Listing 8 est que le changement d'heure n'est pas atomique. Autrement dit, le client peut modifier la valeur de la variable minutes sans modifier la valeur de la variable hours . Cela peut entraîner un objet de la classe Time à contenir des données incohérentes. Je préférerais introduire une seule fonction pour régler l'heure, ce qui prendrait trois arguments, ce qui ferait de régler l'heure une opération atomique. Mais c'est un argument faible.


Il est facile de trouver d'autres conditions dans lesquelles la publicité de ces variables peut entraîner des problèmes. En fin de compte, cependant, il n'y a aucune raison convaincante de les rendre private . Je pense toujours que rendre ces variables publiques est un mauvais style, mais ce n'est peut-être pas une mauvaise conception. Je pense que c'est un mauvais style, car il ne coûte presque rien pour entrer dans les fonctions appropriées pour accéder à ces membres, et cela vaut vraiment la peine de vous protéger du petit risque associé à la survenue éventuelle de problèmes de fermeture.


Par conséquent, dans de tels cas rares, lorsque le principe d'ouverture-fermeture n'est pas violé, l'interdiction des variables public - et protected dépend davantage du style et non du contenu.


Pas de variables globales ... du tout!


L'argument contre les variables globales est le même que l'argument contre les variables de membre public. Aucun module qui dépend d'une variable globale ne peut être fermé à partir d'un module qui peut y écrire. Tout module qui utilise cette variable d'une manière non prévue par d'autres modules cassera ces modules. Il est trop risqué d'avoir plusieurs modules, selon les caprices d'un seul module malveillant.
D'un autre côté, dans les cas où les variables globales ont un petit nombre de modules qui en dépendent ou ne peuvent pas être utilisées de manière incorrecte, elles ne nuisent pas. Le concepteur doit évaluer combien d'intimité est sacrifiée et déterminer si la commodité fournie par la variable globale en vaut la peine.


Là encore, des problèmes de style entrent en jeu. Les alternatives à l'utilisation de variables globales sont généralement peu coûteuses. Dans de tels cas, l'utilisation d'une technique qui introduit, bien que faible, mais un risque de fermeture au lieu d'une technique qui élimine complètement un tel risque, est un signe de mauvais style. Cependant, l'utilisation de variables globales est parfois très pratique. Un exemple typique est les variables globales cout et cin. Dans de tels cas, si le principe d'ouverture-proximité n'est pas violé, vous pouvez sacrifier le style pour des raisons de commodité.


RTTI est dangereux


Une autre interdiction courante est l'utilisation de dynamic_cast . Très souvent, dynamic_cast ou toute autre forme de détermination du type d'exécution (RTTI) est accusé d'être une technique extrêmement dangereuse et doit donc être évitée. Dans le même temps, ils donnent souvent un exemple de l'extrait 9, qui viole manifestement le principe d'ouverture et de proximité. Cependant, le Listing 10 montre un exemple d'un programme similaire qui utilise dynamic_cast sans violer le principe d'ouverture-fermeture.


La différence entre eux est que dans le premier cas, illustré dans l'extrait 9, le code doit être changé à chaque fois qu'un nouveau descendant de la classe Shape apparaît (sans mentionner que c'est une solution absolument ridicule). Cependant, dans le listing 10, aucune modification n'est requise dans ce cas. Par conséquent, le code du Listing 10 ne viole pas le principe d'ouverture-fermeture.
Dans ce cas, la règle de base est que RTTI peut être utilisé si le principe d'ouverture-fermeture n'est pas violé.


 // 9 //RTTI,   -. class Shape {}; class Square : public Shape { private: Point itsTopLeft; double itsSide; friend DrawSquare(Square*); }; class Circle : public Shape { private: Point itsCenter; double itsRadius; friend DrawCircle(Circle*); }; void DrawAllShapes(Set<Shape*>& ss) { for (Iterator<Shape*>i(ss); i; i++) { Circle* c = dynamic_cast<Circle*>(*i); Square* s = dynamic_cast<Square*>(*i); if (c) DrawCircle(c); else if (s) DrawSquare(s); } } 

 // 10 //RTTI,    -. class Shape { public: virtual void Draw() cont = 0; }; class Square : public Shape { // . }; void DrawSquaresOnly(Set<Shape*>& ss) { for (Iterator<Shape*>i(ss); i; i++) { Square* s = dynamic_cast<Square*>(*i); if (s) s->Draw(); } } 

Conclusion


Je pourrais parler longtemps du principe d'ouverture-proximité. À bien des égards, ce principe est le plus important pour la programmation orientée objet. Le respect de ce principe particulier offre les principaux avantages de la technologie orientée objet, à savoir la réutilisation et le support.


, - -. , , , , , .

Source: https://habr.com/ru/post/fr472186/


All Articles