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 comoEmployee. Não pode adicionar nada além denull, porque o tipo real dos elementos pode serManagere o que você tem em mãos pode ser umContractor.List<? super Manager>— uma lista de Manager ou de algum supertipo. Você pode adicionar umManager(ele serve, seja qual for o tipo real). A leitura devolveObject, porque o tipo real pode serEmployeeouObject.List<?>— uma lista de alguma coisa. Lê-se comoObject, 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 escrever | Porque |
|---|---|
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 field | Membros 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
? extendspara parâmetros dos quais você lê e? superpara 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 einstanceof List<String>são impossíveis; passe um tokenClass<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>.