O Preço da Compatibilidade — Wildcards e Type Erasure

O Preço da Compatibilidade — Wildcards e Type Erasure

Wildcards e type erasure em Java: por que os genéricos são invariantes e os arrays covariantes, ? extends e ? super com a regra PECS, o wildcard sem limite, o apagamento visto no bytecode, o que ele proíbe, o token de classe, a poluição do heap e o preço da compatibilidade.
Java

• • 18 min de leitura

Por que uma lista de inteiros não é uma lista de números, como os wildcards resolvem isso, e o que o compilador faz com os genéricos antes de o programa rodar.

"Escreva uma Vez para Todo Tipo" terminou com duas perguntas. Se Integer é um Number, uma Lista<Integer> é uma Lista<Number>? E por que não se pode criar um array com new E[4]? As duas têm a mesma origem: uma decisão de projeto tomada em 2004, quando os genéricos chegaram ao Java 5, para que milhões de linhas de código escritas antes deles continuassem funcionando. Esta aula mostra essa decisão, o type erasure, as limitações que ela impõe e os wildcards, a ferramenta que torna os métodos genéricos flexíveis sem abrir mão da segurança. Os exemplos usam a Lista<E> construída na aula anterior.

Uma lista de inteiros não é uma lista de números

Um método que soma uma lista de números:

static double somar(Lista<Number> numeros) {
    double total = 0;
    for (int i = 0; i < numeros.tamanho(); i++) {
        total += numeros.obter(i).doubleValue();
    }
    return total;
}

Chamá-lo com uma Lista<Integer> parece óbvio, e não compila:

Inv.java:10: error: incompatible types: Lista<Integer> cannot be converted to Lista<Number>
        IO.println(somar(inteiros));
                         ^

A razão fica clara se imaginarmos que o compilador aceitasse. Um método que recebe uma Lista<Number> pode, legitimamente, adicionar qualquer número a ela, inclusive um Double:

static void estragar(Lista<Number> numeros) {
    numeros.adicionar(3.14);   // perfeitamente válido para uma Lista<Number>
}

Se uma Lista<Integer> pudesse ser passada como Lista<Number>, esse método colocaria um Double numa lista de inteiros, e quem lesse a lista esperando um Integer receberia um ClassCastException. Para impedir isso, os genéricos do Java são invariantes: Lista<Integer> e Lista<Number> são tipos sem relação nenhuma, mesmo que Integer e Number sejam relacionados.

Os arrays escolheram o caminho oposto

Os arrays, que existem desde o Java 1.0, são covariantes: um Integer[] é um Number[]. O resultado é exatamente o problema descrito acima, só que detectado na execução:

Integer[] inteiros = {1, 2, 3};
Number[] numeros = inteiros;     // compila: arrays são covariantes
numeros[0] = 3.14;               // compila também...
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Double

Cada array sabe, durante a execução, o tipo real dos seus elementos, e confere cada gravação. Por isso o erro é pego, mas tarde, e com um custo em toda escrita. Os genéricos preferiram pegar o erro na compilação e pagar o preço da invariância. É uma das razões pelas quais genéricos e arrays não se misturam bem, como você vai ver no fim desta aula.

Wildcards: flexibilidade com segurança

A invariância é segura, mas rígida demais: o método somar só quer ler números, e para leitura não há perigo em aceitar uma lista de inteiros. O wildcard ? permite expressar isso.

? extends: listas que produzem valores

static double somar(Lista<? extends Number> numeros) {
    double total = 0;
    for (int i = 0; i < numeros.tamanho(); i++) {
        total += numeros.obter(i).doubleValue();
    }
    return total;
}

Lista<? extends Number> significa "uma lista de algum tipo que é Number ou um subtipo dele, não sei qual". Aceita Lista<Integer>, Lista<Double> e Lista<Number>:

IO.println(somar(inteiros) + " " + somar(reais));   // 3.0 0.5

O preço é que o compilador não sabe qual é o tipo exato. Ele sabe que o que sai da lista é algum Number, e por isso permite ler e chamar doubleValue(). Mas não sabe o que pode entrar, e por isso proíbe adicionar:

