¿Qué puede salir mal si se viola el principio de sustitución de Liskov?


27

Estaba siguiendo esta pregunta muy votada sobre la posible violación del principio de sustitución de Liskov. Sé cuál es el principio de sustitución de Liskov, pero lo que aún no está claro en mi mente es qué podría salir mal si yo, como desarrollador, no pienso en el principio mientras escribo código orientado a objetos.


66
¿Qué puede salir mal si no sigues LSP? En el peor de los casos: ¡terminas invocando Code-thulhu! ;)
FrustratedWithFormsDesigner

1
Como autor de esa pregunta original, debo agregar que era una pregunta bastante académica. Aunque las violaciones pueden causar errores en el código, nunca he tenido un error grave o un problema de mantenimiento que pueda atribuir a una violación de LSP.
Paul T Davies

2
@Paul Así que nunca tuve un problema con sus programas debido a las jerarquías OO complicadas (que no diseñó usted mismo, pero tal vez tuvo que extender) donde los contratos se rompieron de izquierda a derecha por personas que no estaban seguras sobre el propósito de la clase base ¿para empezar? ¡Te envidio! :)
Andres F.

@PaulTDavies la gravedad de la consecuencia depende de si los usuarios (programadores que usan la biblioteca) tienen un conocimiento detallado de la implementación de la biblioteca (es decir, tienen acceso y están familiarizados con el código de la biblioteca). Finalmente, los usuarios pondrán docenas de comprobaciones condicionales o envoltorios de compilación alrededor de la biblioteca para dar cuenta de no LSP (comportamiento específico de clase). El peor de los casos sucedería si la biblioteca es un producto comercial de código cerrado.
rwong

@ Andreas y rwong, por favor ilustra esos problemas con una respuesta. La respuesta aceptada apoya a Paul Davies en que las consecuencias parecen menores (una excepción) que se notará y rectificará rápidamente si tiene un buen compilador, un analizador estático o una prueba de unidad mínima.
user949300

Respuestas:


31

Creo que se dice muy bien en esa pregunta, que es una de las razones por las que se votó tan bien.

Ahora, al llamar a Close () en una Tarea, existe la posibilidad de que la llamada falle si se trata de un ProjectTask con el estado iniciado, cuando no lo sería si fuera una Tarea base.

Imagina si vas a:

public void ProcessTaskAndClose(Task taskToProcess)
{
    taskToProcess.Execute();
    taskToProcess.DateProcessed = DateTime.Now;
    taskToProcess.Close();
}

En este método, ocasionalmente la llamada .Close () explotará, por lo que ahora, en función de la implementación concreta de un tipo derivado, debe cambiar la forma en que este método se comporta de cómo se escribiría este método si Task no tuviera subtipos que pudieran ser entregado a este método.

Debido a violaciones de la sustitución de liskov, el código que usa su tipo deberá tener un conocimiento explícito del funcionamiento interno de los tipos derivados para tratarlos de manera diferente. Esto combina estrechamente el código y, en general, hace que la implementación sea más difícil de usar de manera consistente.


¿Eso significa que una clase secundaria no puede tener sus propios métodos públicos que no se declaran en la clase principal?
Songo

@Songo: No necesariamente: puede, pero esos métodos son "inalcanzables" desde un puntero base (o referencia o variable o como lo llame el lenguaje que use) y necesita información de tipo de tiempo de ejecución para consultar qué tipo tiene el objeto antes de que pueda llamar a esas funciones. Pero este es un asunto que está fuertemente relacionado con la sintaxis y la semántica de los idiomas.
Emilio Garavaglia

2
No. Esto es para cuando se hace referencia a una clase secundaria como si fuera un tipo de la clase primaria, en cuyo caso los miembros que no se declaran en la clase primaria son inaccesibles.
Chewy Gumball

