Pular para o conteúdo

LSP - Liskov Substitution Principle

O princípio origina-se de Barbara Liskov (palestra de 1987, artigo com Jeannette Wing, 1994) na forma da substitutabilidade forte de tipos comportamentais:

Seja φ(x) uma propriedade demonstrável sobre objetos x do tipo T. Então φ(y) deve ser verdadeira para objetos y do tipo S, onde S é subtipo de T.

Em termos práticos, um subtipo deve respeitar o contrato do supertipo. O contrato inclui:

  • Pré-condições não podem ser fortalecidas no subtipo (o subtipo não pode exigir mais do cliente do que o supertipo exigia).
  • Pós-condições não podem ser enfraquecidas no subtipo (o subtipo deve entregar pelo menos o que o supertipo garantia).
  • Invariantes do supertipo devem ser preservadas.
  • Restrição de histórico: operações no subtipo não podem permitir mudanças de estado proibidas pelo supertipo (ex.: tornar mutável algo imutável).
  • Regras de variância: covariância de retornos, contravariância de parâmetros, e nenhuma exceção nova fora da hierarquia esperada.

Compilar não é o suficiente — Java garante a compatibilidade de assinatura, mas não garante a compatibilidade de comportamento, que é o que o LSP exige.

Sem LSP, o polimorfismo torna-se uma armadilha. O cliente que programa contra o supertipo passa a precisar de if (obj instanceof SubtipoProblematico) para tratar casos especiais — o que destrói o OCP - Open-Closed Principle e reintroduz acoplamento. LSP é a condição que faz o polimorfismo (e, portanto, o OCP) ser confiável.

O exemplo canônico é Retângulo/Quadrado: um Quadrado “é um” Retângulo do ponto de vista matemático, mas se Retangulo expõe setLargura e setAltura independentes, Quadrado precisa violar essa independência (ao setar largura, muda altura junto). Um cliente que espera area == largura * altura após setLargura(5) num objeto Retangulo que na verdade é Quadrado obtém resultado surpreendente. O “é-um” intuitivo não implica substitutabilidade comportamental.

LSP não é opcional no sentido de “às vezes vale a pena violar” — violá-lo silenciosamente é sempre um bug latente. O que é discutível é o modelo de herança escolhido. Muitas violações de LSP são, na verdade, sintomas de que a relação não deveria ser de herança (is-a), mas de composição (has-a) ou de interfaces separadas (ISP - Interface Segregation Principle).

ContaPoupanca fortalece a pré-condição de sacar, lançando exceção onde a superclasse permitiria a operação, quebrando clientes que dependem do contrato de Conta.

public class Conta {
protected BigDecimal saldo;
public Conta(BigDecimal saldoInicial) {
this.saldo = saldoInicial;
}
public void sacar(BigDecimal valor) {
this.saldo = this.saldo.subtract(valor);
}
public BigDecimal saldo() {
return saldo;
}
}
public class ContaPoupanca extends Conta {
public ContaPoupanca(BigDecimal saldoInicial) {
super(saldoInicial);
}
@Override
public void sacar(BigDecimal valor) {
if (valor.compareTo(new BigDecimal("100")) > 0) {
throw new UnsupportedOperationException("Poupança não permite saque acima de 100");
}
super.sacar(valor);
}
}

Um serviço que recebe Conta e faz conta.sacar(valor) funciona para Conta, mas explode para ContaPoupanca. O cliente precisaria de instanceof para se proteger — violação de LSP.

Depois — contrato honrado com abstração adequada

Seção intitulada “Depois — contrato honrado com abstração adequada”

Modelamos a política de saque explicitamente no contrato: sacar pode falhar de forma prevista e retorna um resultado. A pós-condição comum que todos os tipos honram: o saque nunca ultrapassa o disponível daquela conta (o saldo puro na poupança; saldo + limite na corrente, onde o saldo pode ficar negativo até o limite) e a recusa é sinalizada de forma uniforme via ResultadoSaque — nunca por exceção inesperada.

