Um Tipo, Várias Formas — Polimorfismo na Prática

Um Tipo, Várias Formas — Polimorfismo na Prática

Polimorfismo em Java: tipo estático e tipo dinâmico, despacho dinâmico, trocar um switch por classes que implementam um contrato, instanceof com padrão e cast, sobrecarga decidida na compilação, membros estáticos escondidos, a armadilha do construtor e retornos covariantes.
Java

• • 16 min de leitura

Como o Java decide qual método executar, como verificar e converter tipos quando é preciso, e como trocar cadeias de if por objetos que sabem o que fazer.

Nas três aulas anteriores, o polimorfismo apareceu de passagem: no laço de contas de "Do Geral ao Específico", que cobrava tarifa só da conta corrente, no descrever das formas e no Cofre que notificava sem saber por qual meio, em "Contratos sem Implementação". Esta aula o coloca no centro. Você vai entender as duas regras que governam uma chamada de método, ver onde o polimorfismo não age e aprender a usá-lo como ferramenta de projeto, substituindo decisões espalhadas pelo código por objetos que trazem o próprio comportamento.

As duas regras de uma chamada

Toda chamada de método sobre um objeto envolve dois tipos: o tipo estático, o tipo declarado da variável, que o compilador conhece, e o tipo dinâmico, a classe real do objeto, que só é conhecida durante a execução. Em Forma f = new Circulo(1.0);, o tipo estático de f é Forma e o dinâmico é Circulo.

As duas regras, já vistas separadamente, agora juntas:

  1. O tipo estático decide o que pode ser chamado. O compilador só aceita f.area() porque Forma declara area(). Um método que só exista em Circulo não pode ser chamado por uma variável Forma.
  2. O tipo dinâmico decide qual versão executa. Durante a execução, a JVM olha o objeto real e executa a versão de area() da classe dele, ou da mãe mais próxima que tenha uma.

A segunda regra é o despacho dinâmico. Por dentro, cada classe tem uma tabela com os endereços das versões de cada método que ela oferece, e cada objeto sabe a que classe pertence. Uma chamada polimórfica consulta essa tabela. O custo é pequeno, e o JIT de "Do Código ao Bytecode" frequentemente o elimina: quando percebe que, num trecho do programa, todos os objetos são da mesma classe, ele chama a versão certa diretamente e pode até copiar o corpo do método no lugar da chamada. Na prática, o polimorfismo em Java custa quase nada.

Substituindo decisões por objetos

A principal utilidade do polimorfismo aparece quando o comportamento de um programa varia conforme um "tipo" de alguma coisa. A forma ingênua é decidir com switch:

double precoFinal(double preco, String tipoDeDesconto) {
    return switch (tipoDeDesconto) {
        case "nenhum" -> preco;
        case "percentual" -> preco * 0.90;
        case "fixo" -> Math.max(0, preco - 30);
        default -> throw new IllegalArgumentException("Desconto desconhecido: " + tipoDeDesconto);
    };
}

Funciona, mas tem problemas que crescem com o programa. Cada novo tipo de desconto exige alterar este método, e provavelmente outros: o que exibe a descrição do desconto, o que valida os parâmetros, o que gera o relatório. Todos têm o mesmo switch, e esquecer um deles é questão de tempo. Os parâmetros, 10% e R$ 30, estão fixos no código. E um erro de digitação em "percentual" só aparece na execução.

A alternativa polimórfica transforma cada tipo de desconto numa classe, a partir de um contrato:

public interface Desconto {
    double aplicar(double preco);
    String descricao();
}
public class DescontoPercentual implements Desconto {
    private final double percentual;

    public DescontoPercentual(double percentual) {
        if (percentual <= 0 || percentual >= 100) {
            throw new IllegalArgumentException("Percentual inválido: " + percentual);
        }
        this.percentual = percentual;
    }

    @Override
    public double aplicar(double preco) {
        return preco * (1 - percentual / 100);
    }

    @Override
    public String descricao() {
        return percentual + "% de desconto";
    }
}
public class DescontoFixo implements Desconto {
    private final double valor;

    public DescontoFixo(double valor) {
        this.valor = valor;
    }

    @Override
    public double aplicar(double preco) {
        return Math.max(0, preco - valor);
    }

    @Override
    public String descricao() {
        return "R$ " + valor + " de desconto";
    }
}

