SOLID de verdade – Liskov Substitution Principle (LSP)

Liskov Substitution Principle

Na minha opinião, o Liskov Substitution Principle é o ponto de convergência de todos os princípios SOLID e outras melhores práticas de OOP. Para aplicá-lo bem, é preciso conhecer os demais. Por isso ele é o último dessa série.

O problema do Liskov Substitution Principle (LSP)

Embora qualquer pessoa desenvolvedora deva preferir composição ao invés de herança, hierarquias de classe sempre estarão presentes em qualquer sistema. Em geral, a pergunta que se faz para toda abstração, antes de acoplá-la a qualquer hierarquia é: você é “um tipo de”?

Um meio de transporte que deu errado

Por exemplo, quando quero construir uma hierarquia de meios de transporte, posso perguntar a um carro se ele é um tipo de Transporte. A resposta é óbvia. O que nos levaria a um código parecido com esse:

//LSP/Transporte.cs
namespace LSP
{
    public class Transporte
    {
        public string Nome { get; set; }
        public Motor Motor { get; set; }
        public int Velocidade { get; set; }
        public virtual bool LigarMotor()
        {
            Motor.Ligar();
        }
    }
}

//LSP/Carro.cs
namespace LSP
{
    public class Carro : Transporte
    {
        private Tanque _tanque;
        public Carro(Tanque tanque)
        {
            _tanque = tanque;
        }

        public override bool LigarMotor()
        {
            if (_tanque.EstaVazio)
                return false;
            return base.LigarMotor();
        }
    }
}

Há muitos meios de transporte que poderiam ser colocados nesta mesma hierarquia. Alguns tipos de carros (Lamborguini, Ferrari F50, Uninho da firma) e também outros tipos de transporte, como um ônibus por exemplo. Mas o que aconteceria se eu adicionasse, na minha hierarquia, uma bicicleta?

namespace LSP
{
    public class Bicicleta : Transporte
    {

    }
}

Por incrível que pareça, eu ainda conseguiria executar o seguinte código

namespace LSP
{
    class Program
    {
        static void Main(string[] args)
        {
            Transporte bicicleta = new Bicicleta();
            bicicleta.LigarMotor();
        }
    }
}

Me explique que motor está sendo ligado na bicicleta? Você pode até argumentar que “o motor da bicicleta é o ser-humano”. Seria um bom argumento na mesma medida em que dizer que uma pessoa é um tipo de motor também é uma boa gambiarra.

O container de bugs

Agora vamos propor outro exemplo. Suponhamos que você tem um container para adição de objetos. Uns você adiciona em memória e outros você adiciona em banco de dados. A interface desenhada para ele seria parecida com essa:

namespace LSP_Add
{
    public interface IContainer
    {
        Add(object o);
        object Remove(object o);
    }
}

No entanto, foi percebida a necessidade de persistir os objetos adicionados em disco. Para não escrever tudo outra vez, você utilizou uma biblioteca de terceiros: PersistenceObjects. Embora tenha economizado a complexidade de persistir em disco, você precisará escrever um wrapper para que fique transparente ao usuário onde os objetos estão sendo persistidos.

public class ContainerPersistenceWrapper : IContainer
    {
        (...)
        public void Add(object o)
        {
            var persistableObject = (PersistableObject)o; 
            _persistenceObjects.Add(persistableObject);
        }

        public object Remove(object o)
        {
            var persistableObject = (PersistableObject)o; 
            var removeu = _persistenceObjects.Remove(persistableObject);
            if (removeu)
                return o;
            return null;
        }
    }

E assim a sua primeira interface continuaria funcionando perfeitamente. Até que algo do tipo acontecesse:

            IContainer container = new ContainerPersistenceWrapper();
            UmObjeto umObjeto = new UmObjeto();
            container.Add(umObjeto);

Esse código é perfeitamente compilável. Acabei de compilar ele, inclusive. Mas quando rodei, obtive a seguinte mensagem:

dotnet run
Unhandled exception. System.InvalidCastException: Unable to cast object of type 'LSP_Add.UmObjeto' to type 'LSP_Add.PersistableObject'.
   at LSP_Add.ContainerPersistenceWrapper.Add(Object o) in D:\codigo\csharp\solid_ebook\LSP-Add\ContainerPersistenceWrapper.cs:line 14
   at LSP_Add.Program.Main(String[] args) in D:\codigo\csharp\solid_ebook\LSP-Add\Program.cs:line 11

O que houve de errado? PersistenceObjects é um tipo de container, sem dúvidas. A hierarquia de classes, em ambos os casos, parece correta. No entanto, fomos confrontados com situações onde o código parece não funcionar.

O caso do container ainda é mais complexo. Para entender o que está acontecendo, seria necessário descobrir por que o UmObjeto está sendo adicionado naquele ponto. E porque estamos utilizando aquele container e não outra estrutura. Qual deveria ser o comportamento correto do sistema?

O que diz o princípio?

A linguagem técnica pode assustar no começo, mas se ler com calma, vai entender direitinho

O que procuramos é algo como a seguinte propriedade da substituição: Se para cada objeto O1, do tipo S, há um objeto O2, do tipo T, de tal forma que um programa P definido em termos de T, o comportamento de P não é alterado quando O1 é substituído por O2. Então S é um subtipo de T.

Trocando em miúdos, se você tem duas classes (tanto faz se elas herdam da mesma classe ou implementam uma interface em comum) e ao passá-las para um código, nada precisa ser alterado nesse código, então elas são subtipos uma da outra. Ou seja, o programa que recebe esses objetos não pode precisar saber qual o tipo exato ele está recebendo e tão pouco precisar ser modificado por este motivo.

Qual foi o erro?

Como ficou claro, o Liskov Substitution Principle opera nas relações de herança e usabilidade das classes. E principalmente, essa troca não pode ser a causa de novas exceções ou erros no processamento. Quando o LSP é violado, não é seguro substituir as estratégias das classes.

Os dois exemplos anteriores demonstram apenas duas possíveis violações do LSP. Existem mais. E é sobre elas que vamos falar a seguir.

Invariantes

Começo pelo mais difícil e o mais sutil dos erros: Sim, bicicletas são meios de transporte, da mesma forma que um carro. Nossa metáfora não está errada. Porém, carro e bicicleta possuem características intrínsecas, sendo que algumas são excludentes. Para todos os efeitos, ao considerar que uma bicicleta tem um motor, estamos chegando bastante próximos de uma moto.

O erro reside na forma como obtivemos nossa resposta a pergunta “é um tipo de?”. Ainda que o mundo real dê boas dicas para a construção de hierarquias, ao construí-las temos de ter em mente que o que define o tipo é o comportamento. E do ponto de vista do sistema, uma bicicleta não é um meio de transporte. Pelo menos não motorizado, como a nossa classe inicial espera.

Não poderia deixar de usar o exemplo mais clássico. Ainda que a matemática diga o contrário, um quadrado não é um tipo de retângulo. Consegue imaginar o que aconteceria se fosse passado um quadrado como parâmetro neste teste?

    public TestarCalcularAreaRetangulo(Retangulo retangulo)
    {
        retangulo.Altura = 5;
        retangulo.Largura = 4;
        Assert.Equal(20, retangulo.Area());
    }

Certamente ele falharia, dado que a condição de existência de um quadrado é que ele tenha lados iguais. Ao alterar o valor da propriedade Altura, ou você o transformaria em um retângulo 5×4 ou alteraria todos os lados para 4. Incluindo a altura. A área então seria 16 e não 20.

Design by contract

As múltiplas formas de manter as invariantes também são consideradas design by contract. Contudo, eu gostaria de ressaltar aqui a forma mais óbvia, se observado o nome: Não alterar a interface. Isso é mais difícil de fazer quando a linguagem é estaticamente tipada ou passa por verificações em tempo de compilação. Já o mesmo não é verdade para linguagens de tipagem dinâmica.

Por isso, ao alterar os parâmetros de entrada ou retorno, não restrinja a hierarquia de classes. Se for o caso amplie. Parâmetros de entrada não podem ser de nós inferiores aos definidos na interface principal. De forma inversa, os parâmetros de saída não podem retornar nós superiores na hierarquia de classes.

