O que pode dar errado se o princípio da substituição de Liskov for violado?

27

Eu estava seguindo esta questão altamente votada sobre possível violação do princípio da substituição de Liskov. Sei qual é o princípio da Substituição de Liskov, mas o que ainda não está claro em minha mente é o que pode dar errado se eu, como desenvolvedor, não pensar sobre o princípio ao escrever código orientado a objetos.

Nerd
fonte
6
O que pode dar errado se você não seguir o LSP? Na pior das hipóteses: você acaba convocando Code-thulhu! ;)
FrustratedWithFormsDesigner
1
Como autor dessa pergunta original, devo acrescentar que era uma questão bastante acadêmica. Embora violações possam causar erros no código, nunca tive um problema sério de manutenção ou bug que possa ser causado por uma violação do LSP.
Paul T Davies
2
@Paul Então, você nunca teve problemas com seus programas devido a hierarquias complicadas de OO (que você não criou, mas talvez tenha que estender), onde os contratos eram quebrados à esquerda e à direita por pessoas que não tinham certeza sobre o objetivo da classe base começar com? Eu te invejo! :)
Andres F.
@PaulTDavies, a gravidade da consequência depende se os usuários (programadores que usam a biblioteca) têm conhecimento detalhado da implementação da biblioteca (ou seja, têm acesso e estão familiarizados com o código da biblioteca.) Eventualmente, os usuários farão dezenas de verificações condicionais ou criarão wrappers ao redor da biblioteca para considerar não-LSP (comportamento específico da classe). O pior cenário seria se a biblioteca fosse um produto comercial de código fechado.
amigos estão dizendo sobre rwong
@ Andres e rwong, ilustrem esses problemas com uma resposta. A resposta aceita apoia Paul Davies, pois as consequências parecem pequenas (uma exceção) que serão rapidamente percebidas e corrigidas se você tiver um bom compilador, analisador estático ou um teste de unidade mínimo.
precisa saber é o seguinte

Respostas:

31

Eu acho que está muito bem afirmado nessa questão, que é uma das razões pelas quais foi votado com tanta ênfase.

Agora, ao chamar Close () em uma tarefa, há uma chance de a chamada falhar se for uma ProjectTask com o status iniciado, quando não ocorreria se fosse uma tarefa base.

Imagine se você quiser:

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

Nesse método, ocasionalmente a chamada .Close () é acionada. Portanto, agora, com base na implementação concreta de um tipo derivado, é necessário alterar a maneira como esse método se comporta da maneira como esse método seria escrito se a Tarefa não tivesse subtipos que pudessem ser entregue a esse método.

Devido a violações de substituição liskov, o código que usa seu tipo precisará ter conhecimento explícito do funcionamento interno dos tipos derivados para tratá-los de maneira diferente. Isso une fortemente o código e geralmente dificulta o uso consistente da implementação.

Jimmy Hoffa
fonte
Isso significa que uma classe filho não pode ter seus próprios métodos públicos que não são declarados na classe pai?
Songo
@Songo: Não necessariamente: pode, mas esses métodos são "inacessíveis" a partir de um ponteiro base (ou referência ou variável ou o idioma que você usa) e você precisa de algumas informações sobre o tempo de execução para consultar qual o tipo de objeto antes que você possa chamar essas funções. Mas esse é um assunto fortemente relacionado à sintaxe e semântica de idiomas.
Emilio Garavaglia
2
Não. Isso ocorre quando uma classe filho é referenciada como se fosse um tipo da classe pai; nesse caso, os membros que não são declarados na classe pai ficam inacessíveis.
Chewy Gumball
1
@ Phil Yep; esta é a definição de acoplamento rígido: alterar uma coisa causa mudanças em outras. Uma classe fracamente acoplada pode ter sua implementação alterada sem exigir que você altere o código fora dela. É por isso que os contratos são bons; eles orientam você a não exigir mudanças nos consumidores do seu objeto: cumpra o contrato e os consumidores não precisarão de modificações; portanto, é conseguido um acoplamento flexível. Quando seus consumidores precisam codificar para sua implementação, em vez de seu contrato, isso é um acoplamento rígido e necessário ao violar o LSP.
Jimmy Hoffa
1
@ user949300 O sucesso de qualquer software para realizar seu trabalho não é uma medida da qualidade, dos custos a longo prazo ou a curto prazo. Os princípios de design são tentativas de oferecer diretrizes para reduzir os custos de longo prazo do software, não para fazer o software "funcionar". As pessoas podem seguir todos os princípios que desejam enquanto ainda não implementam uma solução funcional, ou seguir nenhum e implementar uma solução funcional. Embora as coleções de java possam funcionar para muitas pessoas, isso não significa que o custo para trabalhar com elas a longo prazo seja o mais barato possível.
Jimmy Hoffa
13

Se você não cumprir o contrato que foi definido na classe base, as coisas podem falhar silenciosamente quando você obtém resultados que estão fora.

LSP nos estados da wikipedia

  • As pré-condições não podem ser reforçadas em um subtipo.
  • As pós-condições não podem ser enfraquecidas em um subtipo.
  • Invariantes do supertipo devem ser preservados em um subtipo.