LeEscreve.java:3: error: incompatible types: int cannot be converted to CAP#1
        numeros.adicionar(1);

O CAP#1 é o nome que o compilador dá ao "tipo desconhecido" que o wildcard representa. Se aquela lista for, na verdade, uma Lista<Double>, adicionar um inteiro seria errado, e o compilador não arrisca. Uma lista ? extends é, na prática, só de leitura, porque ela produz valores para você.

? super: listas que consomem valores

O caso contrário: um método que só quer gravar inteiros numa lista.

static void preencherComUns(Lista<? super Integer> destino, int quantidade) {
    for (int i = 0; i < quantidade; i++) {
        destino.adicionar(1);
    }
}

Lista<? super Integer> significa "uma lista de algum tipo que é Integer ou um supertipo dele". Aceita Lista<Integer>, Lista<Number> e Lista<Object>, porque em qualquer uma delas cabe um Integer:

preencherComUns(numeros, 2);    // Lista<Number>
preencherComUns(objetos, 1);    // Lista<Object>
preencherComUns(inteiros, 1);   // Lista<Integer>

Agora a restrição é inversa. Adicionar um Integer é sempre seguro. Mas o que sai da lista pode ser um Number ou um Object qualquer, e a leitura só garante o tipo Object:

LeEscreve.java:4: error: incompatible types: CAP#1 cannot be converted to Integer
        Integer x = destino.obter(0);

Uma lista ? super consome os valores que você entrega.

PECS: a regra para escolher

Joshua Bloch resumiu a escolha numa sigla fácil de lembrar: PECS, Producer Extends, Consumer Super. Se o parâmetro produz valores que o método lê, use ? extends T. Se ele consome valores que o método grava, use ? super T. Se faz as duas coisas, não use wildcard.

O exemplo clássico junta os dois lados, um método que copia elementos de uma lista para outra:

static <T> void copiar(Lista<? extends T> origem, Lista<? super T> destino) {
    for (int i = 0; i < origem.tamanho(); i++) {
        destino.adicionar(origem.obter(i));
    }
}

A origem produz, e o destino consome. Com essa assinatura, dá para copiar uma Lista<Double> para uma Lista<Number>, ou uma Lista<Integer> para uma Lista<Object>, combinações que a versão sem wildcards recusaria. A biblioteca padrão tem um método com exatamente essa forma: Collections.copy(List<? super T> dest, List<? extends T> src).

Uma regra prática complementar: wildcards aparecem nos parâmetros de métodos, quase nunca nos tipos de retorno. Um método que devolve Lista<? extends Number> obrigaria quem o chama a lidar com um tipo desconhecido. Os wildcards existem para tornar as entradas flexíveis.

O wildcard sem limite

Lista<?> significa "uma lista de algum tipo qualquer". Serve para métodos que não se importam com o tipo dos elementos:

static int contar(Lista<?> qualquer) {
    return qualquer.tamanho();
}

Ela aceita qualquer Lista, e só permite ler elementos como Object. Não é o mesmo que Lista<Object>: essa última só aceita listas declaradas exatamente como Lista<Object>, pela invariância. E também não é o mesmo que o tipo cru Lista, porque, ao contrário dele, mantém a verificação: não deixa adicionar nada além de null.

Type erasure: os genéricos somem na compilação

Agora, a decisão de 2004. Quando os genéricos foram acrescentados ao Java 5, havia uma exigência inegociável: o código antigo, sem genéricos, precisava continuar funcionando e conversando com o código novo. Uma ArrayList criada por uma biblioteca antiga tinha de poder ser passada a um método novo que esperava ArrayList<String>, e vice-versa. A solução escolhida foi o apagamento de tipos (type erasure): os genéricos existem só na compilação. O compilador usa os parâmetros de tipo para verificar o código e depois os apaga, substituindo cada T pelo seu limite, que é Object quando não há limite declarado.

Dá para ver isso acontecendo. As duas listas abaixo são, na execução, exatamente a mesma classe:

Lista<String> a = new Lista<>();
Lista<Integer> b = new Lista<>();
IO.println(a.getClass() == b.getClass());    // true
IO.println(a.getClass().getName());          // Lista

