Princípios SOLID: Liskov Substitution
1. Introdução ao Princípio de Liskov Substitution (LSP)
O Princípio de Substituição de Liskov (LSP) foi formulado por Barbara Liskov em 1987, durante sua palestra "Data Abstraction and Hierarchy". A definição original estabelece que, se S é um subtipo de T, então objetos do tipo T podem ser substituídos por objetos do tipo S sem alterar as propriedades desejáveis do programa — como corretude, invariantes e comportamento observável.
Em Arquitetura de Software, o LSP é fundamental para garantir que hierarquias de herança e abstrações mantenham contratos previsíveis. Quando um cliente depende de uma interface ou classe base, ele deve ser capaz de usar qualquer implementação concreta sem surpresas. Violações do LSP comprometem a substituibilidade, tornando o sistema frágil e difícil de estender.
Este artigo explora as implicações arquiteturais do LSP, com exemplos práticos de violações, estratégias de correção e impacto em design de sistemas.
2. A Base Formal: Subtipos e Contratos
O LSP está intimamente ligado ao conceito de Design by Contract (DbC), introduzido por Bertrand Meyer. DbC define três elementos contratuais:
- Pré-condições: condições que devem ser verdadeiras antes da execução de um método
- Pós-condições: condições que devem ser verdadeiras após a execução
- Invariantes: condições que se mantêm verdadeiras durante toda a vida de um objeto
O LSP estende DbC ao afirmar que subtipos não podem fortalecer pré-condições nem enfraquecer pós-condições. Em outras palavras, um subtipo deve aceitar pelo menos os mesmos parâmetros que o tipo base e garantir pelo menos os mesmos resultados.
Exemplo de herança que respeita contratos:
class ContaBancaria {
depositar(valor: number): void {
// pré-condição: valor > 0
if (valor <= 0) throw new Error("Valor inválido");
// pós-condição: saldo aumenta em valor
this.saldo += valor;
}
}
class ContaPoupanca extends ContaBancaria {
depositar(valor: number): void {
// mesma pré-condição (não fortalecida)
if (valor <= 0) throw new Error("Valor inválido");
// mesma pós-condição, com comportamento adicional
this.saldo += valor;
this.aplicarRendimento();
}
}
Exemplo de violação de contrato:
class ContaEspecial extends ContaBancaria {
depositar(valor: number): void {
// pré-condição fortalecida: valor mínimo de 100
if (valor < 100) throw new Error("Valor mínimo não atingido");
this.saldo += valor;
}
}
3. Violações Clássicas do LSP na Prática
O exemplo mais famoso de violação do LSP é o problema do "quadrado-retângulo". Intuitivamente, um quadrado "é um" retângulo, mas comportamentalmente a herança falha.
class Retangulo {
protected largura: number;
protected altura: number;
definirLargura(valor: number): void {
this.largura = valor;
}
definirAltura(valor: number): void {
this.altura = valor;
}
calcularArea(): number {
return this.largura * this.altura;
}
}
class Quadrado extends Retangulo {
definirLargura(valor: number): void {
this.largura = valor;
this.altura = valor; // violação: altera estado inesperado
}
definirAltura(valor: number): void {
this.altura = valor;
this.largura = valor; // violação: altera estado inesperado
}
}
O problema: se um cliente usa Retangulo e espera que definirLargura apenas mude a largura, a subclasse Quadrado quebra essa expectativa. O código abaixo falha:
function redimensionar(ret: Retangulo): void {
ret.definirLargura(5);
ret.definirAltura(4);
console.log(ret.calcularArea()); // esperado: 20, mas para Quadrado retorna 16
}
Outras violações comuns incluem:
- Métodos que lançam exceções não declaradas na interface base
- Métodos que retornam tipos incompatíveis com a especificação original
- Métodos que não fazem nada (implementações vazias) quando o contrato exige ação
4. Impacto Arquitetural do LSP
Violações do LSP têm consequências graves na arquitetura:
-
Polimorfismo quebrado: o sistema não pode tratar objetos derivados como iguais aos base, forçando verificações de tipo com
instanceof. -
Acoplamento aumentado: clientes precisam conhecer implementações específicas, violando o Princípio da Inversão de Dependência (DIP).
-
Manutenção complexa: cada nova subclasse pode introduzir comportamentos imprevisíveis, exigindo testes extensivos.
Arquitetos frequentemente encontram código como este quando o LSP é violado:
function processarForma(forma: Retangulo): void {
if (forma instanceof Quadrado) {
// tratamento especial para quadrado
forma.definirLargura(5);
} else {
forma.definirLargura(5);
forma.definirAltura(4);
}
}
Esse padrão de condicionais é um sinal claro de que a hierarquia de herança está mal projetada.
5. Estratégias para Garantir Substituibilidade
A principal estratégia é preferir composição sobre herança. Em vez de modelar Quadrado como subclasse de Retangulo, use uma interface comum.
interface Forma {
calcularArea(): number;
}
class Retangulo implements Forma {
constructor(private largura: number, private altura: number) {}
calcularArea(): number {
return this.largura * this.altura;
}
}
class Quadrado implements Forma {
constructor(private lado: number) {}
calcularArea(): number {
return this.lado * this.lado;
}
}
Agora ambos implementam a mesma interface sem violar contratos. O cliente pode usar polimorfismo sem surpresas:
function exibirArea(forma: Forma): void {
console.log(forma.calcularArea());
}
Outras estratégias incluem:
- Usar interfaces segregadas (ISP) com contratos claros e coesos
- Evitar herança de classes concretas; preferir interfaces ou classes abstratas
- Aplicar o padrão Strategy para comportamentos variáveis
6. LSP em Camadas Arquiteturais
O LSP é crucial em camadas de infraestrutura, como repositórios e serviços. Considere um sistema que precisa trocar de banco de dados:
interface IRepositorioUsuario {
salvar(usuario: Usuario): void;
buscarPorId(id: number): Usuario | null;
}
class PostgresRepositorio implements IRepositorioUsuario {
salvar(usuario: Usuario): void {
// lógica específica do PostgreSQL
}
buscarPorId(id: number): Usuario | null {
// consulta SQL
}
}
class MongoRepositorio implements IRepositorioUsuario {
salvar(usuario: Usuario): void {
// lógica específica do MongoDB
}
buscarPorId(id: number): Usuario | null {
// consulta NoSQL
}
}
Se ambas as implementações respeitam os contratos da interface, a substituição é transparente:
class ServicoUsuario {
constructor(private repositorio: IRepositorioUsuario) {}
criarUsuario(nome: string): void {
const usuario = new Usuario(nome);
this.repositorio.salvar(usuario);
}
}
Para garantir o LSP, cada implementação deve:
- Aceitar os mesmos tipos de parâmetros
- Retornar os mesmos tipos
- Lançar exceções compatíveis
- Manter invariantes de estado
7. Testes e Validação do LSP
Testes de contrato são essenciais para verificar o LSP. Eles testam a interface base contra todas as implementações:
function testarRepositorio(repositorio: IRepositorioUsuario): void {
// Teste de pré-condição: salvar usuário válido
const usuario = new Usuario("João");
repositorio.salvar(usuario);
// Teste de pós-condição: buscar por ID retorna o usuário
const encontrado = repositorio.buscarPorId(usuario.id);
assert(encontrado?.nome === "João");
// Teste de exceção: buscar ID inexistente retorna null
const naoEncontrado = repositorio.buscarPorId(999);
assert(naoEncontrado === null);
}
Checklist para revisão de código:
- As subclasses mantêm as mesmas pré-condições (não as fortalecem)?
- As pós-condições são pelo menos tão fortes quanto as da classe base?
- As exceções lançadas são subconjunto das exceções da interface?
- Os invariantes de estado são preservados?
Ferramentas como TypeScript com strict mode, ESLint com regras de herança, e analisadores estáticos podem detectar violações potenciais.
8. Conclusão e Relação com Outros Princípios SOLID
O LSP trabalha em sinergia com outros princípios SOLID:
- OCP (Open/Closed Principle): classes abertas para extensão, fechadas para modificação — o LSP garante que extensões não quebrem o comportamento existente
- DIP (Dependency Inversion Principle): dependa de abstrações, não de concreções — o LSP assegura que essas abstrações sejam substituíveis
- ISP (Interface Segregation Principle): interfaces coesas reduzem a chance de violações de contrato
Resumo das práticas essenciais:
1. Modele hierarquias de herança com base em comportamento, não em atributos
2. Prefira composição sobre herança para evitar acoplamento rígido
3. Defina contratos claros com pré-condições e pós-condições
4. Teste todas as implementações contra os mesmos contratos da interface
5. Evite condicionais baseadas em tipo (instanceof) — elas indicam violação do LSP
O LSP é a base para arquiteturas flexíveis e extensíveis. Sem ele, o polimorfismo torna-se uma promessa vazia, e o sistema degenera em cascatas de condicionais e tratamento especial para cada implementação.
Referências
- The Liskov Substitution Principle (Original Paper by Barbara Liskov) — Artigo seminal de 1987 que definiu o princípio de substituição de tipos
- Design by Contract (Bertrand Meyer) — Documentação oficial do conceito de DbC que fundamenta o LSP
- SOLID Principles: Liskov Substitution (Robert C. Martin) — Artigo de Uncle Bob sobre a relevância atual do LSP em arquitetura limpa
- Liskov Substitution Principle Explained (Refactoring Guru) — Tutorial prático com exemplos de violações e correções do LSP
- TypeScript Handbook: Interfaces and Inheritance — Documentação oficial do TypeScript sobre interfaces e herança, útil para implementar LSP
- Square-Rectangle Problem and LSP (Martin Fowler) — Análise detalhada do problema clássico quadrado-retângulo por Martin Fowler