Princípio de substituição de Liskov

Esse princípio é a capacidade de substituir um instância da classe parente com a instância da subclasse sem comprometer o funcionamento do algoritmo.

Categoria de Programação

Postado em 21 abril 2022

Atualizado em 21 abril 2022

Visualizações: 561



Princípio de substituição de Liskov, ou em inglês "Liskov Substitution Principle, foi apresentado por Barbara Liskov. Essa senhora já programava antes mesmo da linguagem orientada a objetos (Object-Oriented Language) existir.

Segundo Liskov, em meados de 1980 quando ela começou a trabalhar com linguagem orientada a objetos ela observou que a linguagem era utilizada em dois aspectos:

Simples Herança (inheritance)

Parente
Filho

Um desses aspectos é a herança. A classe “Filho” estende a classe “Parente”.

Quando a classe “Filho” estende a classe “Parente”, ela pode usar a implementação já definida na classe parente, sobrepor(override) as funções da classe parente e adicionar as funções que achar necessário.

Subtipagem (Subtype)

Ave
Pato
Marreco
Galinha

Outro aspecto que era utilizado era a definição de classes hierárquicas. Quando uma classe estende a superclasse ela se torna um subtipo da superclasse e pode ser utilizada da seguinte maneira:

let pato = new Pato();
let marreco = new Marreco();

if (pato instanceof Ave) {
	console.log('Esse animal é uma ave');
}

if (marreco instanceof Ave) {
	console.log('Esse animal também é uma ave');
}

Porém após analisar esses dois aspectos de como a linguagem orientada a objetos era implementada, Liskov achou que os programadores da época não tinham entendido muito bem como essa linguagem deveria ser utilizada.

Origem do princípio de substituição de Liskov

Enquanto Liskov vasculhava os documentos que explicavam a implementação da linguagem orientada a objetos ela percebeu que algo estava errado sobre essa implementação utilizada na época.

Duas subclasses estendiam a mesma classe, porém retornavam dois resultados diferentes.

Ave
Pato
Avestruz

As subclasses “Pato” e “Avestruz” estendem a superclasse “Ave”. Imagine se tivéssemos o seguinte contexto:

class Ave {
  voar() {
  	console.log('Aves voam');
  }
}

class Pato extends Ave {
  voar() {
  	console.log('Pato voa');
  }
}

class Avestruz extends Ave {
  voar() {
  	console.log('Avestruz corre');
  }
}

As subclasses “Pato” e “Avestruz” possuem comportamentos diferentes, pois um avestruz não pode voar, mas o avestruz é uma ave.

No principio de substituição de Liskov, a superclasse deverá funcionar mesmo sem saber como as subclasses serão usadas. Ou seja, no exemplo das aves, se escrevermos o algoritmo abaixo:

function rasgarOsCeus(Ave ave) {
	return ave.voar();
}

let avestruz = new Avestruz();    
rasgarOsCeus(avestruz);

O avestruz não pode voar, muito menos rasgar os céus. Ao invés de sair voando, o avestruz irá sair correndo. Isso é um comportamento inesperado. É sobre isso que esse princípio fala.

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

O princípio de substituição é a capacidade de substituir um instância da classe parente com a instância da subclasse sem comprometer o funcionamento do algoritmo. Ou seja, a subclasse não pode retornar resultados estranhos.

Esse princípio combina muito com o príncipio aberto-fechado, pois um completa o outro. Mas veremos isso mais tarde.

Invés de usar o exemplo da ave, usaremos um exemplo mais real.

class Loja {
	constructor() {
    	this.funcionarios = 0;
    }
}

class LojinhaDaEsquina extends Loja {
	constructor() {
    	super();
    	this.funcionarios = 2;
    }
}

class LojinhaDaDonaMaria extends Loja {
	constructor() {
    	super();
    	this.funcionarios = "1";
    }
}

function somaDosFuncionarios() {
	let lojas = [
    	new LojinhaDaEsquina(),
        new LojinhaDaDonaMaria()
    ];
    
    var soma = 0;
    for (let i = 0; i < lojas.length; i++) {
    	soma += lojas[i].funcionarios;
    }
    
    return soma;
}