Não existe uma classe Lista<String> na JVM. Existe só Lista. O javap -s mostra a assinatura real dos métodos no bytecode:

public void adicionar(E);
  descriptor: (Ljava/lang/Object;)V
public E obter(int);
  descriptor: (I)Ljava/lang/Object;

No bytecode, adicionar recebe um Object e obter devolve um Object. E onde foi parar a segurança? O compilador insere o cast em quem usa a classe. Um método que lê de uma Lista<String>:

static String primeiro(Lista<String> nomes) {
    return nomes.obter(0);
}

vira, no bytecode:

2: invokevirtual #7    // Method Lista.obter:(I)Ljava/lang/Object;
5: checkcast     #13   // class java/lang/String
8: areturn

O checkcast é o cast que você escreveria à mão na versão com Object de "Escreva uma Vez para Todo Tipo". Os genéricos não eliminaram os casts. Eles os tornaram automáticos e comprovadamente seguros, porque o compilador só os insere depois de verificar que o tipo certo sempre entra.

O que o apagamento impede

Como o tipo real de T não existe durante a execução, tudo o que precisaria dele é proibido:

public class Restricoes<T> {
    T criar() { return new T(); }                                      // erro
    T[] criarArray(int n) { return new T[n]; }                         // erro
    boolean teste(Object o) { return o instanceof Lista<String>; }     // erro
    static T estatico;                                                 // erro
}
Restricoes.java:2: error: unexpected type
Restricoes.java:3: error: generic array creation
Restricoes.java:4: error: Object cannot be safely cast to Lista<String>
Restricoes.java:5: error: non-static type variable T cannot be referenced from a static context

Uma por uma:

  • new T(): na execução, T é Object, e a JVM não saberia qual classe instanciar.
  • new T[n]: um array precisa saber o tipo exato dos elementos para fazer a verificação de ArrayStoreException vista no início da aula, e esse tipo não existe. É por isso que a Lista usa um Object[] interno com cast.
  • instanceof Lista<String>: na execução, só existe Lista, e não há como saber se ela foi declarada com String. instanceof Lista<?> é permitido, porque não pergunta pelo parâmetro.
  • Campos static do tipo T: existe uma única classe Lista para todas as Lista<X>. Um campo estático seria compartilhado por todas, e não pode ter um tipo que varia de uma para outra.

Outra consequência aparece na sobrecarga:

static void processar(Lista<String> textos) { }
static void processar(Lista<Integer> numeros) { }
Clash.java:3: error: name clash: processar(Lista<Integer>) and processar(Lista<String>) have the same erasure

Depois do apagamento, os dois métodos teriam a mesma assinatura, processar(Lista). A solução é dar nomes diferentes: processarTextos e processarNumeros.

Quando é preciso o tipo: o token de classe

Se um método genérico realmente precisa criar objetos ou arrays de T, a saída é pedir o tipo explicitamente, como um parâmetro Class<T>:

static <T> T criar(Class<T> tipo) throws Exception {
    return tipo.getDeclaredConstructor().newInstance();
}

@SuppressWarnings("unchecked")
static <T> T[] criarArray(Class<T> tipo, int tamanho) {
    return (T[]) java.lang.reflect.Array.newInstance(tipo, tamanho);
}
StringBuilder sb = criar(StringBuilder.class);
String[] arr = criarArray(String.class, 3);    // um String[] de verdade

O objeto Class<T>, obtido com NomeDaClasse.class, carrega durante a execução a informação que o apagamento removeu. É uma técnica de reflexão, assunto de "O Programa que se Examina", e o throws Exception é explicado em "Quando o Contrato se Quebra". Frameworks usam esse "token de tipo" o tempo todo: quando uma biblioteca converte um texto JSON num objeto, você passa Pessoa.class justamente para ela saber o que criar.

Poluição do heap

O apagamento abre uma brecha quando genéricos se misturam com tipos crus:

Lista<String> textos = new Lista<>();
Lista crua = textos;           // tipo cru: sem verificação
crua.adicionar(42);            // um Integer entra numa Lista<String>
IO.println("até aqui, tudo bem: " + textos.tamanho());
String s = textos.obter(0);    // o erro aparece aqui
até aqui, tudo bem: 1
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String

