Do Geral ao Específico — Herança e a Classe Object

Do Geral ao Específico — Herança e a Classe Object

Herança em Java: extends, a chamada a super e a ordem de construção, sobrescrita com @Override, o tipo da variável contra o tipo do objeto, a classe Object e o toString, final em classes e métodos, o princípio de Liskov e quando preferir composição a herdar.
Java

• • 16 min de leitura

Como uma classe pode herdar o que outra já tem, mudar só o que precisa e ser usada onde a outra é esperada, e por que toda classe do Java descende de uma mesma raiz.

"O que Fica Dentro, o que Fica Fora" deixou a ContaBancaria sólida: dados protegidos, regras garantidas, extrato e numeração automática. Agora o banco quer dois tipos novos de conta. A conta corrente cobra uma tarifa de R$ 2,50 por saque. A poupança rende juros mensais. Os dois tipos fazem tudo o que a conta comum faz e acrescentam um detalhe. Copiar a classe inteira duas vezes para mudar esse detalhe criaria três cópias das mesmas regras, e cada correção futura teria de ser feita em três lugares. Esta aula apresenta a herança, a forma do Java de criar uma classe a partir de outra, e a classe Object, da qual todas as outras descendem.

extends: herdando de uma classe

A palavra extends declara que uma classe é uma versão especializada de outra:

public class ContaPoupanca extends ContaBancaria {

    public ContaPoupanca(String titular, double saldoInicial) {
        super(titular, saldoInicial);
    }

    public void renderJuros(double taxaMensal) {
        double juros = getSaldo() * taxaMensal;
        if (juros > 0) {
            depositar(juros);
        }
    }
}

ContaPoupanca é a subclasse (ou classe filha), e ContaBancaria é a superclasse (ou classe mãe). A subclasse herda todos os membros da superclasse: uma poupança tem número, titular, saldo, extrato, e os métodos depositar, sacar e getSaldo, sem que nenhuma linha deles tenha sido reescrita. A subclasse só acrescenta o que é novo, o método renderJuros.

Repare que renderJuros usa getSaldo() e depositar(), e não o campo saldo diretamente. Os campos privados da superclasse existem dentro de cada poupança, mas continuam privados: nem a subclasse os acessa. Isso é bom. A subclasse é obrigada a passar pelas mesmas portas que qualquer outro código, e as regras da conta continuam valendo para ela. O rendimento aparece no extrato porque passou por depositar.

super: construindo a parte herdada

Todo objeto de uma subclasse contém, por dentro, a parte da superclasse, e essa parte precisa ser construída. A chamada super(titular, saldoInicial) executa o construtor de ContaBancaria com esses argumentos, com todas as validações, a numeração e a inicialização dos campos. Como o this(...) de "Moldes e Instâncias", ela precisa ser a primeira instrução do construtor.

Se você não chamar super(...), o compilador insere uma chamada ao construtor sem parâmetros da superclasse. Como ContaBancaria não tem um, o erro aparece:

SemSuper.java:2: error: no suitable constructor found for ContaBancaria(no arguments)

É mais uma garantia: não há como criar uma poupança sem passar pelo porteiro da conta.

A construção acontece sempre de cima para baixo. Com uma hierarquia de três níveis:

static class Veiculo {
    Veiculo() { IO.println("1. construtor de Veiculo"); }
}
static class Carro extends Veiculo {
    Carro() { IO.println("2. construtor de Carro"); }
}
static class Taxi extends Carro {
    Taxi() {
        super();
        IO.println("3. construtor de Taxi");
    }
}

new Taxi() imprime:

1. construtor de Veiculo
2. construtor de Carro
3. construtor de Taxi

Cada construtor chama o de cima antes de executar o próprio corpo. A base fica pronta antes da parte especializada, porque a parte especializada pode depender dela. O static antes de class só é necessário porque, neste exemplo, as três classes estão declaradas dentro de outra, as classes aninhadas que aparecem em "Ordem e Critério".

Sobrescrita: mudando um comportamento herdado

A conta corrente não acrescenta um método novo: ela muda o comportamento de um que já existe, o saque, que passa a cobrar tarifa. Para permitir isso de forma controlada, vamos preparar a ContaBancaria com um ponto de extensão, um método que calcula a tarifa e que na conta comum devolve zero:

public boolean sacar(double valor) {
    exigirPositivo(valor);
    double total = valor + tarifaDeSaque(valor);
    if (total > saldo) {
        return false;
    }
    saldo -= total;
    registrar(-total);
    return true;
}

protected double tarifaDeSaque(double valor) {
    return 0.0;
}

Aqui aparece o protected da tabela de "O que Fica Dentro, o que Fica Fora": o método é visível para as subclasses (e para o mesmo pacote), mas não faz parte do que a conta oferece ao público. A conta corrente sobrescreve esse método:

public class ContaCorrente extends ContaBancaria {
    private static final double TARIFA_POR_SAQUE = 2.50;

    public ContaCorrente(String titular, double saldoInicial) {
        super(titular, saldoInicial);
    }

    @Override
    protected double tarifaDeSaque(double valor) {
        return TARIFA_POR_SAQUE;
    }
}

Sobrescrever (override) é declarar na subclasse um método com a mesma assinatura (nome e tipos dos parâmetros) de um método da superclasse. Quando sacar for chamado numa conta corrente, a chamada interna tarifaDeSaque(valor) executa a versão da conta corrente. A lógica do saque, com a validação, a verificação de saldo e o registro no extrato, continua escrita uma vez só, na superclasse. A subclasse muda apenas a peça que precisa mudar.

Esse desenho, em que a superclasse define o algoritmo e as subclasses preenchem passos específicos, tem nome: Template Method, um dos padrões de projeto clássicos. Ele funciona melhor do que sobrescrever o sacar inteiro, porque a regra do saldo não pode ser esquecida por acidente numa subclasse.

Não confunda sobrescrita com a sobrecarga de "Dividir para Resolver". Sobrecarga é o mesmo nome com parâmetros diferentes, na mesma classe. Sobrescrita é a mesma assinatura, na subclasse, substituindo a versão herdada.

@Override: peça ao compilador para conferir

A anotação @Override declara a intenção de sobrescrever. Ela parece opcional, e tecnicamente é, mas veja o que acontece com um erro de digitação no tipo do parâmetro:

protected double tarifaDeSaque(int valor) {   // int, e não double
    return 5.0;
}

Sem @Override, isso compila. Não é uma sobrescrita, porque a assinatura é diferente, e sim um método novo, uma sobrecarga que ninguém chama. A tarifa nunca é cobrada, e nada avisa. Com @Override, o compilador recusa:

Typo.java:3: error: method does not override or implement a method from a supertype
    @Override
    ^

Use @Override em toda sobrescrita. Ela transforma um erro silencioso de lógica num erro de compilação. Anotações em geral são assunto de "O Programa que se Examina".

Uma subclasse é uma superclasse

Uma poupança é uma conta bancária. Por isso, uma variável do tipo ContaBancaria pode apontar para um objeto ContaPoupanca ou ContaCorrente:

ContaBancaria comum = new ContaBancaria("Ana", 1000.0);
ContaCorrente corrente = new ContaCorrente("Bruno", 1000.0);
ContaPoupanca poupanca = new ContaPoupanca("Carla", 1000.0);

poupanca.renderJuros(0.005);

ContaBancaria[] contas = {comum, corrente, poupanca};
for (ContaBancaria conta : contas) {
    conta.sacar(100.0);
    IO.println(conta);
}
#1 Ana: R$ 900,00
#2 Bruno: R$ 897,50
#3 Carla: R$ 905,00 (poupança)

O laço trata as três contas como ContaBancaria e chama o mesmo sacar em todas. Mesmo assim, a conta do Bruno pagou tarifa. A versão de tarifaDeSaque executada é decidida durante a execução, pelo tipo real do objeto, e não pelo tipo da variável. Esse mecanismo, chamado de despacho dinâmico, é a base do polimorfismo, e a aula "Um Tipo, Várias Formas" é dedicada a ele. A linha da Carla, com o "(poupança)" no fim, é a mesma ideia aplicada ao toString, explicado mais adiante nesta aula.

O tipo da variável ainda importa para uma coisa: o que o compilador deixa você chamar. Pela variável de tipo ContaBancaria, só os métodos de ContaBancaria estão disponíveis, mesmo que o objeto seja uma poupança:

ContaBancaria c = new ContaPoupanca("Dani", 10);
c.renderJuros(0.01);
E1.java:4: error: cannot find symbol
        c.renderJuros(0.01);
         ^
  symbol:   method renderJuros(double)

O compilador só sabe que c é alguma conta, e nem toda conta rende juros. É o tipo da variável que determina o que pode ser chamado, e o tipo do objeto que determina qual versão executa. Guarde essas duas regras juntas, porque elas explicam quase tudo sobre polimorfismo.

Object: a raiz de tudo

Uma classe sem extends herda, implicitamente, de java.lang.Object. Por isso, toda classe do Java descende de Object, direta ou indiretamente. ContaCorrente estende ContaBancaria, que estende Object:

Object o = new ContaBancaria("Eva", 50);
IO.println(o.getClass().getName());                    // ContaBancaria
IO.println(o.getClass().getSuperclass().getName());    // java.lang.Object

Qualquer objeto pode ser guardado numa variável Object, inclusive textos e números: Object texto = "oi"; funciona, e Object numero = 42; também, com o 42 embrulhado automaticamente num objeto Integer, um mecanismo que aparece em "Escreva uma Vez para Todo Tipo".

Object define os métodos que todo objeto tem. Os mais importantes são:

  • toString(): devolve um texto que descreve o objeto. A versão herdada devolve o nome da classe e um código, o ContaBancaria@7d68ef40 que você viu em "Moldes e Instâncias".
  • equals(Object) e hashCode(): definem quando dois objetos são considerados iguais. A versão herdada compara identidade, como o ==. Sobrescrevê-los corretamente é delicado e tem uma aula própria, "Iguais ou o Mesmo?".
  • getClass(): devolve o tipo real do objeto durante a execução.

O toString é chamado automaticamente sempre que um objeto é concatenado com texto ou passado para IO.println. Sobrescrevê-lo substitui o antigo método resumo():

@Override
public String toString() {
    return String.format(Locale.of("pt", "BR"), "#%d %s: R$ %,.2f", numero, titular, saldo);
}

E a poupança reaproveita a versão da mãe, acrescentando um detalhe:

@Override
public String toString() {
    return super.toString() + " (poupança)";
}

super.toString() chama a versão da superclasse. É a forma de estender um comportamento herdado em vez de substituí-lo por completo: aproveitar o que a mãe faz e somar alguma coisa.

final: fechando a porta

Nem toda classe deve poder ser estendida. O final na declaração de uma classe proíbe subclasses:

public class Fin extends String { }
Fin.java:1: error: cannot inherit from final String

O String é final justamente para proteger a imutabilidade de "O que Fica Dentro, o que Fica Fora". Se fosse possível criar uma subclasse de String com um campo mutável, um objeto dessa subclasse poderia ser passado a qualquer código que espera um String e mudar depois, e todas as garantias desapareceriam. Pela mesma razão, a classe Ponto da aula anterior foi declarada final.

Um método também pode ser final, e então não pode ser sobrescrito. Na ContaBancaria, sacar e depositar são candidatos naturais: são eles que garantem as regras da conta, e uma subclasse que os sobrescrevesse poderia contornar tudo. O ponto de extensão planejado é tarifaDeSaque. O sacar pode ficar fechado.

Herança: poderosa e perigosa

O Java permite herdar de uma única classe. class Multi extends ContaCorrente, ContaPoupanca nem chega a ser entendido pelo compilador. A herança múltipla de classes, permitida em C++, traz ambiguidades difíceis (o que fazer quando as duas mães têm o mesmo método?), e o Java preferiu evitá-las. A próxima aula mostra a alternativa: uma classe pode implementar vários contratos.

A herança é uma das ferramentas mais poderosas da orientação a objetos e também uma das mais mal usadas. Dois conselhos, que a experiência de décadas consolidou:

Use herança só quando a relação "é um" for verdadeira em todos os sentidos. Uma poupança é uma conta, e tudo o que se pode fazer com uma conta se pode fazer com uma poupança. O teste mais rigoroso é o princípio da substituição de Liskov: um objeto da subclasse deve poder ser usado em qualquer lugar onde se espera a superclasse, sem que o programa se comporte de forma incorreta. Se a subclasse precisa recusar operações da mãe ou mudar o significado delas, a relação está errada, mesmo que pareça natural na linguagem do dia a dia. O exercício 5 mostra um exemplo clássico.

