Herança como especialização (o “é-um” que adiciona regras)
Herança em Java é um mecanismo para criar um tipo mais específico a partir de outro, reaproveitando comportamento e permitindo polimorfismo (tratar objetos diferentes por um tipo comum). Em termos práticos, herança funciona bem quando a subclasse realmente é uma versão mais especializada da superclasse e mantém as mesmas promessas (contrato) do tipo base.
Uma boa heurística: use herança quando a subclasse puder ser usada em qualquer lugar onde a superclasse é esperada, sem “surpresas” (mesmas regras essenciais, apenas mais específicas).
Quando a herança costuma ser uma boa escolha
- Existe um comportamento central estável que faz sentido para todas as variações.
- As subclasses só especializam (adicionam restrições, detalhes, ou implementações) sem quebrar expectativas do tipo base.
- Você precisa de polimorfismo: uma API que recebe o tipo base e funciona com várias implementações.
extends e o modelo mental: “contrato do pai, detalhes do filho”
Em Java, uma classe herda de outra com extends. A subclasse recebe os membros acessíveis do pai (campos e métodos) e pode:
- Adicionar novos métodos e campos.
- Sobrescrever métodos (override) para alterar a implementação.
- Chamar a implementação do pai com
super.
class Animal { ... } class Cachorro extends Animal { ... }Sobrescrita (override): regras e boas práticas
Sobrescrever é redefinir um método herdado mantendo a mesma assinatura (nome + parâmetros) e um tipo de retorno compatível. Use @Override para o compilador validar que você está realmente sobrescrevendo.
Regras importantes
- Você não pode reduzir a visibilidade do método sobrescrito (ex.: de
publicparaprotected). - Você pode aumentar a visibilidade (ex.: de
protectedparapublic). - Métodos
finalnão podem ser sobrescritos. - Métodos
staticnão são sobrescritos; eles são “escondidos” (hiding), o que não é polimorfismo.
class Base { protected void processar() { } } class Filha extends Base { @Override public void processar() { } }super: reutilizando a implementação do pai com segurança
super serve para acessar membros da superclasse e para chamar construtores do pai. Em sobrescrita, é comum usar super.metodo() quando você quer estender o comportamento, não substituí-lo por completo.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
class Notificacao { public void enviar() { System.out.println("Enviando..."); } } class NotificacaoComLog extends Notificacao { @Override public void enviar() { System.out.println("LOG: antes"); super.enviar(); System.out.println("LOG: depois"); } }protected: acesso para subclasses (e o custo disso)
protected permite que subclasses acessem membros do pai (e também classes do mesmo pacote). Isso pode ser útil para extensibilidade, mas aumenta acoplamento: subclasses passam a depender de detalhes internos do pai.
Uso prático de protected
- Para expor “ganchos” (hooks) de extensão: métodos que o pai chama e que o filho pode sobrescrever.
- Para permitir que subclasses implementem variações sem duplicar lógica.
Evite usar protected para expor campos mutáveis, pois isso torna invariantes difíceis de manter e aumenta o risco de subclasses quebrarem regras internas.
Polimorfismo: regras do despacho dinâmico
Polimorfismo em Java ocorre quando você usa uma referência do tipo base apontando para um objeto de um tipo derivado. A chamada de método é resolvida em tempo de execução (despacho dinâmico) para o método sobrescrito do objeto real.
class Forma { public double area() { return 0; } } class Retangulo extends Forma { private final double w, h; public Retangulo(double w, double h) { this.w = w; this.h = h; } @Override public double area() { return w * h; } } class Circulo extends Forma { private final double r; public Circulo(double r) { this.r = r; } @Override public double area() { return Math.PI * r * r; } } // Polimorfismo: Forma pode ser Retangulo ou Circulo Forma f1 = new Retangulo(2, 3); Forma f2 = new Circulo(1); System.out.println(f1.area()); // chama Retangulo.area() System.out.println(f2.area()); // chama Circulo.area()Regras práticas do polimorfismo
- O tipo da referência define quais métodos você pode chamar (o que o compilador aceita).
- O tipo real do objeto define qual implementação roda (se houver override).
- Campos não são polimórficos: acessar um campo depende do tipo da referência, não do objeto real.
Exemplo incremental: hierarquia pequena e uso correto de herança
Vamos construir um exemplo onde herança faz sentido: um fluxo de pagamento com variações que compartilham um núcleo comum e diferem em detalhes.
Passo 1 — Criar a superclasse com o comportamento comum
Suponha que todo pagamento precisa validar valor e gerar um recibo textual. A forma de calcular a taxa varia.
abstract class Pagamento { protected final double valor; protected Pagamento(double valor) { if (valor <= 0) throw new IllegalArgumentException("valor invalido"); this.valor = valor; } public final double totalComTaxa() { return valor + calcularTaxa(); } protected abstract double calcularTaxa(); public String recibo() { return "Valor: " + valor + ", Taxa: " + calcularTaxa() + ", Total: " + totalComTaxa(); } }Note dois pontos:
totalComTaxa()éfinalpara manter a regra central estável (evita subclasses alterarem o fluxo).calcularTaxa()é um “gancho”protected abstractpara especialização.
Passo 2 — Criar subclasses especializadas com extends
class PagamentoCartao extends Pagamento { public PagamentoCartao(double valor) { super(valor); } @Override protected double calcularTaxa() { return valor * 0.03; } } class PagamentoBoleto extends Pagamento { public PagamentoBoleto(double valor) { super(valor); } @Override protected double calcularTaxa() { return 2.50; } }Passo 3 — Usar polimorfismo em uma API
Uma rotina pode operar sobre Pagamento sem conhecer as subclasses.
class Caixa { public void processar(Pagamento pagamento) { System.out.println(pagamento.recibo()); } } // Uso Caixa caixa = new Caixa(); caixa.processar(new PagamentoCartao(100)); caixa.processar(new PagamentoBoleto(100));Aqui a herança está cumprindo bem seu papel: especialização via override e polimorfismo para consumo uniforme.
Limites do “é-um”: quando a herança começa a ficar inadequada
Agora imagine que surge um requisito: alguns pagamentos precisam de desconto, outros precisam de parcelamento, e outros precisam de cashback. Essas características podem se combinar (cartão com desconto, boleto com desconto, cartão parcelado com cashback etc.).
Se você tentar resolver isso com herança, a tendência é explodir o número de classes:
PagamentoCartaoComDescontoPagamentoCartaoParceladoPagamentoCartaoParceladoComDescontoPagamentoBoletoComDesconto- ...
Esse é um sinal clássico de que você está tentando usar herança para modelar combinações de características, e não especializações naturais.
Risco 1 — Fragile Base Class (classe base frágil)
Quando subclasses dependem de detalhes internos do pai (muitas vezes via protected), pequenas mudanças na superclasse podem quebrar subclasses ou alterar comportamento de forma sutil.
Exemplo típico: a superclasse muda a ordem de chamadas internas, e uma subclasse que sobrescrevia um “gancho” passa a ser chamada em outro momento, gerando efeitos colaterais.
Risco 2 — Hierarquias profundas e difíceis de entender
Quanto mais níveis de herança, mais difícil fica responder perguntas simples:
- “De onde vem esse comportamento?”
- “Qual override está ativo?”
- “Que invariantes o pai assume que o filho respeita?”
Hierarquias profundas também aumentam o custo de manutenção: mudanças no topo têm impacto em cascata.
Critérios práticos: herança vs. composição (decisão no dia a dia)
| Sinal | Tende a herança | Tende a composição |
|---|---|---|
| Relação conceitual | Subtipo é realmente uma especialização do tipo base | Você está adicionando capacidades “plugáveis” |
| Variações | Poucas e estáveis | Muitas combinações possíveis |
| Extensibilidade | Subclasses raramente precisam acessar detalhes internos | Você quer trocar partes em runtime/configuração |
| Acoplamento | Aceitável depender do contrato público do pai | Evitar dependência de detalhes do pai (fragile base) |
| Testabilidade | OK quando o fluxo é simples | Melhor quando você quer testar peças isoladas |
Regra prática: se você está criando subclasses para “adicionar um comportamento opcional” (desconto, log, auditoria, cache), isso costuma ser melhor resolvido com composição/delegação do que com herança.
Refatoração do exemplo: quando a herança vira combinatória
Vamos evoluir o exemplo de pagamentos para suportar desconto sem criar subclasses para cada combinação.
Passo 1 — Identificar o que varia: regras adicionais sobre o total
O cálculo de taxa já varia por tipo de pagamento (cartão, boleto). O desconto é uma regra adicional que pode ser aplicada a qualquer pagamento. Isso é uma “característica combinável”.
Passo 2 — Introduzir um componente de desconto (estratégia)
interface PoliticaDeDesconto { double calcularDesconto(double subtotal); } class SemDesconto implements PoliticaDeDesconto { @Override public double calcularDesconto(double subtotal) { return 0; } } class DescontoPercentual implements PoliticaDeDesconto { private final double percentual; public DescontoPercentual(double percentual) { if (percentual < 0 || percentual > 1) throw new IllegalArgumentException("percentual invalido"); this.percentual = percentual; } @Override public double calcularDesconto(double subtotal) { return subtotal * percentual; } }Passo 3 — Aplicar composição no tipo base sem explodir a hierarquia
Sem reescrever o capítulo de composição, aqui o ponto é: em vez de criar subclasses “com desconto”, adicionamos um colaborador que pode variar.
abstract class Pagamento { protected final double valor; private final PoliticaDeDesconto desconto; protected Pagamento(double valor, PoliticaDeDesconto desconto) { if (valor <= 0) throw new IllegalArgumentException("valor invalido"); this.valor = valor; this.desconto = (desconto == null) ? new SemDesconto() : desconto; } public final double subtotalComTaxa() { return valor + calcularTaxa(); } public final double totalFinal() { double subtotal = subtotalComTaxa(); return subtotal - desconto.calcularDesconto(subtotal); } protected abstract double calcularTaxa(); public String recibo() { return "Valor: " + valor + ", Taxa: " + calcularTaxa() + ", Total: " + totalFinal(); } }Agora as subclasses continuam especializando apenas a taxa, e o desconto é combinável sem novas subclasses.
Passo 4 — Ajustar subclasses para o novo construtor com super
class PagamentoCartao extends Pagamento { public PagamentoCartao(double valor, PoliticaDeDesconto desconto) { super(valor, desconto); } @Override protected double calcularTaxa() { return valor * 0.03; } } class PagamentoBoleto extends Pagamento { public PagamentoBoleto(double valor, PoliticaDeDesconto desconto) { super(valor, desconto); } @Override protected double calcularTaxa() { return 2.50; } }Passo 5 — Uso polimórfico com combinações sem novas classes
Caixa caixa = new Caixa(); PoliticaDeDesconto d10 = new DescontoPercentual(0.10); caixa.processar(new PagamentoCartao(100, d10)); caixa.processar(new PagamentoBoleto(100, new SemDesconto()));Repare no resultado arquitetural:
- Herança continua onde faz sentido (especialização do cálculo de taxa).
- O que é “opcional e combinável” (desconto) foi retirado da hierarquia.
- Menos subclasses, menos risco de hierarquia profunda e menos dependência de detalhes internos.
Checklist rápido para evitar abusos de herança
- Se você precisa de 3+ níveis de herança para “organizar”, reavalie: pode ser sinal de acoplamento e baixa clareza.
- Se subclasses precisam acessar muitos detalhes do pai via
protected, cuidado com fragile base class. - Se novos requisitos geram “explosão” de subclasses por combinação, herança não é a ferramenta certa para essa parte.
- Prefira métodos
finalpara manter fluxos críticos estáveis e exponha apenas ganchos mínimos (protected) para extensão. - Use polimorfismo para consumir variações por um tipo comum, mas mantenha o contrato do tipo base consistente.