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?
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?
Respuestas:
Un gran ejemplo que ilustra el LSP (dado por el tío Bob en un podcast que escuché recientemente) fue cómo a veces algo que suena bien en lenguaje natural no funciona en el código.
En matemáticas, a Square
es a 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
derivan de Rectangle
, a continuación, una Square
debe estar en cualquier parte utilizable se espera una Rectangle
. Esto genera un comportamiento extraño.
Imagina que tienes SetWidth
y SetHeight
métodos en tu Rectangle
clase base; Esto parece perfectamente lógico. Sin embargo, si su Rectangle
referencia apunta a a Square
, entonces, SetWidth
y SetHeight
no tiene sentido porque establecer uno cambiaría al otro para que coincida. En este caso, Square
falla la prueba de sustitución de Liskov Rectangle
y la abstracción de haber Square
heredado Rectangle
es mala.
Todos deberían ver los otros valiosos carteles motivacionales de los Principios SÓLIDOS .
Square.setWidth(int width)
se implementara así this.width = width; this.height = width;
:? En este caso, se garantiza que el ancho es igual a la altura.
El principio de sustitución de Liskov (LSP, lsp) es un concepto en Programación Orientada a Objetos que establece:
Las funciones que usan punteros o referencias a clases base deben poder usar objetos de clases derivadas sin saberlo.
En esencia, LSP se trata de interfaces y contratos, así como de cómo decidir cuándo extender una clase versus usar otra estrategia, como la composición, para lograr su objetivo.
La manera eficaz la mayoría que he visto para ilustrar este punto estaba en cabeza primero OOA & D . Presentan un escenario en el que usted es desarrollador de un proyecto para crear un marco para juegos de estrategia.
Presentan una clase que representa un tablero que se ve así:
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 marco del juego también debe ser compatible con tableros de juegos 3D para acomodar juegos que tienen vuelo. Entonces ThreeDBoard
se introduce una clase que se extiende Board
.
A primera vista, esto parece una buena decisión. Board
proporciona tanto el Height
y Width
propiedades y ThreeDBoard
proporciona el eje Z.
Cuando se descompone es cuando observa a todos los demás miembros heredados de Board
. Los métodos para AddUnit
, GetTile
, GetUnits
y así sucesivamente, todos toman ambos parámetros x e y en la Board
clase pero la 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 la Board
clase y los métodos heredados de la Board
clase pierden su significado. Una unidad de código que intente usar la ThreeDBoard
clase como su clase base Board
no tendrá mucha suerte.
Tal vez deberíamos encontrar otro enfoque. En lugar de extenderse Board
, ThreeDBoard
debe estar compuesto de Board
objetos. Un Board
objeto por unidad del eje Z.
Esto nos permite utilizar buenos principios orientados a objetos como la encapsulación y la reutilización y no viola el LSP.
La sustituibilidad es un principio en la programación orientada a objetos que establece que, en un programa de computadora, si S es un subtipo de T, entonces los objetos de tipo T pueden reemplazarse por objetos de tipo S
Hagamos un ejemplo simple en Java:
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 volar, eso significa que estamos rompiendo el principio LSP.
public class Bird{
}
public class FlyingBirds extends Bird{
public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{}
Bird bird
. Tienes que lanzar el objeto a FlyingBirds para hacer uso de la mosca, lo cual no es bueno, ¿verdad?
Bird bird
, eso significa que no puede usar fly()
. Eso es. Pasar a Duck
no cambia este hecho. Si el cliente lo ha hecho FlyingBirds bird
, incluso si se supera Duck
, siempre debería funcionar de la misma manera.
LSP se refiere a invariantes.
El ejemplo clásico está 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 funcionan los captadores y los establecedores, Rectangle
debe satisfacer lo 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 violada por una implementación correcta de Square
, por lo tanto, no es un sustituto válido de Rectangle
.
Robert Martin tiene un excelente artículo sobre el Principio de sustitución de Liskov . Discute formas sutiles y no tan sutiles de cómo se puede violar el principio.
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 evidentes de este principio es el uso de la información de tipo de tiempo de ejecución de C ++ (RTTI) para seleccionar una función basada en el 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, la
DrawShape
función está mal formada. Debe conocer todas las derivadas posibles de laShape
clase, y debe cambiarse siempre queShape
se creen nuevas derivadas de . 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 la
Rectangle
clase como se describe a continuación: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 un 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. Dado que la relación ISA se mantiene, es lógico modelar la
Square
clase como derivadaRectangle
. [...]
Square
heredará las funcionesSetWidth
ySetHeight
. Estas funciones son completamente inapropiadas para aSquare
, ya que el ancho y la altura de un cuadrado son idénticos. Esto debería ser una pista importante de que hay un problema con el diseño. Sin embargo, hay una manera de evitar el problema. Podríamos anularSetWidth
ySetHeight
[...]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, elSquare
objeto se corromperá porque la altura no cambiará. Esta es una clara violación de LSP. La función no funciona para derivadas de sus argumentos.[...]
Now the rule for the preconditions and postconditions for derivatives, as stated by Meyer is: ...when redefining a routine [in a derivative], you may only replace its precondition by a weaker one, and its postcondition by a stronger one.
si una precondición de clase infantil es más fuerte que una precondición de clase de padre, no podría sustituir a un niño por un padre sin violar la precondición. De ahí el LSP.
El LSP es necesario cuando algún código cree que está llamando a los métodos de un tipo T
, y sin saberlo puede llamar a los métodos de un tipo S
, donde S extends T
(es decir S
, hereda, deriva o es un subtipo del 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 que las expectativas (es decir, invariantes) para los métodos de tipo T
(p Rectangle
. Ej. ) No se infrinjan cuando se invocan los métodos de tipo S
(p Square
. Ej. ).
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, por ejemplo, los establecedores de rectángulo inmutables esperan que las dimensiones se modifiquen de forma independiente, pero los establecedores cuadrados inmutables 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
tenga 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 se debe a que si la persona que llama cree que tiene un tipo T
, cree que está llamando a un método T
, entonces proporciona argumentos de tipo Ti
y asigna la salida al tipo To
. Cuando realmente llama al método correspondiente de S
, entonces cada Ti
argumento de entrada se asigna a un Si
parámetro de entrada, y la So
salida se asigna al tipo To
. Por lo tanto, si Si
no fuera contravariante wrt a Ti
, entonces Xi
se Si
podría asignar un subtipo , que no sería un subtipo de Ti
.
Además, para los idiomas (p. Ej., Scala o Ceilán) que tienen anotaciones de variación del sitio de definición en los parámetros de polimorfismo de tipo (es decir, genéricos), la codirección o contradirección de la anotación de variación para cada parámetro de tipo del tipo T
debe ser opuesta 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, se invierte la dirección de varianza requerida. Esta regla se aplica de forma recursiva.
Subtipar es apropiado donde los invariantes pueden ser enumerados.
Hay mucha investigación en curso sobre cómo modelar invariantes, para que el compilador los aplique.
Typestate (consulte la página 3) declara y aplica invariantes de estado ortogonales para escribir. Alternativamente, los invariantes pueden hacerse cumplir mediante la conversión de 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. Una API tic-tac-toe puede ser otro ejemplo de empleo de tipeo para imponer invariantes en tiempo de compilación. El sistema de tipos puede incluso ser Turing completo, por ejemplo, Scala . Los lenguajes y demostradores de teoremas de tipo dependiente formalizan los modelos de mecanografía de orden superior.
Debido a la necesidad de que la semántica abstraiga sobre la extensión , espero que el empleo de la tipificación para modelar invariantes, es decir, una semántica denotativa de orden superior unificada, sea superior al Typestate. 'Extensión' significa la composición ilimitada y permutada del desarrollo modular no coordinado. Porque me parece ser la antítesis de la unificación y, por lo tanto, los grados de libertad, tener dos modelos mutuamente dependientes (por ejemplo, tipos y Typestate) para expresar la semántica compartida, que no puede unificarse entre sí para una composición extensible . Por ejemplo, la extensión similar a un problema de expresión se unificó en los dominios de subtipo, sobrecarga de funciones y tipado paramétrico.
Mi posición teórica es que para que exista el conocimiento (ver sección “La centralización es ciega y no apta”), nunca habrá un modelo general que pueda exigir una cobertura del 100% de todos los posibles invariantes 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 estar aumentando. Esta es la fuerza entrópica. Probar todos los cálculos posibles de una extensión potencial, es calcular a priori todas las extensiones posibles.
Es por eso que existe el Teorema de detención, es decir, es indecidible si todos los programas posibles en un lenguaje de programación completo de Turing terminan. Se puede demostrar que algún programa específico termina (uno en el que se han definido y calculado todas las posibilidades). Pero es imposible demostrar que toda la extensión posible de ese programa finaliza, a menos que las posibilidades de extensión de ese programa no estén completas (por ejemplo, a través del tipeo dependiente). Dado que el requisito fundamental para la integridad de Turing es la recursión ilimitada , es intuitivo comprender 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:
Veo rectángulos y cuadrados en cada respuesta, y cómo violar el LSP.
Me gustaría mostrar cómo se puede conformar el LSP con un ejemplo del mundo real:
<?php
interface Database
{
public function selectQuery(string $sql): array;
}
class SQLiteDatabase implements Database
{
public function selectQuery(string $sql): array
{
// sqlite specific code
return $result;
}
}
class MySQLDatabase implements Database
{
public function selectQuery(string $sql): array
{
// mysql specific code
return $result;
}
}
Este diseño se ajusta al LSP porque el comportamiento permanece sin cambios, independientemente de la implementación que elijamos usar.
Y sí, puede violar LSP en esta configuración haciendo un cambio simple de esta manera:
<?php
interface Database
{
public function selectQuery(string $sql): array;
}
class SQLiteDatabase implements Database
{
public function selectQuery(string $sql): array
{
// sqlite specific code
return $result;
}
}
class MySQLDatabase implements Database
{
public function selectQuery(string $sql): array
{
// mysql specific code
return ['result' => $result]; // This violates LSP !
}
}
Ahora los subtipos no se pueden usar de la misma manera, ya que ya no producen el mismo resultado.
Database::selectQuery
admitir solo el subconjunto de SQL compatible con todos los motores de base de datos. Eso no es práctico ... Dicho esto, el ejemplo es aún más fácil de entender que la mayoría de los demás utilizados aquí.
Hay una lista de verificación para determinar si usted está violando o no a Liskov.
Lista de Verificación:
Restricción de historial : al anular un método, no está permitido modificar una propiedad no modificable en la clase base. Eche un vistazo a este código y podrá ver que el Nombre se define como no modificable (conjunto privado), pero SubType presenta un nuevo método que permite modificarlo (mediante reflexión):
public class SuperType
{
public string Name { get; private set; }
public SuperType(string name, int age)
{
Name = name;
Age = age;
}
}
public class SubType : SuperType
{
public void ChangeName(string newName)
{
var propertyType = base.GetType().GetProperty("Name").SetValue(this, newName);
}
}
Hay otros 2 elementos: contravarianza de los argumentos del método y covarianza de los tipos de retorno . Pero no es posible en C # (soy un desarrollador de C #), así que no me importan.
Referencia:
El LSP es una regla sobre el contrato de las clases: si una clase base cumple un contrato, entonces las clases derivadas del LSP también deben cumplir ese contrato.
En pseudo-pitón
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.
2 + "2"
). ¿Quizás confunde "fuertemente tipado" con "tipado estáticamente"?
En pocas palabras, dejemos rectángulos rectángulos y cuadrados cuadrados, ejemplo práctico al extender una clase principal, debe PRESERVAR la API principal exacta o EXTENDERLA.
Digamos que tiene una base de ItemsRepository.
class ItemsRepository
{
/**
* @return int Returns number of deleted rows
*/
public function delete()
{
// perform a delete query
$numberOfDeletedRows = 10;
return $numberOfDeletedRows;
}
}
Y una subclase que lo extiende:
class BadlyExtendedItemsRepository extends ItemsRepository
{
/**
* @return void Was suppose to return an INT like parent, but did not, breaks LSP
*/
public function delete()
{
// perform a delete query
$numberOfDeletedRows = 10;
// we broke the behaviour of the parent class
return;
}
}
Entonces podría tener un Cliente trabajando con la API Base ItemsRepository y confiando en ella.
/**
* Class ItemsService is a client for public ItemsRepository "API" (the public delete method).
*
* Technically, I am able to pass into a constructor a sub-class of the ItemsRepository
* but if the sub-class won't abide the base class API, the client will get broken.
*/
class ItemsService
{
/**
* @var ItemsRepository
*/
private $itemsRepository;
/**
* @param ItemsRepository $itemsRepository
*/
public function __construct(ItemsRepository $itemsRepository)
{
$this->itemsRepository = $itemsRepository;
}
/**
* !!! Notice how this is suppose to return an int. My clients expect it based on the
* ItemsRepository API in the constructor !!!
*
* @return int
*/
public function delete()
{
return $this->itemsRepository->delete();
}
}
El LSP se rompe cuando la sustitución de la clase padre con una subclase rompe el contrato de la API .
class ItemsController
{
/**
* Valid delete action when using the base class.
*/
public function validDeleteAction()
{
$itemsService = new ItemsService(new ItemsRepository());
$numberOfDeletedItems = $itemsService->delete();
// $numberOfDeletedItems is an INT :)
}
/**
* Invalid delete action when using a subclass.
*/
public function brokenDeleteAction()
{
$itemsService = new ItemsService(new BadlyExtendedItemsRepository());
$numberOfDeletedItems = $itemsService->delete();
// $numberOfDeletedItems is a NULL :(
}
}
Puede obtener más información sobre cómo escribir software mantenible en mi curso: https://www.udemy.com/enterprise-php/
Las funciones que usan punteros o referencias a clases base deben poder usar objetos de clases derivadas sin saberlo.
Cuando leí por primera vez acerca de LSP, supuse que se refería a esto 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 el lenguaje en sí garantiza o no el LSP. Por ejemplo, en este sentido estricto, ThreeDBoard es ciertamente sustituible por Board, en lo que respecta al compilador.
Después de leer más sobre el concepto, descubrí que el LSP generalmente se interpreta de manera más amplia que eso.
En resumen, lo que significa que el código del cliente "sepa" que el objeto detrás del puntero es de un tipo derivado en lugar del tipo de puntero no está restringido a la seguridad de tipo. La adherencia al LSP también es comprobable mediante el sondeo del comportamiento real de los objetos. Es decir, examinar el impacto del estado de un objeto y los argumentos del método en los resultados de las llamadas al método, o los tipos de excepciones lanzadas desde el objeto.
Volviendo al ejemplo nuevamente, en teoría, los métodos de la Junta pueden funcionar bien en ThreeDBoard. Sin embargo, en la práctica, será muy difícil evitar diferencias de comportamiento que el cliente no pueda manejar adecuadamente, sin obstaculizar la funcionalidad que ThreeDBoard pretende agregar.
Con este conocimiento en mano, evaluar la adherencia a 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.
Supongo que todo el mundo cubrió lo que LSP es técnicamente: básicamente desea poder abstraerse de los detalles del subtipo y usar supertipos de manera segura.
Entonces Liskov tiene 3 reglas subyacentes:
Regla de firma: debe haber una implementación válida de cada operación del supertipo en el subtipo sintácticamente. Algo que un compilador podrá verificar por usted. Hay una pequeña regla sobre lanzar menos excepciones y ser al menos tan accesible como los métodos de supertipo.
Regla de métodos: La implementación de esas operaciones es semánticamente sólida.
Regla de propiedades: esto va más allá de las llamadas a funciones individuales.
Todas estas propiedades deben conservarse y la funcionalidad adicional de subtipo no debe violar las propiedades de supertipo.
Si se resuelven estas tres cosas, se ha abstraído de las cosas subyacentes y está escribiendo código débilmente acoplado.
Fuente: Desarrollo de programas en Java - Barbara Liskov
Un ejemplo importante del uso de LSP es en las pruebas de software .
Si tengo una clase A que es una subclase de B que cumple con LSP, entonces puedo reutilizar el conjunto de pruebas de B para probar A.
Para probar completamente la subclase A, probablemente necesito agregar algunos casos de prueba más, pero como mínimo puedo reutilizar todos los casos de prueba de la superclase B.
Una forma de darse cuenta es esto construyendo lo que McGregor llama una "jerarquía paralela para las pruebas": mi ATest
clase heredará de BTest
. Se necesita alguna forma de inyección para garantizar que el caso de prueba funcione con objetos de tipo A en lugar de tipo B (un patrón de método de plantilla simple funcionará).
Tenga en cuenta que reutilizar el conjunto de superpruebas para todas las implementaciones de subclase es, de hecho, una forma de comprobar que estas implementaciones de subclase son compatibles con LSP. Por lo tanto, también se puede argumentar que se debe ejecutar el conjunto de pruebas de superclase en el contexto de cualquier subclase.
Consulte también la respuesta a la pregunta de Stackoverflow " ¿Puedo implementar una serie de pruebas reutilizables para probar la implementación de una interfaz? "
Vamos a ilustrar en Java:
class TrasportationDevice
{
String name;
String getName() { ... }
void setName(String n) { ... }
double speed;
double getSpeed() { ... }
void setSpeed(double d) { ... }
Engine engine;
Engine getEngine() { ... }
void setEngine(Engine e) { ... }
void startEngine() { ... }
}
class Car extends TransportationDevice
{
@Override
void startEngine() { ... }
}
No hay problema aquí, ¿verdad? Un automóvil es definitivamente un dispositivo de transporte, y aquí podemos ver que anula el método startEngine () de su superclase.
Agreguemos otro dispositivo de transporte:
class Bicycle extends TransportationDevice
{
@Override
void startEngine() /*problem!*/
}
¡Todo no está yendo según lo planeado ahora! Sí, una bicicleta es un dispositivo de transporte, sin embargo, no tiene un motor y, por lo tanto, el método startEngine () no se puede implementar.
Estos son los tipos de problemas a los que conduce la violación del Principio de sustitución de Liskov, y generalmente se pueden reconocer mediante un método que no hace nada, o incluso no se puede implementar.
La solución a estos problemas es una jerarquía de herencia correcta, y en nuestro caso resolveríamos el problema diferenciando las clases de dispositivos de transporte con y sin motores. Aunque una bicicleta es un dispositivo de transporte, no tiene motor. En este ejemplo, nuestra definición de dispositivo de transporte es incorrecta. No debe tener un motor.
Podemos refactorizar nuestra clase TransportationDevice de la siguiente manera:
class TrasportationDevice
{
String name;
String getName() { ... }
void setName(String n) { ... }
double speed;
double getSpeed() { ... }
void setSpeed(double d) { ... }
}
Ahora podemos extender el dispositivo de transporte para dispositivos no motorizados.
class DevicesWithoutEngines extends TransportationDevice
{
void startMoving() { ... }
}
Y extienda el dispositivo de transporte para dispositivos motorizados. Aquí es más apropiado agregar el objeto Motor.
class DevicesWithEngines extends TransportationDevice
{
Engine engine;
Engine getEngine() { ... }
void setEngine(Engine e) { ... }
void startEngine() { ... }
}
Por lo tanto, nuestra clase de automóviles se vuelve más especializada, mientras se adhiere al Principio de sustitución de Liskov.
class Car extends DevicesWithEngines
{
@Override
void startEngine() { ... }
}
Y nuestra clase de bicicletas también cumple con el Principio de sustitución de Liskov.
class Bicycle extends DevicesWithoutEngines
{
@Override
void startMoving() { ... }
}
Esta formulación del LSP es demasiado fuerte:
Si para cada objeto o1 del tipo S hay un objeto o2 del tipo T, de modo 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.
Lo que básicamente significa que S es otra implementación completamente encapsulada de exactamente lo mismo que T. Y podría ser audaz y decidir que el rendimiento es parte del comportamiento de P ...
Entonces, básicamente, cualquier uso de enlace tardío viola el LSP. ¡El objetivo de OO es obtener un comportamiento diferente cuando sustituimos un objeto de un tipo por otro de otro tipo!
La formulación citada por wikipedia es mejor ya que la propiedad depende del contexto y no necesariamente incluye todo el comportamiento del programa.
En una oración muy simple, podemos decir:
La clase secundaria no debe violar sus características de clase base. Debe ser capaz con eso. Podemos decir que es lo mismo que subtipar.
Principio de sustitución de Liskov (LSP)
Todo el tiempo diseñamos un módulo de programa y creamos algunas jerarquías de clases. Luego ampliamos algunas clases creando algunas clases derivadas.
Debemos asegurarnos de que las nuevas clases derivadas se extiendan sin reemplazar la funcionalidad de las clases antiguas. De lo contrario, las nuevas clases pueden producir efectos no deseados cuando se usan en módulos de programa existentes.
El Principio de sustitución de Liskov establece que si un módulo de programa está usando una clase Base, entonces la referencia a la clase Base se puede reemplazar con una clase Derivada sin afectar la funcionalidad del módulo del programa.
Ejemplo:
A continuación se muestra el ejemplo clásico por el cual se viola el Principio de sustitución de Liskov. En el ejemplo, se usan 2 clases: Rectángulo y Cuadrado. Supongamos que el objeto Rectangle se usa en algún lugar de la aplicación. Extendemos la aplicación y agregamos la clase Square. La clase cuadrada es devuelta por un patrón de fábrica, basado en algunas condiciones y no sabemos exactamente qué tipo de objeto será devuelto. Pero sabemos que es un rectángulo. Obtenemos el objeto rectángulo, establecemos el ancho en 5 y la altura en 10 y obtenemos el área. Para un rectángulo con ancho 5 y altura 10, el área debe ser 50. En cambio, el resultado será 100
// Violation of Likov's Substitution Principle
class Rectangle {
protected int m_width;
protected int m_height;
public void setWidth(int width) {
m_width = width;
}
public void setHeight(int height) {
m_height = height;
}
public int getWidth() {
return m_width;
}
public int getHeight() {
return m_height;
}
public int getArea() {
return m_width * m_height;
}
}
class Square extends Rectangle {
public void setWidth(int width) {
m_width = width;
m_height = width;
}
public void setHeight(int height) {
m_width = height;
m_height = height;
}
}
class LspTest {
private static Rectangle getNewRectangle() {
// it can be an object returned by some factory ...
return new Square();
}
public static void main(String args[]) {
Rectangle r = LspTest.getNewRectangle();
r.setWidth(5);
r.setHeight(10);
// user knows that r it's a rectangle.
// It assumes that he's able to set the width and height as for the base
// class
System.out.println(r.getArea());
// now he's surprised to see that the area is 100 instead of 50.
}
}
Conclusión:
Este principio es solo una extensión del Principio Open Close y significa que debemos asegurarnos de que las nuevas clases derivadas amplíen las clases base sin cambiar su comportamiento.
Ver también: Abrir Cerrar Principio
Algunos conceptos similares para una mejor estructura: Convención sobre configuración
Algún apéndice:
Me pregunto por qué nadie escribió acerca de las invariantes, condiciones previas y condiciones de publicación de la clase base que las clases derivadas deben obedecer. Para que una clase D derivada sea completamente sustituible por la clase B básica, la clase D debe obedecer ciertas condiciones:
Por lo tanto, el derivado debe tener en cuenta las tres condiciones anteriores impuestas por la clase base. Por lo tanto, las reglas de subtipo están predeterminadas. Lo que significa que la relación 'IS A' se obedecerá solo cuando el subtipo obedezca ciertas reglas. Estas reglas, en forma de invariantes, precondiciones y condiciones posteriores, deben decidirse mediante un ' contrato de diseño ' formal .
Más discusiones sobre esto disponibles en mi blog: Principio de sustitución de Liskov
El LSP en términos simples establece que los objetos de la misma superclase deben poder intercambiarse entre sí sin romper nada.
Por ejemplo, si tenemos una Cat
y una Dog
clase derivada de una Animal
clase, cualquier función que use la clase Animal debería poder usar Cat
o Dog
comportarse normalmente.
¿Sería útil implementar ThreeDBoard en términos de una matriz de placa?
Tal vez desee tratar las rebanadas de ThreeDBoard en varios planos como un tablero. En ese caso, es posible que desee abstraer una interfaz (o clase abstracta) para que Board permita múltiples implementaciones.
En términos de interfaz externa, es posible que desee factorizar una interfaz de placa para TwoDBoard y ThreeDBoard (aunque ninguno de los métodos anteriores se ajusta).
Un cuadrado es un rectángulo donde el ancho es igual a la altura. Si el cuadrado establece dos tamaños diferentes para el ancho y la altura, viola el cuadrado invariante. Esto se soluciona mediante la introducción de efectos secundarios. Pero si el rectángulo tenía un setSize (alto, ancho) con la condición previa 0 <alto y 0 <ancho. El método del subtipo derivado requiere height == width; una precondición más fuerte (y eso viola lsp). Esto muestra que aunque el cuadrado es un rectángulo, no es un subtipo válido porque la condición previa se fortalece. La solución (en general, algo malo) causa un efecto secundario y esto debilita la condición posterior (que viola lsp). setWidth en la base tiene la condición de publicación 0 <ancho. El derivado lo debilita con altura == ancho.
Por lo tanto, un cuadrado redimensionable no es un rectángulo redimensionable.
Este principio fue introducido por Barbara Liskov en 1987 y extiende el Principio Abierto-Cerrado al enfocarse en el comportamiento de una superclase y sus subtipos.
Su importancia se hace evidente cuando consideramos las consecuencias de violarlo. Considere una aplicación que usa la siguiente clase.
public class Rectangle
{
private double width;
private double height;
public double Width
{
get
{
return width;
}
set
{
width = value;
}
}
public double Height
{
get
{
return height;
}
set
{
height = value;
}
}
}
Imagine que un día, el cliente exige la capacidad de manipular cuadrados además de rectángulos. Como un cuadrado es un rectángulo, la clase de cuadrado debe derivarse de la clase Rectangle.
public class Square : Rectangle
{
}
Sin embargo, al hacerlo nos encontraremos con dos problemas:
Un cuadrado no necesita variables de altura y ancho heredadas del rectángulo y esto podría generar un desperdicio significativo en la memoria si tenemos que crear cientos de miles de objetos cuadrados. Las propiedades de establecimiento de ancho y alto heredadas del rectángulo no son apropiadas para un cuadrado ya que el ancho y alto de un cuadrado son idénticos. Para establecer el alto y el ancho en el mismo valor, podemos crear dos nuevas propiedades de la siguiente manera:
public class Square : Rectangle
{
public double SetWidth
{
set
{
base.Width = value;
base.Height = value;
}
}
public double SetHeight
{
set
{
base.Height = value;
base.Width = value;
}
}
}
Ahora, cuando alguien establece el ancho de un objeto cuadrado, su altura cambiará en consecuencia y viceversa.
Square s = new Square();
s.SetWidth(1); // Sets width and height to 1.
s.SetHeight(2); // sets width and height to 2.
Avancemos y consideremos esta otra función:
public void A(Rectangle r)
{
r.SetWidth(32); // calls Rectangle.SetWidth
}
Si pasamos una referencia a un objeto cuadrado en esta función, violaríamos el LSP porque la función no funciona para derivadas de sus argumentos. El ancho y la altura de las propiedades no son polimórficos porque no se declaran virtuales en rectángulo (el objeto cuadrado se dañará porque la altura no se cambiará).
Sin embargo, al declarar que las propiedades del configurador son virtuales, enfrentaremos otra violación, el OCP. De hecho, la creación de un cuadrado de clase derivado está causando cambios en el rectángulo de la clase base.
La explicación más clara para LSP que encontré hasta ahora ha sido "El Principio de sustitución de Liskov dice que el objeto de una clase derivada debería ser capaz de reemplazar un objeto de la clase base sin traer ningún error en el sistema o modificar el comportamiento de la clase base "desde aquí . El artículo da un ejemplo de código para violar LSP y arreglarlo.
Digamos que usamos un rectángulo en nuestro código
r = new Rectangle();
// ...
r.setDimensions(1,2);
r.fill(colors.red());
canvas.draw(r);
En nuestra clase de geometría aprendimos que un cuadrado es un tipo especial de rectángulo porque su ancho tiene la misma longitud que su altura. Hagamos una Square
clase también basada en esta información:
class Square extends Rectangle {
setDimensions(width, height){
assert(width == height);
super.setDimensions(width, height);
}
}
Si reemplazamos el Rectangle
con Square
en nuestro primer código, entonces se romperá:
r = new Square();
// ...
r.setDimensions(1,2); // assertion width == height failed
r.fill(colors.red());
canvas.draw(r);
Esto es porque el Square
tiene una nueva condición de que no tenemos en la Rectangle
clase: width == height
. Según LSP, las Rectangle
instancias deben ser sustituibles por Rectangle
instancias de subclase. Esto se debe a que estas instancias pasan la verificación de tipo para Rectangle
instancias y, por lo tanto, causarán errores inesperados en su código.
Este fue un ejemplo para la parte de "precondiciones que no pueden fortalecerse en un subtipo" en el artículo wiki . En resumen, violar LSP probablemente causará errores en su código en algún momento.
LSP dice que "los objetos deben ser reemplazables por sus subtipos". Por otro lado, este principio apunta a
Las clases secundarias nunca deben romper las definiciones de tipo de la clase principal.
y el siguiente ejemplo ayuda a comprender mejor el LSP.
Sin LSP:
public interface CustomerLayout{
public void render();
}
public FreeCustomer implements CustomerLayout {
...
@Override
public void render(){
//code
}
}
public PremiumCustomer implements CustomerLayout{
...
@Override
public void render(){
if(!hasSeenAd)
return; //it isn`t rendered in this case
//code
}
}
public void renderView(CustomerLayout layout){
layout.render();
}
Fijación por LSP:
public interface CustomerLayout{
public void render();
}
public FreeCustomer implements CustomerLayout {
...
@Override
public void render(){
//code
}
}
public PremiumCustomer implements CustomerLayout{
...
@Override
public void render(){
if(!hasSeenAd)
showAd();//it has a specific behavior based on its requirement
//code
}
}
public void renderView(CustomerLayout layout){
layout.render();
}
Le animo a leer el artículo: Violar el principio de sustitución de Liskov (LSP) .
Puede encontrar una explicación sobre el Principio de sustitución de Liskov, pistas generales que lo ayudarán a adivinar si ya lo ha violado y un ejemplo de enfoque que lo ayudará a hacer que su jerarquía de clases sea más segura.
EL PRINCIPIO DE SUSTITUCIÓN DE LISKOV (del libro de Mark Seemann) establece que deberíamos poder reemplazar una implementación de una interfaz con otra sin romper el cliente o la implementación. Es este principio el que permite abordar los requisitos que se produzcan en el futuro, incluso si podemos ' No los preveo hoy.
Si desconectamos la computadora de la pared (Implementación), ni la toma de corriente de la pared (Interfaz) ni la computadora (Cliente) se descomponen (de hecho, si es una computadora portátil, incluso puede funcionar con sus baterías por un período de tiempo) . Sin embargo, con el software, un cliente a menudo espera que un servicio esté disponible. Si se eliminó el servicio, obtenemos una NullReferenceException. Para hacer frente a este tipo de situación, podemos crear una implementación de una interfaz que no haga "nada". Este es un patrón de diseño conocido como Objeto Nulo, [4] y corresponde aproximadamente a desenchufar la computadora de la pared. Debido a que estamos utilizando un acoplamiento flexible, podemos reemplazar una implementación real con algo que no hace nada sin causar problemas.
El Principio de sustitución de Likov establece que si un módulo de programa está usando una clase Base, entonces la referencia a la clase Base se puede reemplazar con una clase Derivada sin afectar la funcionalidad del módulo del programa.
Intención: los tipos derivados deben ser completamente sustitutos de sus tipos base.
Ejemplo: tipos de retorno covariantes en java.
Aquí hay un extracto de esta publicación que aclara las cosas muy bien:
[..] para comprender algunos principios, es importante darse cuenta de cuándo se ha violado. Esto es lo que haré ahora.
¿Qué significa la violación de este principio? Implica que un objeto no cumple el contrato impuesto por una abstracción expresada con una interfaz. En otras palabras, significa que identificó mal sus abstracciones.
Considere el siguiente ejemplo:
interface Account
{
/**
* Withdraw $money amount from this account.
*
* @param Money $money
* @return mixed
*/
public function withdraw(Money $money);
}
class DefaultAccount implements Account
{
private $balance;
public function withdraw(Money $money)
{
if (!$this->enoughMoney($money)) {
return;
}
$this->balance->subtract($money);
}
}
¿Es esto una violación de LSP? Si. Esto se debe a que el contrato de la cuenta nos dice que se retiraría una cuenta, pero este no es siempre el caso. Entonces, ¿qué debo hacer para solucionarlo? Acabo de modificar el contrato:
interface Account
{
/**
* Withdraw $money amount from this account if its balance is enough.
* Otherwise do nothing.
*
* @param Money $money
* @return mixed
*/
public function withdraw(Money $money);
}
Voilà, ahora el contrato está satisfecho.
Esta violación sutil a menudo impone a un cliente la capacidad de distinguir entre los objetos concretos empleados. Por ejemplo, dado el contrato de la primera cuenta, podría tener el siguiente aspecto:
class Client
{
public function go(Account $account, Money $money)
{
if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
return;
}
$account->withdraw($money);
}
}
Y, esto viola automáticamente el principio abierto-cerrado [es decir, para el requisito de retiro de dinero. Porque nunca se sabe lo que sucede si un objeto que viola el contrato no tiene suficiente dinero. Probablemente simplemente no devuelve nada, probablemente se lanzará una excepción. Entonces tienes que comprobar sihasEnoughMoney()
, lo que no forma parte de una interfaz. Entonces, esta verificación forzada dependiente de la clase concreta es una violación de OCP].
Este punto también aborda una idea errónea que encuentro con bastante frecuencia sobre la violación de LSP. Dice "si el comportamiento de un padre cambió en un niño, entonces viola el LSP". Sin embargo, no lo hace, siempre y cuando un niño no viole el contrato de sus padres.