Programação Java

Generics em Java: parâmetros de tipo, limites, wildcards e type erasure

Intermediário8 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Generics permitem escrever uma classe ou um método uma única vez e usá-lo com segurança para vários tipos, levando os erros de cast do tempo de execução para o tempo de compilação. Este guia explica como declarar e usar parâmetros de tipo, como limites e wildcards expressam do que um método precisa, por que uma List<Dog> não é uma List<Animal>, o que o type erasure permite e o que impede, e quais padrões idiomáticos — PECS, métodos genéricos, type tokens — deixam o código genérico legível.

O que você vai aprender

  • Declarar classes, interfaces e métodos genéricos com parâmetros de tipo significativos
  • Aplicar limites superiores e inferiores e a regra PECS a parâmetros com wildcard
  • Explicar a invariância e por que arrays se comportam de forma diferente das coleções genéricas
  • Descrever o type erasure e contornar seus limites com class tokens e métodos auxiliares
  • Reconhecer raw types e warnings unchecked como bugs esperando para acontecer

Antes da chegada dos generics no Java 5, uma List guardava Object, toda leitura exigia um cast e um cast errado aparecia como uma ClassCastException longe do código que o causou. Generics levam essa verificação para o compilador: uma List<String> só pode conter strings, e get devolve uma String sem cast. A mecânica é simples até você esbarrar em wildcards e em type erasure, que é onde a maioria dos desenvolvedores deixa de entender o que está escrevendo. Este guia leva você pelos dois. Os exemplos usam Java 21.

Classes e interfaces genéricas

Um parâmetro de tipo é um marcador de posição para um tipo, declarado entre os sinais de menor e maior depois do nome da classe e usado dentro dela em qualquer lugar onde um tipo poderia aparecer:

public final class Pair<A, B> {
    private final A first;
    private final B second;

    public Pair(A first, B second) {
        this.first = first;
        this.second = second;
    }

    public A first()  { return first; }
    public B second() { return second; }

    public <C> Pair<A, C> withSecond(C newSecond) {      // generic method inside a generic class
        return new Pair<>(first, newSecond);
    }
}

Pair<String, Integer> p = new Pair<>("age", 42);
Integer age = p.second();                                 // no cast
Pair<String, Boolean> q = p.withSecond(true);

O diamond <> em new Pair<>(...) pede ao compilador que infira os argumentos a partir do tipo de destino, então você só os escreve uma vez. Por convenção, parâmetros de tipo são uma única letra maiúscula: T para um tipo, E para um elemento, K/V para chave e valor, R para um resultado. Nomes mais longos, como Request, são válidos e às vezes mais claros em assinaturas complexas.

Parâmetros de tipo precisam ser tipos de referência. List<int> não compila; você escreve List<Integer> e paga o custo do boxing, e é por isso que a API de streams tem IntStream e que java.util.function tem IntPredicate, ToLongFunction e outras especializações para primitivos.

Interfaces genéricas funcionam do mesmo jeito. Comparable<T>, Iterable<T>, Function<T, R> e Map<K, V> são as que você mais implementa ou recebe como parâmetro, e uma classe pode fixar o parâmetro ao implementá-la — class Version implements Comparable<Version> —, o que permite que compareTo receba um Version em vez de um Object.

Métodos genéricos

Um método pode introduzir seu próprio parâmetro de tipo, declarado antes do tipo de retorno. É a escolha certa para utilitários estáticos e para qualquer método cuja relação entre tipos não dependa da classe que o contém:

public static <T> List<T> repeat(T value, int times) {
    List<T> result = new ArrayList<>(times);
    for (int i = 0; i < times; i++) result.add(value);
    return result;
}

public static <T, R> List<R> mapAll(List<T> input, Function<? super T, ? extends R> fn) {
    List<R> out = new ArrayList<>(input.size());
    for (T item : input) out.add(fn.apply(item));
    return out;
}