Na execução, a lista é só uma Lista com um Object[] dentro, e o 42 entra sem resistência. O erro só aparece na leitura, no checkcast que o compilador inseriu, possivelmente muito longe e muito depois da linha culpada. Essa situação, em que uma variável de tipo genérico aponta para um objeto que contém elementos de outro tipo, se chama poluição do heap (heap pollution). O compilador avisa sempre que ela é possível, com os avisos "unchecked" da aula anterior. É por isso que esses avisos não devem ser ignorados nem silenciados sem necessidade: cada um é um ponto em que a garantia dos genéricos pode ser furada.

O preço, e por que ele foi pago

O apagamento tem má fama, e as limitações são reais: não dá para criar T, nem arrays genéricos, nem consultar o parâmetro de tipo na execução. Os genéricos do C#, lançados um ano depois, em 2005, preservam o tipo na execução (são reificados), e não têm essas restrições.

Mas a escolha do Java tinha motivos concretos. Em 2004 já existiam milhões de linhas de código Java com coleções sem parâmetro de tipo, espalhadas por bibliotecas, servidores de aplicação e sistemas de empresas. Com o apagamento, uma biblioteca atualizada para genéricos continuava funcionando com código antigo, e o código antigo continuava funcionando com ela, sem recompilar nada e sem duplicar a biblioteca de coleções. A migração pôde acontecer aos poucos, projeto por projeto, ao longo de anos. É o valor da compatibilidade que "Uma Linguagem para Torradeiras que Conquistou o Mundo" apontou como um dos motivos da longevidade do Java, e aqui aparece o preço dele. Os genéricos de hoje ainda carregam essa decisão.

O Projeto Valhalla, no OpenJDK, trabalha há anos em mudanças profundas nessa área, incluindo tipos que se comportam como primitivos e, no futuro, genéricos que funcionem com eles sem embrulho. Até o JDK 27, nada disso chegou a uma versão oficial, nem em preview: o trabalho só pode ser experimentado em builds de acesso antecipado do próprio projeto.

Com genéricos e wildcards dominados, você tem todas as ferramentas para usar, e entender por dentro, a parte da biblioteca que aparece em praticamente todo programa Java: as coleções. A sua Lista<E> foi o primeiro passo. O Java traz prontas não só listas, mas também conjuntos, que não aceitam repetição, e mapas, que associam chaves a valores, cada um em várias implementações com custos diferentes. Escolher a coleção certa é uma das decisões mais frequentes da programação em Java.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Supondo Lista<Integer> li e Lista<Number> ln, diga quais atribuições compilam e explique: (a) Lista<Number> x = li; (b) Lista<? extends Number> y = li; (c) Lista<? super Integer> z = ln; (d) Lista<? extends Integer> w = ln; (e) Lista<?> q = li;

Ver resposta

✓ Resposta: (a) Não compila. Os genéricos são invariantes, e Lista<Integer> não é Lista<Number>. (b) Compila. ? extends Number aceita qualquer lista de Number ou subtipo, e Integer é subtipo de Number. (c) Compila. ? super Integer aceita listas de Integer ou de supertipos dele, e Number é supertipo de Integer. (d) Não compila. ? extends Integer exige Integer ou subtipo, e Number é supertipo. (e) Compila. O wildcard sem limite aceita qualquer lista. Uma forma de raciocinar: ? extends X aceita tudo o que está "abaixo" de X na hierarquia, e ? super X tudo o que está "acima".

Exercício 2

A biblioteca padrão declara o método que encontra o maior elemento de uma coleção como static <T extends Comparable<? super T>> T max(Collection<? extends T> coll). Explique cada wildcard dessa assinatura.

Ver resposta

✓ Resposta: Collection<? extends T> segue o PECS: a coleção produz os elementos que o método lê, então aceita coleções de T ou de qualquer subtipo. Comparable<? super T> é mais sutil. O método precisa chamar compareTo em elementos do tipo T. Com Comparable<T>, exigiria que T se comparasse exatamente com T. Mas às vezes é uma superclasse que implementa Comparable. Por exemplo, se Funcionario implements Comparable<Pessoa> (comparando pelo nome, definido em Pessoa), um Funcionario sabe se comparar com qualquer Pessoa, inclusive com outro Funcionario. Com Comparable<T>, uma coleção de funcionários seria recusada. Com Comparable<? super T>, é aceita. Aqui o Comparable consome um T como argumento de compareTo, e o PECS pede super. Assinaturas como essa assustam na primeira leitura, mas cada wildcard existe para aceitar um caso legítimo que a versão mais simples recusaria.

