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:

  1. Polimorfismo quebrado: o sistema não pode tratar objetos derivados como iguais aos base, forçando verificações de tipo com instanceof.

  2. Acoplamento aumentado: clientes precisam conhecer implementações específicas, violando o Princípio da Inversão de Dependência (DIP).

  3. 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