List<String> xs = repeat("x", 3);                    // T inferred as String
List<Integer> lengths = mapAll(xs, String::length);  // T = String, R = Integer

A inferência quase sempre encontra os argumentos. Quando falha — normalmente com uma coleção vazia ou com uma chamada encadeada cujo tipo de destino é desconhecido —, você pode informá-los explicitamente com um type witness: Collections.<String>emptyList().

Parâmetros de tipo limitados

Dentro do método, um T sem limite é, na prática, Object: você pode armazená-lo e devolvê-lo, mas não chamar nada nele. Um limite (bound) restringe o parâmetro e libera os métodos do tipo limitante:

public static <T extends Comparable<? super T>> T max(Collection<? extends T> items) {
    Iterator<? extends T> it = items.iterator();
    T best = it.next();
    while (it.hasNext()) {
        T candidate = it.next();
        if (candidate.compareTo(best) > 0) best = candidate;   // compareTo is available
    }
    return best;
}

T extends Comparable<? super T> se lê como "T pode ser comparado consigo mesmo ou com um supertipo seu". O ? super T faz diferença: se Employee implements Comparable<Employee> e Manager extends Employee, então max(List<Manager>) compila porque Manager é comparável por meio do Comparable<Employee> herdado. Com um limite Comparable<T> simples, a chamada só compilaria inferindo T como Employee, então max devolveria um Employee e você voltaria a fazer cast. Limites múltiplos usam &: <T extends Number & Comparable<T>>, com no máximo uma classe e quantas interfaces quiser.

A palavra-chave é sempre extends, mesmo para interfaces; não existe implements em um limite.

Invariância e por que arrays são diferentes

Tipos genéricos são invariantes: List<Manager> não é subtipo de List<Employee>, embora Manager seja subtipo de Employee. O motivo é a solidez do sistema de tipos. Se a atribuição fosse permitida:

List<Manager> managers = new ArrayList<>();
List<Employee> employees = managers;     // hypothetically allowed
employees.add(new Contractor());         // legal on a List<Employee>...
Manager m = managers.get(0);             // ...and now this explodes

Arrays fizeram a escolha oposta: Manager[] é subtipo de Employee[] (covariância), e a JVM verifica cada escrita em tempo de execução, lançando ArrayStoreException se um Contractor for colocado no que na verdade é um Manager[]. Generics levaram a verificação para o tempo de compilação e abriram mão da covariância para isso. É também por isso que você não pode criar um array genérico (new T[10] e new List<String>[3] são inválidos): a verificação em tempo de execução precisaria de um tipo que o erasure já removeu.

Wildcards e a regra PECS

A invariância é segura, mas restritiva: um método printAll(List<Employee>) recusaria uma List<Manager>. Wildcards devolvem a flexibilidade no lado do uso, declarando do que o método precisa.

  • List<? extends Employee> — uma lista de Employee ou de algum subtipo. Você pode ler os elementos como Employee. Não pode adicionar nada além de null, porque o tipo real dos elementos pode ser Manager e o que você tem em mãos pode ser um Contractor.
  • List<? super Manager> — uma lista de Manager ou de algum supertipo. Você pode adicionar um Manager (ele serve, seja qual for o tipo real). A leitura devolve Object, porque o tipo real pode ser Employee ou Object.
  • List<?> — uma lista de alguma coisa. Lê-se como Object, não se adiciona nada.

O mnemônico é PECS: Producer Extends, Consumer Super. Se o parâmetro produz valores que você lê, use extends; se ele consome valores que você coloca, use super; se faz as duas coisas, use o tipo exato.

// Copy from a producer into a consumer
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (T item : src) dest.add(item);
}

List<Employee> staff = new ArrayList<>();
List<Manager> mgrs = List.of(new Manager("Ada"), new Manager("Grace"));
copy(staff, mgrs);                        // dest is a super of Manager, src produces Managers