// Resultado final: "21"
console.log(somaDosFuncionarios());

Esse é um exemplo de um comportamento estranho. Se tivermos um programa muito mais complexo com várias linhas de códigos, esse problema pode ser frequente.

O exemplo acima é sem dúvidas uma violação dos princípios de substituição de Liskov.

Alguns programadores resolveriam o problema acima da seguinte maneira:

...

function somaDosFuncionarios() {
	let lojas = [
    	new LojinhaDaEsquina(),
        new LojinhaDaDonaMaria()
    ];
    
    var soma = 0;
    for (let i = 0; i < lojas.length; i++) {
        // Checar o tipo da propriedade
    	if (typeof lojas[i].funcionarios === 'string') {
        	soma += parseInt(lojas[i].funcionarios);
        } else {
        	soma += lojas[i].funcionarios;
        }
    }
    
    return soma;
}
// Resultado final: 3
console.log(somaDosFuncionarios());

A correção acima funciona, porém não resolve o problema principal. E além disso, viola o princípio aberto-fechado, pois pode existir a necessidade de modificar o algoritmo acima caso implementarmos alguma funcionalidade nova no programa.

Há algumas formas de resolver esse problema, uma dessas formas é fazendo a conversão do tipo da variável:

class Loja {
	constructor(funcionarios) {
    	this.funcionarios = parseInt(funcionarios);
    }
}

class LojinhaDaEsquina extends Loja {
	constructor() {
    	super(2);
    }
}

class LojinhaDaDonaMaria extends Loja {
	constructor() {
    	super("1");
    }
}

...

// Resultado final: 3
console.log(somaDosFuncionarios());

Agora a modificação acima está de acordo com o princípio de substituição de Liskov. Porém, a modificação acima está quase violando o princípio aberto-fechado.

Imagine que tivéssemos que implementar uma nova loja. A “lojinha da rocinha” que estende a classe loja.

subclasse nova
Loja
Lojinha da esquina
Lojinha da dona maria
Lojinha da rocinha

A lojinha da rocinha ainda está em fase de desenvolvimento, por isso não sabemos quantos funcionários vão trabalhar nela. E ainda existe uma grande possibilidade dessa loja ser apenas operada por robôs, sem humanos. Por essa razão, resolvemos passar o “null” como parâmetro.

class LojinhaDaRocinha extends Loja {
	constructor() {
    	// Poderíamos passar o 0 como argumento, mas não faremos isso
    	super(null);
    }
}

Poderíamos passar o “0” invés do “null”, mas queremos ter certeza que a loja da rocinha não será somada ainda. Nesse caso teríamos que modificar a classe “Loja”, para podermos passar “null” como parâmetro.

class Loja {
   	constructor(funcionarios) {
       	// Modificar de novo?? T.T
       	this.funcionarios = parseInt(funcionarios);
    }
}

Mas não faremos isso.

Sintaxe e semântica

Na linguagem humana, podemos dizer que a “lojinha da esquina”, “lojinha da dona maria” e a “lojinha da rocinha” são lojas. Por essa razão acabamos estendendo a mesma superclasse. Mas isso não funciona muito bem na linguagem orientada a objetos.

Se a lojinha da rocinha vai ser operada por robôs, ela terá grandes chances de se comportar de maneira diferente das demais lojas que são operadas por humanos, podendo trazer resultados inesperados quando utilizada. Isso resulta em modificações que tornam o código muito mais complexo.

Invés de estender a mesma classe, devemos resolver esse problema da seguinte maneira:

Loja
Lojinha da esquina
Lojinha da dona maria
Loja inteligente
Lojinha da rocinha

Criamos uma nova superclasse para a lojinha da rocinha.

Se quisermos que essas duas lojas compartilhem algumas funcionalidades, podemos implementar interfaces como mostrado no princípio aberto-fechado.

Conclusão

Relacionar exemplos da vida real com programação orientada a objetos, nem sempre trará resultados esperados. Se subclasses se comportam de maneiras diferentes, devemos repensar qual superclasse elas devem estender.