Mais uma classe, SemDesconto, devolve o preço intacto. Agora, a novidade de marketing: um desconto progressivo, de 10% a partir de R$ 150 e 20% a partir de R$ 500. É uma classe nova, sem tocar em nenhuma das existentes:

public class DescontoProgressivo implements Desconto {
    @Override
    public double aplicar(double preco) {
        if (preco >= 500) {
            return preco * 0.80;
        }
        if (preco >= 150) {
            return preco * 0.90;
        }
        return preco;
    }

    @Override
    public String descricao() {
        return "progressivo por faixa";
    }
}

O código que usa os descontos não sabe quantos tipos existem:

Desconto[] promocoes = {
    new SemDesconto(),
    new DescontoPercentual(10),
    new DescontoFixo(30),
    new DescontoProgressivo()
};
for (Desconto desconto : promocoes) {
    IO.println("%-22s -> R$ %.2f".formatted(desconto.descricao(), desconto.aplicar(200.0)));
}
sem desconto           -> R$ 200.00
10.0% de desconto      -> R$ 180.00
R$ 30.0 de desconto    -> R$ 170.00
progressivo por faixa  -> R$ 180.00

Compare as duas versões. Na polimórfica, tudo o que diz respeito a um tipo de desconto (cálculo, descrição, validação e parâmetros) está numa só classe. Um tipo novo é uma classe nova, e o compilador garante que ela implemente todos os métodos do contrato, então não há switch para esquecer. O código existente, já testado, não muda. Essa propriedade tem nome: o princípio aberto-fechado, segundo o qual um módulo deve estar aberto para extensão e fechado para modificação. É um dos cinco princípios conhecidos como SOLID, que você vai rever em "Lições de Quem Veio Antes".

Isso não significa que todo switch deva virar uma hierarquia de classes. Se as variações são poucas, estáveis e cabem em uma linha cada, o switch é mais simples e mais legível. O polimorfismo vale a pena quando os tipos têm comportamento significativo, quando novos tipos surgem com frequência, ou quando a mesma decisão se repete em vários lugares. Em "Hierarquias Fechadas", você vai ver que o Java moderno oferece um meio-termo interessante entre as duas abordagens.

instanceof e cast: quando é preciso saber o tipo

Às vezes o código recebe um tipo genérico e precisa de algo específico de uma subclasse. O operador instanceof pergunta se um objeto é de determinado tipo:

static class Animal { String som() { return "..."; } }

static class Cachorro extends Animal {
    @Override String som() { return "au"; }
    void buscarBolinha() { IO.println("buscando a bolinha"); }
}

static class Gato extends Animal {
    @Override String som() { return "miau"; }
}
Animal[] animais = {new Cachorro(), new Gato()};
for (Animal a : animais) {
    IO.println(a.som());
    if (a instanceof Cachorro c) {
        c.buscarBolinha();
    }
}
au
buscando a bolinha
miau

A forma a instanceof Cachorro c faz duas coisas: testa o tipo e, se o teste passar, cria a variável c, já do tipo Cachorro, válida dentro do if. Ela existe desde o Java 16 e se chama pattern matching para instanceof. Em código antigo você vai encontrar a forma longa, com uma conversão explícita, o cast:

if (a instanceof Cachorro) {
    Cachorro c = (Cachorro) a;
    c.buscarBolinha();
}

O cast entre tipos de referência não transforma o objeto, como fazia o cast entre números de "Cada Valor em sua Caixa". Ele só muda o tipo estático da referência, uma declaração ao compilador de que você sabe que o objeto é daquele tipo. Se você estiver errado, a JVM confere na execução e interrompe o programa:

Animal a = new Gato();
Cachorro c = (Cachorro) a;
Exception in thread "main" java.lang.ClassCastException: class Cast$Gato cannot be cast to class Cast$Cachorro ...

A forma com padrão é sempre preferível: o teste e a conversão ficam juntos, e não há como fazer o cast sem ter testado.

Um conselho importante: instanceof em excesso é sinal de polimorfismo mal aproveitado. Uma cadeia de testes como if (forma instanceof Circulo c) { ... } else if (forma instanceof Retangulo r) { ... } para calcular a área reproduz exatamente o switch que o polimorfismo deveria eliminar. Se o comportamento depende do tipo, ele provavelmente pertence ao próprio tipo, como um método. O instanceof é legítimo quando você não controla as classes envolvidas, quando o comportamento realmente não pertence a elas, ou na fronteira do programa, ao receber objetos de fora. E, como você vai ver em "Hierarquias Fechadas", o Java moderno tornou essas verificações bem mais seguras quando a hierarquia é fechada.

