Pregunta ¿Qué es un ejemplo del Principio de Sustitución de Liskov?


He oído que el Principio de Sustitución de Liskov (LSP) es un principio fundamental del diseño orientado a objetos. ¿Qué es y cuáles son algunos ejemplos de su uso?


683
2017-09-11 15:17


origen


Respuestas:


Un gran ejemplo que ilustra LSP (dado por el tío Bob en un podcast que escuché recientemente) fue cómo a veces algo que suena bien en el lenguaje natural no funciona del todo en el código.

En matemáticas, un Square es un Rectangle. De hecho, es una especialización de un rectángulo. El "es un" hace que quieras modelar esto con herencia. Sin embargo, si en el código que hizo Square deriva de Rectangle, Entonces un Square debe ser utilizable en cualquier lugar que esperes Rectangle. Esto genera un comportamiento extraño.

Imagina que tuviste SetWidth y SetHeight métodos en su Rectangle clase base; esto parece perfectamente lógico. Sin embargo, si tu Rectangle referencia apunta a una Square, entonces SetWidth y SetHeight no tiene sentido porque establecer uno cambiaría el otro para que coincida. En este caso Square falla la prueba de sustitución Liskov con Rectangle y la abstracción de tener Square heredar de Rectangle es malo

Ustedes deberían ver el otro invaluable SOLID Principios Motivational Posters.


654
2018-02-25 04:44



El Principio de Sustitución de Liskov (LSP, ) es un concepto en Programación Orientada a Objetos que dice:

Funciones que usan punteros o   las referencias a las clases base deben ser   capaz de usar objetos de clases derivadas   sin saberlo.

En esencia, LSP se trata de interfaces y contratos, así como de cómo decidir cuándo ampliar una clase y utilizar otra estrategia, como la composición, para lograr su objetivo.

La forma más efectiva que he visto para ilustrar este punto fue en Head First OOA & D. Presentan un escenario en el que eres un desarrollador en un proyecto para construir un marco para los juegos de estrategia.

Presentan una clase que representa un tablero que se ve así:

Class Diagram

Todos los métodos toman las coordenadas X e Y como parámetros para ubicar la posición del mosaico en la matriz bidimensional de Tiles. Esto permitirá que un desarrollador de juegos administre unidades en el tablero durante el transcurso del juego.

El libro continúa cambiando los requisitos para decir que el trabajo del marco del juego también debe ser compatible con los tableros 3D para acomodar los juegos que tienen vuelo. Entonces una ThreeDBoard se introduce la clase que se extiende Board.

A primera vista, parece una buena decisión. Board proporciona tanto el Height y Width propiedades y ThreeDBoard proporciona el eje Z

Donde se rompe es cuando miras a todos los otros miembros heredados de Board. Los métodos para AddUnit, GetTile, GetUnits y así sucesivamente, todos toman los parámetros X e Y en el Board clase, pero el ThreeDBoard necesita un parámetro Z también.

Entonces debe implementar esos métodos nuevamente con un parámetro Z. El parámetro Z no tiene contexto para el Board clase y los métodos heredados de la Board clase pierde su significado. Una unidad de código que intenta usar el ThreeDBoardclase como su clase base Board estaría muy de suerte.

Tal vez deberíamos encontrar otro enfoque. En lugar de extender Board, ThreeDBoard debe estar compuesto de Board objetos. Uno Board objeto por unidad del eje Z.

Esto nos permite usar buenos principios orientados a objetos como encapsulación y reutilización y no viola LSP.


385
2017-09-11 15:34



LSP se refiere a invariantes.

El ejemplo clásico viene dado por la siguiente declaración de pseudocódigo (implementaciones omitidas):

class Rectangle {
    int getHeight()
    void setHeight(int value)
    int getWidth()
    void setWidth(int value)
}

class Square : Rectangle { }

Ahora tenemos un problema aunque la interfaz coincide. La razón es que hemos violado invariantes derivados de la definición matemática de cuadrados y rectángulos. La forma en que getters y setters funcionan, una Rectangle debería satisfacer la siguiente invariante:

void invariant(Rectangle r) {
    r.setHeight(200)
    r.setWidth(100)
    assert(r.getHeight() == 200 and r.getWidth() == 100)
}

Sin embargo, esta invariante debe ser violado por una correcta implementación de Square, por lo tanto, no es un sustituto válido de Rectangle.


110
2017-09-12 13:45