Prefira composição quando a relação for "tem um". Um carro tem um motor, e não é um motor. Uma classe que só quer reaproveitar código de outra, sem ser de fato uma versão dela, deve guardar um objeto da outra como campo e delegar a ele, em vez de herdar. A herança acopla a subclasse aos detalhes da mãe: uma mudança interna na superclasse pode quebrar subclasses que dependiam dela. Esse problema tem até nome, classe base frágil. Planejar a extensão, como fizemos com tarifaDeSaque, e fechar o resto com final reduz esse risco.

Mas repare numa coisa estranha no desenho atual: a ContaBancaria comum, que deveria ser só o tronco comum, também pode ser criada com new. Num banco em que toda conta é corrente ou poupança, uma "conta genérica" não deveria existir. E há situações em que classes sem nenhum parentesco precisam oferecer a mesma operação, como um e-mail e um SMS, que sabem "enviar uma mensagem". As duas situações pedem a mesma ideia, a de um contrato que diz o que fazer sem dizer como.

Fontes e leituras recomendadas

  • The Java Language Specification, Java SE 25, seção 8.4.8, "Inheritance, Overriding, and Hiding": https://docs.oracle.com/javase/specs/jls/se25/html/jls-8.html#jls-8.4.8. As regras exatas de herança e sobrescrita, inclusive as restrições sobre tipos de retorno e modificadores de acesso.
  • Documentação da classe Object (Java SE 25): https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Object.html. Os métodos que todo objeto herda, com os contratos que as sobrescritas devem respeitar.
  • Joshua Bloch, Effective Java, 3ª edição, itens 18 e 19: "Favor composition over inheritance" e "Design and document for inheritance or else prohibit it". Os dois itens mais citados sobre os riscos da herança.
  • Barbara Liskov e Jeannette Wing, "A Behavioral Notion of Subtyping" (1994): https://dl.acm.org/doi/10.1145/197320.197383. O artigo que formalizou o princípio da substituição. É técnico, mas a introdução é acessível.
  • Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides, Padrões de Projeto (Bookman): o livro clássico dos padrões de projeto, onde o Template Method está descrito. A introdução discute herança e composição e é leitura obrigatória sobre o tema.

Exercícios

Exercício 1

Sem rodar, diga o que o trecho imprime, supondo as classes da aula (a conta corrente cobra R$ 2,50 por saque).

ContaBancaria a = new ContaCorrente("Ana", 100);
ContaBancaria b = new ContaBancaria("Bia", 100);
a.sacar(97.50);
b.sacar(97.50);
boolean ok = a.sacar(1);
IO.println(a.getSaldo() + " " + b.getSaldo() + " " + ok);
Ver resposta

✓ Resposta: Imprime 0.0 2.5 false. a aponta para uma conta corrente. Mesmo sendo uma variável do tipo ContaBancaria, o saque executa a tarifa da conta corrente: 97,50 + 2,50 = 100, e o saldo zera. b é uma conta comum, sem tarifa: sobram 2,50. O segundo saque de a precisaria de 1 + 2,50 = 3,50, mais do que o saldo zero, e é recusado. É o despacho dinâmico: o tipo da variável é o mesmo nos dois casos, mas o comportamento segue o tipo real de cada objeto.

Exercício 2

Explique por que, sem a anotação @Override, o método tarifaDeSaque(int valor) da aula compila e a tarifa nunca é cobrada.

Ver resposta

✓ Resposta: A assinatura de um método inclui os tipos dos parâmetros. tarifaDeSaque(int) e tarifaDeSaque(double) são métodos diferentes, então a subclasse não sobrescreveu o método da mãe: ganhou um método novo, que sobrecarrega o nome. O sacar da superclasse chama tarifaDeSaque(valor) com um double, e só a versão com double é compatível com essa chamada. Por isso executa sempre a versão herdada, que devolve zero. O programa compila e roda sem nenhum erro aparente, e o defeito só aparece quando alguém percebe que a tarifa não está sendo cobrada. Com @Override, o compilador verifica que o método realmente sobrescreve algo e recusa a versão com int, transformando um erro de lógica silencioso num erro de compilação.

Exercício 3

