Princípio de substituição de Liskov - Liskov Substitution Principle

Esse princípio diz que uma classe derivada deve ser substituível pela sua classe base sem apresentar comportamentos inesperados.

Categoria de Programação

Postado em 21 abril 2022

Atualizado em 05 junho 2023

Palavras-chave: arquitetura,liskov,substitution,principle,principio,substituição,programação,programming

Visualizações: 1681

A programação orientada a objetos trouxe uma nova forma de escrever código. Uma das principais vantagens foi a abstração. Com a abstração podemos definir a estrutura de uma classe antecipadamente. A classe que implementa essa abstração é obrigada a obedecer essa estrutura pré-definida.

Além disso, existe a herança. Classes podem estender outras classes já implementadas e realizar o polimorfismo (subtipagem). O polimorfismo permite a alteração de variáveis e métodos da classe parente pela classe derivada. Quando alteramos um método da classe parente, estamos mudando o seu comportamento. Essa alteração de comportamento deve honrar a classe parente. É isso que diz Bertrand Meyer em seu artigo “Design por contrato”. O contrato da classe base deve ser honrada pela classe derivada. O princípio de substituição de Liskov (Liskov Substitution Principle) reafirma esse princípio.

O que é o princípio de substituição de Liskov (LSP)?

O LSP é o terceiro princípio SOLID abordado na arquitetura limpa. Esse princípio diz que uma classe derivada deve ser substituível pela sua classe base. Isso significa que uma classe que estende outra classe, não deve apresentar comportamentos inesperados.

Através do polimorfismo, é possível alterar o funcionamento dos métodos da classe parente. Alterar os métodos em si, não se torna um problema entre a classe parente e a classe derivada. O problema acontece com a classe que depende desse funcionamento.

exemplo de extensão princípio de substituição de liskov

O usuário depende do “Cadastro manual”. A classe “Cadastro online” estende o “Cadastro manual” para herdar suas funcionalidades e aplica o polimorfismo em alguns métodos, alterando um pouco o comportamento da classe derivada em relação a classe parente.

Quando o usuário usa a classe derivada, ele espera no mínimo conseguir fazer o cadastro. Além disso, o usuário também espera conseguir fazer o contrato online com menos dificuldade e obter resultados com mais eficiência. “Menos dificuldade” representa uma pré-condição e “obter resultados com mais eficiência” representa uma pós-condição. Uma classe derivada deve honrar a sua classe parente. Quando dizemos que devemos honrar a classe base, nos referimos ao contrato.

Contrato entre classes

O contrato entre a classe base e a classe derivada se chama Design por contrato. Esse conceito foi criado por Bertrand Meyer e diz o seguinte:

Ao redefinir uma rotina em uma classe derivada, você deve apenas repor a sua pré-condição por uma mais fraca e repor a sua pós-condição por uma mais forte

“Redefinir uma rotina” indica o polimorfismo. Ao sobrepor um método de uma classe parente, podemos apenas repor a pré-condição com uma mais fraca e a pós-condição com uma mais forte. As palavras “fraco” e “forte” se referem a restrição dessas condições. A classe que depende da classe parente, não poderá usar a classe derivada com os mesmos argumentos se a classe derivada tiver pré-condições mais fortes que a classe parente. O mesmo acontece com a pós-condição, pois a classe dependente espera um resultado mínimo para operar corretamente.

Consequências da violação do princípio de substituição de Liskov

Quando uma classe derivada não honra o contrato com a classe parente, mecanismos extras devem ser adicionados ao sistema. Adicionar mecanismos extras por violações de princípios só polui o código com condições que além de serem difíceis de localizar, poderiam ter sido evitados.

if (instance instanceof SuperClass) {
    instance.setSomeVar(10);
} else {
    instance.setSomeAnotherVar(0);
}

O exemplo acima é um mecanismo extra. Isso é um resultado de uma classe derivada que não honrou o contrato com a classe base e consequentemente violou o princípio de substituição de Liskov, pois a classe derivada não pode ser substituída pela sua classe parente.