As próprias assinaturas do JDK seguem essa regra: Collections.copy(List<? super T> dest, List<? extends T> src), Stream.map(Function<? super T, ? extends R>), Collection.addAll(Collection<? extends E>). Quando você vê ? super T em um Comparator ou Consumer, é o PECS em ação — um Comparator<Object> consegue comparar funcionários, então sort(List<T>, Comparator<? super T>) o aceita.

Wildcards pertencem aos parâmetros de método. Tipos de retorno devem ser concretos: um método que devolve List<? extends Shape> obriga cada chamador a lidar com o wildcard sem benefício algum.

Type erasure

Generics só existem em tempo de compilação. Depois da verificação, o compilador apaga os parâmetros de tipo: um T sem limite vira Object, um T extends Number limitado vira Number, e casts são inseridos nos pontos de chamada. List<String> e List<Integer> são a mesma classe em tempo de execução. Esse foi o preço da compatibilidade com o bytecode anterior aos generics, e ele tem consequências:

Você não pode escreverPorque
new T()Não há classe para instanciar em tempo de execução
new T[n]Um array precisa de um tipo de elemento em tempo de execução
obj instanceof List<String>O parâmetro não existe mais; instanceof List<?> é permitido
catch (T e)Tipos de exceção precisam ser reificáveis
Sobrecargas f(List<String>) e f(List<Integer>)Têm a mesma assinatura depois do erasure
static T fieldMembros estáticos são compartilhados por todas as parametrizações

A saída padrão para "preciso da classe em tempo de execução" é um type token: passar Class<T> explicitamente.

public static <T> T firstOfType(List<?> items, Class<T> type) {
    for (Object item : items) {
        if (type.isInstance(item)) return type.cast(item);   // safe, checked cast
    }
    return null;
}

Integer n = firstOfType(List.of("a", 7, 2.0), Integer.class);   // 7

Class<T>.cast e isInstance dão a você um cast verificado, sem warning unchecked. Frameworks como Jackson e Spring usam esse padrão e o estendem com subclasses de ParameterizedTypeReference/TypeReference para capturar um tipo completo como List<Foo> via reflection sobre uma subclasse anônima — o único lugar em que o erasure mantém a assinatura completa intacta.

O erasure também introduz os bridge methods: quando Version implements Comparable<Version>, o compilador gera um compareTo(Object) oculto que faz o cast e delega, para que o código que chama pela interface apagada continue funcionando. Você só vai notá-los em stack traces e em reflection.

Raw types e warnings unchecked

Um raw type é um tipo genérico usado sem argumentos: List list = new ArrayList();. Ele só existe para que código anterior ao Java 5 compile, e usá-lo desliga as verificações de tipo para tudo o que encosta nele. Cada raw type em uma base de código moderna é uma ClassCastException latente.

Um warning unchecked significa que o compilador não conseguiu provar que um cast ou uma chamada é type-safe — tipicamente um cast para um tipo parametrizado, como (List<String>) obj. Nunca o suprima com @SuppressWarnings("unchecked") sem um comentário que prove por que é seguro, e coloque a anotação no menor escopo possível (uma variável local, não a classe). O padrão Class<T>.cast visto acima evita o warning por completo em casts para um único tipo.

Um caso específico e comum: varargs genéricos. List<String>... lists cria um array de um tipo genérico, e o compilador emite um warning em cada ponto de chamada. Se o método só lê o array, anote-o com @SafeVarargs (permitido em métodos static, final e private) e o warning some dos pontos de chamada.

Limites recursivos e builders fluentes

<T extends Comparable<T>> é um limite de tipo recursivo: o parâmetro aparece no próprio limite. O mesmo truque permite que uma classe base de builder devolva o tipo concreto da subclasse a partir dos seus métodos fluentes:

abstract class Builder<B extends Builder<B>> {
    protected String name;
    public B name(String name) { this.name = name; return self(); }
    protected abstract B self();
}

final class UserBuilder extends Builder<UserBuilder> {
    private String email;
    public UserBuilder email(String email) { this.email = email; return this; }
    @Override protected UserBuilder self() { return this; }
    public User build() { return new User(name, email); }
}