Acrescente um IO.println ao construtor de ContaBancaria ("criando conta") e outro ao de ContaPoupanca ("criando poupança"). Preveja a ordem das mensagens em new ContaPoupanca("Gil", 10). Seria possível inverter essa ordem?

Ver resposta

✓ Resposta: Aparece primeiro "criando conta" e depois "criando poupança". O construtor de ContaPoupanca começa com super(titular, saldoInicial), que executa todo o construtor de ContaBancaria antes de voltar ao corpo da subclasse. Não é possível inverter: o Java exige que a chamada ao construtor da superclasse aconteça antes de o corpo do construtor usar o objeto. A razão é de segurança. A parte herdada precisa estar pronta e válida antes de a subclasse trabalhar sobre ela. Se a subclasse pudesse executar código antes, poderia chamar getSaldo() sobre uma conta ainda não inicializada. Versões recentes do Java passaram a permitir algumas instruções antes do super(...), desde que não usem o objeto em construção, por exemplo para validar argumentos. A ordem da construção em si continua a mesma.

Exercício 4

Uma variável do tipo ContaBancaria aponta para uma poupança, e é preciso render juros. Por que conta.renderJuros(0.01) não compila, e qual seria a melhor forma de organizar o código para não precisar dessa chamada?

Ver resposta

✓ Resposta: O compilador decide o que pode ser chamado pelo tipo da variável, e ContaBancaria não tem renderJuros. Ele não pode supor que o objeto é uma poupança, porque a mesma variável poderia apontar para uma conta corrente. Existe uma forma de perguntar o tipo real e converter a referência, com instanceof e cast, que aparece em "Um Tipo, Várias Formas" e, em versão moderna, em "Hierarquias Fechadas". Mas a melhor solução costuma ser não perder a informação do tipo: se aquele trecho do programa lida com poupanças, a variável deve ser do tipo ContaPoupanca, e a chamada compila. Usar o tipo mais genérico só faz sentido onde o código realmente trata todas as contas da mesma forma, como no laço de saques da aula.

Exercício 5

Um programador cria class Quadrado extends Retangulo, usando uma versão mutável de Retangulo com métodos setLargura e setAltura. No Quadrado, ele sobrescreve os dois para alterarem largura e altura juntas, e assim manter o quadrado quadrado. Mostre um código que funciona com retângulos e quebra com quadrados, e explique qual princípio foi violado.

Ver resposta

✓ Resposta:

void dobrarLargura(Retangulo r) {
    double alturaAntes = r.getAltura();
    r.setLargura(r.getLargura() * 2);
    // Para qualquer retângulo, a altura não deveria ter mudado:
    if (r.getAltura() != alturaAntes) {
        IO.println("A altura mudou sozinha!");
    }
}

Com um Retangulo 2 × 3, a largura vai para 4 e a altura continua 3, como esperado. Com um Quadrado 2 × 2, setLargura(4) também muda a altura para 4, a mensagem aparece e a área, que deveria ter dobrado, quadruplicou. Todo código escrito para retângulos, que pressupõe que largura e altura mudam de forma independente, passa a se comportar errado quando recebe um quadrado. É uma violação do princípio da substituição de Liskov: o quadrado não pode substituir o retângulo sem alterar o comportamento correto do programa. Na geometria, "todo quadrado é um retângulo" é verdade. No código, com retângulos mutáveis, não é, porque os comportamentos não são compatíveis. Com a versão imutável do exercício 3 de "O que Fica Dentro, o que Fica Fora", o problema desaparece, já que não há setters para entrar em conflito. É mais um argumento a favor da imutabilidade.

Comentários

Mais em Java

Fronteiras Explícitas — o Sistema de Módulos
Fronteiras Explícitas — o Sistema de Módulos

Como o module-info.java declara o que cada parte do programa exporta e de que…

Do Código ao Bytecode — JDK, javac, JVM e o Primeiro Programa
Do Código ao Bytecode — JDK, javac, JVM e o Primeiro Programa

Instalação do JDK 25, a diferença entre JDK, JRE e JVM, o primeiro programa…

O Programa que se Examina — Anotações e Reflexão
O Programa que se Examina — Anotações e Reflexão

Como declarar anotações próprias em Java, escolher a retenção certa e usar a…