Pré e pós condições

Outra situação que não é revelada durante a compilação é a validação de input de dados. Por exemplo: Se a classe pai aceita uma entrada numérica, com a faixa de 0 até 100, a classe que a herda não pode alterar essa mesma faixa para 0 até 99,99999999. Neste caso em específico, você está fortalecendo pré-condições para que o algoritmo funcione corretamente. Se a subclasse for utilizada por um algoritmo que espera uma faixa diferente, um erro será levantado.

Pós-condições estariam ligadas a execução de algoritmos após o cálculo da classe. Ou seja, todo o comportamento esperado da validação de dados ao retornar um método, assim como a destruição do objeto em si. Enfraquecer pós-condições seria, portanto, fazer menos que a classe pai faz ao concluir a sua tarefa. Deixar de salvar um dado, não fechar uma conexão e não enviar a notificação do evento seriam bons exemplos de enfraquecimento das pós-condições.

Como corrigir e evitar?

Na minha opinião este é um dos princípios mais complexos de evitar a sua violação. Isto porque, enquanto os demais possuem heurísticas bastante objetivas, o Liskov Substitution Principle não as tem. É necessário contar, principalmente, com a experiência do arquiteto da solução. Com as experiências vividas anteriormente, é esperado que ele saiba pra onde o sistema pode caminhar. Mas nem sempre é possível acertar.

Por isso, quanto mais cedo o erro for identificado e corrigido, melhor para todo mundo. Ter de reescrever todo um componente porque não esperava que uma bicicleta poderia ser considerado um meio de transporte é aceitável. Fazer uma pessoa implementar um motor que não é. É o tipo de programação que vai resultar em futuros bugs inesperados e que vão cobrar altos juros para pagar a dívida técnica.

Teste unitários garantem as invariantes

Só de olhar para a classe é quase impossível perceber as invariantes. Tomando o caso clássico do quadrado, você só perceberia que um quadrado “só tem um lado” quando precisasse alterar o outro. Apenas perceberia que ao mudar a altura, também deveria modificar a largura (um comportamento esquisito, diga-se) quando passasse um quadrado na função que espera um retângulo.

Você já sabe que testes unitários servem para, além de garantir que o código funciona, documentar o sistema. Por isso, documente também as invariantes da sua classe. Certifique-se de que ninguém está alterando comportamentos inadvertidamente. Demonstre nos testes quais são as faixas aceitáveis e esperadas pelo objeto. E garanta que, antes de estender uma classe, os desenvolvedores irão olhar para os testes da classe estendida. E mais: farão com que a classe filha passe pelos mesmos testes que a classe pai.

Siga os princípios SOLID e refine as suas metáforas

Não há erro na metáfora da bicicleta como meio de transporte. A diferença entre a bicicleta e o carro é que o carro é um meio de transporte motorizado. A bicicleta não é. Aqui podemos segregar as interfaces, refinando a metáfora.

public class Transporte
    {
        public string Nome { get; set; }
        public int Velocidade { get; set; }
    }

    public class TransporteMotorizado : Transporte
    {
        public Motor Motor { get; set; }
        public virtual bool LigarMotor()
        {
            return Motor.Ligar();
        }
    }

    public class Bicicleta : Transporte
    {

    }

E assim não é mais possível ligar o motor da bicicleta.

Você poderia criar uma interface TransporteTracionado, com um método “IniciarTracao”. O que talvez seja mais adequado para uma bicicleta.

Outra forma de melhorar as suas metáforas é perguntar “é um tipo de”, olhando para o comportamento esperado pelo sistema também. Férias e afastamento devem pertencer ao mesmo ramo na hierarquia de classes de eventos da folha de pagamento? Quando estou vendendo um medicamento, estou vendendo um item do estoque ou o lote? São exemplos de estruturas de dados distintas, mas que no sistema podem ter implementações comuns.

Cuidado ao degenerar métodos

