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

Aula 9 — Programação Orientada a Objetos

Autor

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

Data de Publicação

18 de setembro de 2026

Slides Lista de aulas

1 Proposta da Aula

Na Aula 8, a Herança nos deu um jeito poderoso de compartilhar estrutura: um filho que extends um pai incorpora fisicamente seu DNA. Mas “compartilhar DNA” e “poder substituir com segurança” são perguntas diferentes — a primeira é sobre estrutura, a segunda é sobre comportamento observável. Esta aula fecha essa lacuna.

O roteiro, em quatro perguntas:

  1. Quando a JVM decide, em tempo de execução, qual código realmente roda — e o que exatamente essa decisão promete manter estável?
  2. O que significa, com rigor (não só “parece a mesma coisa”), dizer que um filho pode substituir o pai em qualquer lugar do sistema?
  3. Por que um Quadrado “ser” um Retângulo no mundo real não garante, sozinho, que ele seja um substituto válido no código?
  4. Como transformar o erro de “acidente imprevisto” em parte estruturada e confiável do design de um objeto?

Considere o sistema de pagamentos que atravessa a disciplina: Pagavel p = new Pix(); compila porque Pix é um subtipo de Pagavel. Mas o compilador só verificou a sintaxe — nada nele garante que trocar um Pix por um Boleto em produção não vai quebrar alguma suposição que o resto do sistema fazia sobre o comportamento de Pagavel. É exatamente essa garantia que faltava, e que os próximos blocos formalizam.

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

O polimorfismo de inclusão é o mecanismo que permite tratar diferentes subtipos de forma uniforme através de uma abstração comum: se \(S\) é um subtipo de \(T\), objetos do tipo \(T\) podem ser substituídos por instâncias de \(S\) sem que o sistema precise conhecer a identidade concreta da classe.

// T e Pagavel, S e Pix
Pagavel p = new Pix();

// O sistema aceita 'p' em qualquer lugar que peça um Pagavel
checkoutController.processar(p);

Duas visões coexistem sobre essa mesma linha: a lente do compilador enxerga só o tipo declarado (Pagavel) e garante que as mensagens enviadas são válidas para ele; a realidade do Heap é que o objeto é, de fato, um Pix completo, com todo o estado e comportamento da sua classe concreta. Um sistema bem desenhado nunca precisa de instanceof para operar — essa transparência é o próprio objetivo do polimorfismo de inclusão.

A mecânica do Late Binding. Diferente de linguagens de Early Binding, em que o compilador decide estaticamente qual código executar olhando o tipo da variável, o Java resolve a chamada em tempo de execução, seguindo um protocolo fixo:

  1. O código chama o método sobre uma referência de tipo Pagavel.
  2. A JVM segue o ponteiro da referência até o objeto no Heap.
  3. Ela consulta a VTable (Virtual Method Table) da classe concreta daquele objeto (ex.: Pix).
  4. Ela executa o endereço de memória da implementação específica encontrada na tabela.
public void verificar(Pagavel p, String id) {
    if (p.pagamentoConfirmado(id)) { // a JVM faz o lookup na VTable aqui
        notificarSucesso();
    }
}

Essa descentralização transforma o objeto num agente inteligente: não é quem chama o método que decide o comportamento, é o próprio objeto que “declara”: “Eu sou um Pix, logo sei como confirmar meu pagamento.”

A sobrescrita como preservação de contrato. @Override permite que o especialista mude o como, preservando rigorosamente o o quê. Três exigências sustentam isso: a assinatura deve ser idêntica (ou covariante no retorno) para não quebrar o contrato; a semântica deve ser preservada — se o pai promete “validar”, o filho não pode mudar a lógica para “deletar”; e a anotação @Override não é decoração, é um guarda de segurança que impede a criação acidental de um método novo por erro de digitação (assinatura levemente diferente que o compilador aceitaria como um método distinto).

public class Boleto implements Pagavel {
    @Override
    public boolean pagamentoConfirmado(String id) {
        // logica especifica: boleto demora ate 48h para compensar
        System.out.println("Consultando compensacao bancaria: " + id);
        return bancoAPI.verificarStatus(id);
    }
}

O cliente de Pagavel continua chamando o mesmo método, com a mesma assinatura — só que recebe uma resposta mais sofisticada e condizente com a realidade do Boleto. Essa é a peça que falta para a pergunta desta aula: “mesma assinatura” não é o mesmo que “mesmo contrato” — e é exatamente a distância entre as duas que o LSP formaliza a seguir.

2.1 Late Binding, VTable e o Contrato de Sobrescrita

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

3 O Princípio da Substituição de Liskov

O Princípio de Substituição de Liskov (LSP), proposto por Barbara Liskov, é o critério que eleva a pergunta de sintaxe (“o código compila?”) para semântica (“o programa continua correto sob substituição?”). Formalmente:

Se \(S\) é um subtipo de \(T\), então objetos do tipo \(T\) podem ser substituídos por objetos do tipo \(S\) sem que as propriedades desejáveis do programa sejam alteradas.

Em notação lógica, sendo \(\phi(x)\) o conjunto de todas as propriedades observáveis (comportamento, tempo de resposta, estado final) que o sistema espera de um objeto do tipo \(T\):

\[\forall x: T,\ \phi(x) \implies \forall y: S,\ \phi(y)\]

A intuição é a de um cliente cego para a implementação: o código que consome Pagavel não deveria conseguir distinguir, pelo comportamento observado, se recebeu um Pix ou um Boleto.

// O cliente espera um Pagavel. Ele assume que:
// 1. O metodo criarCobranca retorna um ID valido.
// 2. Nao havera travas inesperadas se o valor for positivo.
void realizarCheckOut(Pagavel p, double valor) {
    String id = p.criarCobranca(valor);
    if (id == null) throw new Error("Violação de LSP!");
}

Se passarmos um Pix e ele retornar null, o programa quebra — não porque Pix “não é” um Pagavel" sintaticamente (ele implementa a interface, o código compila), mas porque seu comportamento observável surpreende quem depende do contrato dePagavel`. A conclusão central: se o comportamento de um subtipo muda a ponto de surpreender quem usa o tipo base, a hierarquia está errada — não importa quão bem ela reflita uma taxonomia do mundo real.

4 As Três Regras de Contrato do LSP

O LSP se operacionaliza em três regras verificáveis, todas sobre o comportamento de métodos sobrescritos.

Regra 1 — Pré-condições: não seja mais exigente. A subclasse deve ser tão tolerante quanto o pai; pré-condições podem ser mantidas ou enfraquecidas, nunca fortalecidas.

// Na interface/pai:
// Pre-condicao: valor > 0
String criarCobranca(double valor);

// Na subclasse (VIOLACAO):
public String criarCobranca(double valor) {
    // Fortalecendo a pre-condicao (exigindo mais)
    if (valor < 100.0) throw new IllegalArgumentException("Valor muito baixo");
    return "ID-OK";
}

Se a base aceita qualquer valor positivo, o filho não pode restringir esse domínio exigindo valores acima de 100 — isso quebraria qualquer código cliente que já passasse valores legítimos (como 50), disparando uma IllegalArgumentException inesperada. O código que usa Pagavel espera que R$5,00 seja válido; se o objeto concreto falha ali, o tipo foi quebrado.

Regra 2 — Pós-condições: cumpra as promessas. A subclasse deve ser tão generosa quanto o pai; pós-condições podem ser mantidas ou fortalecidas, nunca enfraquecidas.

// No pai: promete retornar uma String de identificacao unica

// Na subclasse (VIOLACAO):
public String criarCobranca(double valor) {
    // Enfraquecendo a garantia: retorna string vazia
    return "";
}

Se o pai prometia um resultado útil, o filho não pode retornar “nada” ou um estado inconsistente — ele pode até entregar algo mais refinado (um ID mais específico, por exemplo), mas nunca menos do que o prometido.

Regra 3 — Preservação de invariantes. A subclasse deve manter íntegro o estado herdado; as “leis de física” da base continuam valendo no filho. Se a base garante que saldo >= 0, um sacar() sobrescrito no filho não pode deixar o saldo negativo — o filho tem acesso à “cozinha” (atributos protected), mas não pode deixar o gás ligado ao sair.

Note que as três regras são, na prática, a mesma preocupação vista de três ângulos: o comportamento do subtipo não pode surpreender ninguém que confiava no comportamento do tipo base — seja recusando entradas que antes eram aceitas, entregando menos do que era prometido, ou corrompendo um estado que a base jurava proteger.

5 O Paradoxo Círculo-Elipse

O erro mais comum ao violar o LSP é confundir a taxonomia do mundo real com o comportamento de software. O exemplo clássico: geometricamente, todo Círculo é uma Elipse — mas no software mutável, essa relação pode se inverter.

Uma Elipse tem o método setAxes(a, b), que permite alterar os dois eixos de forma independente. Um Circulo exige, por natureza, que a == b. Se forçarmos a herança (Circulo extends Elipse), o que acontece quando o cliente chama elipse.setAxes(10, 20) num objeto que ele acredita ser uma Elipse, mas que na verdade é um Circulo?

Não há terceira opção honesta: ou o Circulo quebra sua própria natureza (aceitando \(a \neq b\), e deixando de ser um círculo de verdade), ou ele quebra a expectativa do cliente de que os eixos ficariam exatamente \(10\) e \(20\) (lançando uma exceção que o código escrito para Elipse nunca previu). Qualquer uma das duas saídas viola o LSP.

