Substituição e Falha: o Princípio de Liskov e a Taxonomia de Exceções

Aula 9 — Programação Orientada a Objetos

Marcos M. Raimundo — Instituto de Computação, UNICAMP

2026-09-18

Proposta da Aula

Herança (Aula 8) deu reuso de estrutura — o filho incorpora o DNA do pai.

Mas DNA compartilhado não responde uma pergunta diferente: posso substituir o pai pelo filho com segurança?

Roteiro: binding e VTable → definição formal do LSP → as três regras de contrato → o paradoxo Círculo-Elipse → contratos de exceção → taxonomia do erro → Checked vs. Unchecked.

Do Binding ao Contrato: Late Binding, VTable e @Override

Subtipagem e Substituibilidade

// T e Pagavel, S e Pix
Pagavel p = new Pix();
checkoutController.processar(p); // aceita 'p' em qualquer lugar que peça Pagavel

Lente do compilador: só vê o tipo declarado (Pagavel) — valida as mensagens.

Realidade do Heap: o objeto é de fato um Pix completo, com todo seu estado e comportamento.

Um sistema bem desenhado nunca precisa de instanceof para operar — essa transparência é o objetivo.

Late Binding: Quem Decide o Código?

Early Binding (C/C++ estático): decisão do compilador, olhando o tipo da variável.

Late Binding (Java): a JVM olha a identidade real do objeto no Heap, no momento da chamada.

O objeto “diz”: “Eu sou um Pix, logo sei como confirmar meu pagamento.”

A Mecânica da Decisão: a VTable

public void verificar(Pagavel p, String id) {
    if (p.pagamentoConfirmado(id)) { // a JVM faz o lookup na VTable aqui
        notificarSucesso();
    }
}

Sobrescrita como Preservação de Contrato

public class Boleto implements Pagavel {
    @Override
    public boolean pagamentoConfirmado(String id) {
        return bancoAPI.verificarStatus(id); // outra logica, mesma promessa
    }
}

Mesma assinatura, semântica preservada — se o pai promete “validar”, o filho não vira “deletar”.

@Override não é decoração: é um guarda contra criar um método novo por erro de digitação.

Mas atenção: “mesma assinatura” não é o mesmo que “mesmo contrato” — é essa distância que o LSP formaliza a seguir.

Late Binding, VTable e o Contrato de Sobrescrita

Se o compilador só enxerga o tipo declarado (Pagavel), por que a chamada ainda “sabe” executar o código certo de Pix ou Boleto em tempo de execução?

Escreva sua resposta e compare com um colega antes de avançar (2 min).

  • □ A VTable consultada pela JVM pertence à classe concreta do objeto no Heap, não ao tipo declarado da referência.
  • □ Em Early Binding, a decisão de qual código executar é tomada pelo compilador; em Late Binding, pela JVM em tempo de execução.
  • □ A anotação @Override garante, por si só, que a semântica do método sobrescrito é preservada, independente do que o código do filho faça.
  • □ Se Boleto e Pix implementam pagamentoConfirmado com a mesma assinatura, o método efetivamente executado depende só da identidade real do objeto, nunca do tipo da variável usada para chamá-lo.

Late Binding, VTable e o Contrato de Sobrescrita — Resposta

VTable e Late Binding — Resposta

  • ✔ A VTable consultada pela JVM pertence à classe concreta do objeto no Heap, não ao tipo declarado da referência — é exatamente o passo 3 do protocolo de despacho.
  • ✔ Em Early Binding, a decisão de qual código executar é tomada pelo compilador; em Late Binding, pela JVM em tempo de execução — a distinção central do bloco.
  • ✗ A anotação @Override garante, por si só, que a semântica do método sobrescrito é preservada, independente do que o código do filho faça — @Override só garante a correspondência de assinatura contra erro de digitação; a preservação de semântica é responsabilidade do programador, não do compilador.
  • ✔ Se Boleto e Pix implementam pagamentoConfirmado com a mesma assinatura, o método efetivamente executado depende só da identidade real do objeto, nunca do tipo da variável usada para chamá-lo — é a definição de Late Binding.

