Principio abierto-cerrado

Hola Habr! Aqu铆 hay una traducci贸n de un art铆culo de Robert Martin del Principio Abierto-Cerrado , que public贸 en enero de 1996. El art铆culo, por decirlo suavemente, no es el 煤ltimo. Pero en RuNet, los art铆culos del t铆o Bob sobre SOLID se vuelven a contar solo en forma truncada, por lo que pens茅 que una traducci贸n completa no ser铆a superflua.



Decid铆 comenzar con la letra O, ya que el principio de apertura-cierre, de hecho, es central. Entre otras cosas, hay muchas sutilezas importantes a las que vale la pena prestarles atenci贸n:


  • Ning煤n programa puede ser "cerrado" al 100%.
  • La programaci贸n orientada a objetos (OOP) opera no con objetos f铆sicos del mundo real, sino con conceptos, por ejemplo, el concepto de "ordenar".

Este es el primer art铆culo en mi columna de Notas del ingeniero para The C ++ Report . Los art铆culos publicados en esta columna se centrar谩n en el uso de C ++ y OOP y abordar谩n las dificultades en el desarrollo de software. Intentar茅 hacer que los materiales sean pragm谩ticos y 煤tiles para los ingenieros en ejercicio. Para la documentaci贸n del dise帽o orientado a objetos en estos art铆culos, usar茅 la notaci贸n de Buch.


Hay muchas heur铆sticas asociadas con la programaci贸n orientada a objetos. Por ejemplo, "todas las variables miembro deben ser privadas", o "se deben evitar las variables globales", o "la determinaci贸n de tipo en tiempo de ejecuci贸n es peligrosa". 驴Cu谩l es la raz贸n de tales heur铆sticas? 驴Por qu茅 son verdad? 驴Son siempre ciertas? Esta columna explora el principio de dise帽o que subyace a estas heur铆sticas: el principio de apertura-cierre.
Ivar Jacobson dijo: 鈥淭odos los sistemas cambian durante el ciclo de vida. Esto debe tenerse en cuenta al dise帽ar un sistema que tenga m谩s de una versi贸n esperada ". 驴C贸mo podemos dise帽ar un sistema para que sea estable frente al cambio y que tenga m谩s de una versi贸n esperada? Bertrand Meyer nos cont贸 sobre esto en 1988, cuando se formul贸 el ahora famoso principio de apertura-cercan铆a:


Las entidades del programa (clases, m贸dulos, funciones, etc.) deben estar abiertas para expansi贸n y cerradas para cambios.


Si un cambio en el programa implica una cascada de cambios en los m贸dulos dependientes, entonces aparecen signos indeseables de un dise帽o "malo" en el programa.


El programa se vuelve fr谩gil, inflexible, impredecible y sin uso. El principio de apertura-cercan铆a resuelve estos problemas de una manera muy directa. 脡l dice que es necesario dise帽ar m贸dulos que nunca cambien . Cuando los requisitos cambian, debe expandir el comportamiento de dichos m贸dulos agregando un c贸digo nuevo, en lugar de cambiar el c贸digo antiguo que ya funciona.


Descripci贸n


Los m贸dulos que cumplen con el principio de apertura-cercan铆a tienen dos caracter铆sticas principales:


  1. Abierto a la expansi贸n. Esto significa que el comportamiento del m贸dulo se puede ampliar. Es decir, podemos agregar un nuevo comportamiento al m贸dulo de acuerdo con los requisitos cambiantes para la aplicaci贸n o para satisfacer las necesidades de las nuevas aplicaciones.
  2. Cerrado por cambio. El c贸digo fuente de dicho m贸dulo es intocable. Nadie tiene derecho a hacerle cambios.

Parece que estos dos signos no encajan entre s铆. La forma est谩ndar de extender el comportamiento de un m贸dulo es hacer cambios en 茅l. Un m贸dulo que no se puede cambiar generalmente se considera un m贸dulo con comportamiento fijo. 驴C贸mo se pueden cumplir estas dos condiciones opuestas?


La clave de la soluci贸n es la abstracci贸n.


En C ++, utilizando los principios del dise帽o orientado a objetos, es posible crear abstracciones fijas que pueden representar un conjunto ilimitado de posibles comportamientos.


