Desvendando o SOLID: Liskov Substitution Principle

Vitor Ferraz Varela
7 min readApr 20, 2021

--

Introdução

No artigo de hoje iremos dar continuação na nossa série sobre desvendando o SOLID, anteriormente falamos sobre o princípio do aberto e fechado e como podemos escrever classes que sejam abertas para extensão mas fechadas para mudanças, obtendo assim interfaces mais estáveis e um código com mais reuso e facilidade de dar manutenção.

Além disso também começamos a notar como alguns conceitos começam a se entrelaçar: acoplamento, abstrações, coesão e estabilidades das classes.

Hoje iremos abordar sobre o princípio de substituição de Liskov, que acaba se tornando um complemento dos princípios que falamos anteriormente, e de que forma ele se relaciona com conceitos de herança e composição que falamos muito em orientação a objetos (OOP).

O princípio da substituição de Liskov

O princípio foi cunhado pela programadora Barbara Liskov em 1987 numa conferência abordando temas sobre abstrações e hierarquia. Posteriormente o princípio foi introduzido de forma mais formal em um artigo feito em conjunto de Barbara Liskov e Jeannette Wing em 1994.

A definição de forma resumida no artigo diz:

Se q(x) é uma propriedade demonstrável dos objetos x de tipo T. Então q(y) deve ser verdadeiro para objetos y de tipo S onde S é um subtipo de T.

Bom, confesso que quando li isso pela primeira não ficou nem um pouco claro e acredito que você que esteja lendo também ficou na dúvida sobre o que significa essa definição na prática.

Então para aqueles que não são matemáticos, vamos ver uma definição mais simples:

O princípio diz que objetos da superclasse, ou seja classe mãe, podem ser substituídos por objetos de subclasses, classes filhas, sem quebrar a aplicação.

A partir desta definição, notamos que o princípio de Liskov é fortemente vinculado com o conceito de herança de classes.

Herança e Liskov

Quando utilizamos herança, devemos tomar cuidado com os contratos que estamos definindo, pois utilizar herança faz com que classes filhas implementem ou tenham sem necessidade métodos que estão fora do seu escopo. Quando isso acontece podemos ter diversos efeitos colaterais.

Existe duas coisas que são importante levar em consideração ao utilizar herança, que são pré e pós-condições que a super classe ou classe mãe definiu.

Em resumo precondições são os dados que chegam em um método, ou seja o input, e por pós-condições seria o retorno que métodos devolvem, ou seja output.

Acoplamento

Sempre que uma classe depende da outra para existir, é acoplamento. E, dependendo da forma com que esse acoplamento é feito, podemos ter problemas no futuro e isso fica fortemente ligado com as pré e pós-condições.

É fácil perceber que a subclasse, ou classe filha, é totalmente acoplada à super classe, ou classe mãe. Assim mudança super classe afetam diretamente as que herdam dela.

Desta forma alterações nas pré e pós-condições devem ser feitas com cautela.

A classe filha, em tese, só deveria poder afrouxar a precondição.

Pense no caso em que a classe mãe tem um método que recebe inteiros de 1 a 100. A classe filho pode sobrescrever esse método e permitir o método a receber inteiros de 1 a 200. Veja que, dessa forma, todo o código que já fazia uso da classe pai continua funcionando.

Por outro lado, a pós-condição só pode ser apertada; ela nunca pode afrouxar.

Pense em um método que devolve um inteiro, de 1 a 100. As classes que a usam entendem isso. A classe filha sobrescreve o método e devolve números só entre 1 a 50. Os clientes continuarão a funcionar, afinal eles já entendiam saídas entre 1 e 50.

Assim, entende-se que não podemos nunca apertar uma precondição, e nem afrouxar uma pós-condição. É um dos principais pontos que o Princípio de Substituição de Liskov aborda.

Ao herdar, você deve sempre lembrar do contrato estabelecido pela classe mãe.

Outro ponto importante que vale a pena salientar, é sobre o uso de exceções. Caso a super-classe não lance exceções então as sub-classes também não devem lançar, exceto quando estas exceções são subtipos das exceções lançadas pelos métodos da super-classe.

Apesar de parecer simples, na prática é complicado fazer esse tipo de análise em tempo de desenvolvimento. Em muitos casos acabamos vendo a herança sendo feita desnecessariamente, além do problema de acoplamento que vimos anteriormente, pode acontecer diversos de efeitos colaterais e problemas para dar manutenção e adicionar funcionalidades novas.

Caso realmente seja uma necessidade usar herança, tente ao máximo pensar em modelar hierarquias nas quais as classes filhas precisam conhecer pouco (ou não conhecer nada) dos detalhes da classe mãe, desta forma reduzimos o acoplamento e além construir classes que estão bem encapsuladas.

Exemplo na prática

Um dos exemplos mais comuns sobre o princípio de Liskov, é o exemplo do Quadrado e Retângulo. Afinal, eles são bem parecidos a única diferença é que o quadrado é um tipo especial de retângulo que possuí todos os lados iguais. Seria esse o cenário igual para usar herança e reaproveitar o código? Bom, vamos explorar um pouco mais esse exemplo.