Voltando à pergunta: a chamada “sabe” o código certo porque a decisão nunca foi do compilador — ele só valida a assinatura contra o tipo declarado. Quem decide, em cada chamada, é a JVM, consultando a VTable da classe real do objeto no Heap.

O Princípio da Substituição de Liskov

O Rigor de Barbara Liskov

De “o código compila” para “o programa continua correto sob substituição”.

Definição: se \(S\) é subtipo de \(T\), objetos de \(T\) podem ser substituídos por objetos de \(S\) sem alterar as propriedades desejáveis do programa.

\[\forall x: T,\ \phi(x) \implies \forall y: S,\ \phi(y)\] \(\phi(x)\) = todas as propriedades observáveis (comportamento, tempo de resposta, estado final) esperadas de um \(T\).

O Cliente Cego para a Implementação

void realizarCheckOut(Pagavel p, double valor) {
    String id = p.criarCobranca(valor);
    if (id == null) throw new Error("Violação de LSP!");
}

Se um Pix retornar null, o programa quebra — não por erro de sintaxe, mas por comportamento observável surpreendente.

Conclusão central: comportamento que surpreende quem usa a base = hierarquia errada, mesmo que o código compile.

As Três Regras de Contrato do LSP

Regra 1 — Pré-condições: Não Seja Exigente

// Pai: Pre-condicao: valor > 0
// Filho (VIOLACAO):
public String criarCobranca(double valor) {
    if (valor < 100.0) throw new IllegalArgumentException("Valor muito baixo");
    return "ID-OK";
}

Pré-condições podem ser mantidas ou enfraquecidas, nunca fortalecidas — um cliente que já passava R$50,00 agora quebra sem aviso.

Regra 2 — Pós-condições: Cumpra as Promessas

// Pai: promete um ID unico e valido
// Filho (VIOLACAO):
public String criarCobranca(double valor) {
    return ""; // enfraquecendo a garantia
}

Pós-condições podem ser mantidas ou fortalecidas, nunca enfraquecidas — o filho pode entregar mais, nunca menos do que o prometido.

Regra 3 — Preservação de Invariantes

Se a base garante saldo >= 0, nenhum sacar() sobrescrito no filho pode deixar o saldo negativo.

O filho acessa a “cozinha” (protected) — mas não pode deixar o gás ligado ao sair.

As três regras são uma só ideia: o subtipo não pode surpreender quem confiava no comportamento do tipo base.

O Paradoxo Círculo-Elipse

A Falácia do “É-UM”

Mundo real: todo Círculo é uma Elipse. Todo Quadrado é um Retângulo.

Software mutável: Elipse.setAxes(a, b) altera os eixos independentemente; Circulo exige \(a = b\).

Forçar Circulo extends Elipse cria uma pergunta sem resposta honesta.

O Dilema de setAxes(10, 20)

Não há terceira opção honesta — qualquer saída viola o LSP.

A Lição do Paradoxo

Quadrado ser Retângulo (mundo real) \(\neq\) Quadrado ser subtipo válido de Retângulo (software).

Se o comportamento mutável de \(S\) não condiz com \(T\), \(S\) não é um subtipo de \(T\) — mesmo que a taxonomia do mundo real diga que sim.

Saída honesta: objetos imutáveis, ou abandonar a herança por uma interface comum mais genérica (FormaGeometrica).

O Paradoxo Círculo-Elipse e a Falácia do “É-UM”

Por que um Quadrado “ser” um Retângulo no mundo real não garante que ele seja um subtipo válido de Retângulo no código?

Escreva sua resposta e compare com um colega antes de avançar (2 min).

  • □ Se Retangulo tiver um método setAxes(a, b) que altera largura e altura de forma independente, forçar Quadrado extends Retangulo cria uma chamada sem resposta honesta.
  • □ O paradoxo desaparece completamente se Retangulo e Quadrado forem tornados imutáveis, sem nenhum método que altere os eixos depois da criação.
  • □ Uma classe FormaGeometrica mais genérica, sem o método setAxes, resolveria o mesmo problema sem forçar uma relação de herança inválida.
  • □ Toda relação de taxonomia do mundo real (“todo X é um Y”) pode ser traduzida diretamente para uma relação extends no código, sem risco de violar o LSP.