Se alguma dessas opções não for válida, o chamador poderá obter um resultado que não espera.

Zavior
fonte
1
Você pode pensar em exemplos concretos para demonstrar isso?
Mark Booth
1
@MarkBooth O problema da elipse do círculo / retângulo quadrado pode ser útil para demonstrá-lo; o artigo da wikipedia é um bom ponto de partida: en.wikipedia.org/wiki/Circle-ellipse_problem
Ed Hastings
7

Considere um caso clássico dos anais das perguntas da entrevista: você derivou o Circle da Ellipse. Por quê? Porque um círculo é uma elipse, é claro!

Exceto ... ellipse tem duas funções:

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

Claramente, eles devem ser redefinidos para o Círculo, porque um Círculo tem um raio uniforme. Você tem duas possibilidades:

  1. Depois de chamar set_alpha_radius ou set_beta_radius, ambos são configurados para a mesma quantidade.
  2. Depois de chamar set_alpha_radius ou set_beta_radius, o objeto não é mais um círculo.

A maioria dos idiomas OO não suporta o segundo, e por uma boa razão: seria surpreendente descobrir que seu círculo não é mais um círculo. Portanto, a primeira opção é a melhor. Mas considere a seguinte função:

some_function(Ellipse byref e)

Imagine que alguma função_ chame e.set_alpha_radius. Mas como e era realmente um círculo, surpreendentemente também possui seu raio beta.

E aqui reside o princípio da substituição: uma subclasse deve ser substituída por uma superclasse. Caso contrário, coisas surpreendentes acontecem.

Kaz Dragon
fonte
1
Eu acho que você pode ter problemas se usar objetos mutáveis. Um círculo também é uma elipse. Mas se você substituir uma elipse que também é um círculo por outra elipse (que é o que você está fazendo usando um método setter), não há garantia de que a nova elipse também será um círculo (os círculos são um subconjunto adequado de elipses).
Giorgio
2
Em um mundo puramente funcional (com objetos imutáveis), o método set_alpha_radius (d) teria o tipo de retorno elipse (na classe elipse e na circunferência).
Giorgio
@ Giorgio Sim, eu deveria ter mencionado que esse problema ocorre apenas com objetos mutáveis.
quer
@KazDragon: Por que alguém substituiria uma elipse por um objeto de círculo quando sabemos que uma elipse NÃO É um círculo? Se alguém fizer isso, não entenderá corretamente as entidades que está tentando modelar. Mas, ao permitir essa substituição, não estamos incentivando o entendimento frouxo do sistema subjacente que estamos tentando modelar em nosso software e, assim, criando software ruim com efeito?
maverick
@ Maverick Eu acredito que você leu o relacionamento que descrevi para trás. A relação proposta é-um é o contrário: um círculo é uma elipse. Especificamente, um círculo é uma elipse onde os raios alfa e beta são idênticos. E assim, a expectativa pode ser que qualquer função que espere uma elipse como parâmetro possa igualmente fazer um círculo. Considere o cálculo da área (Ellipse). Passar um círculo para isso renderia o mesmo resultado. Mas o problema é que o comportamento das funções de mutação do Ellipse não é substituível pelos do Circle.
Kaz Dragon
6

Nas palavras dos leigos:

Seu código terá muitas cláusulas CASE / switch por todo o lado.

Cada uma dessas cláusulas CASE / switch precisará de novos casos adicionados de tempos em tempos, o que significa que a base de código não é tão escalável e sustentável quanto deveria.

O LSP permite que o código funcione mais como hardware:

Você não precisa modificar o seu iPod porque comprou um novo par de alto-falantes externos, já que os antigos e os novos externos respeitam a mesma interface, eles são intercambiáveis ​​sem que o iPod perca a funcionalidade desejada.

Tulains Córdova
fonte
2
-1: em torno de uma resposta ruim
Thomas Eding
3
@ Thomas Discordo. É uma boa analogia. Ele fala sobre não quebrar as expectativas, e é disso que trata o LSP. (embora a parte sobre o caso / switch é um pouco fraco, eu concordo)
Andres F.
2
E então a Apple quebrou o LSP alterando os conectores. Essa resposta continua viva.
Magus
Eu não entendo o que as instruções do switch têm a ver com o LSP. se você está se referindo à mudança typeof(someObject)para decidir o que "está autorizado a fazer", com certeza, mas isso é outro antipadrão.
Sara #
Uma redução drástica na quantidade de instruções de troca é um efeito colateral desejável do LSP. Como os objetos podem representar qualquer outro objeto que estenda a mesma interface, não é necessário cuidar de casos especiais.
Tulains Córdova
1

para dar um exemplo da vida real com o UndoManager do java

herda de AbstractUndoableEditcujo contrato especifica que possui 2 estados (desfeitos e refeitos) e pode alternar entre eles com chamadas únicas para undo()eredo()

no entanto, o UndoManager possui mais estados e atua como um buffer de desfazer (cada chamada undodesfaz algumas, mas não todas as edições, enfraquecendo a pós-condição)