Robert Martin tiene un excelente documento sobre el Principio de Sustitución de Liskov. Discute formas sutiles y no tan sutiles en las cuales el principio puede ser violado.

Algunas partes relevantes del documento (tenga en cuenta que el segundo ejemplo está muy condensado):

Un ejemplo simple de una violación de LSP

Una de las violaciones más flagrantes de este principio es el uso de C ++   Información de tipo de tiempo de ejecución (RTTI) para seleccionar una función basada en   tipo de un objeto. es decir.:

void DrawShape(const Shape& s)
{
  if (typeid(s) == typeid(Square))
    DrawSquare(static_cast<Square&>(s)); 
  else if (typeid(s) == typeid(Circle))
    DrawCircle(static_cast<Circle&>(s));
}

Claramente el DrawShape la función está mal formada. Debe saber sobre   toda posible derivada de la Shape clase, y debe ser cambiado   siempre que nuevos derivados de Shape son creados. De hecho, muchos ven la estructura de esta función como un anatema para el Diseño Orientado a Objetos.

Cuadrado y rectángulo, una violación más sutil.

Sin embargo, hay otras formas mucho más sutiles de violar el LSP.   Considere una aplicación que usa el Rectangle clase como se describe   abajo:

class Rectangle
{
  public:
    void SetWidth(double w) {itsWidth=w;}
    void SetHeight(double h) {itsHeight=w;}
    double GetHeight() const {return itsHeight;}
    double GetWidth() const {return itsWidth;}
  private:
    double itsWidth;
    double itsHeight;
};

[...] Imagine que algún día los usuarios exigen la capacidad de manipular   cuadrados además de rectángulos. [...]

Claramente, un cuadrado es un rectángulo para todos los propósitos y propósitos normales.   Como la relación ISA es válida, es lógico modelar el Square   clase como derivada de Rectangle. [...]

Square heredará el SetWidth y SetHeight funciones. Estas   las funciones son completamente inapropiadas para Square, dado que el ancho y   la altura de un cuadrado es idéntica. Esto debería ser una pista significativa   que hay un problema con el diseño. Sin embargo, hay una manera de   eludir el problema. Podríamos anular SetWidth y SetHeight [...]

Pero considere la siguiente función:

void f(Rectangle& r)
{
  r.SetWidth(32); // calls Rectangle::SetWidth
}

Si pasamos una referencia a un Square objeto en esta función, el    Square el objeto se dañará porque la altura no se cambiará.   Esta es una clara violación de LSP. La función no funciona   derivados de sus argumentos.

[...]


68
2017-09-12 13:34



LSP es necesario cuando algún código cree que está llamando a los métodos de un tipo Ty, sin saberlo, llamar a los métodos de un tipo S, dónde S extends T (es decir. S hereda, deriva de, o es un subtipo de, el supertipo T)

Por ejemplo, esto ocurre cuando una función con un parámetro de entrada de tipo T, se llama (es decir, se invoca) con un valor de argumento de tipo S. O, donde un identificador de tipo T, se le asigna un valor de tipo S.

val id : T = new S() // id thinks it's a T, but is a S

LSP requiere las expectativas (es decir, invariantes) para los métodos de tipo T (p.ej. Rectangle), no ser violado cuando los métodos de tipo S (p.ej. Square) se llaman en su lugar.

val rect : Rectangle = new Square(5) // thinks it's a Rectangle, but is a Square
val rect2 : Rectangle = rect.setWidth(10) // height is 10, LSP violation

Incluso un tipo con campos inmutables todavía tiene invariantes, p. el inmutable Los ajustadores de rectángulo esperan que las dimensiones se modifiquen de forma independiente, pero inmutable Los instaladores de cuadrados violan esta expectativa.

class Rectangle( val width : Int, val height : Int )
{
   def setWidth( w : Int ) = new Rectangle(w, height)
   def setHeight( h : Int ) = new Rectangle(width, h)
}

class Square( val side : Int ) extends Rectangle(side, side)
{
   override def setWidth( s : Int ) = new Square(s)
   override def setHeight( s : Int ) = new Square(s)
}

LSP requiere que cada método del subtipo S debe tener parámetros de entrada contravariantes y una salida covariante.

Contravariante significa que la varianza es contraria a la dirección de la herencia, es decir, el tipo Si, de cada parámetro de entrada de cada método del subtipo S, debe ser el mismo o un supertipo del tipo Ti del parámetro de entrada correspondiente del método correspondiente del supertipo T.