Exercício 3

Explique por que a Lista<E> da aula anterior usa um Object[] interno em vez de E[], e mostre uma alternativa que produza um E[] de verdade, dizendo qual é o custo dela.

Ver resposta

✓ Resposta: new E[4] não compila, porque criar um array exige conhecer o tipo exato dos elementos durante a execução, para as verificações de ArrayStoreException, e o apagamento remove E, que vira Object. A alternativa é receber o tipo no construtor: public Lista(Class<E> tipo), e criar o array com (E[]) java.lang.reflect.Array.newInstance(tipo, 4). Aí o array interno é de fato um String[] numa lista de textos. O custo é que quem usa a lista precisa passar o token, com new Lista<>(String.class), o que é mais verboso e impossível para tipos genéricos como Lista<Lista<String>>, porque não existe Lista<String>.class. Por isso a própria biblioteca, no ArrayList, preferiu a abordagem do Object[] com cast: é mais simples para quem usa, e o cast é seguro porque a classe controla o que entra.

Exercício 4

No exemplo de poluição do heap, o ClassCastException apareceu na linha String s = textos.obter(0), e não na linha crua.adicionar(42). Explique por que, e diga como o compilador tentou avisar.

Ver resposta

✓ Resposta: Por causa do apagamento, na execução a lista é só uma Lista com um Object[] interno, e adicionar recebe um Object. Não há nada que impeça o 42 de entrar: nenhuma verificação acontece na gravação. A verificação só existe no checkcast que o compilador insere onde um valor da lista é usado como String. É ali que o erro explode, longe da causa, o que torna o problema difícil de rastrear. Num programa real, as duas linhas poderiam estar em classes diferentes e ser executadas com horas de diferença. O compilador avisou: o uso do tipo cru Lista e a chamada crua.adicionar(42) geram avisos "unchecked" ("unchecked call to adicionar(E) as a member of the raw type Lista"). No exemplo da aula, esses avisos aparecem na compilação e foram ignorados, justamente o que não se deve fazer. A lição é tratar os avisos "unchecked" como erros em potencial.

Exercício 5

Os métodos processar(Lista<String>) e processar(Lista<Integer>) não podem coexistir. Proponha duas formas de resolver, uma com nomes e outra com um único método genérico, e diga quando cada uma é preferível.

Ver resposta

✓ Resposta: A primeira forma é usar nomes diferentes, processarTextos(Lista<String>) e processarNumeros(Lista<Integer>). É a solução certa quando o processamento é realmente diferente para cada tipo: métodos que fazem coisas diferentes merecem nomes diferentes, como já dizia "Dividir para Resolver" sobre a sobrecarga. A segunda forma é um único método genérico, como <T> void processar(Lista<T> itens), ou com wildcard, void processar(Lista<?> itens), que trata todas as listas da mesma forma. É a solução certa quando o processamento não depende do tipo dos elementos, como contar, imprimir ou copiar. Se o método genérico precisar tratar cada tipo de um jeito, com instanceof nos elementos, ele está escondendo dois métodos num só, e a primeira forma volta a ser a melhor.

Comentários

Mais em Java

Extensão — Janelas em Java: AWT, Swing e JavaFX
Extensão — Janelas em Java: AWT, Swing e JavaFX

Nesta aula você conhece a história das interfaces gráficas em Java, do AWT de…

Ordem e Critério — Comparable, Comparator e Coleções Ordenadas
Ordem e Critério — Comparable, Comparator e Coleções Ordenadas

Ordenação em Java: Comparable e a ordem natural, o perigo de comparar…

Dividir para Resolver — Métodos, Parâmetros e Recursão
Dividir para Resolver — Métodos, Parâmetros e Recursão

Como escrever métodos em Java: tipo de retorno, parâmetros, return, passagem…