PrincípioAvançado7 min de leitura9 seções1987, Data aproximada

LSP — Substituição de Liskov

Um subtipo deve poder substituir seu supertipo sem que o código que usa o supertipo perceba a diferença — o que é uma exigência sobre comportamento, não apenas sobre assinatura.

Em 10 segundos

Se o código funciona com o pai, tem de funcionar com o filho — sem checagens de tipo e sem surpresas. Compilar não basta: o subtipo precisa cumprir também as promessas de comportamento que o supertipo fez.

Antes de ter nome

A noção de subtipo compatível já rondava a teoria de tipos, mas foi Liskov quem a amarrou ao comportamento observável, e não só à assinatura.

  • #solid
  • #heranca
  • #contrato
  • #polimorfismo
Analogia

A cópia da chave

Uma cópia da chave da sua casa é aceitável se abrir a mesma porta, do mesmo jeito. Se ela abre mas exige girar três vezes, ou só funciona antes das seis da tarde, ela tecnicamente 'é uma chave' e mesmo assim quebra o que você esperava. A forma encaixa na fechadura; o comportamento é que decide se a substituição é válida.

01Conceito

O problema

Linguagens verificam assinatura, não comportamento. Um subtipo pode ter todos os métodos certos, compilar sem aviso e ainda assim violar o que o código chamador assumia.

O resultado é um polimorfismo que não se sustenta: quem usa a abstração precisa saber qual implementação recebeu, e o benefício de programar contra o tipo desaparece.

Barbara Liskov formulou isso em 1987 como uma condição sobre propriedades: se toda propriedade demonstrável sobre objetos do tipo T vale também para objetos do subtipo S, então S é subtipo de T. Traduzido para o dia a dia, são três regras práticas: pré-condições não podem ser fortalecidas, pós-condições não podem ser enfraquecidas e invariantes do supertipo precisam ser preservadas.

Fortalecer pré-condição é exigir mais: o pai aceitava qualquer inteiro e o filho só aceita positivos. Enfraquecer pós-condição é entregar menos: o pai garantia lista ordenada e o filho devolve em qualquer ordem. Ambos quebram código que já funcionava, sem nenhum erro de compilação.

O exemplo do quadrado e do retângulo é famoso porque expõe a raiz do problema: a taxonomia do mundo real não é a mesma coisa que a relação de subtipo em programação. Matematicamente um quadrado é um retângulo; em código, Quadrado só é subtipo de Retangulo se o retângulo for imutável. É a mutabilidade — poder mudar largura sem mudar altura — que cria a promessa impossível de cumprir.

02Conceito

Como detectar a violação

As violações se anunciam de formas bem reconhecíveis, quase todas envolvendo o chamador precisando saber demais sobre a implementação.

O sinal mais direto é o método que lança 'não suportado'. Se adicionar() existe na interface e uma implementação apenas lança exceção, o contrato está sendo quebrado por construção — e todo chamador precisa saber com qual implementação está falando.

O segundo é o `instanceof` no código polimórfico. Se quem recebe o supertipo precisa perguntar qual é o tipo real para decidir o que fazer, a substituição não é possível na prática.

O terceiro é a documentação com exceções: 'este método funciona para todos os tipos, exceto para X'. Cada exceção assim é uma violação declarada.

O teste que fecha a questão é escrever a suíte contra o supertipo e rodá-la contra todos os subtipos. Se algum precisa de um teste diferente ou de um caso especial, ele não é substituível — e a suíte compartilhada vira um detector permanente de regressão.

Sem o padrão × com o padrão

Modelagem pelo comportamento

  • Ret e Quad implementam a mesma interface Forma
  • não há relação de herança entre eles
  • objetos imutáveis: redimensionar devolve um novo
  • nenhum chamador precisa saber qual recebeu

O custo é abrir mão de uma hierarquia intuitiva e, muitas vezes, adotar imutabilidade — que resolve boa parte das violações de Liskov pela raiz.

A correção quase nunca é ajustar o subtipo: é reconhecer que a relação de herança estava errada. Quando o filho não consegue cumprir a promessa, ele não é um filho.
Como se organiza
  • Contrato de Conta.sacar(v)a promessa que vale para todo subtipo

    • Pré-condiçãoaceita v > 0 — o filho não pode exigir mais

    • Pós-condiçãosaldo diminui em v — o filho não pode entregar menos

    • Invariantesaldo nunca fica indefinido