Covarianza significa que la varianza está en la misma dirección de la herencia, es decir, el tipo So, de la salida de cada método del subtipo S, debe ser el mismo o un subtipo del tipo To de la salida correspondiente del método correspondiente del supertipo T.

Esto es porque si la persona que llama cree que tiene un tipo T, cree que está llamando a un método de T, luego proporciona argumento (s) de tipo Ti y asigna la salida al tipo To. Cuando en realidad está llamando al método correspondiente de S, luego cada Ti El argumento de entrada se asigna a un Si parámetro de entrada, y el So la salida se asigna al tipo To. Por lo tanto si Si no fueron contravariantes w.r.t. a Ti, luego un subtipo Xi- que no sería un subtipo de Si-podría asignarse a Ti.

Además, para idiomas (por ejemplo, Scala o Ceylon) que tienen anotaciones de varianza en el sitio de definición sobre los parámetros de polimorfismo tipo (es decir, genéricos), la dirección co o contradirección de la anotación de varianza para cada parámetro de tipo del tipo T debe ser opuesto o la misma dirección, respectivamente, a cada parámetro de entrada o salida (de cada método de T) que tiene el tipo del parámetro de tipo.

Además, para cada parámetro de entrada o salida que tiene un tipo de función, la dirección de varianza requerida se invierte. Esta regla se aplica recursivamente.


Subtipo es apropiado donde los invariantes se pueden enumerar.

Hay mucha investigación en curso sobre cómo modelar invariantes, para que el compilador los aplique.

Tipo de estado (ver página 3) declara e impone invariantes de estado ortogonales al tipo. Alternativamente, las invariantes pueden ser impuestas por convertir aserciones a tipos. Por ejemplo, para afirmar que un archivo está abierto antes de cerrarlo, File.open () podría devolver un tipo OpenFile, que contiene un método close () que no está disponible en File. UN tic-tac-toe API puede ser otro ejemplo de empleo de tipeo para imponer invariantes en tiempo de compilación. El sistema de tipos puede ser incluso Turing-completo, p. Scala. Los lenguajes de tipo dependiente y los demostradores de teoremas formalizan los modelos de tipificación de orden superior.

Debido a la necesidad de semántica para resumen sobre extensión, Espero que el empleo de tipeo para invariantes del modelo, es decir, semántica denotacional unificada de orden superior, sea superior al tipo de estado. 'Extensión' significa la composición ilimitada y permutada del desarrollo modular descoordinado. Porque me parece ser la antítesis de la unificación y, por lo tanto, de los grados de libertad, tener dos modelos mutuamente dependientes (por ejemplo, tipos y Typestate) para expresar la semántica compartida, que no pueden unificarse entre sí para una composición extensible . Por ejemplo, Problema de expresión-like extensión se unificó en la subtipificación, sobrecarga de función y dominios de tipado paramétrico.

Mi posición teórica es que para conocimiento para existir (ver la sección "La centralización es ciega y no apta"), habrá Nunca ser un modelo general que pueda aplicar una cobertura del 100% de todas las invariantes posibles en un lenguaje informático completo de Turing. Para que exista el conocimiento, existen muchas posibilidades inesperadas, es decir, el desorden y la entropía siempre deben aumentar. Esta es la fuerza entrópica. Para probar todos los cálculos posibles de una extensión potencial, es calcular a priori todas las extensiones posibles.

Esta es la razón por la que existe el Teorema de Detención, es decir, es indecidible si terminan todos los programas posibles en un lenguaje de programación completo de Turing. Se puede probar que algún programa específico finaliza (uno que todas las posibilidades han sido definidas y calculadas). Pero es imposible probar que toda extensión posible de ese programa finaliza, a menos que las posibilidades de extensión de ese programa no se completen (por ejemplo, a través de tipado dependiente). Dado que el requisito fundamental para la completitud de Turing es recursividad ilimitada, es intuitivo entender cómo los teoremas de incompletitud de Gödel y la paradoja de Russell se aplican a la extensión.