Las abstracciones son clases base abstractas, y todas las posibles clases sucesoras representan un conjunto ilimitado de posibles comportamientos. Un m贸dulo puede manipular la abstracci贸n. Dicho m贸dulo est谩 cerrado por cambios, ya que depende de una abstracci贸n fija. Adem谩s, el comportamiento del m贸dulo se puede ampliar creando nuevos descendientes de abstracci贸n.


El siguiente diagrama muestra una opci贸n de dise帽o simple que no cumple con el principio de apertura-cercan铆a. Ambas clases, Client y Server , no son abstractas. No hay garant铆a de que las funciones que son miembros de la clase Server sean virtuales. La clase Client usa la clase Server . Si queremos que el objeto de la clase Client use un objeto de servidor diferente, debemos cambiar la clase Client para hacer referencia a la nueva clase de servidor.


imagen
Cliente cerrado


Y el siguiente diagrama muestra la opci贸n de dise帽o correspondiente, que cumple con el principio de apertura-cercan铆a. En este caso, la clase AbstractServer es una clase abstracta, cuyas funciones miembro son virtuales. La clase Client usa abstracci贸n. Sin embargo, los objetos de la clase Client utilizar谩n objetos de la clase sucesora del Server . Si queremos que los objetos de la clase Client utilicen una clase de servidor diferente, presentaremos un nuevo descendiente de la clase AbstractServer . La clase del Client permanecer谩 sin cambios.


imagen
Cliente abierto


Resumen de Shape


Considere una aplicaci贸n que deber铆a dibujar c铆rculos y cuadrados en una GUI est谩ndar. Los c铆rculos y cuadrados se deben dibujar en un orden espec铆fico. En el orden correspondiente, se compilar谩 una lista de c铆rculos y cuadrados, el programa debe revisar esta lista en el orden y dibujar cada c铆rculo o cuadrado.


En C, utilizando t茅cnicas de programaci贸n de procedimientos que no cumplen con el principio de apertura y cierre, podr铆amos resolver este problema como se muestra en el Listado 1. Aqu铆 vemos muchas estructuras de datos con el mismo primer elemento. Este elemento es un c贸digo de tipo que identifica la estructura de datos como un c铆rculo o cuadrado. La funci贸n DrawAllShapes pasa a trav茅s de una matriz de punteros a estas estructuras de datos, reconociendo el c贸digo de tipo y luego llamando a la funci贸n correspondiente ( DrawCircle o 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 funci贸n DrawAllShapes no cumple el principio de apertura-cierre, ya que no se puede "cerrar" a partir de nuevos tipos de formas. Si quisiera expandir esta funci贸n con la capacidad de dibujar formas de una lista que incluye tri谩ngulos, entonces necesitar铆a cambiar la funci贸n. De hecho, tengo que cambiar la funci贸n para cada nuevo tipo de forma que necesito dibujar.


Por supuesto, este programa es solo un ejemplo. En la vida real, el operador del switch de la funci贸n DrawAllShapes se repetir铆a una y otra vez en varias funciones a lo largo de la aplicaci贸n, y cada una har铆a algo diferente. Agregar nuevas formas a una aplicaci贸n de este tipo significa encontrar todos los lugares donde se usan tales switch (o cadenas if/else ) y agregar una nueva forma a cada una de ellas. Adem谩s, es muy poco probable que todas las switch y las cadenas if/else est茅n tan bien estructuradas como en DrawAllShapes . Es mucho m谩s probable que los predicados en if se combinen con operadores l贸gicos, o los bloques de case y case de las switch de switch se combinen de tal manera que "simplifiquen" un lugar particular en el c贸digo. Por lo tanto, el problema de encontrar y comprender todos los lugares donde necesita agregar una nueva figura no puede ser trivial.


En el Listado 2, mostrar茅 el c贸digo que demuestra una soluci贸n cuadrada / circular que cumple con el principio de apertura-cierre. Se introduce una clase de Shape abstracta. Esta clase abstracta contiene una funci贸n de Draw virtual pura. Las clases Circle y Square son descendientes de la clase 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(); } 

Tenga en cuenta que si queremos extender el comportamiento de la funci贸n DrawAllShapes en el Listado 2 para dibujar un nuevo tipo de forma, todo lo que tenemos que hacer es agregar un nuevo descendiente de la clase Shape . No es necesario cambiar la funci贸n DrawAllShapes . Por lo tanto, DrawAllShapes cumple con el principio de apertura-cercan铆a. Su comportamiento se puede ampliar sin cambiar la funci贸n en s铆.