Herdar é assinar as três cláusulas de uma vez. Uma ContaSalario que proíbe saque acima de um limite fortaleceu a pré-condição e deixou de ser substituível.
03Anatomia

Navegue pelas camadas

Camada 1/3Pré-condições

Camada 1

Pré-condições

o subtipo não exige mais

O que o método precisa para funcionar. Um subtipo pode aceitar mais casos que o pai, nunca menos — quem chamava com um argumento válido antes continua chamando depois.

No código

// pai aceita qualquer valor > 0; filho NÃO pode exigir valor > 100

Se violar: código que funcionava com o supertipo passa a lançar exceção ao receber o subtipo, sem nenhum aviso em tempo de compilação.

04Estrutura

Estrutura

Diagrama de classes
05Implementação

Código

// VIOLACAO CLASSICA: o subtipo quebra a promessa do supertipo
class Retangulo {
  constructor(protected larg: number, protected alt: number) {}
  setLargura(l: number): void { this.larg = l; }
  setAltura(a: number): void { this.alt = a; }
  area(): number { return this.larg * this.alt; }
}

class Quadrado extends Retangulo {
  setLargura(l: number): void { this.larg = l; this.alt = l; } // muda a altura junto!
  setAltura(a: number): void { this.larg = a; this.alt = a; }
}

// Codigo escrito contra Retangulo, valido para qualquer Retangulo
function redimensionar(r: Retangulo): void {
  r.setLargura(5);
  r.setAltura(4);
  console.assert(r.area() === 20, "esperava 20, veio " + r.area());
}

redimensionar(new Retangulo(0, 0)); // ok: 20
redimensionar(new Quadrado(0, 0));  // FALHA: 16 — o quadrado nao e substituivel

// CORRECAO: nao ha relacao de subtipo; sao formas irmas e imutaveis
interface Forma { area(): number }
class Ret implements Forma {
  constructor(private l: number, private a: number) {}
  comLargura(l: number): Ret { return new Ret(l, this.a); }
  area(): number { return this.l * this.a; }
}
class Quad implements Forma {
  constructor(private lado: number) {}
  area(): number { return this.lado ** 2; }
}
06No mundo real

Casos de uso

Conta salário numa hierarquia de contas

Cenário

Um banco modela ContaCorrente, ContaPoupanca e ContaSalario herdando de Conta, que promete sacar(valor) para qualquer valor até o saldo.

Como o padrão entra

A conta salário não pode transferir para terceiros nem sacar acima de um limite. Em vez de lançar exceção em sacar, o modelo separa capacidades: interfaces distintas para o que cada conta realmente faz.

Trade-off: O código do chamador fica mais explícito sobre capacidades e, ao mesmo tempo, mais verboso — não existe mais um tipo 'Conta' universal que sirva para todo caso de uso.

Coleção somente-leitura

Cenário

Uma API devolve uma lista imutável tipada como a interface de lista da linguagem, cujos métodos de escrita lançam exceção.

Como o padrão entra

Separar em duas interfaces — uma de leitura e outra que adiciona escrita — faz o tipo dizer a verdade. Quem só lê recebe a interface de leitura e nunca tem acesso ao que não pode usar.

Trade-off: É a origem de um dos exemplos mais debatidos do problema, presente em bibliotecas de várias linguagens; corrigir depois de publicada a API costuma ser inviável, e o remédio vira documentação.

Implementação de repositório em memória para testes

Cenário

Um repositório falso é usado nos testes no lugar do real, implementando a mesma interface.

Como o padrão entra

Uma suíte de contrato roda contra as duas implementações e cobra as mesmas garantias: ordenação, unicidade, comportamento em caso de ausência. O falso só é aceito se passar.

Trade-off: Manter o falso fiel exige disciplina contínua; sem a suíte compartilhada, ele diverge devagar e os testes passam a validar um comportamento que a produção não tem.

Passo a passoComo validar a substituição
  1. 1

    Escrever o contrato do supertipo

    Liste o que o chamador pode assumir: pré-condições, pós-condições e invariantes. Isso é a promessa — não a assinatura sozinha.

  2. 2

    Rodar a suíte contra todos os subtipos

    Os mesmos testes do contrato devem passar em cada implementação. Se um subtipo precisa de caso especial, ele não é substituível.

  3. 3

    Caçar 'não suportado' e instanceof

    Método que só lança e código polimórfico que pergunta o tipo concreto são violações anunciadas — a hierarquia está mentindo.

  4. 4

    Quebrar a herança quando não couber

    Se o filho não cumpre a promessa, a correção não é adaptar com exceção: são tipos irmãos, composição ou interfaces menores (ISP).