Nossa classe de retângulo possuí altura (height), largura (width) e um método para calcular a área.

Agora vamos ver nossa classe quadrado:

Ótimo agora ambas as classes podem calcular a área, vamos fazer um teste usando elas:

Logo de cara, começamos a ver alguns problemas na nossa modelagem. Por que precisamos passar altura e largura para um quadrado sendo que ele tem os lados iguais? Sempre que fomos construir um quadrado devemos tomar cuidado para passar valores iguais e se por acaso alguém esquecer disso? Quais os impactos?

Esse é um exemplo bem didático, mas as perguntas são ótimas porque levantam vários questionamentos sobre o encapsulamento, efeitos colaterais do uso de herança.

Mas e se a gente alterasse o construtor do quadrado para receber somente um lado.

Perceba que agora estamos alterando a precondição da classe do quadrado e agora ela se torna mais forte que a da classe mãe. Em um quadrado, ambos os lados precisam ser iguais. Em um retângulo, não. De acordo com o princípio de Liskov, não poderíamos fazer essa herança.

Vamos ver um exemplo mais próximo da realidade, para ficar mais claro que tipos de problemas podemos encontrar.

Imagine que temos um classe que faz chamadas para um API para buscar uma lista de usuários:

Agora surgiu a necessidade de ter uma classe que cuide da persistência, desta forma poderíamos usar o mesmo método que recupera os usuários mas agora de forma local.

Vamos fazer uma breve análise sobre esse código. Primeiro podemos notar que a classe DatabaseUserService sobrescreve o método fetchUsers da classe mãe, mas agora usando o userLocalFetcher para buscar os dados de forma local. Outro ponto é que a classe, além do método da classe mãe, ela tem o saveUsers para salvar os dados localmente. Além disso temos um problema, essa classe não deveria poder enviar requests então lançamos uma exceção sobrescrevendo o método sendRequest. Por fim, para inicializar essa classe, por ser filha de ApiUserService precisamos passar o UserRemoteFetcher no construtor.

Esse é um exemplo de tudo que não deveria ser feito no uso de herança, temos quebra de encapsulamento onde a classe filha sabe sobe propriedades da classe mãe de forma desnecessária, problemas na modelagem onde passar no construtor de um classe que busca localmente um objeto relacionado a chamadas de api, também temos alterações na precondição da classe filha se tornando mais forte que a da classe mãe e por fim classe filhas lançando exceções que não são lançadas na classe mãe.

Esse exemplo apesar de ser exagerado sobre práticas ruins no uso de herança, acaba sendo um cenário bem próximo do que podemos encontrar no nosso dia a dia. Tenho certeza que pelo menos alguns deste problemas que foram elencados já encontramos em algum projeto que já demos manutenção ou desenvolvemos novas funcionalidades.

Usar herança é sim complicado, e o seu mau uso pode trazer problemas. Lembre-se de que você não usa herança apenas para “ganhar métodos na sua classe”.

Favoreça a composição

Tendo em vista os tipos de problemas que usar herança pode causar, muitos desenvolvedores sugerem o uso de composição em vez de herança.

Vamos entender um pouco sobre as vantagens de usar a composição no lugar da herança. A relação da classe principal com a classe dependida não é tão íntima quanto a relação existente entre classes mãe e filha, portanto, quebrar o encapsulamento se torna mais difícil.

Além disso ganhamos a flexibilidade em compor funcionalidade dentro do seu respectivos domínios e barreiras. Boa parte dos padrões de projetos utilizam a composição exatamente por causa dessa flexibilidade e boas práticas.

Vamos dar uma olhada nos exemplos anteriores agora usando a composição:

Agora tanto o quadrado quanto o retângulo sabe calcular a sua área de forma correta e além disso mantem o encapsulamento entre as classe. Outro ponto positivo caso a gente queria compor novos comportamentos, basta criar um protocolo para isso, onde cada classe irá implementar da forma que necessita.

Testabilidade

Outro ponto positivo sobre o uso de composição é na hora de fazer testes automatizados. Usar mocks de objetos se torna algo mais fácil de se fazer do que usando herança. Escrever código usando TDD é um exemplo de como começamos a pensar numa modelagem usando composição, pois escrevemos o teste primeiro e se o teste se torna algo complicado de se fazer, provavelmente estamos com problemas na nossa modelagem e usando herança esses tipos de problemas se tornam mais evidentes.

Conclusão

Para finalizar, gostaria de ressaltar que apesar dos problemas levantando ao longo deste artigo sobre o uso de herança, é um recurso que faz parte é excelente recurso de linguagens orientadas e faz parte do nosso dia lidar com frameworks que usam herança além de código legado.

Faz parte do nosso papel como pessoa desenvolvedora fazer o melhor uso dessa funcionalidade levando em conta os prós e contras, até mesmo para poder evoluir o código que estamos trabalhando.

Como vimos, existem outras alternativas sobre o uso de herança, como a composição que trás diversas vantagens na qualidade e modelagem do nosso código.

Muito obrigado por acompanhar até aqui e me siga no Medium para não perder os próximos artigos.

Vocês podem me encontrar nas redes sociais:

Linkedin | Instagram | Twitter

--

--