Ir para o conteúdo principal
Background Image

Princípio Liskov Substitution Principle do S.O.L.I.D. na prática

··7 minutos·
Rafael Issao
Autor
Rafael Issao
Apaixonado por tecnologia, programação e inovação. Adoro compartilhar conhecimento e aprender coisas novas todos os dias.
Tabela de conteúdos
SOLID na prática - Este artigo faz parte de uma série de artigos.
Parte 3: Esse Artigo

Herança é considerado um dos pilares da programação orientada à objetos, mas talvez seja o pilar com a maior falta de compreensão.

A falta de compreensão vem dos desenvolvedores que não sabem exatamente quando utilizar herança mas principalmente dos professores, cursos ou líderes técnicos que ensinam o conceito, propósito e o uso da herança de forma incompleta ou errada.

Esta falta de compreensão causa consequência devastadora no código dos projetos.

Neste artigo exploro o “LSP” que nos ajuda a lembrar exatamente o propósito de uma herança e quando utilizá-la.

Nunca use herança se…
#

Quando começamos a aprender POO, sempre ouvimos falar de herança.

Algumas das dicas que ouvimos sobre herança:

  • Use para reaproveitar código
  • Use a relação “is-a” ou “é-um” para descobrir se usa ou não a herança

Infelizmente estas dicas não são boas.

Podemos até reescrever as dicas acima.

Não use herança:

  • Para reaproveitar o código da classe pai
  • Mesmo que tenha relação “is-a” ou “é-um”

Vamos reaprender sobre herança do ponto de vista do “LSP”.

Consequências de uma herança mal feita
#

Imagine o seguinte código utilizando a classe Stack do Java:

        var pilha = new Stack<Integer>();

        pilha.push(1);
        pilha.push(2);
        pilha.push(3);

        int tamanhoPilha = pilha.size();
        for (int i = 0; i < tamanhoPilha; i++) {
            System.out.println(pilha.pop());
        }

Este código imprime a sequência 3, 2 e 1 no terminal.

A invariante de uma pilha é o famoso LIFO: O último elemento a entrar é o primeiro a sair.

Ou seja, não deveria ser possível tirar o elemento “2” antes do elemento “3”.

Mas o que acontece, por exemplo se:

        var pilha = new Stack<Integer>();

        pilha.push(1);
        pilha.push(2);
        pilha.push(3);

        // É possível remover o 2! Quebramos o invariante.
        pilha.remove(1);

        int tamanhoPilha = pilha.size();

        for (int i = 0; i < tamanhoPilha; i++) {
            System.out.println(pilha.pop());
        }

Conseguimos remover o “2” antes do “3”! E assim este código imprime “3” e “1”.

Por que uma pilha, que só deveria ser possível remover o último elemento que entrou, tem um método chamado “remove()” na API?

Se olharmos a implementação da Stack encontramos a herança mal feita:

public class Stack<E> extends Vector<E> {
    // ... implementation ...
}

Esta classe foi criada com a herança para reaproveitar o código que já existia na classe Vector.

E uma pilha pode ser considerada um vetor também, se olharmos somente para a estrutura de dados.

Mas a classe Stack, criada a partir da herança, possibilita ao usuário ignorar a invariante da pilha por completo.

A solução ideal nesta situação seria criar a Stack com composição:

class StackComComposicao<T> {
    private Vector<T> vetor = new Vector<>();

    void push(T elemento) {
        // Implementacao
    }

    T pop() {
        // Implementacao
        return null; // Placeholder for implementation
    }
}

Assim, limitamos a API da Stack com somente duas operações: pop e push.

LSP em ação
#

Para evitar estas heranças problemáticas, existe o “Liskov Substitution Principle” que diz exatamente quando devemos utilizar a herança.

“LSP” diz para usar herança quando existe a necessidade de substituir um objeto por um outro em uma função.

Escrevendo formalmente:

  • A função que usa uma classe base deve conseguir usar suas subclasses sem a necessidade de distinção
  • Os serviços da classe derivada não devem exigir mais e prometer nada menos do que os serviços correspondentes da classe base

Escrevendo tecnicamente:

  • Crie herança se existe um outro elemento (Objeto, método ou função) que utiliza qualquer objeto da herança sem precisar usar o “instance of”.
  • Quando criar a herança para este elemento, todas as classes da hierarquia devem ter os mesmos métodos públicos com a mesma assinatura.
  • Não pode ter métodos públicos especializados em nenhuma classe que faz parte da hierarquia