O subtipo que lança 'não suportado'

Compila, roda, passa no code review. E quebra depois.

A hierarquia compila e o diagrama parece certo — mas um subtipo implementa o método só para lançar. Quem programa contra o pai precisa saber qual filho recebeu.

abstract class Conta {
  constructor(protected saldo: number) {}
  abstract sacar(valor: number): void;
}

class ContaCorrente extends Conta {
  sacar(valor: number): void {
    if (valor > this.saldo) throw new Error("saldo insuficiente");
    this.saldo -= valor;
  }
}

// Compila. Encaixa no tipo. Quebra a promessa.
class ContaSalario extends Conta {
  sacar(_valor: number): void {
    throw new Error("nao suportado"); // <- LSP morto aqui
  }
}

function pagar(contas: Conta[], valor: number) {
  for (const c of contas) c.sacar(valor); // explode com ContaSalario
}

// O chamador precisa saber QUAL conta recebeu — o polimorfismo mentiu.

O que quebra

  • Em runtimeCódigo que funcionava com ContaCorrente explode ao receber ContaSalario, sem aviso em compilação.
  • No chamadorAparece if (c instanceof ContaSalario) — o polimorfismo morreu e o benefício do supertipo sumiu.
  • Na documentação'Funciona para todas as contas, exceto salário' — cada exceção assim é uma violação declarada.

A saída: Separe capacidades: quem não saca não implementa sacar. Interfaces menores (ISP) fazem o tipo dizer a verdade; herança que força mentira não é herança útil.

08Cuidado

Armadilhas

  1. Modelar pela taxonomia do mundo real

    'Um quadrado é um retângulo', 'um pinguim é uma ave', 'uma conta salário é uma conta'. Todas verdadeiras no mundo e potencialmente falsas em código, porque o que importa é se o subtipo cumpre as promessas comportamentais do supertipo. A pergunta certa não é 'é um?', é 'pode substituir?'.

  2. Resolver com exceção de 'não suportado'

    Quando o subtipo não consegue cumprir um método, a saída fácil é lançar exceção. Isso transforma um erro de modelagem em bug de execução e obriga todo chamador a saber qual implementação recebeu. É sinal de que a interface precisa ser dividida — que é onde o ISP entra.

  3. Ignorar que mutabilidade cria promessas

    Boa parte das violações clássicas desaparece com objetos imutáveis: sem setters, não há como o subtipo quebrar a independência entre campos. Antes de brigar com a hierarquia, vale perguntar se o tipo precisava mesmo ser mutável.

09Decisão

Quando usar × quando evitar

Use quando

3
  • Sempre que existir herança ou implementação de interface.
  • Ao revisar hierarquias com exceções de 'não suportado'.
  • Ao publicar contratos que terceiros vão implementar.

Evite quando

3
  • Modelar por semelhança do mundo real em vez de por comportamento.
  • Usar herança para reaproveitar código quando a substituição não se sustenta.
  • Aceitar exceções como forma de 'adaptar' um subtipo que não cabe.

Testar o que ficou

3 armadilhas de LSP — Substituição de Liskov viram perguntas, com o nome do padrão escondido, misturadas com padrões parecidos.

Onde isto aparece de verdade

Você já usa isto, talvez sem saber o nome.

  • List: ArrayList × LinkedListQualquer implementação de List entra onde List é esperado; trocar ArrayList por LinkedList não quebra quem só depende do contrato.
  • Arrays.asList que quebra no add()A List de tamanho fixo devolvida por Arrays.asList lança em .add(): o subtipo que promete menos que o supertipo, a violação clássica do JDK.
  • drivers atrás de uma interfacePostgres ou MySQL por trás da mesma interface de banco só são substituíveis se cada driver cumprir o contrato inteiro, sem surpresas.

O padrão em uma linha

Sem nome de domínio, sem cerimônia. Só a ideia.

// Subtipo substitui o base sem surpresa.
function migrar(aves: Ave[]) { for (const a of aves) a.voar(); }

O que ele cobra

Nenhum salto a mais entre a chamada e o efeito.

  • Manter o contrato do supertipo honesto limita o que cada subtipo pode fazer diferente
  • Verificar a substituibilidade de verdade exige testes sobre o contrato, não só sobre a classe

Não vale se: não há herança nem polimorfismo em jogo — sem subtipos substituindo um supertipo, a regra não tem sobre o que valer.

Onde isto aparece

Continue por aqui