isso leva à situação hipotética em que você adiciona um UndoManager a um CompoundEdit antes de chamar e end()depois desfazer o CompoundEdit fará com que ele chame undo()cada edição depois de deixar suas edições parcialmente desfeitas

Eu rolei o meu próprio UndoManagerpara evitar isso (eu provavelmente deveria renomeá-lo para UndoBufferembora)

catraca arrepiante
fonte
1

Exemplo: você está trabalhando com uma estrutura de interface do usuário e cria seu próprio controle de interface do usuário personalizado subclassificando a Controlclasse base. A Controlclasse base define um método getSubControls()que deve retornar uma coleção de controles aninhados (se houver). Mas você substitui o método para realmente retornar uma lista de datas de nascimento dos presidentes dos Estados Unidos.

Então, o que pode dar errado com isso? É óbvio que a renderização do controle falhará, pois você não retorna uma lista de controles conforme o esperado. Provavelmente a interface do usuário falhará. Você está violando o contrato ao qual as subclasses de Controle devem aderir.

JacquesB
fonte
0

Você também pode vê-lo do ponto de vista da modelagem. Quando você diz que uma instância da classe Atambém é uma instância da classe, Bimplica que "o comportamento observável de uma instância da classe Atambém pode ser classificado como comportamento observável de uma instância da classe B" (Isso só é possível se a classe Bfor menos específica do que classe A.)

Portanto, violar o LSP significa que há alguma contradição no seu design: você está definindo algumas categorias para seus objetos e depois não as respeita em sua implementação, algo deve estar errado.

Como fazer uma caixa com uma etiqueta: "Esta caixa contém apenas bolas azuis" e depois jogar uma bola vermelha nela. Qual é o uso de uma etiqueta desse tipo se ela mostra informações incorretas?

Giorgio
fonte
0

Herdei recentemente uma base de código que contém alguns dos principais violadores de Liskov. Em aulas importantes. Isso me causou enormes quantidades de dor. Deixe-me explicar o porquê.

Eu tenho Class A, que deriva Class B. Class Ae Class Bcompartilhe várias propriedades que Class Asubstituem sua própria implementação. Definir ou obter uma Class Apropriedade tem um efeito diferente de definir ou obter exatamente a mesma propriedade Class B.

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

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

Deixando de lado o fato de que essa é uma maneira absolutamente terrível de fazer a tradução no .NET, existem vários outros problemas com esse código.

Nesse caso, Nameé usado como um índice e uma variável de controle de fluxo em vários locais. As classes acima estão espalhadas por toda a base de código, tanto na forma bruta quanto na derivada. Violar o princípio de substituição de Liskov neste caso significa que eu preciso conhecer o contexto de cada chamada para cada uma das funções que levam a classe base.

O código usa objetos de ambos Class Ae Class B, portanto, não posso simplesmente tornar Class Aabstrato para forçar as pessoas a usar Class B.

Existem algumas funções utilitárias muito úteis que operam Class Ae outras funções utilitárias muito úteis que operam Class B. Idealmente, eu gostaria de ser capaz de usar qualquer função de utilitário que pode operar em Class Aon Class B. Muitas das funções que aceitam um Class Bpoderiam facilmente aceitar um, Class Ase não fosse pela violação do LSP.

O pior de tudo é que esse caso em particular é realmente difícil de refatorar, pois todo o aplicativo depende dessas duas classes, opera nas duas classes o tempo todo e quebraria de centenas de maneiras se eu mudar isso (o que vou fazer) de qualquer forma).

O que terei que fazer para corrigir isso é criar uma NameTranslatedpropriedade, que será a Class Bversão da Namepropriedade e muito, muito cuidadosamente, alterar todas as referências à Namepropriedade derivada para usar minha nova NameTranslatedpropriedade. No entanto, ao errar uma dessas referências, o aplicativo inteiro pode explodir.

Dado que a base de código não possui testes de unidade, é quase o cenário mais perigoso que um desenvolvedor pode enfrentar. Se eu não alterar a violação, tenho que gastar uma quantidade enorme de energia mental acompanhando que tipo de objeto está sendo operado em cada método e, se eu corrigir a violação, poderia fazer o produto inteiro explodir em um momento inoportuno.

Stephen
fonte
O que aconteceria se dentro da classe derivada você sombreada a propriedade herdada com um tipo diferente de coisa que tinha o mesmo nome [por exemplo, uma classe aninhada] e criou novos identificadores BaseNamee TranslatedNamepara o acesso tanto o estilo de classe-A Namee o significado de classe B? Qualquer tentativa de acessar Nameuma variável do tipo Bseria rejeitada com um erro do compilador, para garantir que todas as referências fossem convertidas em um dos outros formulários.
Supercat 23/12
Eu não trabalho mais naquele lugar. Teria sido muito difícil de resolver. :-)
Stephen
-4

Se você quiser sentir o problema de violar o LSP, pense no que acontece se você tiver apenas .dll / .jar da classe base (sem código fonte) e precisar criar uma nova classe derivada. Você nunca pode concluir esta tarefa.

sgud
fonte
1
Isso apenas abre mais perguntas, em vez de ser uma resposta.
19412 Frank