Una interpretación de estos teoremas los incorpora en una comprensión conceptual generalizada de la fuerza entrópica:

  • Teoremas de incompletitud de Gödel: cualquier teoría formal, en la cual todas las verdades aritméticas puedan ser probadas, es inconsistente.
  • La paradoja de Russell: cada regla de membresía para un conjunto que puede contener un conjunto, enumera el tipo específico de cada miembro o se contiene a sí mismo. Por lo tanto, los conjuntos no se pueden extender o son recursión ilimitada. Por ejemplo, el conjunto de todo lo que no es una tetera, se incluye a sí mismo, que se incluye a sí mismo, que se incluye a sí mismo, etc. Por lo tanto, una regla es incoherente si (puede contener un conjunto y) no enumera los tipos específicos (es decir, permite todos los tipos no especificados) y no permite la extensión ilimitada. Este es el conjunto de conjuntos que no son miembros de sí mismos. Esta incapacidad para ser consistentes y completamente enumerados sobre todas las extensiones posibles, son los teoremas de incompletitud de Gödel.
  • Principio de la substición de Liskov: en general, es un problema indecidible si cualquier conjunto es el subconjunto de otro, es decir, la herencia es generalmente indecidible.
  • Linsky Referencing: es indecidible lo que es el cálculo de algo, cuando se describe o se percibe, es decir, la percepción (realidad) no tiene un punto de referencia absoluto.
  • El teorema de Coase: no existe un punto de referencia externo, por lo que cualquier barrera a posibilidades externas ilimitadas fallará.
  • Segunda ley de la termodinámica: todo el universo (un sistema cerrado, es decir, todo) tiende al máximo desorden, es decir, a posibilidades máximas independientes.

39
2017-11-26 16:35



La sustituibilidad es un principio en la programación orientada a objetos que establece que, en un programa informático, si S es un subtipo de T, entonces los objetos de tipo T pueden ser reemplazados por objetos de tipo S

Hagamos un ejemplo simple en Java:

Mal ejemplo

public class Bird{
    public void fly(){}
}
public class Duck extends Bird{}

El pato puede volar porque es un pájaro, pero ¿qué pasa con esto?

public class Ostrich extends Bird{}

El avestruz es un pájaro, pero no puede volar, la clase de avestruz es un subtipo de la clase Pájaro, pero no puede usar el método de la mosca, eso significa que estamos rompiendo el principio LSP.

Buen ejemplo

public class Bird{
}
public class FlyingBirds extends Bird{
    public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{} 

32
2017-07-04 19:58



El LSP es una regla sobre el contrato de las clases: si una clase base satisface un contrato, entonces las clases derivadas del LSP también deben satisfacer ese contrato.

En Pseudo-python

class Base:
   def Foo(self, arg): 
       # *... do stuff*

class Derived(Base):
   def Foo(self, arg):
       # *... do stuff*

satisface LSP si cada vez que llama a Foo en un objeto Derivado, da exactamente los mismos resultados que llamar a Foo en un objeto Base, siempre que arg sea el mismo.


19
2017-11-08 17:53



Las funciones que usan punteros o referencias a clases base deben ser capaces de usar objetos de clases derivadas sin saberlo.

Cuando leí por primera vez acerca de LSP, asumí que esto se suponía en un sentido muy estricto, esencialmente equiparándolo con la implementación de la interfaz y la conversión de tipo seguro. Lo que significaría que LSP está asegurado o no por el idioma en sí. Por ejemplo, en este sentido estricto, ThreeDBoard es ciertamente sustituible para Board, en lo que respecta al compilador.

Después de leer más sobre el concepto, descubrí que LSP generalmente se interpreta más ampliamente que eso.

En resumen, lo que significa para el código del cliente "saber" que el objeto detrás del puntero es de un tipo derivado en lugar del tipo de puntero no está restringido a la seguridad del tipo. La adherencia a LSP también se puede probar al probar el comportamiento real de los objetos. Es decir, examinar el impacto de los argumentos de estado y método de un objeto sobre los resultados de las llamadas al método o los tipos de excepciones arrojados desde el objeto.

Volviendo al ejemplo otra vez, En teoria los métodos de la Junta pueden funcionar perfectamente en ThreeDBoard. En la práctica, sin embargo, será muy difícil evitar diferencias en el comportamiento que el cliente puede no manejar adecuadamente, sin obstaculizar la funcionalidad que ThreeDBoard pretende agregar.

Con este conocimiento en la mano, evaluar el cumplimiento de LSP puede ser una gran herramienta para determinar cuándo la composición es el mecanismo más apropiado para extender la funcionalidad existente, en lugar de la herencia.


18
2017-09-11 16:04