Ouvi dizer que o Princípio de Substituição de Liskov (LSP) é um princípio fundamental do design orientado a objetos. O que é e quais são alguns exemplos de seu uso?
Ouvi dizer que o Princípio de Substituição de Liskov (LSP) é um princípio fundamental do design orientado a objetos. O que é e quais são alguns exemplos de seu uso?
Respostas:
Um ótimo exemplo de ilustração do LSP (dado pelo tio Bob em um podcast que ouvi recentemente) foi como às vezes algo que soa bem na linguagem natural não funciona muito bem no código.
Em matemática, a Square
é a Rectangle
. Na verdade, é uma especialização de um retângulo. O "é um" faz com que você queira modelar isso com herança. No entanto, se no código que você Square
derivou Rectangle
, a Square
deve ser utilizável em qualquer lugar que você espera a Rectangle
. Isso cria um comportamento estranho.
Imagine que você tinha SetWidth
e SetHeight
métodos em sua Rectangle
classe base; isso parece perfeitamente lógico. No entanto, se sua Rectangle
referência apontou para a Square
, então SetWidth
e SetHeight
não faz sentido, pois definir uma mudaria a outra para corresponder a ela. Nesse caso, Square
falha no teste de substituição de Liskov Rectangle
e a abstração de Square
herdar Rectangle
é ruim.
Vocês devem conferir os outros pôsteres motivacionais de inestimáveis princípios do SOLID .
Square.setWidth(int width)
fosse implementado assim this.width = width; this.height = width;
:? Nesse caso, é garantido que a largura seja igual à altura.
O princípio da substituição de Liskov (LSP, lsp) é um conceito na programação orientada a objetos que declara:
Funções que usam ponteiros ou referências a classes base devem poder usar objetos de classes derivadas sem conhecê-lo.
No fundo, o LSP trata de interfaces e contratos, bem como de como decidir quando estender uma classe versus usar outra estratégia, como a composição, para atingir seu objetivo.
A maneira eficaz máximo que eu já vi para ilustrar este ponto estava no Head First OOA & D . Eles apresentam um cenário em que você é um desenvolvedor de um projeto para criar uma estrutura para jogos de estratégia.
Eles apresentam uma classe que representa um quadro semelhante a este:
Todos os métodos usam as coordenadas X e Y como parâmetros para localizar a posição do bloco na matriz bidimensional de Tiles
. Isso permitirá que um desenvolvedor de jogos gerencie unidades no tabuleiro durante o decorrer do jogo.
O livro continua alterando os requisitos para dizer que o trabalho da estrutura do jogo também deve suportar tabuleiros de jogos em 3D para acomodar jogos com vôo. Portanto, ThreeDBoard
é introduzida uma classe que se estende Board
.
À primeira vista, isso parece ser uma boa decisão. Board
fornece tanto a Height
e Width
propriedades e ThreeDBoard
fornece o eixo Z.
O ponto em que ocorre é quando você olha para todos os outros membros herdados Board
. Os métodos para a AddUnit
, GetTile
, GetUnits
e assim por diante, todos levar ambos os parâmetros X e Y na Board
classe mas o ThreeDBoard
precisa de um parâmetro Z, bem.
Portanto, você deve implementar esses métodos novamente com um parâmetro Z O parâmetro Z não tem contexto para a Board
classe e os métodos herdados da Board
classe perdem seu significado. Uma unidade de código que tenta usar a ThreeDBoard
classe como sua classe base Board
seria muito azarada.
Talvez devêssemos encontrar outra abordagem. Em vez de estender Board
, ThreeDBoard
deve ser composto de Board
objetos. Um Board
objeto por unidade do eixo Z.
Isso nos permite usar bons princípios orientados a objetos, como encapsulamento e reutilização, e não viola o LSP.
A substituibilidade é um princípio na programação orientada a objetos, afirmando que, em um programa de computador, se S é um subtipo de T, objetos do tipo T podem ser substituídos por objetos do tipo S
vamos fazer um exemplo simples em Java:
public class Bird{
public void fly(){}
}
public class Duck extends Bird{}
O pato pode voar por causa de um pássaro, mas e quanto a isso:
public class Ostrich extends Bird{}
Avestruz é um pássaro, mas não pode voar, a classe Ostrich é um subtipo da classe Bird, mas não pode usar o método fly, isso significa que estamos quebrando o princípio do LSP.
public class Bird{
}
public class FlyingBirds extends Bird{
public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{}
Bird bird
. Você tem que lançar o objeto no FlyingBirds para usar o fly, o que não é legal, certo?
Bird bird
, significa que não pode usá-lo fly()
. É isso aí. Passar a Duck
não altera esse fato. Se o cliente tiver FlyingBirds bird
, mesmo que seja aprovado Duck
, sempre funcionará da mesma maneira.
O LSP diz respeito aos invariantes.
O exemplo clássico é dado pela seguinte declaração de pseudo-código (implementações omitidas):
class Rectangle {
int getHeight()
void setHeight(int value)
int getWidth()
void setWidth(int value)
}
class Square : Rectangle { }
Agora temos um problema, embora a interface corresponda. O motivo é que violamos os invariantes decorrentes da definição matemática de quadrados e retângulos. Da maneira que getters e setters funcionam, a Rectangle
deve satisfazer o seguinte invariante:
void invariant(Rectangle r) {
r.setHeight(200)
r.setWidth(100)
assert(r.getHeight() == 200 and r.getWidth() == 100)
}
No entanto, esse invariante deve ser violado por uma implementação correta de Square
, portanto, não é um substituto válido de Rectangle
.
Robert Martin tem um excelente artigo sobre o Princípio da Substituição de Liskov . Ele discute maneiras sutis e não tão sutis pelas quais o princípio pode ser violado.
Algumas partes relevantes do artigo (observe que o segundo exemplo está fortemente condensado):
Um exemplo simples de violação do LSP
Uma das violações mais flagrantes desse princípio é o uso de informações de tipo de tempo de execução do C ++ (RTTI) para selecionar uma função com base no tipo de um objeto. ou seja:
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, a
DrawShape
função está mal formada. Ele deve conhecer todas as derivadas possíveis daShape
classe e deve ser alterado sempre que novas derivadas deShape
são criadas. De fato, muitos vêem a estrutura dessa função como um anátema para o Design Orientado a Objetos.Quadrado e retângulo, uma violação mais sutil.
No entanto, existem outras maneiras, muito mais sutis, de violar o LSP. Considere um aplicativo que use a
Rectangle
classe conforme descrito abaixo: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 um dia os usuários exijam a capacidade de manipular quadrados além de retângulos. [...]
Claramente, um quadrado é um retângulo para todas as intenções e propósitos normais. Como o relacionamento ISA é válido, é lógico modelar a
Square
classe como sendo derivadaRectangle
. [...]
Square
herdará as funçõesSetWidth
eSetHeight
. Essas funções são totalmente inapropriadas para aSquare
, pois a largura e a altura de um quadrado são idênticas. Isso deve ser uma pista significativa de que há um problema com o design. No entanto, existe uma maneira de contornar o problema. Poderíamos substituirSetWidth
eSetHeight
[...]Mas considere a seguinte função:
void f(Rectangle& r) { r.SetWidth(32); // calls Rectangle::SetWidth }
Se passarmos uma referência a um
Square
objeto para essa função, oSquare
objeto será corrompido porque a altura não será alterada. Esta é uma clara violação do LSP. A função não funciona para derivadas de seus 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.
se uma pré-condição de classe filho for mais forte que uma pré-condição de classe pai, você não poderá substituir um filho por um pai sem violar a pré-condição. Daí o LSP.
O LSP é necessário quando algum código pensa que está chamando os métodos de um tipo T
e pode, sem saber, chamar os métodos de um tipo S
, onde S extends T
(ou seja S
, herda, deriva ou é um subtipo do supertipo T
).
Por exemplo, isso ocorre onde uma função com um parâmetro de entrada do tipo T
é chamada (ou seja, invocada) com um valor de argumento do tipo S
. Ou, onde um identificador de tipo T
, recebe um valor do tipo S
.
val id : T = new S() // id thinks it's a T, but is a S
O LSP exige que as expectativas (ou seja, invariantes) de métodos do tipo T
(por exemplo Rectangle
), não sejam violadas quando os métodos do tipo S
(por exemplo Square
) são chamados.
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
Mesmo um tipo com campos imutáveis ainda possui invariantes, por exemplo, os setters retangulares imutáveis esperam que as dimensões sejam modificadas independentemente, mas os setters quadrados imutáveis violam essa 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)
}
O LSP exige que cada método do subtipo S
tenha parâmetros de entrada contravariáveis e uma saída covariante.
Contravariante significa que a variação é contrária à direção da herança, ou seja, o tipo Si
, de cada parâmetro de entrada de cada método do subtipo S
, deve ser o mesmo ou um supertipo do tipo Ti
do parâmetro de entrada correspondente do método correspondente do supertipo T
.
Covariância significa que a variação está na mesma direção da herança, ou seja, o tipo So
da saída de cada método do subtipo S
, deve ser o mesmo ou um subtipo do tipo To
da saída correspondente do método correspondente do supertipo T
.
Isso ocorre porque se o chamador pensa que tem um tipo T
, pensa que está chamando um método de T
, ele fornece argumentos do tipo Ti
e atribui a saída ao tipo To
. Quando, na verdade, ele está chamando o método correspondente de S
, cada Ti
argumento de entrada é atribuído a um Si
parâmetro de entrada e a So
saída é atribuída ao tipo To
. Assim, se Si
não houvesse contravenção Ti
, então um subtipo Xi
- que não seria um subtipo de - Si
poderia ser atribuído Ti
.
Além disso, para idiomas (por exemplo, Scala ou Ceilão) que possuem anotações de variação do local da definição nos parâmetros do polimorfismo de tipo (ou seja, genéricos), a co-ou a contra-direção da anotação de variação para cada parâmetro do tipo T
deve ser oposta ou na mesma direção respectivamente para cada parâmetro de entrada ou saída (de todo método de T
) que tenha o tipo do parâmetro de tipo.
Além disso, para cada parâmetro de entrada ou saída que possui um tipo de função, a direção de variação necessária é invertida. Esta regra é aplicada recursivamente.
A subtipagem é apropriada onde os invariantes podem ser enumerados.
Há muita pesquisa em andamento sobre como modelar invariantes, para que eles sejam impostos pelo compilador.
Typestate (na página 3) declara e aplica os invariantes de estado ortogonais ao tipo. Como alternativa, os invariantes podem ser aplicados convertendo asserções em tipos . Por exemplo, para afirmar que um arquivo está aberto antes de fechá-lo, File.open () pode retornar um tipo OpenFile, que contém um método close () que não está disponível em File. Uma API do jogo da velha pode ser outro exemplo de emprego de digitação para impor invariantes em tempo de compilação. O sistema de tipos pode até ser completo para Turing, por exemplo, Scala . Provedores de linguagens e teoremas de tipo dependente formalizam os modelos de digitação de ordem superior.
Devido à necessidade de a semântica abstrair sobre a extensão , espero que o emprego da digitação para modelar invariantes, ou seja, semântica denotacional de ordem superior unificada, seja superior ao Typestate. «Extensão», a composição permutada sem limites do desenvolvimento modular não coordenado. Porque me parece ser a antítese da unificação e, portanto, os graus de liberdade, ter dois modelos mutuamente dependentes (por exemplo, tipos e Typestate) para expressar a semântica compartilhada, que não pode ser unificada entre si para composição extensível . Por exemplo, a extensão semelhante ao Problema de Expressão foi unificada nos domínios de subtipagem, sobrecarga de função e digitação paramétrica.
Minha posição teórica é que, para que o conhecimento exista (consulte a seção “A centralização é cega e imprópria”), nunca haverá um modelo geral que possa impor 100% de cobertura de todos os invariantes possíveis em uma linguagem de computador completa em Turing. Para que o conhecimento exista, existem muitas possibilidades inesperadas, ou seja, desordem e entropia devem sempre estar aumentando. Essa é a força entrópica. Para provar todos os cálculos possíveis de uma extensão em potencial, é calcular a priori toda extensão possível.
É por isso que o Teorema Halting existe, ou seja, é indecidível se todos os programas possíveis em uma linguagem de programação completa de Turing terminam. Pode-se provar que algum programa específico termina (um em que todas as possibilidades foram definidas e computadas). Mas é impossível provar que toda a extensão possível desse programa termina, a menos que as possibilidades de extensão desse programa não sejam completas de Turing (por exemplo, via digitação dependente). Como o requisito fundamental para a completitude de Turing é a recursão ilimitada , é intuitivo entender como os teoremas da incompletude de Gödel e o paradoxo de Russell se aplicam à extensão.
Uma interpretação desses teoremas os incorpora em um entendimento conceitual generalizado da força entrópica:
Vejo retângulos e quadrados em todas as respostas e como violar o LSP.
Gostaria de mostrar como o LSP pode ser conformado com um exemplo do 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;
}
}
Esse design está em conformidade com o LSP porque o comportamento permanece inalterado, independentemente da implementação que escolhemos usar.
E sim, você pode violar o LSP nesta configuração, fazendo uma alteração simples como esta:
<?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 !
}
}
Agora, os subtipos não podem ser usados da mesma maneira, pois não produzem mais o mesmo resultado.
Database::selectQuery
para suportar apenas o subconjunto de SQL suportado por todos os mecanismos de banco de dados. Isso não é prático ... Dito isso, o exemplo ainda é mais fácil de entender do que a maioria dos outros usados aqui.
Há uma lista de verificação para determinar se você está violando Liskov ou não.
Lista de controle:
Restrição do histórico : ao substituir um método, você não tem permissão para modificar uma propriedade não modificável na classe base. Dê uma olhada nesses códigos e você poderá ver que Name está definido como não modificável (conjunto privado), mas o SubType introduz um novo método que permite modificá-lo (através da reflexão):
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);
}
}
Existem outros 2 itens: Contravariância de argumentos de método e Covariância de tipos de retorno . Mas não é possível em C # (eu sou desenvolvedor de C #), então não me importo com eles.
Referência:
O LSP é uma regra sobre o contrato das classes: se uma classe base satisfaz um contrato, as classes derivadas do LSP também devem satisfazer esse contrato.
Em pseudo-python
class Base:
def Foo(self, arg):
# *... do stuff*
class Derived(Base):
def Foo(self, arg):
# *... do stuff*
satisfaz o LSP se toda vez que você chama Foo em um objeto Derivado, ele fornece exatamente os mesmos resultados que chamar Foo em um objeto Base, desde que arg seja o mesmo.
2 + "2"
). Talvez você confunda "fortemente tipado" com "estaticamente digitado"?
Para encurtar a história, vamos deixar retângulos retângulos e quadrados, exemplo prático ao estender uma classe pai, você precisa PRESERVAR a API pai exata ou EXTENDÊ-LO.
Digamos que você tenha um ItemsRepository básico .
class ItemsRepository
{
/**
* @return int Returns number of deleted rows
*/
public function delete()
{
// perform a delete query
$numberOfDeletedRows = 10;
return $numberOfDeletedRows;
}
}
E uma subclasse estendendo-a:
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;
}
}
Em seguida, você pode ter um cliente trabalhando com a API Base ItemsRepository e confiando nela.
/**
* 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();
}
}
O LSP é interrompido quando a substituição da classe pai por uma subclasse quebra o contrato da 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 :(
}
}
Você pode aprender mais sobre como escrever software sustentável em meu curso: https://www.udemy.com/enterprise-php/
Funções que usam ponteiros ou referências a classes base devem poder usar objetos de classes derivadas sem conhecê-lo.
Quando li pela primeira vez sobre o LSP, supus que isso fosse feito em um sentido muito estrito, essencialmente igualando-o à interface de implementação e à conversão de tipo seguro. O que significa que o LSP é garantido ou não pelo próprio idioma. Por exemplo, nesse sentido estrito, o ThreeDBoard certamente é substituível pelo Board, no que diz respeito ao compilador.
Depois de ler mais sobre o conceito, descobri que o LSP geralmente é interpretado de maneira mais ampla que isso.
Em resumo, o que significa para o código do cliente "saber" que o objeto atrás do ponteiro é de um tipo derivado, e não o tipo de ponteiro, não está restrito à segurança de tipo. A adesão ao LSP também pode ser testada através da investigação do comportamento real dos objetos. Ou seja, examinar o impacto dos argumentos de estado e método de um objeto nos resultados das chamadas de método ou os tipos de exceções geradas pelo objeto.
Voltando ao exemplo novamente, em teoria os métodos da placa podem funcionar perfeitamente no ThreeDBoard. Na prática, porém, será muito difícil evitar diferenças no comportamento que o cliente pode não lidar adequadamente, sem prejudicar a funcionalidade que o ThreeDBoard pretende adicionar.
Com esse conhecimento em mãos, avaliar a aderência ao LSP pode ser uma ótima ferramenta para determinar quando a composição é o mecanismo mais apropriado para estender a funcionalidade existente, em vez de herança.
Acho que todo mundo meio que abordou o que é LSP tecnicamente: você basicamente quer abstrair dos detalhes do subtipo e usar supertipos com segurança.
Então Liskov tem três regras subjacentes:
Regra de assinatura: deve haver uma implementação válida de todas as operações do supertipo no subtipo sintaticamente. Algo que um compilador poderá verificar por você. Há uma pequena regra sobre lançar menos exceções e ser pelo menos tão acessível quanto os métodos de supertipo.
Métodos Regra: A implementação dessas operações é semanticamente correta.
Regra de propriedades: Isso vai além das chamadas de função individuais.
Todas essas propriedades precisam ser preservadas e a funcionalidade extra de subtipo não deve violar as propriedades de supertipo.
Se essas três coisas foram resolvidas, você abstraiu o material subjacente e está escrevendo código fracamente acoplado.
Fonte: Desenvolvimento de Programa em Java - Barbara Liskov
Um exemplo importante do uso do LSP está nos testes de software .
Se eu tiver uma classe A que seja uma subclasse de B compatível com LSP, poderei reutilizar o conjunto de testes de B para testar A.
Para testar completamente a subclasse A, provavelmente preciso adicionar mais alguns casos de teste, mas no mínimo posso reutilizar todos os casos de teste da superclasse B.
Uma maneira de perceber isso é criando o que McGregor chama de "Hierarquia paralela para teste": Minha ATest
classe herdará BTest
. É necessária alguma forma de injeção para garantir que o caso de teste funcione com objetos do tipo A, e não do tipo B (um padrão simples de método de modelo funcionará).
Observe que a reutilização do conjunto de super testes para todas as implementações de subclasses é de fato uma maneira de testar se essas implementações de subclasses são compatíveis com LSP. Assim, também se pode argumentar que se deve executar o conjunto de testes da superclasse no contexto de qualquer subclasse.
Consulte também a resposta à pergunta Stackoverflow " Posso implementar uma série de testes reutilizáveis para testar a implementação de uma interface? "
Vamos ilustrar em 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() { ... }
}
Não há nenhum problema aqui, certo? Um carro é definitivamente um dispositivo de transporte, e aqui podemos ver que ele substitui o método startEngine () de sua superclasse.
Vamos adicionar outro dispositivo de transporte:
class Bicycle extends TransportationDevice
{
@Override
void startEngine() /*problem!*/
}
Tudo não está indo como planejado agora! Sim, uma bicicleta é um dispositivo de transporte, no entanto, não possui um mecanismo e, portanto, o método startEngine () não pode ser implementado.
Esses são os tipos de problemas aos quais a violação do Princípio de Substituição de Liskov leva, e geralmente podem ser reconhecidos por um método que não faz nada, ou mesmo não pode ser implementado.
A solução para esses problemas é uma hierarquia de herança correta e, no nosso caso, resolveríamos o problema diferenciando classes de dispositivos de transporte com e sem motores. Mesmo que uma bicicleta seja um dispositivo de transporte, ela não tem um motor. Neste exemplo, nossa definição de dispositivo de transporte está errada. Não deve ter um motor.
Podemos refatorar nossa classe TransportationDevice da seguinte maneira:
class TrasportationDevice
{
String name;
String getName() { ... }
void setName(String n) { ... }
double speed;
double getSpeed() { ... }
void setSpeed(double d) { ... }
}
Agora podemos estender o TransportationDevice para dispositivos não motorizados.
class DevicesWithoutEngines extends TransportationDevice
{
void startMoving() { ... }
}
E estenda o TransportationDevice para dispositivos motorizados. Aqui é mais apropriado adicionar o objeto Engine.
class DevicesWithEngines extends TransportationDevice
{
Engine engine;
Engine getEngine() { ... }
void setEngine(Engine e) { ... }
void startEngine() { ... }
}
Assim, nossa classe Car se torna mais especializada, ao mesmo tempo em que adere ao Princípio de Substituição de Liskov.
class Car extends DevicesWithEngines
{
@Override
void startEngine() { ... }
}
E nossa classe de bicicleta também está em conformidade com o Princípio de Substituição de Liskov.
class Bicycle extends DevicesWithoutEngines
{
@Override
void startMoving() { ... }
}
Essa formulação do LSP é muito forte:
Se para cada objeto o1 do tipo S existe um objeto o2 do tipo T, de modo que, para todos os programas P definidos em termos de T, o comportamento de P permanece inalterado quando o1 é substituído por o2, então S é um subtipo de T.
O que basicamente significa que S é outra implementação completamente encapsulada da mesma coisa que T. E eu poderia ser ousado e decidir que o desempenho faz parte do comportamento de P ...
Portanto, basicamente, qualquer uso de ligação tardia viola o LSP. O objetivo principal do OO é obter um comportamento diferente quando substituímos um objeto de um tipo por outro de outro!
A formulação citada pela wikipedia é melhor, pois a propriedade depende do contexto e não inclui necessariamente todo o comportamento do programa.
Em uma frase muito simples, podemos dizer:
A classe filho não deve violar suas características de classe base. Deve ser capaz com isso. Podemos dizer que é o mesmo que subtipagem.
Princípio da Substituição de Liskov (LSP)
O tempo todo projetamos um módulo de programa e criamos algumas hierarquias de classe. Em seguida, estendemos algumas classes, criando algumas classes derivadas.
Devemos garantir que as novas classes derivadas se estendam apenas sem substituir a funcionalidade das classes antigas. Caso contrário, as novas classes podem produzir efeitos indesejados quando usadas em módulos de programas existentes.
O Princípio de Substituição de Liskov afirma que, se um módulo de programa estiver usando uma classe Base, a referência à classe Base poderá ser substituída por uma classe Derived sem afetar a funcionalidade do módulo de programa.
Exemplo:
Abaixo está o exemplo clássico para o qual o Princípio de Substituição de Liskov é violado. No exemplo, 2 classes são usadas: Retângulo e Quadrado. Vamos supor que o objeto Rectangle seja usado em algum lugar do aplicativo. Estendemos o aplicativo e adicionamos a classe Square. A classe square é retornada por um padrão de fábrica, com base em algumas condições e não sabemos exatamente o tipo de objeto que será retornado. Mas sabemos que é um retângulo. Obtemos o objeto retângulo, definimos a largura para 5 e a altura para 10 e obtemos a área. Para um retângulo com largura 5 e altura 10, a área deve ser 50. Em vez disso, o 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.
}
}
Conclusão:
Esse princípio é apenas uma extensão do Princípio de Fechamento Aberto e significa que devemos garantir que novas classes derivadas estendam as classes base sem alterar seu comportamento.
Veja também: Abrir Fechar Princípio
Alguns conceitos semelhantes para melhor estrutura: Convenção sobre configuração
Algum adendo:
Gostaria de saber por que ninguém escreveu sobre o Invariant, pré-condições e pós-condições da classe base que devem ser obedecidas pelas classes derivadas. Para que uma classe D derivada seja completamente sustentável pela classe Base B, a classe D deve obedecer a certas condições:
Portanto, o derivado deve estar ciente das três condições acima impostas pela classe base. Portanto, as regras de subtipagem são pré-decididas. O que significa que o relacionamento 'IS A' deve ser obedecido somente quando certas regras forem obedecidas pelo subtipo. Essas regras, na forma de invariantes, pré-condições e pós-condição, devem ser decididas por um ' contrato de projeto ' formal .
Outras discussões sobre isso estão disponíveis no meu blog: Princípio de substituição de Liskov
O LSP, em termos simples, afirma que objetos da mesma superclasse devem poder ser trocados entre si sem quebrar nada.
Por exemplo, se tivermos Cat
uma Dog
classe e uma derivada de uma Animal
classe, qualquer função que utilize a classe Animal deverá poder usar Cat
ou Dog
comportar-se normalmente.
A implementação do ThreeDBoard em termos de um conjunto de placas seria tão útil?
Talvez você queira tratar fatias do ThreeDBoard em vários planos como uma placa. Nesse caso, convém abstrair uma interface (ou classe abstrata) para o Board para permitir várias implementações.
Em termos de interface externa, você pode considerar uma interface de placa para o TwoDBoard e o ThreeDBoard (embora nenhum dos métodos acima se encaixe).
Um quadrado é um retângulo em que a largura é igual à altura. Se o quadrado definir dois tamanhos diferentes para a largura e a altura, ele violará o invariante do quadrado. Isso é contornado com a introdução de efeitos colaterais. Mas se o retângulo tiver um setSize (altura, largura) com a pré-condição 0 <altura e 0 <largura. O método do subtipo derivado requer height == width; uma pré-condição mais forte (e que viola a lsp). Isso mostra que, embora quadrado seja um retângulo, ele não é um subtipo válido porque a pré-condição é reforçada. A solução alternativa (geralmente uma coisa ruim) causa um efeito colateral e isso enfraquece a condição pós (que viola lsp). setWidth na base tem a condição de postagem 0 <width. O derivado o enfraquece com a altura == largura.
Portanto, um quadrado redimensionável não é um retângulo redimensionável.
Este princípio foi introduzido por Barbara Liskov em 1987 e estende o Princípio Aberto-Fechado, concentrando-se no comportamento de uma superclasse e seus subtipos.
Sua importância se torna óbvia quando consideramos as consequências de violá-la. Considere um aplicativo que usa a seguinte classe.
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 um dia o cliente exija a capacidade de manipular quadrados além de retângulos. Como um quadrado é um retângulo, a classe quadrada deve ser derivada da classe Rectangle.
public class Square : Rectangle
{
}
No entanto, ao fazer isso, encontraremos dois problemas:
Um quadrado não precisa das variáveis de altura e largura herdadas do retângulo e isso pode gerar um desperdício significativo de memória se tivermos que criar centenas de milhares de objetos quadrados. As propriedades do setter de largura e altura herdadas do retângulo são inadequadas para um quadrado, pois a largura e a altura de um quadrado são idênticas. Para definir altura e largura para o mesmo valor, podemos criar duas novas propriedades da seguinte maneira:
public class Square : Rectangle
{
public double SetWidth
{
set
{
base.Width = value;
base.Height = value;
}
}
public double SetHeight
{
set
{
base.Height = value;
base.Width = value;
}
}
}
Agora, quando alguém definir a largura de um objeto quadrado, sua altura mudará de acordo e vice-versa.
Square s = new Square();
s.SetWidth(1); // Sets width and height to 1.
s.SetHeight(2); // sets width and height to 2.
Vamos seguir em frente e considerar esta outra função:
public void A(Rectangle r)
{
r.SetWidth(32); // calls Rectangle.SetWidth
}
Se passarmos uma referência a um objeto quadrado para essa função, violaremos o LSP porque a função não funciona para derivadas de seus argumentos. As propriedades width e height não são polimórficas porque não são declaradas virtuais em retângulo (o objeto quadrado será corrompido porque a altura não será alterada).
No entanto, ao declarar as propriedades do setter como virtuais, enfrentaremos outra violação, o OCP. De fato, a criação de um quadrado de classe derivado está causando alterações no retângulo da classe base.
A explicação mais clara para o LSP que encontrei até agora foi "O Princípio de Substituição de Liskov diz que o objeto de uma classe derivada deve ser capaz de substituir um objeto da classe base sem causar erros no sistema ou modificar o comportamento da classe base " daqui . O artigo fornece um exemplo de código para violar o LSP e corrigi-lo.
Digamos que usamos um retângulo em nosso código
r = new Rectangle();
// ...
r.setDimensions(1,2);
r.fill(colors.red());
canvas.draw(r);
Em nossa aula de geometria, aprendemos que um quadrado é um tipo especial de retângulo, porque sua largura tem o mesmo comprimento que sua altura. Vamos fazer uma Square
aula também com base nesta informação:
class Square extends Rectangle {
setDimensions(width, height){
assert(width == height);
super.setDimensions(width, height);
}
}
Se substituirmos Rectangle
por Square
no nosso primeiro código, ele será quebrado:
r = new Square();
// ...
r.setDimensions(1,2); // assertion width == height failed
r.fill(colors.red());
canvas.draw(r);
Isso ocorre porque o Square
tem um novo pré-requisito que não tínhamos na Rectangle
classe: width == height
. De acordo com o LSP, as Rectangle
instâncias devem ser substituíveis pelas Rectangle
instâncias da subclasse. Isso ocorre porque essas instâncias passam na verificação de tipo para Rectangle
instâncias e, portanto, causam erros inesperados no seu código.
Este foi um exemplo para a parte "pré-condições não podem ser reforçadas em um subtipo" no artigo da wiki . Então, para resumir, violar o LSP provavelmente causará erros no seu código em algum momento.
O LSP diz que "Os objetos devem ser substituíveis por seus subtipos". Por outro lado, esse princípio aponta para
As classes filho nunca devem quebrar as definições de tipo da classe pai.
e o exemplo a seguir ajuda a entender melhor o LSP.
Sem 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();
}
Fixação 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();
}
Convido você a ler o artigo: Violando o princípio de substituição de Liskov (LSP) .
Você pode encontrar uma explicação sobre o Princípio da Substituição de Liskov, dicas gerais para adivinhar se você já o violou e um exemplo de abordagem que o ajudará a tornar sua hierarquia de classes mais segura.
O PRINCÍPIO DE SUBSTITUIÇÃO DE LISKOV (do livro de Mark Seemann) afirma que deveríamos ser capazes de substituir uma implementação de uma interface por outra sem quebrar o cliente ou a implementação.É esse princípio que permite atender aos requisitos que ocorrerem no futuro, mesmo que possamos ' não os prevejo hoje.
Se desconectarmos o computador da parede (Implementação), nem a tomada (Interface) nem o computador (Cliente) quebram (na verdade, se for um laptop, ele pode funcionar com as baterias por um período de tempo) . Com o software, no entanto, um cliente geralmente espera que um serviço esteja disponível. Se o serviço foi removido, obtemos uma NullReferenceException. Para lidar com esse tipo de situação, podemos criar uma implementação de uma interface que não faz "nada". Este é um padrão de design conhecido como Objeto Nulo, [4] e corresponde aproximadamente a desconectar o computador da parede. Como estamos usando acoplamentos soltos, podemos substituir uma implementação real por algo que não faz nada sem causar problemas.
O Princípio de Substituição da Likov afirma que, se um módulo de programa estiver usando uma classe Base, a referência à classe Base poderá ser substituída por uma classe Derived sem afetar a funcionalidade do módulo de programa.
Intenção - Os tipos derivados devem ser completamente substitutos para seus tipos de base.
Exemplo - Tipos de retorno de co-variante em java.
Aqui está um trecho deste post que esclarece bem as coisas:
[..] para compreender alguns princípios, é importante perceber quando isso foi violado. É isso que vou fazer agora.
O que significa a violação deste princípio? Isso implica que um objeto não cumpre o contrato imposto por uma abstração expressa com uma interface. Em outras palavras, significa que você identificou suas abstrações incorretas.
Considere o seguinte exemplo:
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);
}
}
Isso é uma violação do LSP? Sim. Isso ocorre porque o contrato da conta nos diz que uma conta seria retirada, mas esse nem sempre é o caso. Então, o que devo fazer para corrigi-lo? Acabei de modificar o 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à, agora o contrato está satisfeito.
Essa violação sutil geralmente impõe ao cliente a capacidade de diferenciar objetos concretos empregados. Por exemplo, dado o primeiro contrato da conta, ele pode se parecer com o seguinte:
class Client
{
public function go(Account $account, Money $money)
{
if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
return;
}
$account->withdraw($money);
}
}
E isso viola automaticamente o princípio de aberto / fechado [isto é, para requisito de retirada de dinheiro. Porque você nunca sabe o que acontece se um objeto que viola o contrato não tiver dinheiro suficiente. Provavelmente não retorna nada, provavelmente uma exceção será lançada. Então você tem que verificar sehasEnoughMoney()
- o que não faz parte de uma interface. Portanto, essa verificação forçada dependente da classe de concreto é uma violação do OCP].
Este ponto também aborda um equívoco que encontro com frequência sobre a violação do LSP. Ele diz que "se o comportamento de um pai mudou em um filho, isso viola o LSP". No entanto, isso não acontece - desde que uma criança não viole o contrato de seus pais.