Gosh, hay algunos conceptos erróneos extraños sobre qué OCP y LSP y algunos se deben a la falta de coincidencia de algunas terminologías y ejemplos confusos. Ambos principios son solo la "misma cosa" si los implementa de la misma manera. Los patrones generalmente siguen los principios de una forma u otra con pocas excepciones.
Las diferencias se explicarán más abajo, pero primero echemos un vistazo a los principios mismos:
Principio Abierto-Cerrado (OCP)
Según el tío Bob :
Debería poder extender el comportamiento de una clase, sin modificarlo.
Tenga en cuenta que la palabra extender en este caso no significa necesariamente que deba subclasificar la clase real que necesita el nuevo comportamiento. ¿Ves cómo mencioné en la primera falta de coincidencia de terminología? La palabra clave extend
solo significa subclases en Java, pero los principios son más antiguos que Java.
El original vino de Bertrand Meyer en 1988:
Las entidades de software (clases, módulos, funciones, etc.) deben estar abiertas para extensión, pero cerradas para modificación.
Aquí es mucho más claro que el principio se aplica a las entidades de software . Un mal ejemplo sería anular la entidad de software ya que está modificando el código por completo en lugar de proporcionar algún punto de extensión. El comportamiento de la entidad de software en sí debería ser extensible y un buen ejemplo de esto es la implementación del patrón de estrategia (porque es el más fácil de mostrar del conjunto de patrones de GoF en mi humilde opinión):
// Context is closed for modifications. Meaning you are
// not supposed to change the code here.
public class Context {
// Context is however open for extension through
// this private field
private IBehavior behavior;
// The context calls the behavior in this public
// method. If you want to change this you need
// to implement it in the IBehavior object
public void doStuff() {
if (this.behavior != null)
this.behavior.doStuff();
}
// You can dynamically set a new behavior at will
public void setBehavior(IBehavior behavior) {
this.behavior = behavior;
}
}
// The extension point looks like this and can be
// subclassed/implemented
public interface IBehavior {
public void doStuff();
}
En el ejemplo anterior, el Context
está bloqueado para modificaciones adicionales. La mayoría de los programadores probablemente desearían subclasificar la clase para extenderla, pero aquí no lo hacemos porque supone que su comportamiento se puede cambiar a través de cualquier cosa que implemente la IBehavior
interfaz.
Es decir, la clase de contexto está cerrada para modificación pero abierta para extensión . En realidad, sigue otro principio básico porque estamos poniendo el comportamiento con la composición del objeto en lugar de la herencia:
"Favorecer ' composición de objeto ' sobre ' herencia de clase '". (Banda de cuatro 1995: 20)
Dejaré que el lector lea sobre ese principio ya que está fuera del alcance de esta pregunta. Para continuar con el ejemplo, digamos que tenemos las siguientes implementaciones de la interfaz IBehavior:
public class HelloWorldBehavior implements IBehavior {
public void doStuff() {
System.println("Hello world!");
}
}
public class GoodByeBehavior implements IBehavior {
public void doStuff() {
System.out.println("Good bye cruel world!");
}
}
Usando este patrón podemos modificar el comportamiento del contexto en tiempo de ejecución, a través del setBehavior
método como punto de extensión.
// in your main method
Context c = new Context();
c.setBehavior(new HelloWorldBehavior());
c.doStuff();
// prints out "Hello world!"
c.setBehavior(new GoodByeBehavior());
c.doStuff();
// prints out "Good bye cruel world!"
Por lo tanto, siempre que desee ampliar la clase de contexto "cerrado", hágalo subclasificando su dependencia colaboradora "abierta". Claramente, esto no es lo mismo que subclasificar el contexto en sí, pero es OCP. LSP tampoco menciona esto.
Extendiéndose con Mixins en lugar de Herencia
Hay otras formas de hacer OCP que no sean subclases. Una forma es mantener sus clases abiertas para la extensión mediante el uso de mixins . Esto es útil, por ejemplo, en lenguajes basados en prototipos más que en clases. La idea es enmendar un objeto dinámico con más métodos o atributos según sea necesario, en otras palabras, objetos que se mezclan o "mezclan" con otros objetos.
Aquí hay un ejemplo de JavaScript de un mixin que representa una plantilla HTML simple para anclas:
// The mixin, provides a template for anchor HTML elements, i.e. <a>
var LinkMixin = {
render: function() {
return '<a href="' + this.link +'">'
+ this.content
+ '</a>;
}
}
// Constructor for a youtube link
var YoutubeLink = function(content, youtubeId) {
this.content = content;
this.setLink(this.youtubeId);
};
// Methods are added to the prototype
YoutubeLink.prototype = {
setLink: function(youtubeid) {
this.link = 'http://www.youtube.com/watch?v=' + youtubeid;
}
};
// Extend YoutubeLink prototype with the LinkMixin using
// underscore/lodash extend
_.extend(YoutubeLink.protoype, LinkMixin);
// When used:
var ytLink = new YoutubeLink("Cool Movie!", "idOaZpX8lnA");
console.log(ytLink.render());
// will output:
// <a href="http://www.youtube.com/watch?=vidOaZpX8lnA">Cool Movie!</a>
La idea es extender los objetos dinámicamente y la ventaja de esto es que los objetos pueden compartir métodos incluso si están en dominios completamente diferentes. En el caso anterior, puede crear fácilmente otros tipos de anclajes html extendiendo su implementación específica con LinkMixin
.
En términos de OCP, los "mixins" son extensiones. En el ejemplo anterior, YoutubeLink
nuestra entidad de software está cerrada para modificaciones, pero abierta para extensiones mediante el uso de mixins. La jerarquía de objetos se aplana, lo que hace que sea imposible verificar los tipos. Sin embargo, esto no es realmente algo malo, y explicaré más adelante que buscar tipos es generalmente una mala idea y rompe la idea con polimorfismo.
Tenga en cuenta que es posible hacer herencia múltiple con este método ya que la mayoría de las extend
implementaciones pueden mezclar múltiples objetos:
_.extend(MyClass, Mixin1, Mixin2 /* [, ...] */);
Lo único que debe tener en cuenta es no colisionar los nombres, es decir, los mixins definen el mismo nombre de algunos atributos o métodos, ya que se anularán. En mi humilde experiencia, esto no es un problema y si sucede, es una indicación de un diseño defectuoso.
Principio de sustitución de Liskov (LSP)
El tío Bob lo define simplemente por:
Las clases derivadas deben ser sustituibles por sus clases base.
Este principio es antiguo, de hecho, la definición del tío Bob no diferencia los principios, ya que eso hace que LSP aún esté estrechamente relacionado con OCP por el hecho de que, en el ejemplo de Estrategia anterior, se usa el mismo supertipo ( IBehavior
). Así que veamos su definición original por Barbara Liskov y veamos si podemos encontrar algo más sobre este principio que se parezca a un teorema matemático:
Lo que se quiere aquí es algo así como la siguiente propiedad de sustitución: si para cada objeto o1
de tipo S
hay un objeto o2
de tipo T
tal que para todos los programas P
definidos en términos de T
, el comportamiento de P
no cambia cuando o1
se sustituye por, o2
entonces S
es un subtipo de T
.
Hagamos caso omiso de esto por un tiempo, note que no menciona clases en absoluto. En JavaScript, puedes seguir LSP aunque no esté explícitamente basado en clases. Si su programa tiene una lista de al menos un par de objetos JavaScript que:
- necesita ser calculado de la misma manera,
- tener el mismo comportamiento, y
- son de alguna manera completamente diferentes
... entonces se considera que los objetos tienen el mismo "tipo" y realmente no importa para el programa. Esto es esencialmente polimorfismo . En sentido genérico; No debería necesitar saber el subtipo real si está utilizando su interfaz. OCP no dice nada explícito sobre esto. También señala un error de diseño que la mayoría de los programadores novatos hacen:
Cada vez que sienta la necesidad de verificar el subtipo de un objeto, lo más probable es que lo haga INCORRECTAMENTE.
De acuerdo, por lo que no podría ser mal todo el tiempo, pero si usted tiene la necesidad de hacer algo de comprobación de tipos con instanceof
o enumeraciones, que podría estar haciendo el programa un poco más enrevesado por sí mismo de lo que debe ser. Pero este no es siempre el caso; Hacks rápidos y sucios para hacer que las cosas funcionen es una buena concesión para hacer en mi mente si la solución es lo suficientemente pequeña, y si practicas una refactorización despiadada , puede mejorar una vez que los cambios lo exijan.
Hay formas de evitar este "error de diseño", dependiendo del problema real:
- La superclase no está llamando a los requisitos previos, obligando a la persona que llama a hacerlo en su lugar.
- A la superclase le falta un método genérico que necesita la persona que llama.
Ambos son "errores" comunes de diseño de código. Puede realizar un par de refactorizaciones diferentes, como el método pull-up o refactorizar un patrón como el patrón Visitor .
En realidad, me gusta mucho el patrón Visitor, ya que puede encargarse de los grandes espaguetis con sentencias if y es más sencillo de implementar de lo que pensarías en el código existente. Digamos que tenemos el siguiente contexto:
public class Context {
public void doStuff(string query) {
// outcome no. 1
if (query.Equals("Hello")) {
System.out.println("Hello world!");
}
// outcome no. 2
else if (query.Equals("Bye")) {
System.out.println("Good bye cruel world!");
}
// a change request may require another outcome...
}
}
// usage:
Context c = new Context();
c.doStuff("Hello");
// prints "Hello world"
c.doStuff("Bye");
// prints "Bye"
Los resultados de la declaración if se pueden traducir a sus propios visitantes, ya que cada uno depende de alguna decisión y algún código para ejecutar. Podemos extraer estos de esta manera:
public interface IVisitor {
public bool canDo(string query);
public void doStuff();
}
// outcome 1
public class HelloVisitor implements IVisitor {
public bool canDo(string query) {
return query.Equals("Hello");
}
public void doStuff() {
System.out.println("Hello World");
}
}
// outcome 2
public class ByeVisitor implements IVisitor {
public bool canDo(string query) {
return query.Equals("Bye");
}
public void doStuff() {
System.out.println("Good bye cruel world");
}
}
En este punto, si el programador no sabía sobre el patrón Visitante, implementaría la clase Contexto para verificar si es de cierto tipo. Debido a que las clases Visitor tienen un canDo
método booleano , el implementador puede usar esa llamada al método para determinar si es el objeto correcto para hacer el trabajo. La clase de contexto puede usar todos los visitantes (y agregar nuevos) así:
public class Context {
private ArrayList<IVisitor> visitors = new ArrayList<IVisitor>();
public Context() {
visitors.add(new HelloVisitor());
visitors.add(new ByeVisitor());
}
// instead of if-statements, go through all visitors
// and use the canDo method to determine if the
// visitor object is the right one to "visit"
public void doStuff(string query) {
for(IVisitor visitor : visitors) {
if (visitor.canDo(query)) {
visitor.doStuff();
break;
// or return... it depends if you have logic
// after this foreach loop
}
}
}
// dynamically adds new visitors
public void addVisitor(IVisitor visitor) {
if (visitor != null)
visitors.add(visitor);
}
}
Ambos patrones siguen OCP y LSP, sin embargo, ambos señalan cosas diferentes sobre ellos. Entonces, ¿cómo se ve el código si viola uno de los principios?
Violar un principio pero seguir el otro
Hay maneras de romper uno de los principios, pero aún se debe seguir el otro. Los ejemplos a continuación parecen inventados, por una buena razón, pero en realidad los he visto aparecer en el código de producción (e incluso peor):
Sigue OCP pero no LSP
Digamos que tenemos el código dado:
public interface IPerson {}
public class Boss implements IPerson {
public void doBossStuff() { ... }
}
public class Peon implements IPerson {
public void doPeonStuff() { ... }
}
public class Context {
public Collection<IPerson> getPersons() { ... }
}
Este código sigue el principio abierto-cerrado. Si llamamos al GetPersons
método del contexto , obtendremos un grupo de personas, todas con sus propias implementaciones. Eso significa que IPerson está cerrado por modificación, pero abierto por extensión. Sin embargo, las cosas se vuelven oscuras cuando tenemos que usarlo:
// in some routine that needs to do stuff with
// a collection of IPerson:
Collection<IPerson> persons = context.getPersons();
for (IPerson person : persons) {
// now we have to check the type... :-P
if (person instanceof Boss) {
((Boss) person).doBossStuff();
}
else if (person instanceof Peon) {
((Peon) person).doPeonStuff();
}
}
¡Tienes que hacer la verificación de tipos y la conversión de tipos! ¿Recuerdas cómo mencioné anteriormente cómo la verificación de tipo es algo malo ? ¡Oh no! Pero no temas, como también se mencionó anteriormente, o bien realizas algunas refactorizaciones pull-up o implementas un patrón de visitante. En este caso, simplemente podemos hacer una refactorización pull-up después de agregar un método general:
public class Boss implements IPerson {
// we're adding this general method
public void doStuff() {
// that does the call instead
this.doBossStuff();
}
public void doBossStuff() { ... }
}
public interface IPerson {
// pulled up method from Boss
public void doStuff();
}
// do the same for Peon
El beneficio ahora es que ya no necesita saber el tipo exacto, siguiendo LSP:
// in some routine that needs to do stuff with
// a collection of IPerson:
Collection<IPerson> persons = context.getPersons();
for (IPerson person : persons) {
// yay, no type checking!
person.doStuff();
}
Sigue LSP pero no OCP
Veamos un código que sigue a LSP pero no a OCP, es un poco artificial, pero tenga paciencia sobre este, es un error muy sutil:
public class LiskovBase {
public void doStuff() {
System.out.println("My name is Liskov");
}
}
public class LiskovSub extends LiskovBase {
public void doStuff() {
System.out.println("I'm a sub Liskov!");
}
}
public class Context {
private LiskovBase base;
// the good stuff
public void doLiskovyStuff() {
base.doStuff();
}
public void setBase(LiskovBase base) { this.base = base }
}
El código hace LSP porque el contexto puede usar LiskovBase sin conocer el tipo real. Usted pensaría que este código también sigue a OCP, pero mire de cerca, ¿está realmente cerrada la clase ? ¿Qué pasa si el doStuff
método hizo algo más que imprimir una línea?
La respuesta si sigue a OCP es simplemente: NO , no es porque en este diseño de objeto se nos requiere anular el código por completo con otra cosa. Esto abre la lata de gusanos de cortar y pegar, ya que debe copiar el código de la clase base para que todo funcione. El doStuff
método seguro está abierto para la extensión, pero no estaba completamente cerrado para la modificación.
Podemos aplicar el patrón de método de plantilla en esto. El patrón del método de plantilla es tan común en los marcos que podría haberlo estado utilizando sin saberlo (por ejemplo, componentes de Java Swing, formularios y componentes de C #, etc.). Aquí hay una forma de cerrar el doStuff
método de modificación y asegurarse de que permanezca cerrado marcándolo con la final
palabra clave de java . Esa palabra clave evita que cualquiera pueda subclasificar la clase aún más (en C # puede usar sealed
para hacer lo mismo).
public class LiskovBase {
// this is now a template method
// the code that was duplicated
public final void doStuff() {
System.out.println(getStuffString());
}
// extension point, the code that "varies"
// in LiskovBase and it's subclasses
// called by the template method above
// we expect it to be virtual and overridden
public string getStuffString() {
return "My name is Liskov";
}
}
public class LiskovSub extends LiskovBase {
// the extension overridden
// the actual code that varied
public string getStuffString() {
return "I'm sub Liskov!";
}
}
Este ejemplo sigue a OCP y parece una tontería, lo cual es, pero imagina que se amplió con más código para manejar. Sigo viendo el código implementado en la producción, donde las subclases anulan completamente todo y el código anulado se corta y pega principalmente entre implementaciones. Funciona, pero como con toda la duplicación de código, también es una configuración para las pesadillas de mantenimiento.
Conclusión
Espero que todo esto aclare algunas preguntas sobre OCP y LSP y las diferencias / similitudes entre ellos. Es fácil descartarlos como iguales, pero los ejemplos anteriores deberían mostrar que no lo son.
Tenga en cuenta que, reuniendo del código de muestra anterior:
OCP se trata de bloquear el código de trabajo pero aún así mantenerlo abierto de alguna manera con algún tipo de puntos de extensión.
Esto es para evitar la duplicación de código encapsulando el código que cambia como en el ejemplo del patrón de Método de plantilla. También permite fallar rápidamente, ya que los cambios importantes son dolorosos (es decir, cambiar un lugar, romperlo en cualquier otro lugar). En aras del mantenimiento, el concepto de encapsular el cambio es algo bueno, porque los cambios siempre ocurren.
LSP se trata de permitir que el usuario maneje diferentes objetos que implementan un supertipo sin verificar cuál es el tipo real. De esto se trata inherentemente el polimorfismo .
Este principio proporciona una alternativa para realizar la verificación de tipos y la conversión de tipos, que puede salirse de control a medida que aumenta el número de tipos y puede lograrse mediante la refactorización pull-up o la aplicación de patrones como Visitor.