1
@ Phil Sip; Esta es la definición de acoplamiento estrecho: cambiar una cosa provoca cambios en otras cosas. Una clase débilmente acoplada puede cambiar su implementación sin requerir que cambie el código fuera de ella. Esta es la razón por la cual los contratos son buenos, lo guían en cómo no exigir cambios a los consumidores de su objeto: cumpla con el contrato y los consumidores no necesitarán modificaciones, por lo que se logra un acoplamiento suelto. Cuando sus consumidores necesitan codificar su implementación en lugar de su contrato, esto es un acoplamiento estrecho, y se requiere al violar el LSP.
Jimmy Hoffa

1
@ user949300 El éxito de cualquier software para lograr su trabajo no es una medida de la calidad, los costos a largo o corto plazo. Los principios de diseño son intentos de generar pautas para reducir los costos a largo plazo del software, no para hacer que el software "funcione". Las personas pueden seguir todos los principios que desean sin poder implementar una solución de trabajo, o no seguir ninguno e implementar una solución de trabajo. Aunque las colecciones de Java pueden funcionar para muchas personas, eso no significa que el costo de trabajar con ellas a la larga sea tan barato como podría ser.
Jimmy Hoffa

13

Si no cumple con el contrato que se ha definido en la clase base, las cosas pueden fallar silenciosamente cuando obtiene resultados que están desactivados.

LSP en estados de Wikipedia

  • Las condiciones previas no pueden fortalecerse en un subtipo.
  • Las condiciones posteriores no pueden debilitarse en un subtipo.
  • Las invariantes del supertipo deben conservarse en un subtipo.

Si alguno de estos no se cumple, la persona que llama puede obtener un resultado que no espera.


1
¿Puedes pensar en algún ejemplo concreto para demostrar esto?
Mark Booth

1
@ MarkBooth El problema círculo-elipse / cuadrado-rectángulo podría ser útil para demostrarlo; El artículo de Wikipedia es un buen lugar para comenzar: en.wikipedia.org/wiki/Circle-ellipse_problem
Ed Hastings

7

Considere un caso clásico de los anales de las preguntas de la entrevista: ha derivado Circle de Ellipse. ¿Por qué? Porque un círculo es una elipse AN-AN, por supuesto

Excepto ... elipse tiene dos funciones:

Ellipse.set_alpha_radius(d)
Ellipse.set_beta_radius(d)

Claramente, estos deben ser redefinidos para Circle, porque un Circle tiene un radio uniforme. Tienes dos posibilidades:

  1. Después de llamar a set_alpha_radius o set_beta_radius, ambos se establecen en la misma cantidad.
  2. Después de llamar a set_alpha_radius o set_beta_radius, el objeto ya no es un círculo.

La mayoría de los idiomas OO no admiten el segundo, y por una buena razón: sería sorprendente descubrir que su círculo ya no es un círculo. Entonces la primera opción es la mejor. Pero considere la siguiente función:

some_function(Ellipse byref e)

Imagine que alguna función llama a e.set_alpha_radius. Pero debido a que e era realmente un Círculo, sorprendentemente también tiene establecido su radio beta.

Y aquí radica el principio de sustitución: una subclase debe ser sustituible por una superclase. De lo contrario, suceden cosas sorprendentes.


1
Creo que puede tener problemas si usa objetos mutables. Un círculo también es una elipse. Pero si reemplaza una elipse que también es un círculo con otra elipse (que es lo que está haciendo utilizando un método de establecimiento), no hay garantía de que la nueva elipse también sea un círculo (los círculos son un subconjunto adecuado de elipses).
Giorgio

2
En un mundo puramente funcional (con objetos inmutables), el método set_alpha_radius (d) tendría un tipo de retorno elipse (tanto en la elipse como en la clase de círculo).
Giorgio

@ Giorgio Sí, debería haber mencionado que este problema solo ocurre con objetos mutables.
Kaz Dragon