En el mundo real, la clase Shape contendr铆a muchos otros m茅todos. Y, sin embargo, agregar una nueva forma a la aplicaci贸n sigue siendo muy simple, ya que todo lo que necesita hacer es ingresar un nuevo heredero e implementar estas funciones. No es necesario explorar toda la aplicaci贸n en busca de lugares que requieran cambios.


Por lo tanto, los programas que cumplen con el principio de apertura-cercan铆a se cambian agregando un nuevo c贸digo, y no cambiando el existente; no cambian en cascada los cambios caracter铆sticos de los programas que no corresponden a este principio.


Estrategia de entrada cerrada


Obviamente, ning煤n programa puede estar 100% cerrado. Por ejemplo, 驴qu茅 le sucede a la funci贸n DrawAllShapes en el Listado 2 si decidimos que primero deben dibujarse c铆rculos y luego cuadrados? La funci贸n DrawAllShapes no DrawAllShapes cerrada por este tipo de cambio. En general, no importa cu谩n "cerrado" est茅 el m贸dulo, siempre hay alg煤n tipo de cambio desde el cual no est谩 cerrado.


Como el cierre no puede ser completo, debe ser introducido estrat茅gicamente. Es decir, el dise帽ador debe elegir los tipos de cambios a partir de los cuales se cerrar谩 el programa. Esto requiere algo de experiencia. Un desarrollador experimentado conoce a los usuarios y la industria lo suficientemente bien como para calcular la probabilidad de varios cambios. Luego se asegura de que se respete el principio de apertura-cercan铆a para los cambios m谩s probables.


Uso de la abstracci贸n para lograr una cercan铆a adicional.


驴C贸mo podemos cerrar la funci贸n DrawAllShapes de los cambios en el orden de dibujo? Recuerde que el cierre se basa en la abstracci贸n. Por lo tanto, para cerrar DrawAllShapes del pedido, necesitamos alg煤n tipo de "abstracci贸n de pedido". Un caso especial de pedido, presentado anteriormente, es dibujar figuras de un tipo frente a figuras de otro tipo.


La pol铆tica de pedidos implica que con dos objetos, puede determinar cu谩l debe dibujarse primero. Por lo tanto, podemos definir un m茅todo para la clase Shape llamado Precedes , que toma otro objeto Shape como argumento y devuelve un valor booleano true si el objeto Shape que recibi贸 este mensaje necesita ser ordenado antes que el objeto Shape que fue Pasado como argumento.


En C ++, esta funci贸n se puede representar como una sobrecarga del operador "<". El Listado 3 muestra la clase Shape con m茅todos de clasificaci贸n.


Ahora que tenemos una manera de determinar el orden de los objetos de la clase Shape , podemos ordenarlos y luego dibujarlos. El Listado 4 muestra el c贸digo C ++ correspondiente. Utiliza las clases Set , OrderedSet e OrderedSet de la categor铆a Components desarrollada en mi libro (Dise帽o de aplicaciones C ++ orientadas a objetos utilizando el m茅todo Booch, Robert C. Martin, Prentice Hall, 1995).


Entonces, hemos implementado el orden de los objetos de la clase Shape y dibuj谩ndolos en el orden apropiado. Pero todav铆a no tenemos una implementaci贸n de la abstracci贸n del orden. Obviamente, cada objeto Shape debe anular el m茅todo Precedes para determinar el orden. 驴C贸mo puede funcionar esto? 驴Qu茅 c贸digo se debe escribir en Circle::Precedes para que los c铆rculos se dibujen en cuadrados? Presta atenci贸n al listado 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; } 

Est谩 claro que esta funci贸n no cumple con el principio de apertura-cercan铆a. No hay forma de cerrarlo de los nuevos descendientes de la clase Shape . Cada vez que aparece un nuevo descendiente de la clase Shape , esta funci贸n debe cambiarse.


Uso de un enfoque basado en datos para lograr el cierre


La cercan铆a de los herederos de la clase Shape se puede lograr utilizando un enfoque tabular que no provoca cambios en cada clase heredada. Un ejemplo de este enfoque se muestra en el Listado 6.