Então você pode criar uma herança se existe um elemento horizontal à ela que faz o uso dos objetos daquela hierarquia sem a necessidade da distinção por tipo, por exemplo uma lousa:

Por enquanto, o apagador que temos no sistema é suficiente. Mas os professores começaram a reclamar que o apagador que temos demora mais para apagar a lousa.

Em vez de alterar a classe Apagador, a equipe faz uma herança da classe Apagador para que a lousa consiga usar o Apagador e o Apagador Eficiente:

Para a lousa não importa o tipo do apagador. Mas qualquer que seja o apagador, ele deve apagar a lousa.

No desenho acima, se a lousa não existe, não existe também a necessidade de fazer a herança.

Resumindo, para usar herança seguindo o “LSP” devemos:

  • Sempre ter um elemento horizontal que pode utilizar qualquer tipo de objeto que existe na herança, sem a necessidade de distinção por tipo.
  • Quando criar a herança, todos os objetos devem ter o mesmo comportamento. Nem a mais e nem a menos.

LSP vs OCP + DRY
#

Mas quando escrevemos o código pensando em vários princípios, existe a necessidade do famoso “tradeoff”.

Por exemplo, imagine uma situação onde uma classe “B” sempre deve ter os mesmos métodos da classe “A”.

Se você adicionar um método na classe A, este método deve estar no B.

Se você alterar o nome de um método na classe A, a alteração também deve refletir na classe B.

Você faria herança e quebraria o “LSP”?

Ou implementaria a classe “B” com composição e delegação mas quebraria o “OCP” e o DRY?

class A2 {
    void reaproveita() {
        // ...
    }
}

class B2 extends A2 {
    // ...
}
class A1 {
    void reaproveita() {
        // ...
    }
}

class B1 {
    A1 a = new A1();

    void reaproveita() {
        a.reaproveita();
    }
}

A resposta é sempre quebrar o “OCP” + DRY.

Quando você quebra o LSP, o problema pode se estender para o código que utiliza a classe “A” e “B”.

Por exemplo, talvez tenha um método específico na classe “B” que você queira usar dentro de uma função que recebe “A”:

class A {
    // ...
}

class B extends A {
    void executaMetodoQueExisteSoNoB() {
        // ...
    }
}

public class InstanceOfExample {
    void recebeAMasQueroFazerCoisaDoB(A a) {
        if (a instanceof B) {
            B b = (B) a;
            b.executaMetodoQueExisteSoNoB();
        }
    }
}

E assim se espalha no seu projeto inteiro o comparador com “instanceof”. O seu projeto inteiro quebra o “OCP” e também o DRY.

Mas se você quebra o “OCP” + DRY, a consequência fica presa somente na classe “A” e “B”.

Não vão surgir funções ou métodos que recebem A e que tentem usar como B.

Então nunca quebre o LSP. As consequências são piores.

Se você tem dúvida se vale a pena ou não utilizar herança, não utilize!

Mas não tenha a mente limitada
#

Eu falei que tem “tradeoff”. Mas dependendo do contexto, talvez seja possível ter um design de código onde não quebramos o “LSP”, “OCP” e o DRY!

Por exemplo, com Groovy, poderíamos escrever o seguinte código:

class A {
    void reaproveita() {
        // ...
    }
}

class B {
    // Anotacao da Linguagem Groovy
    @Delegate
    A a = new A()
}

A anotação @Delegate faz com que o compilador crie todos os métodos públicos que existem na classe “A” e delegue para o objeto “a”.

Não quebramos o “LSP” pois não fizemos a herança.

Não quebramos o “OCP” pois qualquer alteração na classe “A”, não precisamos mexer na classe “B”.

Não quebramos o DRY pois o esforço de alteração fica somente na classe “A”.

Sempre tenha uma mente aberta para poder pensar em várias possibilidades no seu design de código!

Conclusão
#

Herança é uma funcionalidade que é difícil de utilizar corretamente. Mas o “LSP” ajuda-nos a pensar se vale a pena ou não utilizar herança.

Na minha experiência profissional, é raro surgir a necessidade de usar uma herança. Uma interface ou uma composição já resolve a maioria dos problemas.

Por isso:

Na dúvida, não use herança!

O que você achou do artigo?

Se tiver mais curiosidade sobre o assunto, venha falar comigo!

🥒🥒 Espero que tenham curtido e até o próximo artigo! 🥒🥒

SOLID na prática - Este artigo faz parte de uma série de artigos.
Parte 3: Esse Artigo