@KazDragon: ¿Por qué alguien sustituiría una elipse con un objeto circular cuando sabemos que una elipse NO ES un círculo? Si alguien hace eso, no tienen una comprensión correcta de las entidades que están tratando de modelar. Pero al permitir esta sustitución, ¿no estamos fomentando una comprensión floja del sistema subyacente que estamos tratando de modelar en nuestro software, y creando así un mal software en efecto?
rebelde

@maverick Creo que has leído la relación que describí al revés. La relación propuesta es-una es al revés: un círculo es una elipse. Específicamente, un círculo es una elipse donde los radios alfa y beta son idénticos. Y entonces, la expectativa podría ser que cualquier función que espera una elipse como parámetro podría igualmente tomar un círculo. Considere Calculate_area (Elipse). Pasar un círculo a eso arrojaría el mismo resultado. Pero el problema es que el comportamiento de las funciones de mutación de Ellipse no son sustituibles por las de Circle.
Kaz Dragon

6

En palabras simples:

Su código tendrá una gran cantidad de cláusulas CASE / switch por todas partes.

Cada una de esas cláusulas CASE / switch necesitará agregar nuevos casos de vez en cuando, lo que significa que la base del código no es tan escalable y fácil de mantener como debería ser.

LSP permite que el código funcione más como el hardware:

No tiene que modificar su iPod porque compró un nuevo par de altavoces externos, ya que tanto los altavoces externos antiguos como los nuevos respetan la misma interfaz, son intercambiables sin que el iPod pierda la funcionalidad deseada.


2
-1: mala respuesta
Thomas Eding

3
@Thomas no estoy de acuerdo. Es una buena analogía. Habla de no romper las expectativas, de eso se trata el LSP. (aunque la parte sobre el caso / cambio es un poco débil, estoy de acuerdo)
Andres F.

2
Y luego Apple rompió el LSP al cambiar los conectores. Esta respuesta sigue viva.
Magus

No entiendo qué declaraciones de cambio tienen que ver con LSP. si te estás refiriendo a cambiar typeof(someObject)para decidir qué estás "permitido hacer", entonces seguro, pero ese es otro antipatrón por completo.
sara

Una reducción drástica en la cantidad de declaraciones de cambio es un efecto secundario deseable de LSP. Como los objetos pueden representar cualquier otro objeto que extienda la misma interfaz, no es necesario atender casos especiales.
Tulains Córdova

1

para dar un ejemplo de la vida real con UndoManager de java

hereda de AbstractUndoableEditcuyo contrato especifica que tiene 2 estados (deshacer y rehacer) y puede ir entre ellos con llamadas únicas a undo()yredo()

sin embargo, UndoManager tiene más estados y actúa como un búfer de deshacer (cada llamada para deshacer undoalgunas pero no todas las ediciones, debilitando la condición posterior)

Esto lleva a la situación hipotética en la que agrega un UndoManager a un CompoundEdit antes de llamar y end()luego deshacer en ese CompoundEdit lo llevará a invocar undo()en cada edición una vez que deje sus ediciones parcialmente deshacidas

Rodé el mío UndoManagerpara evitar eso (aunque probablemente debería cambiarle el nombre UndoBuffer)


1

Ejemplo: está trabajando con un marco de UI y crea su propio control de UI personalizado subclasificando la Controlclase base. La Controlclase base define un método getSubControls()que debería devolver una colección de controles anidados (si los hay). Pero anula el método para devolver una lista de fechas de nacimiento de los presidentes de los Estados Unidos.

Entonces, ¿qué puede salir mal con esto? Es obvio que la representación del control fallará, ya que no devuelve una lista de controles como se esperaba. Lo más probable es que la IU se bloquee. Está incumpliendo el contrato al que se espera que se adhieran las subclases de Control.


0

También puede verlo desde un punto de vista de modelado. Cuando dice que una instancia de clase Atambién es una instancia de clase B, implica que "el comportamiento observable de una instancia de clase Atambién puede clasificarse como comportamiento observable de una instancia de clase B" (Esto solo es posible si la clase Bes menos específica que clase A)