User u = new UserBuilder().name("Ada").email("[email protected]").build();
// Without the recursive bound, name() would return Builder and email() would not exist

Use com moderação: é poderoso, mas difícil de ler, e muitas vezes um único builder concreto atende melhor.

Erros comuns

Raw types. new ArrayList() ou Map map jogam fora as verificações. Adicione os argumentos de tipo; use o diamond para escrevê-los uma vez só.

Devolver wildcards. List<? extends Number> compute() empurra complexidade para cada chamador. Devolva List<Number> ou um tipo específico.

Wildcards dos dois lados. void merge(List<? extends T> a, List<? extends T> b) não consegue adicionar nada de b em a. Quando um parâmetro é lido e escrito, use o próprio parâmetro de tipo.

Supor que List<String> sobrevive até o tempo de execução. Desserialização, reflection e instanceof enxergam apenas List. Use type tokens ou conversões explícitas.

Misturar arrays e generics. Campos T[], new ArrayList<String>[n] e List<String>[] geram warnings ou erros. Prefira List<List<String>>.

Abusar de Object para fugir dos generics. Um método que recebe Object e faz cast por dentro é o mundo pré-generics com passos extras. Introduza um parâmetro de tipo.

Limitar só com Comparable<T>. Rejeita subclasses de uma classe comparável. Use Comparable<? super T>.

Principais pontos

  • Parâmetros de tipo levam as falhas de cast para o tempo de compilação; declare-os em classes, interfaces ou métodos individuais e deixe o diamond e a inferência preencherem os argumentos.
  • Limites (T extends X) tornam os métodos do tipo limitante chamáveis; Comparable<? super T> é o limite correto para elementos ordenáveis.
  • Tipos genéricos são invariantes. Use ? extends para parâmetros dos quais você lê e ? super para parâmetros nos quais você escreve (PECS); devolva tipos concretos.
  • O erasure remove os argumentos de tipo em tempo de execução, então new T(), arrays genéricos e instanceof List<String> são impossíveis; passe um token Class<T> quando precisar do tipo.
  • Raw types e warnings unchecked não tratados são bugs esperando para acontecer; corrija-os em vez de silenciá-los.
  • Arrays são covariantes e verificados em tempo de execução; coleções são invariantes e verificadas em tempo de compilação. Prefira coleções em código genérico.

Fontes e leituras complementares

Perguntas frequentes

Por que não consigo atribuir uma List<Dog> a uma List<Animal>?
Porque tipos genéricos são invariantes. Se a atribuição fosse permitida, o código que tem a List<Animal> em mãos poderia adicionar um Cat ao que na verdade é uma lista de cachorros, e a falha só apareceria depois, como uma ClassCastException em algum lugar sem relação. Use List<? extends Animal> quando você só precisa ler animais da lista.
O que significa "type erasure"?
O compilador verifica os tipos genéricos e depois os remove do bytecode, substituindo os parâmetros de tipo pelo seu limite (geralmente Object) e inserindo casts onde for preciso. Em tempo de execução, uma List<String> e uma List<Integer> são a mesma classe. É por isso que você não pode escrever new T(), T[] nem instanceof List<String>.
Qual é a diferença entre List<Object> e List<?>?
List<Object> é uma lista que pode conter qualquer objeto e à qual você pode adicionar qualquer coisa; só uma List<Object> pode ser atribuída a ela. List<?> é uma lista de algum tipo específico desconhecido: qualquer List<X> pode ser atribuída a ela, mas você só pode adicionar null, porque o compilador não sabe o que é X.
Devo usar T extends Comparable<T> ou T extends Comparable<? super T>?
A segunda forma é mais flexível. Se uma classe Employee implementa Comparable<Employee> e Manager estende Employee, Manager é comparável pela implementação da superclasse. Comparable<? super T> aceita isso; Comparable<T> sozinho rejeita uma List<Manager>.