Nesta quarta parte de minhas reflexões, seguimos com a letra "L", obedecendo a ordem proposta pelo acrônimo SOLID. Trabalharei com TypeScript nos exemplos.
Neste artigo
Liskov Substitution Principle
Exemplo abstrato
Exemplo técnico (Front-End)
Exemplo técnico (Back-End)
Exemplo pessoal
Exemplo funcional
As aplicabilidades
Reflexões finais
Liskov Substitution Principle
O Liskov Substitution Principle (ou Princípio da Substituição de Liskov) possui esse nome por ter sido descrito originalmente por Barbara Liskov. Este princípio propõe que uma classe pode ser substituída por suas subclasses sem comprometimento ao funcionamento do sistema.
Inicialmente, esqueçamos um pouco o motivo pelo qual você teria que substituir uma classe por uma subclasse derivada, e vamos focar na aplicabilidade. Suponha o seguinte cenário:
- Eu tenho uma classe chamada
Class
; - Eu tenho uma subclasse chamada
Subclass
que estendeClass
; - Eu tenho um objeto
const obj = new Class()
; - Se eu quiser trocar para
const obj = new Subclass()
, tem que continuar funcionando;
Esse é o resumo prático do princípio. Vamos agora para alguns exemplos.
Exemplo abstrato
Imagine que, estendendo da nossa classe de livro, identificamos livros que não estão completos. Contudo, a classe superiora espera que exista uma quantidade de livros vendidos - funcionalidade que foi "perdida" na subclasse.
🔴 Implementação Incorreta
// Temos a nossa classe para a entidade de livro.
class Book {
private sales: number;
constructor(private title: string, private author: string) {}
// Podemos determinar quantas vendas o livro tem.
public setSales(sales: number) {
this.sales = sales;
}
// E podemos checar quantas vendas o livro tem.
public checkSales() {
console.log(
`O livro "${this.title}" de "${this.author}" vendeu ${this.sales} exemplates!`
);
}
}
class IncompleteBook extends Book {
// VIOLAÇÃO DO PRINCÍPIO: Como um livro incompleto não tem vendas, aqui é retornado um erro.
// Ou seja, não seria possível substituir um livro instanciado como Book por um IncompleteBook sem
// causar algum efeito colateral inesperado.
public checkSales() {
// Não vamos nos apegar no fato de que aqui estamos literalmente causando um erro.
// É um exemplo abstrato.
throw new Error(
"Este livro ainda não foi concluído, portanto, não possui vendas!"
);
}
}
const book = new Book("A Máquina do Tempo", "H. G. Wells");
book.setSales(50000000);
book.checkSales();
const incompleteBook = new IncompleteBook("A Máquina do Tempo", "H. G. Wells");
incompleteBook.setSales(50000000);
incompleteBook.checkSales(); // Aqui, estourará um erro. Literalmente, claro, pelo que fizemos.
🟢 Implementação Correta
A parte interessante do princípio é que alterar a subclasse nem sempre será a solução. Às vezes, o problema está no fato da subclasse existir: talvez a implementação devesse estar na classe superiora?
De qualquer forma, abaixo imagino uma forma um pouco diferente de resolver o problema.
class Book {
private sales: number;
constructor(private title: string, private author: string) {}
public setSales(sales: number) {
this.sales = sales;
}
public checkSales() {
console.log(
`O livro "${this.title}" de "${this.author}" vendeu ${this.sales} exemplares!`
);
}
}
// Draft seria um nome mais adequado, pois pode se tornar um livro posteriormente.
class Draft extends Book {
// Criamos uma nova propriedade para controlar o estado.
private isComplete: boolean = false;
public setComplete(isComplete: boolean) {
this.isComplete = isComplete;
}
// Apenas retornará erro se o status estiver explícito.
public checkSales(): void {
if (this.isComplete) super.checkSales();
}
}
// Agora, ambos os casos funcionam normalmente.
const book = new Book("A Máquina do Tempo", "H. G. Wells");
book.setSales(50000000);
book.checkSales();
const incompleteBook = new IncompleteBook("A Máquina do Tempo", "H. G. Wells");
incompleteBook.setComplete(true);
incompleteBook.setSales(50000000);
incompleteBook.checkSales();
Exemplo técnico (Front-End)
Para a abstração do Front-End, vamos imaginar uma entidade Button que foi estendida para a subclasse DisabledButton. Se eu substituir uma pela outra, que comportamentos inesperados eu poderia ter?
🔴 Implementação Incorreta
// Classe para nossa entidade de botão.
class Button {
// Iniciamos com o texto do botão.
constructor(public label: string, public event: () => void) {}
// Temos a lógica genérica de evento de pressionar.
public onPress() {
console.log("O botão foi pressionado! Executando evento...");
this.event();
}
}
// Contudo, um botão desabilitado não será capaz de executar o método corretamente.
class DisabledButton extends Button {
public onPress() {
console.log("O botão está desabilitado e não poderá ser pressionado.");
}
}
// Ao instanciarmos o botão como Button, temos o comportamento esperado.
const myButton = new Button("Clique aqui", () => console.log("Cliquei!"));
myButton.onPress();
// OUTPUT:
// Cliquei!
// VIOLAÇÃO DO PRINCÍPIO: Se substituirmos por DisabledButton, o comportamento muda.
// Se isso não era o esperado, o princípio foi violado. Ou seja, para ser caracterizado como violação,
// não precisa necessariamente retornar um erro, mas precisa exibir um comportamento anormal inesperado.
const myButton2 = new DisabledButton("Clique aqui", () => console.log("Cliquei!"));
myButton2.onPress();
// OUTPUT:
// O botão está desabilitado e não poderá ser pressionado.
🟢 Implementação Correta
Aqui, pessoalmente, eu diria que o ideal seria não utilizar uma subclasse chamada DisabledButton, mas transformar sua implementação em uma propriedade isDisabled dentro de Button. Algumas perguntas podem surgir:
- Isso não viola o princípio do Single Responsibility? Na minha visão, não. Estar habilitado ou não ainda faz parte do contexto de um botão.
- Como eu consigo saber quando devo ou não descartar a subclasse? Claro, tome cuidado com a refatoração descontrolada. Analise com calma e avalie os riscos.
Exemplo técnico (Back-End)
🔴 Implementação Incorreta
// Aqui temos uma classe ABSTRATA para um banco de dados.
abstract class DatabaseConnection {
abstract connect(): void;
abstract query(query: string): void;
abstract disconnect(): void;
}
// Então podemos especificar a implementação CONCRETA para cada tipo de banco de dados abaixo.
class MySQLConnection extends DatabaseConnection {
connect(): void {
console.log("Conectando ao MySQL.");
// Implementação específica para o MySQL.
}
query(query: string): void {
console.log(`Executando consulta MySQL: ${query}`);
// Implementação específica para o MySQL.
}
disconnect(): void {
console.log("Desconectando do MySQL.");
// Implementação específica para o MySQL.
}
}
class MongoDBConnection extends DatabaseConnection {
connect(): void {
console.log("Conectando ao MongoDB.");
// Implementação específica para o MongoDB.
}
// VIOLAÇÃO DO PRINCÍPIO: Nenhuma implementação foi realmente desenvolvida para esse tipo de banco de dados.
query(query: string): void {
// Nenhuma implementação foi feita.
}
disconnect(): void {
console.log("Desconectando do MongoDB.");
// Implementação específica para o MongoDB.
}
}
// Isso funcionará normalmente.
const mysqlConnection = new MySQLConnection();
mysqlConnection.connect();
mysqlConnection.query('SELECT * FROM MINHA_TABELA');
mysqlConnection.disconnect();
// Se tentarmos mudar para o MongoDB, falhará.
const mongodbConnection = new MongoDBConnection();
mongodbConnection.connect();
mongodbConnection.query('SELECT * FROM MINHA_TABELA'); // Erro: Cannot read property 'query' of undefined
mongodbConnection.disconnect(); // Isso nem mesmo será executado.
🟢 Implementação Correta
A solução é simples: a subclasse deve obedecer às limitações impostas pela classe superiora. Se a classe está exigindo a implementação do método query
, então ele obrigatoriamente precisa ser implementado.
Exemplo pessoal
🔴 Implementação Incorreta
// Imagine que temos uma classe Kong para cada personagem.
abstract class Kong {
abstract attack(): void;
abstract specialAbility(): void;
}
class DonkeyKong extends Kong {
public attack() {
console.log('Donkey Kong está atacando!')
}
public specialAbility() {
console.log('Donkey Kong está usando uma habilidade especial!')
}
}
class DiddyKong extends Kong {
public attack() {
console.log('Diddy Kong está atacando!')
}
// VIOLAÇÃO DO PRINCÍPIO: Este método não foi implementado para Diddy Kong.
// Agora, imagine que trocamos de personagem no meio do jogo. O jogo
// teria travado porque este método não foi implementado.
public specialAbility() {
// Nenhuma implementação foi realmente feita.
}
}
🟢 Implementação Correta
Bom, novamente, a solução é visualmente simples: Diddy Kong deve, obrigatoriamente, implementar os métodos propostos por Kong.
Exemplo funcional
Vamos manter nossos exemplos funcionais falando sobre leitura de arquivos. Suponhamos que temos uma interface, um contrato, para um FileReader para nossa aplicação. Portanto, posso criar vários fileReaders, cada um para uma situação distinta, desde que cumpram nosso contrato.
No entanto, o que acontece se a implementação concreta dessas interfaces não for feita corretamente?
🔴 Implementação Incorreta
// Aqui temos uma interface que nos permite criar
// quantos File Readers (leitores de arquivos) personalizados quisermos.
type FileReaderFunction = (file: string) => { result: string };
// Este funciona corretamente.
const myFileReader: FileReaderFunction = (file) => {
const fileContent = fileSystem.read(file);
return { result: "Aqui está o conteúdo do arquivo: " + fileContent };
};
// VIOLAÇÃO DO PRINCÍPIO: Se você vai implementar uma
// interface, é melhor honrar o que ela propõe. Aqui,
// o leitor de arquivos não foi implementado corretamente.
const anotherFileReader: FileReaderFunction = (file) => {
throw new Error("Não implementado!");
};
🟢 Implementação Correta
Você adivinhou: anotherFileReader deve aderir ao Princípio da Substituição de Liskov, cumprindo e respeitando o contrato de FileReaderFunction.
As aplicabilidades
O Princípio da Substituição de Liskov é muito interessante, pois propõe uma hierarquia consistente entre os membros de um mesmo sistema e, na minha opinião, propõe mais um trabalho reflexivo do que refatoração de código propriamente dita. Como o próprio Martin comenta, costumeiramente, identifica-se os problemas de violação deste princípio quando já é tarde demais, e a solução acaba sendo um if-else.
Contudo, sua aplicabilidade pode muito bem ser vista em tempo de concepção. Um desenvolvedor que é responsável por implementar uma nova funcionalidade estendendo de uma classe pré-existente passará a considerar o quão substituível ela seria, ou se realmente valeria a pena aplicar essa extensão.
Reflexões finais
De todos os princípios que abordei até o momento, creio que esse seja um dos mais contextuais. Na minha humilde opinião, nem sempre ficar "batendo a cabeça" para fazer esse princípio funcionar seja a solução correta - talvez uma simples unificação de contextos faça muito mais sentido.
Aqui neste artigo, abordei o exemplo da entidade Book, e ele estar completo ou não poderia muito bem fazer parte da mesma entidade; mas, por vias didáticas, optei por refatorar considerando o princípio em questão. Independentemente do caminho seguido, o melhor fruto colhido deste princípio é, com certeza, o olhar crítico para as extensões realizadas.
Top comments (0)