Por lo tanto, violar el LSP significa que hay alguna contradicción en su diseño: está definiendo algunas categorías para sus objetos y luego no las está respetando en su implementación, algo debe estar mal.

Como hacer una caja con una etiqueta: "Esta caja contiene solo bolas azules", y luego arrojar una bola roja en ella. ¿De qué sirve una etiqueta de este tipo si muestra información incorrecta?


0

Recientemente heredé una base de código que tiene algunos infractores importantes de Liskov. En clases importantes. Esto me ha causado enormes cantidades de dolor. Déjame explicarte por qué.

Tengo Class A, que se deriva de Class B. Class Ay Class Bcomparte un montón de propiedades que Class Aanula con su propia implementación. Establecer u obtener una Class Apropiedad tiene un efecto diferente al establecer u obtener exactamente la misma propiedad Class B.

public Class A
{
    public virtual string Name
    {
        get; set;
    }
}

Class B : A
{
    public override string Name
    {
        get
        {
            return TranslateName(base.Name);
        }
        set
        {
            base.Name = value;
            FunctionWithSideEffects();
        }
    }
}

Dejando a un lado el hecho de que esta es una forma absolutamente terrible de hacer la traducción en .NET, hay otros problemas con este código.

En este caso Name se usa como índice y variable de control de flujo en varios lugares. Las clases anteriores están plagadas en toda la base de código, tanto en su forma sin procesar como derivada. Violar el principio de sustitución de Liskov en este caso significa que necesito saber el contexto de cada llamada a cada una de las funciones que toman la clase base.

El código usa objetos de ambos Class Ay Class B, por lo tanto, no puedo simplemente hacer un Class Aresumen para obligar a las personas a usar Class B.

Hay algunas funciones de utilidad muy útiles que funcionan Class Ay otras funciones de utilidad muy útiles que funcionan Class B. Idealmente me gustaría ser capaz de utilizar cualquier función de utilidad que puede operar en Class Ael Class B. Muchas de las funciones que toman una Class Bpodrían tomar fácilmente una, Class Asi no fuera por la violación del LSP.

Lo peor de esto es que este caso particular es realmente difícil de refactorizar ya que la aplicación completa depende de estas dos clases, opera en ambas clases todo el tiempo y se rompería de cien maneras si cambio esto (lo que voy a hacer de todas formas).

Lo que tendré que hacer para solucionar esto es crear una NameTranslatedpropiedad, que será la Class Bversión de la Namepropiedad y cambiar muy cuidadosamente todas las referencias a la Namepropiedad derivada para usar mi nueva NameTranslatedpropiedad. Sin embargo, si una de estas referencias se equivoca, toda la aplicación podría explotar.

Dado que el código base no tiene pruebas unitarias a su alrededor, esto está bastante cerca de ser el escenario más peligroso que un desarrollador puede enfrentar. Si no cambio la infracción, tengo que gastar enormes cantidades de energía mental para realizar un seguimiento de qué tipo de objeto se está utilizando en cada método y si soluciono la infracción, podría hacer que todo el producto explote en un momento inoportuno.


¿Qué sucedería si dentro de la clase derivada sombreara la propiedad heredada con un tipo diferente de cosa que tuviera el mismo nombre [por ejemplo, una clase anidada] y creara nuevos identificadores BaseNamey tuviera TranslatedNameacceso tanto al estilo de clase A Namecomo al significado de clase B? Luego, cualquier intento de acceder Namea una variable de tipo Bsería rechazado con un error de compilación, por lo que puede asegurarse de que todas las referencias se conviertan en una de las otras formas.
supercat

Ya no trabajo en ese lugar. Hubiera sido muy incómodo arreglarlo. :-)
Stephen

-4

Si desea sentir el problema de violar LSP, piense qué sucede si solo tiene .dll / .jar de la clase base (sin código fuente) y tiene que construir una nueva clase derivada. Nunca puedes completar esta tarea.


1
Esto solo abre más preguntas en lugar de ser una respuesta.
Frank
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.