Onde o polimorfismo não age

O despacho dinâmico vale para métodos de instância sobrescritos, e só para eles. Três situações surpreendem quem espera o contrário.

A sobrecarga é decidida na compilação

static void imprimir(Object o) { IO.println("versão Object: " + o); }
static void imprimir(String s) { IO.println("versão String: " + s); }

public static void main(String[] args) {
    String texto = "olá";
    Object mesmoTexto = texto;

    imprimir(texto);
    imprimir(mesmoTexto);
    imprimir((String) mesmoTexto);
}
versão String: olá
versão Object: olá
versão String: olá

As duas primeiras chamadas recebem o mesmo objeto, e mesmo assim executam versões diferentes. A escolha entre métodos sobrecarregados acontece na compilação, com base no tipo estático dos argumentos. O compilador só sabe que mesmoTexto é um Object, e escolhe a versão de Object. A sobrescrita escolhe pelo objeto (dinâmico), e a sobrecarga escolhe pela variável (estático). Misturar sobrecarga com hierarquias de tipos é uma fonte clássica de confusão, e o conselho de Joshua Bloch é evitar sobrecargas com o mesmo número de parâmetros quando os tipos podem ser convertidos um no outro.

Métodos estáticos e campos não são polimórficos

static class Pai {
    static String quem() { return "Pai"; }
    String nome = "campo do Pai";
}

static class Filho extends Pai {
    static String quem() { return "Filho"; }
    String nome = "campo do Filho";
}
Pai p = new Filho();
IO.println(p.quem());            // Pai
IO.println(p.nome);              // campo do Pai
IO.println(((Filho) p).nome);    // campo do Filho

Um método static pertence à classe, e não ao objeto, então não há objeto para consultar: a versão é escolhida pelo tipo da variável. O quem() de Filho não sobrescreve o de Pai, apenas o esconde. Chamar um método estático por uma variável de instância, como p.quem(), compila, mas engana quem lê. O certo é Pai.quem(). Com campos acontece o mesmo: Filho tem dois campos nome, o herdado e o próprio, e qual deles é lido depende do tipo da variável. É mais um motivo para manter os campos privados, como em "O que Fica Dentro, o que Fica Fora". Com campos privados e acesso por métodos, a questão nem aparece.

Não chame métodos sobrescrevíveis no construtor

Esta é a armadilha mais sutil:

static class Base {
    Base() {
        IO.println("Base diz: " + descrever());
    }

    String descrever() {
        return "base";
    }
}

static class Derivada extends Base {
    private final String nome;
    private final int nivel;

    Derivada(String nome, int nivel) {
        super();
        this.nome = nome;
        this.nivel = nivel;
        IO.println("Derivada diz: " + descrever());
    }

    @Override
    String descrever() {
        return nome + " nível " + nivel;
    }
}

new Derivada("teste", 3) imprime:

Base diz: null nível 0
Derivada diz: teste nível 3

O construtor de Base executa primeiro, pela ordem de construção de "Do Geral ao Específico". Ele chama descrever(), e o despacho dinâmico já funciona nesse momento: o objeto é uma Derivada, então executa a versão da Derivada. Mas os campos da Derivada ainda não foram atribuídos e estão com os valores padrão, null e 0, mesmo sendo final. Se descrever chamasse nome.toUpperCase(), o resultado seria uma NullPointerException com a mensagem because "this.nome" is null, dentro de um construtor que parece perfeitamente correto.

A regra: um construtor não deve chamar métodos que as subclasses possam sobrescrever. Se precisar de um método auxiliar, torne-o private ou final.

Tipos de retorno covariantes

Um detalhe útil da sobrescrita: o método da subclasse pode devolver um tipo mais específico que o da superclasse.

static class Forma {
    Forma copiar() { return new Forma(); }
}

static class Circulo extends Forma {
    @Override
    Circulo copiar() { return new Circulo(); }   // devolve Circulo, e não Forma
    double raio() { return 1; }
}