Usando este enfoque, cerramos con 茅xito la funci贸n DrawAllShapes de los cambios relacionados con el pedido, y cada descendiente de la clase Shape , desde la introducci贸n de un nuevo descendiente o de un cambio en la pol铆tica de pedidos para los objetos de la clase Shape dependiendo de su tipo (por ejemplo, los objetos de la clase Squares deber铆an ser dibujado primero).


 // 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; } 

El 煤nico elemento que no est谩 cerrado al cambiar el orden de las formas de dibujo es una tabla. La tabla se puede colocar en un m贸dulo separado, separado de todos los dem谩s m贸dulos y, por lo tanto, sus cambios no afectar谩n a otros m贸dulos.


Cierre adicional


Este no es el final de la historia. Cerramos la jerarqu铆a de la clase Shape y la funci贸n DrawAllShapes de cambiar la pol铆tica de ordenamiento en funci贸n del tipo de formas. Sin embargo, los descendientes de la clase Shape no est谩n excluidos de las pol铆ticas de pedido que no est谩n asociadas con los tipos de Shape . Parece que necesitamos organizar el dibujo de formas de acuerdo con una estructura de nivel superior. Un estudio completo de tales problemas est谩 m谩s all谩 del alcance de este art铆culo; sin embargo, un lector interesado podr铆a pensar c贸mo resolver este problema utilizando la clase abstracta OrderedObject contenida en la clase OrderedShape , que hereda de las OrderedObject Shape y OrderedObject .


Heur铆stica y Convenciones


Como se mencion贸 al principio del art铆culo, el principio de apertura-cercan铆a es la motivaci贸n clave detr谩s de muchas heur铆sticas y convenciones que han surgido durante los a帽os de desarrollo del paradigma OOP. Los siguientes son los m谩s importantes.


Hacer que todas las variables miembro sean privadas


Esta es una de las convenciones m谩s duraderas de la OLP. Las variables miembro solo deben ser conocidas por los m茅todos de la clase en la que se definen. Los miembros variables no deben ser conocidos por ninguna otra clase, incluidas las clases derivadas. Por lo tanto, deben declararse con un modificador de acceso private , no public o protected .
A la luz del principio de apertura-cercan铆a, la raz贸n de tal convenci贸n es comprensible. Cuando las variables miembro de la clase cambian, cada funci贸n que depende de ellas debe cambiar. Es decir, la funci贸n no est谩 cerrada por cambios en estas variables.


En OOP, esperamos que los m茅todos de una clase no est茅n cerrados a los cambios en las variables que son miembros de esta clase. Sin embargo, esperamos que cualquier otra clase, incluidas las subclases, est茅 cerrada por cambios en estas variables. Esto se llama encapsulaci贸n.


Pero, 驴qu茅 sucede si tiene una variable sobre la que est谩 seguro de que nunca cambiar谩? 驴Tiene sentido hacerlo private ? Por ejemplo, el Listado 7 muestra la clase de Device que contiene el bool status miembro variable. Almacena el estado de la 煤ltima operaci贸n. Si la operaci贸n fue exitosa, entonces el valor de la variable de status ser谩 true , de lo contrario false .


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

Sabemos que el tipo o significado de esta variable nunca cambiar谩. Entonces, 驴por qu茅 no hacerlo public y darle acceso directo al cliente? Si la variable realmente nunca cambia, si todos los clientes siguen las reglas y solo leen de esta variable, entonces no hay nada de malo en el hecho de que la variable es p煤blica. Sin embargo, considere lo que suceder谩 si uno de los clientes aprovecha la oportunidad para escribir en esta variable y cambiar su valor.


De repente, este cliente puede afectar el funcionamiento de cualquier otro cliente de la clase Device . Esto significa que es imposible cerrar clientes de la clase Device por cambios en este m贸dulo incorrecto. Esto es demasiado riesgo.