A lição: embora um quadrado seja um retângulo no mundo real (e um círculo seja uma elipse), essa relação de taxonomia não se transfere automaticamente para software mutável. Se o comportamento mutável de \(S\) não condiz com o que \(T\) promete, \(S\) não é um subtipo válido de \(T\) — não importa quão natural pareça a relação “É-UM” fora do código. A correção, nesses casos, costuma ser tornar os objetos imutáveis (sem setAxes, um novo objeto a cada mudança de forma) ou abandonar a herança em favor de uma interface comum mais genérica, como FormaGeometrica, sem o método problemático.

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

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

6 Contratos de Exceção e a Instabilidade do LSP

Há uma quarta forma de violar o LSP, além das três regras já vistas: as exceções fazem parte da assinatura de um método, tanto quanto seu tipo de retorno. Uma subclasse que lança uma exceção nova, mais ampla ou não declarada pelo pai “assusta” o cliente com um erro que ele nunca foi preparado para tratar.

public class PagamentoCartaoBloqueado extends Cartao {
    @Override
    public String criarCobranca(double v) {
        // VIOLACAO: o cliente do Pagavel espera um ID ou um erro previsto
        throw new SecurityException("Usuario banido");
    }
}

Nenhum código escrito contra Pagavel/Cartao esperava uma SecurityException — ela não fazia parte do contrato original. Um padrão especialmente traiçoeiro: lançar UnsupportedOperationException (ou qualquer RuntimeException genérica) para “pular” um método do pai que o filho não quis implementar é, invariavelmente, uma violação do LSP e um sinal de que a hierarquia de herança está mal desenhada — se o filho não pode honrar o método, ele provavelmente não deveria ser um subtipo daquele pai.

Essa instabilidade de contrato de exceção é a ponte natural para o próximo assunto: se as exceções são parte do contrato, elas merecem o mesmo cuidado de design que qualquer outro elemento da interface pública de um objeto — e é isso que uma taxonomia de exceções bem projetada entrega.

7 Taxonomia do Erro: Hierarquia e Captura Polimórfica

O erro não é um “acidente” ou algo que simplesmente “deu errado” — é uma saída prevista de uma função quando as pré-condições não permitem o sucesso. Pensando na máquina de estados da Aula 2: se um Pedido não pode transitar para “Pago”, ele transita para “Falha de Pagamento” — ambos são estados válidos e antecipados, não uma quebra da máquina. Usar o fluxo de exceção para essa transição separa o “caminho feliz” do “caminho de recuperação”, às custas de um custo real: lançar uma exceção é caro (a montagem do stack trace consome tempo), então reserve-a para situações realmente excepcionais, não para controle de fluxo comum.

A árvore de falhas. Não trate exceções como simples mensagens de texto — trate-as como uma Hierarquia de Tipos que espelha o domínio de negócio, com uma raiz comum (por exemplo, LojaException), grandes categorias (falhas de Infraestrutura — rede, banco — separadas de falhas de Negócio — saldo, estoque) e especialistas que carregam dados específicos da falha.

Captura polimórfica. A vantagem central de organizar exceções como hierarquia (em vez de uma lista plana) é que o catch pode escolher o nível de especificidade da reação — o mesmo polimorfismo do Bloco 2, agora aplicado ao tratamento de erro:

try {
    servicoPagamento.processar(pedido);
} catch (CartaoExpiradoException e) {
    // reacao ultra-especifica: sugerir atualizar os dados do cartao
    view.pedirNovosDados(e.getDataExpiracao());
} catch (PagamentoException e) {
    // reacao por categoria: nao importa se foi saldo ou bloqueio,
    // o tratamento e o mesmo: "pagamento nao autorizado"
    view.notificarFalhaGenerica(e.getMensagemAmigavel());
} catch (LojaException e) {
    // reacao de ultima instancia: algo na loja falhou
    log.registrarCritico(e);
}

Essa estrutura evita que o código fique infestado de if/else de tratamento de erro, mantendo o “caminho feliz” limpo. O cuidado simétrico: não capture Exception (ou pior, Throwable) indiscriminadamente — isso esconderia erros de lógica (como um NullPointerException inesperado) que deveriam, na verdade, interromper a execução para correção, não ser silenciados.

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

Camadas de alto nível — como um CheckoutController — não deveriam lidar com SQLException ou TimeoutException: isso é detalhe de implementação de uma camada inferior. A solução é capturar o erro técnico e re-embalá-lo (wrapping) numa exceção de domínio:

// dentro do meio de pagamento (nivel baixo)
try {
    apiBanco.conectar();
} catch (IOException e) {
    // traduzindo "erro de rede" para "falha de comunicacao no pagamento"
    throw new FalhaComunicacaoException("Banco indisponivel no momento", e);
}