Ao herdar uma classe, você pode utilizar a palavra-chave override (em algumas linguagens, nem disso você precisa) para sobrescrever o código de um método, ignorando todo o código da classe herdada. Quando você faz isso, está degenerando um método. Degenerar é tomar uma forma mais simples. Como as expressões lambda, por exemplo, são formas degeneradas de métodos anônimos, que por sua vez são versões degeneradas de métodos passados como ponteiros e assim por diante.

Se você precisa ignorar tudo o que é feito na classe superior, talvez não devesse herdar daquela classe. Possivelmente há um problema na sua hierarquia de herança. O comportamento, por assim dizer, correto é que você adicione funcionalidade e não que as tire. Ao degenerar métodos, você está abrindo portas para ferir as invariantes da classe, abrindo portas para novos bugs. E não queremos isso.

Palavras finais

Assim chegamos ao final desta série de artigos sobre SOLID. Não vimos na ordem proposta pelo artigo, pois achei que faria mais sentido se os princípios fossem vistos na ordem em que estão apresentados. Apesar do meu esforço, o assunto não foi esgotado. Minha esperança é que você amplie ainda mais esta discussão. E eu vou ficar ainda mais feliz se você questionar todos os aforismos do SOLID.

Isso mesmo: Questione-os. Essa série nasceu de um questionamento sincero diante do que estava posto sobre SOLID. Pesquisando no google, percebi que não era apenas eu a questionar e encontrei artigos onde programadores questionam até mesmo a utilidade de SOLID no design da aplicação. Isso é muito enriquecedor!

Por isso quero encerrar essa série dizendo que SOLID precisa fazer sentido pra você e para sua equipe. E também para o seu software. A equipe é quem precisa saber qual o nível de complexidade que ela está disposta a aceitar no produto. A equipe é quem deve assumir o custo do débito técnico criado durante o projeto e desenvolvimento. É um compromisso de todos.

O grande trabalho de um arquiteto de software é buscar o equilíbrio entre complexidade, acoplamento e coesão. Quem se propõe a seguir listas de boas práticas sem questioná-las jamais será um bom artesão de software, em consequência, um péssimo arquiteto de soluções.

Assim, espero de coração, que essas dicas te ajudem a complementar aquilo que você já sabia sobre SOLID e principalmente: a dar um passo a mais rumo a uma base de código amada (ou menos odiada) por todos.

Um forte abraço e até a próxima.

Lista de artigos da série:

Bibliografia

COPELAND, David Bryant: SOLID is not solid: five object-oriented princples to create a codebase everyon will hate. Disponível em: <<https://naildrivin5.com/blog/2019/11/11/solid-is-not-solid-rexamining-the-single-responsibility-principle.html>> e outros links sobre SOLID no mesmo site. Acesso em 31 jan 2020

ENZLER, Urs. Clean code cheat sheet. Disponível em:  <<https://www.planetgeek.ch/2014/11/18/clean-code-cheat-sheet-v-2-4/>> Acesso em 19 fev de 2020;

INTERFACE SEGREGATION PRINCIPLE: Disponível em: <<https://refactoring.guru/pt-br/didp/principles/solid-principles/lsp>> Acesso em 20 fev 2020

MARTIN, Robert C. Arquitetura Limpa: o guia do artesão para estrutura e design de software. Rio de Janeiro: Alta Books, 2018

MARTIN, Robert C. The Liskov substitution principle: Disponível em: <<http://web.archive.org/web/20151128004108/http://www.objectmentor.com/resources/articles/lsp.pdf>> Acesso em 30 jan 2020

MARTIN, Robert C.; MARTIN, Micah: Agile Principles, Patterns and Practices in C#. Prentice Hall, 2006

MARTINS, Celso. Princípios de projeto III: design por contrato. Disponível em: <<https://thebestpractices.wordpress.com/category/invariantes/>> Acesso em 10 mar 2020

THOMPSON, John. Liskov Substitution Principle. Disponível em: <<https://springframework.guru/principles-of-object-oriented-design/liskov-substitution-principle/>> Acesso em 02 mar. 2020

5 thoughts on “SOLID de verdade – Liskov Substitution Principle (LSP)

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Esse site utiliza o Akismet para reduzir spam. Aprenda como seus dados de comentários são processados.