Quem tem uma referência de Circulo pode escrever c.copiar().raio() sem cast. Quem tem uma referência de Forma recebe uma Forma, como prometido. É compatível com a substituição de Liskov: devolver algo mais específico nunca quebra quem esperava algo mais geral.

Polimorfismo como forma de pensar

Com esta aula, as quatro ideias clássicas da orientação a objetos estão completas. O encapsulamento protege os dados dentro dos objetos. A herança permite construir tipos a partir de outros. A abstração, com classes abstratas e interfaces, separa o que um tipo faz de como ele faz. O polimorfismo permite que código escrito para a abstração funcione com qualquer implementação, inclusive as que ainda não foram escritas.

Mas há uma operação tão básica que até agora usamos sem questionar: perguntar se dois objetos são iguais. Em "Operadores, Texto e Conversa com o Usuário", o == falhou com textos, e o equals salvou o dia. Em "Muitos Valores sob um Nome", o mesmo aconteceu com arrays, e quem resolveu foi o Arrays.equals. Mas o que o equals faz com as classes que nós escrevemos? Duas instâncias de Ponto com as mesmas coordenadas são iguais? Por padrão, não.

Fontes e leituras recomendadas

  • The Java Language Specification, Java SE 25, seção 15.12, "Method Invocation Expressions": https://docs.oracle.com/javase/specs/jls/se25/html/jls-15.html#jls-15.12. Como o compilador escolhe entre métodos sobrecarregados e como a JVM seleciona a versão na execução.
  • JEP 394, "Pattern Matching for instanceof": https://openjdk.org/jeps/394. A proposta que tornou definitivo o instanceof com variável de padrão, com exemplos e as regras de escopo da variável.
  • Joshua Bloch, Effective Java, 3ª edição, itens 19 e 52: o item 19 explica o perigo de chamar métodos sobrescrevíveis em construtores, e o item 52, "Use overloading judiciously", traz o exemplo da sobrecarga escolhida pelo tipo estático.
  • Martin Fowler, Refatoração, 2ª edição (Novatec), "Substituir condicional por polimorfismo": a refatoração que transforma um switch sobre tipos numa hierarquia polimórfica, passo a passo, com critérios de quando vale a pena.
  • Robert C. Martin, "The Open-Closed Principle" (1996): https://web.archive.org/web/20060822033314/http://www.objectmentor.com/resources/articles/ocp.pdf. O artigo original sobre o princípio aberto-fechado, com o exemplo clássico de formas geométricas.

Exercícios

Exercício 1

Sem rodar, diga o que o trecho imprime, usando as classes Animal, Cachorro e Gato da aula.

static String descrever(Animal a) { return "um animal que faz " + a.som(); }
static String descrever(Cachorro c) { return "um cachorro que faz " + c.som(); }

// no main:
Animal x = new Cachorro();
Cachorro y = new Cachorro();
IO.println(descrever(x));
IO.println(descrever(y));
Ver resposta

✓ Resposta: Imprime "um animal que faz au" e "um cachorro que faz au". A escolha entre as duas versões de descrever é uma sobrecarga, decidida na compilação pelo tipo estático do argumento: x é declarado como Animal, então vai para a versão de Animal, mesmo apontando para um cachorro. y é declarado como Cachorro e vai para a versão mais específica. Já a chamada a.som() dentro do método é uma sobrescrita, decidida na execução pelo objeto real, que nos dois casos é um cachorro, e por isso as duas linhas terminam em "au". As duas regras aparecem no mesmo exemplo.

Exercício 2

Crie uma classe DescontoCombinado que implemente Desconto recebendo dois outros descontos no construtor e aplicando um depois do outro. Mostre o resultado de combinar 10% com R$ 30 sobre R$ 200.

Ver resposta

✓ Resposta:

public class DescontoCombinado implements Desconto {
    private final Desconto primeiro;
    private final Desconto segundo;

    public DescontoCombinado(Desconto primeiro, Desconto segundo) {
        this.primeiro = primeiro;
        this.segundo = segundo;
    }

    @Override
    public double aplicar(double preco) {
        return segundo.aplicar(primeiro.aplicar(preco));
    }

    @Override
    public String descricao() {
        return primeiro.descricao() + " + " + segundo.descricao();
    }
}