Exemplo típico de violação de LSP

Geralmente, a violação do princípio de substituição de Liskov acontece devido a intuição do ser humano. Invés de fazer a herança de classes por comportamento, fazemos por semântica. Imagine o seguinte cenário:

exemplo de herança entre classes princípio de substituição de liskov

No exemplo acima, o gato e o cachorro estendem a classe “Mamífero”. É certo afirmar que os dois são mamíferos, pois é assim na vida real. Intuitivamente, compartilhamos a mesma classe “Mamífero” entre os dois animais. Se esse tipo de extensão é certo ou errado, isso vai depender do contexto desse sistema.

Se a classe “Mamífero” tivesse apenas os métodos Comer() e Dormir(), não teríamos problemas em estende-lo com as classes cachorro e gato. Mas o que aconteceria se a classe “Mamífero” tivesse um método chamado LevarParaPassear()? Nesse caso, o cachorro iria atender as expectativas da sua classe parente honrando o contrato. Porém, o gato provavelmente não honraria o contrato, pois normalmente os gatos não são levados para passear. O gato provavelmente retornaria uma excessão ou um resultado fraco. A classe “Dono” que depende da classe “Mamífero” espera no mínimo que ele possa levar o mamífero para passear. Porém, o gato não atende ao requerimento mínimo do dono. Nessa ocasião, adiciona-se mecanismos extras para evitar comportamentos estranhos.

Dono dono = new Dono();
Mamifero[] mamiferos = new Mamifero[2];
mamiferos[0] = new Cachorro();
mamiferos[1] = new Gato();

for (Mamifero mamifero : mamiferos) {
	// Mecanismo extra (ignora se for gato)
    if (mamifero instanceof Gato) {
	    continue;
    }
	
	dono.levar(mamifero.passear()):
}

Sabemos que o gato não pode passear, por isso adicionamos uma condição para ignorar o mamífero caso ele seja um gato. Quando isso acontece, quer dizer que o contrato entre a classe “Mamifero” e a classe “Gato” não foi obedecido, violando os princípios de substituição de Liskov.

Em resumo, é certo afirmar que o gato e o cachorro são mamíferos. Porém, o problema acontece quando a classe parente começa a impor comportamentos a suas classes derivadas. As classes derivadas não devem ser obrigadas a adotar comportamentos que elas não suportam.

Como saber se estou violando o princípio de substituição de Liskov?

A melhor forma saber se estamos violando ou não o LSP é substituindo a classe derivada pela sua classe base. Se as duas classes atenderem aos requerimentos mínimos da classe que dependem delas, provavelmente não há violação.

Entretanto, há casos em que classes violam o LSP sem apresentar os sintomas. Quando não há sintomas aparentes de violação de princípio, a classe violadora pode passar despercebida. A violação ao longo prazo pode ser destrutiva, porque os desenvolvedores continuam realizando acoplamento entre a classe violadora com as outras. Quando a violação ao longo prazo é descoberta, ela é difícil de ser desfeita, pois as classes estão projetadas para funcionar umas com as outras. Mudar a classe violadora afetaria todas as suas dependências, exigindo uma modificação em cadeia nas dependências transitivas, demandando muito mais tempo. Por isso, muitos programadores preferem usar mecanismos extras do que consertar as irregularidades causadas pela classe violadora.

Como aplicar o princípio de substituição de Liskov?

Se analisarmos o princípio de modo detalhado, vemos que Liskov propôs requerimentos padrões para realizar o polimorfismo entre a classe parente e a classe derivada (subtipo):

  • O tipo do parâmetro de um método do subtipo deve ser contravariante ao supertipo
  • O tipo do valor retornando pelo método do subtipo deve ser covariante ao supertipo
  • O método do subtipo não pode lançar excessões, a menos que essas excessões sejam subtipos das excessões do supertipo