O Paradoxo Círculo-Elipse — Resposta

Por que Quadrado “ser” Retângulo não garante subtipagem — Resposta

  • ✔ Se Retangulo tiver um método setAxes(a, b) que altera largura e altura de forma independente, forçar Quadrado extends Retangulo cria uma chamada sem resposta honesta — é exatamente o dilema do diagrama.
  • ✔ O paradoxo desaparece completamente se Retangulo e Quadrado forem tornados imutáveis, sem nenhum método que altere os eixos depois da criação — sem mutação, não há chamada que force a escolha entre violar a natureza do objeto ou a expectativa do cliente.
  • ✔ Uma classe FormaGeometrica mais genérica, sem o método setAxes, resolveria o mesmo problema sem forçar uma relação de herança inválida — é a segunda saída honesta mencionada na aula.
  • ✗ Toda relação de taxonomia do mundo real (“todo X é um Y”) pode ser traduzida diretamente para uma relação extends no código, sem risco de violar o LSP — é exatamente a falácia que o paradoxo desmonta: taxonomia do mundo real não garante subtipagem de software.

Voltando à pergunta: o critério de subtipagem em software não é “X é um tipo de Y no mundo real”, é “o comportamento observável de X respeita todas as promessas de Y”. O paradoxo Círculo-Elipse mostra que essas duas perguntas podem ter respostas diferentes.

Contratos de Exceção e a Instabilidade do LSP

Exceções São Parte da Assinatura

public class PagamentoCartaoBloqueado extends Cartao {
    @Override
    public String criarCobranca(double v) {
        throw new SecurityException("Usuario banido"); // VIOLACAO
    }
}

Nenhum código escrito contra Pagavel esperava essa exceção — ela não fazia parte do contrato original.

Lançar UnsupportedOperationException para “pular” um método do pai é quase sempre uma violação do LSP.

Ponte: se exceção é contrato, ela merece o mesmo cuidado de design que qualquer outro elemento da interface pública.

Taxonomia do Erro: Hierarquia e Captura Polimórfica

O Erro como Transição de Estado Prevista

Erro não é “acidente”: é a saída prevista de uma função quando a pré-condição falha.

Pedido não pode ir a “Pago”? Ele transita para “Falha de Pagamento” — ambos estados válidos.

Custo real: lançar exceção monta um stack trace — caro. Reserve para o excepcional, não para fluxo comum.

A Árvore de Falhas

Raiz de domínio + categorias (Infraestrutura vs. Negócio) + especialistas — não mensagens de texto soltas.

Captura Polimórfica: Reação por Categoria

try {
    servicoPagamento.processar(pedido);
} catch (CartaoExpiradoException e) {
    view.pedirNovosDados(e.getDataExpiracao());   // ultra-especifica
} catch (PagamentoException e) {
    view.notificarFalhaGenerica(e.getMensagemAmigavel()); // por categoria
} catch (LojaException e) {
    log.registrarCritico(e);                       // ultima instancia
}

O mesmo polimorfismo do Bloco 2, agora aplicado à reação a erro — o catch escolhe o nível de especificidade.

Cuidado simétrico: capturar Exception/Throwable indiscriminadamente esconde bugs que deveriam interromper a execução.

Tradução de Exceções (Wrapping) e o Princípio da Menor Surpresa

O Problema e a Solução: Wrapping

CheckoutController não deveria lidar com SQLException ou TimeoutException — é detalhe de implementação de outra camada.

try {
    apiBanco.conectar();
} catch (IOException e) {
    throw new FalhaComunicacaoException("Banco indisponivel no momento", e);
}

O segundo argumento (e) preserva a causa original — o stack trace técnico não se perde, só fica escondido do chamador.

O controlador agora só entende Pagamento — não SQL, não REST, não socket.

O Princípio da Menor Surpresa

O sistema de erro é parte da interface pública — deve ser tão previsível quanto qualquer método.

Se um método promete SaldoInsuficienteException, ele não pode deixar vazar um NullPointerException interno.