Por otro lado, supongamos que tenemos la clase Time , que se muestra en el Listado 8. 驴Cu谩l es el peligro de la publicidad de las variables que son miembros de esta clase? Es muy poco probable que cambien. Adem谩s, no importa si los m贸dulos del cliente cambian los valores de estas variables o no, ya que se supone un cambio en estas variables. Tambi茅n es muy poco probable que las clases heredadas puedan depender del valor de una variable miembro en particular. Entonces, 驴hay un problema?


 // 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 煤nica queja que podr铆a hacerle al c贸digo en el Listado 8 es que el cambio de hora no es at贸mico. Es decir, el cliente puede cambiar el valor de la variable minutes sin cambiar el valor de la variable hours . Esto puede hacer que un objeto de la clase Time contenga datos inconsistentes. Preferir铆a introducir una sola funci贸n para configurar el tiempo, que tomar铆a tres argumentos, lo que har铆a que configurar el tiempo sea una operaci贸n at贸mica. Pero este es un argumento d茅bil.


Es f谩cil encontrar otras condiciones bajo las cuales la publicidad de estas variables pueda generar problemas. Sin embargo, en 煤ltima instancia, no hay una raz贸n convincente para hacerlos private . Sigo pensando que hacer p煤blicas tales variables es un mal estilo, pero tal vez no sea un mal dise帽o. Creo que este es un mal estilo, porque no cuesta casi nada ingresar a las funciones apropiadas para acceder a estos miembros, y definitivamente vale la pena protegerse del peque帽o riesgo asociado con la posible aparici贸n de problemas con el cierre.


Por lo tanto, en casos tan raros, cuando no se viola el principio de apertura-cierre, la prohibici贸n de public variables public y protected depende m谩s del estilo y no del contenido.


No hay variables globales ... en absoluto!


El argumento contra las variables globales es el mismo que el argumento contra las variables de miembros p煤blicos. Ning煤n m贸dulo que dependa de una variable global puede cerrarse desde un m贸dulo que pueda escribir en 茅l. Cualquier m贸dulo que use esta variable de una manera no prevista por otros m贸dulos los romper谩. Es demasiado arriesgado tener muchos m贸dulos, dependiendo de los caprichos de un solo m贸dulo malicioso.
Por otro lado, en los casos en que las variables globales tienen una peque帽a cantidad de m贸dulos que dependen de ellos o no se pueden usar de manera incorrecta, no causan da帽o. El dise帽ador debe evaluar cu谩nta privacidad se sacrifica y determinar si la conveniencia que brinda la variable global vale la pena.


Aqu铆 nuevamente, los problemas de estilo entran en juego. Las alternativas al uso de variables globales suelen ser de bajo costo. En tales casos, el uso de una t茅cnica que introduce, aunque peque帽o, pero un riesgo de cierre en lugar de una t茅cnica que elimina por completo ese riesgo, es una se帽al de mal estilo. Sin embargo, a veces usar variables globales es realmente conveniente. Un ejemplo t铆pico son las variables globales cout y cin. En tales casos, si no se viola el principio de apertura-cercan铆a, puede sacrificar el estilo por conveniencia.


RTTI es peligroso


Otra prohibici贸n com煤n es el uso de dynamic_cast . Muy a menudo, dynamic_cast o alguna otra forma de determinaci贸n del tipo de tiempo de ejecuci贸n (RTTI) es acusado de ser una t茅cnica extremadamente peligrosa y, por lo tanto, debe evitarse. Al mismo tiempo, a menudo dan un ejemplo del Listado 9, que obviamente viola el principio de apertura-cercan铆a. Sin embargo, el Listado 10 muestra un ejemplo de un programa similar que usa dynamic_cast sin violar el principio de abrir-cerrar.


La diferencia entre ellos es que, en el primer caso, que se muestra en el Listado 9, el c贸digo debe cambiarse cada vez que aparece un nuevo descendiente de la clase Shape (sin mencionar que esta es una soluci贸n absolutamente rid铆cula). Sin embargo, en el Listado 10, no se requieren cambios en este caso. Por lo tanto, el c贸digo en el Listado 10 no viola el principio de abrir-cerrar.
En este caso, la regla general es que RTTI se puede usar si no se viola el principio de apertura-cierre.


 // 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(); } } 

Conclusi贸n


Podr铆a hablar durante mucho tiempo sobre el principio de apertura-cercan铆a. En muchos sentidos, este principio es m谩s importante para la programaci贸n orientada a objetos. El cumplimiento de este principio particular proporciona las ventajas clave de la tecnolog铆a orientada a objetos, a saber, reutilizaci贸n y soporte.


, - -. , , , , , .

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


All Articles