É importante destacar que os requerimentos acima se referem ao tipo do argumento. Se uma classe que herda um método de uma superclasse, sobrepor esse método, ela se torna um subtipo e a superclasse um supertipo. Esses requerimentos já são adotados na maioria das linguagens orientadas a objetos recentes.

exemplo de subtipagem entre classes princípio de substituição de liskov

No exemplo acima, os subtipos substituem o método getName() do supertipo “Mamifero”. Porém, essa substituição deve atender aos requerimentos padrões definidos acima.

A covariância e a contravariância se referem a restrição do tipo do valor atribuído. Em outras palavras, a covariância se refere a capacidade de usar um tipo mais derivado e a contravariância se refere a capacidade de usar um tipo menos derivado. Um valor com tipo mais derivado pode ser mais específico e o menos derivado deve ser menos específico (mais genérico).

Em outras palavras, a covariância permite um tipo mais específico (O gato e o cachorro são covariantes do mamífero). Por outro lado, contravariância permite um tipo mais genérico (O mamífero é um contravariante do gato e do cachorro).

O tipo do parâmetro de um método do subtipo deve ser contravariante ao supertipo

O exemplo abaixo foi elaborado com base no gráfico acima. O subtipo “Gato” é um subtipo do “Mamífero”.

class Mamifero {
	public String getName(int id) {}
}

class Gato extends Mamifero {
	public String getName(int id) {}
}

No algoritmo abaixo, o valor passado ao método gerenciar() é aceito porque o parâmetro definido (Mamifero) é uma contravariante (genérico) do valor de tipo “Gato”.

class GerenciadorDeGatos {
	public void gerenciar(Mamifero mamifero) { //... }
	public void main() {
		Gato gato = new Gato();
		gerenciar(gato); // OK
	}
}

Se fosse ao contrário, provavelmente teríamos um erro de compilação:

class GerenciadorDeMamiferos {
	public void gerenciar(Gato gato) { //... }
	public void main() {
		Mamifero mamifero = new Mamifero();
		gerenciar(mamifero); // ERROR
	}
}

No exemplo acima, é retornado um erro porque o tipo “Mamifero” é uma contravariante do tipo “Gato”. Não podemos passar um valor genérico para uma função que espera um valor específico. Quando Liskov diz que o tipo do parâmetro de um método de um subtipo deve ser contravariante, ela diz que a classe derivada não pode fazer o polimorfismo de um método usando parâmetros mais específicos do que os da superclasse. O exemplo acima lança uma excessão (erro) porque há uma violação do princípio.

O tipo do valor retornando pelo método do subtipo deve ser covariante ao supertipo

Um método de um subtipo deve retornar um valor covariante. Isso quer dizer que o valor retornado deve ser igual ou mais específico do que o supertipo.

class Mamifero {
	public static Mamifero criar() {
		return new Mamifero();
	}
}

class Gato extends Mamifero {
	public static Mamifero criar() {
		return new Gato();
	}
}

Perceba que o método criar() da classe “Gato” retorna um tipo mais específico do que o tipo mamífero.

class Main {
	public static void main(String[] args) {
		Mamifero mamifero = Gato.criar(); // OK
	}
}

Isso é possível porque o gato é um covariante do supertipo “Mamifero”. Logo, podemos usar essa prática normalmente na maioria das linguagens orientadas a objetos.

O método do subtipo não pode lançar excessões, a menos que essas excessões sejam subtipos das excessões do supertipo

Quando precisamos lançar excessões no método do subtipo, devemos lançar apenas excessões que estendem a excessão do supertipo.

class CustomNegativeException : NegativeException {
    public CustomNegativeException(String error) { //... }
}

class Mamifero {
	public String getName(int id) {
		if (id < 0) {
			throw new NegativeException("ID nao pode ser negativo");
		}
		...
	}
}

class Gato extends Mamifero {
	public String getName(int id) {
		if (id < 0) {
			throw new CustomNegativeException("ID do subtipo tambem nao pode ser negativo");
		}
		...
	}
}

No exemplo acima, CustomNegativeException é um subtipo de NegativeException. Logo, ele pode ser lançado em um subtipo como o Gato.

Esses três requerimentos já são padrões em muitas linguagens, por isso quando violamos esses requerimentos, obtemos erros ao execução do código.

Porém, ainda há mais três regras para proteger a superclasse das suas derivadas:

  1. As pré-condições de um método não podem ser mais fortes do que a classe base
  2. As pós-condições de um método não devem ser mais fracas do que a classe base
  3. Invariantes devem ser preservadas

Robert Martin postou em seu artigo “Princípios de Design e Padrões de Projeto” sobre os itens 1 e 2 da lista acima. Ele conclui que os métodos derivados não devem esperar mais e não fornecer menos. “Esperar” se refere a pré-condição e “Fornecer” se refere a pós-condição.

Diferente dos três primeiros requerimentos, essas três regras não se referem ao tipo de uma classe, mas sim, ao valor dela. As pré-condições são realizadas antes da execução do método e as pós-condições são realizadas após a execução do método. Quando uma das duas condições não for satisfeita, excessões devem ser lançadas.

As pré-condições de um método não podem ser mais fortes do que a classe base

Quando dizemos que uma condição deve ser mais forte ou mais fraca, estamos nos referindo a sua restrição.

class Mamifero {
	public String getName(int id) {
		if (id < 0 || id > 9999) {
			throw new Exception("ID invalido");
		}
		...
	}
}

A pré-condição do mamífero é que o parâmetro “id” seja maior do que zero. Caso contrário, uma excessão é lançada. Uma classe derivada da classe “Mamifero” não deve ter uma pré-condição mais forte do que a classe base, pois a classe que depende da classe tipo “Mamifero” não espera que uma classe derivada espere mais, pois isso seria uma violação do princípio de Liskov.

class Gato extends Mamifero {
	public String getName(int id) {
		if (id < 0 && id > 100) {
			throw new NegativeException("ID nao pode ser negativo e nem maior que cem");
		}
		...
	}
}

O polimorfismo acima é uma clara violação de LSP, pois possui uma pré-condição mais forte do que a sua classe base. A condição acima é mais restrita (portanto mais forte) do que a da superclasse. Essa prática impossibilitaria a classe base de substituir a classe derivada quando o parâmetro “id” fosse maior do que cem. Invés de fortalecer a pré-condição, poderíamos enfraquecer ela.

class Gato extends Mamifero {
	public String getName(int id) {
		if (id < 0) {
			throw new CustomNegativeException("ID deve maior que zero");
		}
		...
	}
}

Quanto mais restrita uma condição for, mais forte ela é.

As pós-condições de um método não devem ser mais fracas do que a classe base

A pós-condição é realizada após a execução do método, geralmente, logo antes de retornar o resultado. A pós-condição do método da classe derivada não deve ser menos restrito (menos fraco) do que a classe base.

class Mamifero {
	public String getName(int id) {
		...
		if (name.length() > 12 && !name.isEmpty()) {
    		throw new Exception("Nome e invalido");
		}
		
		return name;
	}
}

class Gato extends Mamifero {
	public String getName(int id) {
		...
		if (name.length() > 12) {
    		throw new Exception("Nome nao deve ter mais do que 12 caracteres");
		}
		
		return name;
	}
}

A classe “Gato” sobrepõe a pós-condição do método da classe “Mamifero” enfraquecendo-a. Isso é uma violação de LSP. Pós-condições em classes derivadas só podem ser fortalecidas. Veja abaixo um exemplo de fortalecimento que poderia ser feito na pós-condição:

class Gato extends Mamifero {
	public String getName(int id) {
		...
		if (name.length() > 12 && !name.isEmpty() && name.length() < 4) {
    		throw new Exception("Nome invalido");
		}
		
		return name;
	}
}

Mais uma pequena restrição foi adicionada na pós-condição da classe derivada, tornando-a mais forte.

Invariantes devem ser preservadas

Uma invariante não é nem uma covariante e nem uma contravariante. Uma invariante é uma propriedade que deve ser preservada na mesma condição que ela foi deixada no supertipo, por isso um subtipo não pode alterar o seu valor.

Uma invariante é uma variável (mais especificamente uma propriedade) que contém um valor importante para que uma classe funcione corretamente. Ao alterar esse valor, a classe deixa de operar corretamente, apresentando valores ou comportamentos inesperados.

Um grande exemplo é quando estendemos uma classe derivada chamada “Quadrado” da superclasse “Retângulo”. Pelo fato do quadrado ser um retângulo, intuitivamente fazemos a herança entre essas duas classes. O retângulo possui altura e comprimento. Porém, o quadrado não precisa de duas medidas, sendo que a sua altura é igual ao seu comprimento. Sendo assim, alteramos a altura ou o comprimento da classe “Retangulo” na classe derivada “Quadrado” para tornar todos os lados do quadrado iguais, comprometendo uma invariável importante para o funcionamento do retângulo. Essa ocasião também é uma violação de LSP, pois não podemos substituir mais a classe derivada pela sua classe base sem obter comportamentos inesperados.

Conclusão

O princípio de substituição de Liskov é o terceiro princípio dos princípios SOLID. Esse princípio protege o funcionamento da classe base impondo regras de polimorfismo. Esse princípio aborda sobre como o tipo e o valor da classe derivada devem ser aplicados.

Projetos práticos

Desenvolvendo o campo de visão de um personagem em um plano 2D

Detectando objetos que entram dentro do campo de visão do personagem. Útil para servir de "gatilho" para eventos em um jogo.

Tutorial de programação do jogo da serpente em javascript

Programando o clássico jogo da serpente usando o framework p5.js. Tutorial indicado para iniciantes da programação que querem aprender os conceitos básico da área criando jogos.

Usando dados fornecidos pelo TSE para simular o gráfico das eleições presidenciais de 2022

Simulação dos gráficos do segundo turno das eleições presidenciais, utilizando python e ferramentas de análise de dados, pandas e jupyter.

Integrando o PHP com Elasticsearch no desenvolvimento de um sistema de busca

Projeto de criação de um sistema de busca usando o framework Symfony e Elasticsearch. A integração com Kibana também é feito de modo remoto com um raspberrypi.

Criando um jogo de guerra nas estrelas em javascript usando a biblioteca p5.js

Jogo simples de guerra espacial desenvolvido em javascript. Esse jogo usa cálculos de física para simular efeitos de atrito e inércia.

Veja também

Um algoritmo não pode ser composto por instruções ambíguas, isso pode trazer resultados inesperados

Os algoritmos na ciência da computação são o principal meio para o desenvolvedor poder escrever instruções para o computador, operando a sua maneira

Nunca se sabe quando tem alguém nos espionando no nosso computador

Um computador conectado à internet está exposto a diversos perigos. O spyware é um deles e é esse malware responsável por roubar contas de redes sociais.

Arquitetura limpa - Clean Architecture

Eficiente quando aplicada em softwares de grande porte que necessitam de manutenção ao longo prazo. Criada em 2012 por Robert Martin.

Princípio da responsabilidade única - Single Responsibility Principle

Princípio que diz que um módulo só deve mudar por um único motivo. Esse motivo pode ser o conteúdo de um módulo ou os atores que dependem dele.

Framework no desenvolvimento de softwares

Conjunto de códigos prontos para a utilização no desenvolvimento de softwares, eliminando processos como planejamento de arquitetura de classes.

Pilha (stack) e fila (queue)

Pilha e fila são tipos de estrutura de dados que contribuem para um gerenciamento de dados mais inteligente e eficaz na programação