Checked vs. Unchecked: o Critério de Responsabilidade

Checked vs. Unchecked: Quem é Responsável?

Unchecked (DNA): erro de lógica, contrato violado. Fail-Fast: valide cedo, lance na hora. Não se recupera bug com try/catch.

Checked (Mundo): falha externa previsível (rede, API, arquivo). O compilador obriga um Plano B — tratar ou reembalar.

As Três Estratégias, num Método Só

public void realizarPagamento(double valor) {
    checkarValor(valor); // 1. UNCHECKED: falha rapida

    try {
        gatewayPrincipal.conectar();
        gatewayPrincipal.pagar(valor);
    } catch (ConexaoException e) {
        gatewayBackup.pagar(valor); // 2. TRATAR: contingencia
    } catch (IOException e) {
        throw new ErroDeNegocioException("Falha na rede bancaria", e); // 3. REEMBALAR
    }
}

Três perguntas diferentes: o chamador errou? / há rota de escape? / não há solução local?

Capturar um erro de DNA “para o sistema não cair” não resolve nada — só esconde o bug atrás de um comportamento incorreto.

Checked, Unchecked e as Três Estratégias de Erro

Um método precisa lidar com um valor inválido do chamador e, separadamente, com uma falha de rede do banco. Por que essas duas situações não deveriam usar a mesma estratégia de tratamento?

Escreva sua resposta e compare com um colega antes de avançar (2 min).

  • □ Um valor inválido passado pelo chamador é uma violação de contrato — a resposta correta é Fail-Fast, não um try/catch que tenta “seguir em frente”.
  • □ Uma falha de rede é uma contingência do mundo externo, que o método pode tentar contornar (rota alternativa) ou reembalar numa exceção de domínio.
  • □ Se um desenvolvedor captura IllegalArgumentException só para evitar que o sistema pare, ele elimina o bug de origem, não apenas adia sua manifestação.
  • □ Ao reembalar uma IOException numa exceção de domínio, é uma boa prática preservar a exceção original como causa, em vez de descartá-la.

Checked, Unchecked e as Três Estratégias — Resposta

Valor inválido vs. falha de rede — Resposta

  • ✔ Um valor inválido passado pelo chamador é uma violação de contrato — a resposta correta é Fail-Fast, não um try/catch que tenta “seguir em frente” — é a essência do design Unchecked.
  • ✔ Uma falha de rede é uma contingência do mundo externo, que o método pode tentar contornar (rota alternativa) ou reembalar numa exceção de domínio — as duas estratégias válidas para Checked.
  • ✗ Se um desenvolvedor captura IllegalArgumentException só para evitar que o sistema pare, ele elimina o bug de origem, não apenas adia sua manifestação — na verdade, ele só esconde o bug atrás de um comportamento silenciosamente incorreto; a causa raiz continua lá.
  • ✔ Ao reembalar uma IOException numa exceção de domínio, é uma boa prática preservar a exceção original como causa, em vez de descartá-la — é o segundo argumento do construtor de FalhaComunicacaoException, essencial para manter o stack trace técnico rastreável.

Voltando à pergunta: as duas situações têm responsáveis diferentes — o valor inválido é um bug de quem chamou o método (corrige-se o código, não se recupera em tempo de execução), enquanto a falha de rede é uma característica do ambiente que o método pode genuinamente contornar ou traduzir.

Conclusão

O que Aprendemos

  1. Late Binding: a JVM decide em tempo de execução, via VTable — @Override preserva assinatura e semântica
  2. LSP: pré-condições não fortalecidas, pós-condições não enfraquecidas, invariantes preservadas, exceções sem surpresa
  3. Círculo-Elipse: taxonomia do mundo real não garante subtipagem válida em software mutável
  4. Taxonomia de exceções: hierarquia de domínio, captura polimórfica, wrapping, e o critério Checked (Mundo) vs. Unchecked (DNA)

Próxima aula: quando o negócio varia em dois ou mais eixos independentes ao mesmo tempo, a herança colapsa — a composição e o padrão Strategy resolvem.

Exercícios Soluções