public sealed interface Conta permits ContaCorrente, ContaPoupanca {
ResultadoSaque sacar(BigDecimal valor);
BigDecimal saldo();
}
public sealed interface ResultadoSaque permits SaqueRealizado, SaqueRecusado {}
public record SaqueRealizado(BigDecimal novoSaldo) implements ResultadoSaque {}
public record SaqueRecusado(String motivo) implements ResultadoSaque {}
public final class ContaCorrente implements Conta {
private BigDecimal saldo;
private final BigDecimal limite;
public ContaCorrente(BigDecimal saldoInicial, BigDecimal limite) {
this.saldo = saldoInicial;
this.limite = limite;
}
@Override
public ResultadoSaque sacar(BigDecimal valor) {
BigDecimal disponivel = saldo.add(limite);
if (valor.compareTo(disponivel) > 0) {
return new SaqueRecusado("Saldo e limite insuficientes");
}
this.saldo = this.saldo.subtract(valor);
return new SaqueRealizado(saldo);
}
@Override
public BigDecimal saldo() {
return saldo;
}
}
public final class ContaPoupanca implements Conta {
private BigDecimal saldo;
public ContaPoupanca(BigDecimal saldoInicial) {
this.saldo = saldoInicial;
}
@Override
public ResultadoSaque sacar(BigDecimal valor) {
if (valor.compareTo(saldo) > 0) {
return new SaqueRecusado("Saldo insuficiente");
}
this.saldo = this.saldo.subtract(valor);
return new SaqueRealizado(saldo);
}
@Override
public BigDecimal saldo() {
return saldo;
}
}
@Service
public class ServicoSaque {
public String executar(Conta conta, BigDecimal valor) {
ResultadoSaque resultado = conta.sacar(valor);
return switch (resultado) {
case SaqueRealizado r -> "Saque ok, saldo: " + r.novoSaldo();
case SaqueRecusado r -> "Recusado: " + r.motivo();
};
}
}

Agora ServicoSaque trata qualquer Conta de maneira uniforme: nenhuma implementação lança exceção surpresa, e a recusa é parte explícita do contrato (pós-condição honrada por todos). Não há instanceof de proteção.

classDiagram
    class Conta {
        <<interface>>
        +sacar(BigDecimal) ResultadoSaque
        +saldo() BigDecimal
    }
    class ContaCorrente {
        +sacar(BigDecimal) ResultadoSaque
        +saldo() BigDecimal
    }
    class ContaPoupanca {
        +sacar(BigDecimal) ResultadoSaque
        +saldo() BigDecimal
    }
    class ResultadoSaque {
        <<interface>>
    }
    class SaqueRealizado
    class SaqueRecusado

    Conta <|.. ContaCorrente
    Conta <|.. ContaPoupanca
    ResultadoSaque <|.. SaqueRealizado
    ResultadoSaque <|.. SaqueRecusado
    Conta ..> ResultadoSaque
  1. Compilar sem erros garante conformidade com LSP? Por quê?
Resposta

Não. O compilador garante compatibilidade de assinatura (sintaxe), mas LSP exige compatibilidade de comportamento — respeitar pré-condições, pós-condições e invariantes do supertipo, o que o compilador não verifica.

  1. O que significa “não fortalecer pré-condições” e “não enfraquecer pós-condições”?
Resposta

O subtipo não pode exigir mais do cliente do que o supertipo exigia (pré-condição), e deve entregar pelo menos o que o supertipo garantia (pós-condição). Violar qualquer um quebra a substitutabilidade.

  1. Por que o problema Retângulo/Quadrado viola LSP?
Resposta

Porque um cliente que espera setar largura e altura independentemente (invariante de Retangulo) obtém comportamento surpreendente com Quadrado, que acopla os dois lados. O “é-um” intuitivo não corresponde à substitutabilidade comportamental.

  1. Qual refatoração costuma eliminar violações de LSP causadas por herança forçada?
Resposta

Trocar herança por composição/delegação — Replace Inheritance with Delegation. Quando a subclasse só aproveita parte do supertipo (o smell Refused Bequest), a herança a obriga a honrar um contrato que ela não cumpre. Delegar a um colaborador elimina a relação de substituição indevida, e costuma vir acompanhada de Extract Interface para expor só o papel realmente necessário (ligando a ISP).