Note o segundo argumento do construtor: a exceção original (e) é preservada como causa, nunca descartada — isso mantém o stack trace completo disponível para depuração técnica, mesmo que a camada superior só veja FalhaComunicacaoException. O controlador agora lida só com o que ele entende (Pagamento), sem precisar saber se o erro veio de SQL, de uma API REST ou de um socket.

Essa técnica adere ao Princípio da Menor Surpresa: o sistema de erro faz parte da interface pública de um objeto, e deve ser tão previsível quanto qualquer outro método. Se um método diz que lança SaldoInsuficienteException, ele não deve deixar escapar um NullPointerException interno — isso vazaria um detalhe de implementação (um bug de inicialização, por exemplo) através de uma interface que prometia apenas erros de negócio bem definidos.

9 Checked vs. Unchecked: o Critério de Responsabilidade

A escolha entre uma exceção Checked ou Unchecked em Java não é uma questão técnica de sintaxe — é sobre quem detém a responsabilidade pelo erro.

Unchecked (violação do DNA). Representa um erro de lógica: o contrato do método foi violado (um valor negativo passado onde só positivos são válidos, por exemplo). O sistema deve falhar rápido (Fail-Fast): validar no construtor ou no início do método, e lançar a exceção imediatamente se algo está errado. Não se recupera um bug com try/catch — o problema não está no fluxo de execução, está no código que chamou o método com argumentos inválidos, e é ali que a correção precisa acontecer. É muito mais fácil depurar um erro que aconteceu na entrada do método do que um erro que só se manifesta minutos depois, longe da causa real.

Checked (contingência do mundo). Representa uma falha externa que o sistema deve prever — rede instável, timeout de API, arquivo ausente. O chamador é obrigado (pelo compilador) a ter um “Plano B”: tratar (buscar uma rota alternativa) ou reembalar (traduzir para uma exceção de domínio, como visto no bloco anterior).

O exemplo que amarra as três estratégias de erro num único método:

public void realizarPagamento(double valor) {
    // 1. UNCHECKED (DNA): falha rapida em caso de violacao de contrato
    checkarValor(valor); // lanca IllegalArgumentException se valor < 0

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

As três decisões coexistem porque respondem perguntas diferentes: “o chamador errou?” (Unchecked, corrige-se acima, nunca no catch); “o mundo falhou, mas há uma rota de escape?” (Tratar, contingência transparente); “o mundo falhou e não há solução local?” (Reembalar, traduzindo para o vocabulário de quem chamou). Confundir as três — por exemplo, capturando uma IllegalArgumentException com um try/catch “para o sistema não cair” — só adia o problema, escondendo um bug real atrás de um comportamento silenciosamente incorreto.

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

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

10 Conclusão

Voltando às quatro perguntas da abertura:

  1. Quem decide, e o que essa decisão preserva: a JVM decide em tempo de execução, consultando a VTable da classe real do objeto — e @Override promete preservar a assinatura e a semântica do método, não só a sintaxe.
  2. Substituir com segurança, formalizado: o LSP exige que o subtipo não fortaleça pré-condições, não enfraqueça pós-condições, e preserve todas as invariantes herdadas — e que suas exceções não surpreendam quem confiava no contrato do tipo base.
  3. Taxonomia do mundo vs. subtipagem de software: o paradoxo Círculo-Elipse mostra que “ser um X no mundo real” não garante “ser um subtipo válido de X no código” — o critério é sempre o comportamento observável, nunca a intuição geométrica ou biológica.
  4. Erro estruturado, não acidente: uma taxonomia de exceções bem desenhada, com captura polimórfica e tradução entre camadas, transforma a falha em parte confiável do design — e o critério Checked/Unchecked decide, caso a caso, se a responsabilidade é de quem chamou (Unchecked, Fail-Fast) ou do ambiente externo (Checked, com plano de contingência).

Ponte para a Aula 10

Até aqui, a disciplina garantiu que um objeto se comporta corretamente sozinho (Aulas 1–5), que ele promete comportamentos estáveis através de interfaces e herança (Aulas 6–8), e que essas promessas seguem válidas sob substituição, mesmo quando algo falha (Aula 9). A próxima pergunta ataca a própria ferramenta usada até aqui para modelar variação: herança resolve bem um eixo de especialização — mas o que acontece quando o negócio precisa variar em dois ou mais eixos independentes ao mesmo tempo (um produto físico ou digital, e, separadamente, nacional ou importado)? A Aula 10 mostra a herança colapsando sob essa explosão combinatória, e a composição — culminando no padrão Strategy — resolvendo exatamente o mesmo problema sem colisão.

Exercícios Soluções