new DescontoCombinado(new DescontoPercentual(10), new DescontoFixo(30)).aplicar(200) dá 150: 200 menos 10% é 180, e menos 30 é 150. A ordem importa: aplicando primeiro o fixo e depois o percentual, o resultado seria (200 − 30) × 0,9 = 153. O mais interessante é que DescontoCombinado é um Desconto feito de outros Desconto, então pode ser combinado de novo, formando cadeias de qualquer tamanho, sem que o caixa saiba. Um objeto que trata um grupo de objetos como se fosse um só é o padrão Composite. Isso só foi possível porque o código depende do contrato, e não das classes concretas.

Exercício 3

Explique por que o construtor de Base imprimiu "null nível 0", embora nome e nivel sejam final e sempre recebam valor no construtor de Derivada. Proponha uma correção.

Ver resposta

✓ Resposta: O construtor de Derivada chama super() antes de atribuir os próprios campos. Durante o construtor de Base, o objeto já existe e já é uma Derivada, então a chamada descrever() é despachada para a versão sobrescrita. Mas os campos da Derivada ainda estão com os valores padrão que todo objeto recebe ao ser alocado, null e 0. O final garante que o campo receba um valor uma única vez, mas não impede que ele seja lido antes disso por um caminho indireto como esse. A correção é não chamar, no construtor da superclasse, um método que as subclasses possam sobrescrever. Se Base precisa de uma descrição na construção, pode usar um método private ou final que não depende das subclasses, ou receber a informação como parâmetro do próprio construtor, que a subclasse passa no super(...).

Exercício 4

O método abaixo usa uma cadeia de instanceof. Reescreva o desenho para eliminá-la, supondo que você pode alterar as classes Circulo, Retangulo e Triangulo.

static String icone(Forma f) {
    if (f instanceof Circulo) {
        return "○";
    } else if (f instanceof Retangulo) {
        return "▭";
    } else if (f instanceof Triangulo) {
        return "△";
    }
    return "?";
}
Ver resposta

✓ Resposta: O ícone depende do tipo, então pertence ao tipo. Declara-se em Forma o método public abstract String icone();, e cada subclasse o implementa: Circulo devolve "○", Retangulo devolve "▭" e Triangulo devolve "△". O método estático desaparece e quem precisar do ícone chama f.icone(). Os ganhos: uma forma nova, como um hexágono, é obrigada pelo compilador a definir o seu ícone, enquanto no método original ela cairia silenciosamente no "?". E a informação sobre cada forma fica toda na classe dela. Se as classes de forma não pudessem ser alteradas, por serem de uma biblioteca, por exemplo, a cadeia de instanceof seria aceitável, e a versão moderna dela, com switch, aparece em "Hierarquias Fechadas".

Exercício 5

Com as classes Pai e Filho da aula, preveja o que Filho f = new Filho(); Pai p = f; IO.println(p.quem() + " " + f.quem() + " " + p.nome + " " + f.nome); imprime. O que essa saída ensina sobre a diferença entre sobrescrever e esconder?

Ver resposta

✓ Resposta: Imprime "Pai Filho campo do Pai campo do Filho". As duas variáveis apontam para o mesmo objeto, mas os métodos estáticos e os campos são resolvidos pelo tipo estático da variável: por p, que é Pai, chegam o método e o campo do pai, e por f, que é Filho, os do filho. Se quem() fosse um método de instância sobrescrito, as duas chamadas executariam a versão do Filho, porque o objeto é um só. Sobrescrever substitui o comportamento para o objeto inteiro, em qualquer referência. Esconder apenas cria outro membro com o mesmo nome, e qual deles é usado depende de por onde se olha. Esconder quase nunca é o que se quer, e é por isso que métodos estáticos devem ser chamados pelo nome da classe e campos devem ser privados.

Comentários

Mais em Java

Comportamento como Valor — Lambdas e Interfaces Funcionais
Comportamento como Valor — Lambdas e Interfaces Funcionais

Como passar comportamento como argumento com expressões lambda em Java…

Medir Antes de Otimizar — JFR, Mission Control e JMH
Medir Antes de Otimizar — JFR, Mission Control e JMH

Como achar com dados onde um programa gasta tempo e memória: gravar com o JDK…

Hierarquias Fechadas — Sealed Classes e Pattern Matching
Hierarquias Fechadas — Sealed Classes e Pattern Matching

Sealed classes e pattern matching em Java: permits, final, sealed e…