O que são Generics e por que eles importam em APIs
Generics permitem parametrizar tipos: você escreve uma classe, interface ou método uma vez e reutiliza com diferentes tipos, mantendo segurança de tipos (o compilador impede usos incorretos) e reduzindo casts.
Compare uma coleção sem Generics (pré-Java 5) com uma genérica:
// Sem generics (evite): perde segurança e exige casts
List lista = new ArrayList();
lista.add("abc");
lista.add(10); // compila, mas mistura tipos
String s = (String) lista.get(1); // ClassCastException em runtime
// Com generics: o compilador protege
List<String> nomes = new ArrayList<>();
nomes.add("abc");
// nomes.add(10); // erro de compilação
String ok = nomes.get(0);Em APIs, Generics ajudam a expressar intenção: List<Cliente> comunica claramente o que entra e sai, e o compilador vira seu “revisor” automático.
Generics com coleções: List e Map no dia a dia
List<T>: sequência tipada
Use List<T> quando a ordem importa e você quer múltiplos itens do mesmo tipo.
List<Integer> ids = List.of(10, 20, 30);
int soma = ids.stream().mapToInt(Integer::intValue).sum();Map<K, V>: chave-valor tipado
Use Map<K, V> para indexar valores por uma chave.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
Map<String, Integer> estoque = new HashMap<>();
estoque.put("SKU-1", 5);
estoque.put("SKU-2", 12);
int qtd = estoque.getOrDefault("SKU-3", 0);Boas práticas de legibilidade:
- Prefira
varapenas quando o tipo genérico ainda ficar óbvio no contexto. - Evite tipos crus (raw types) como
ListouMapsem parâmetros. - Use interfaces no tipo da variável:
List<T>,Map<K,V>, e nãoArrayList<T>diretamente.
Criando classes genéricas: Repositório em memória reutilizável
Um caso clássico: um repositório em memória que armazena entidades de qualquer tipo, desde que você consiga extrair um identificador.
Passo a passo: definindo um contrato de identificação
Crie uma interface pequena para expor o ID. Isso permite ao repositório trabalhar com qualquer entidade que “tenha um id”.
public interface Identificavel<ID> {
ID getId();
}Passo a passo: repositório genérico com Map
O repositório recebe dois parâmetros de tipo: o tipo da entidade (T) e o tipo do ID (ID).
public class RepositorioEmMemoria<T extends Identificavel<ID>, ID> {
private final Map<ID, T> dados = new HashMap<>();
public void salvar(T entidade) {
dados.put(entidade.getId(), entidade);
}
public Optional<T> buscarPorId(ID id) {
return Optional.ofNullable(dados.get(id));
}
public List<T> listarTodos() {
return new ArrayList<>(dados.values());
}
public boolean removerPorId(ID id) {
return dados.remove(id) != null;
}
}Note o uso de T extends Identificavel<ID>: isso é um bounded type parameter (limite), garantindo em tempo de compilação que T tem getId().
Exemplo de uso com duas entidades diferentes
public record Usuario(Long id, String nome) implements Identificavel<Long> {
@Override public Long getId() { return id; }
}
public record Produto(String id, String descricao) implements Identificavel<String> {
@Override public String getId() { return id; }
}
RepositorioEmMemoria<Usuario, Long> repoUsuarios = new RepositorioEmMemoria<>();
repoUsuarios.salvar(new Usuario(1L, "Ana"));
Optional<Usuario> u = repoUsuarios.buscarPorId(1L);
RepositorioEmMemoria<Produto, String> repoProdutos = new RepositorioEmMemoria<>();
repoProdutos.salvar(new Produto("SKU-1", "Caderno"));Sem casts, sem risco de buscar um Produto num repositório de Usuario.
Métodos genéricos: utilitários reutilizáveis
Às vezes você não precisa de uma classe genérica, apenas de um método genérico.
Exemplo: primeiro elemento ou vazio
public final class Colecoes {
private Colecoes() {}
public static <T> Optional<T> primeiro(List<T> lista) {
return (lista == null || lista.isEmpty())
? Optional.empty()
: Optional.of(lista.get(0));
}
}Exemplo: transformar lista (map) com Function
public static <T, R> List<R> mapear(List<T> origem, Function<T, R> fn) {
List<R> saida = new ArrayList<>(origem.size());
for (T item : origem) {
saida.add(fn.apply(item));
}
return saida;
}
List<Usuario> usuarios = List.of(new Usuario(1L, "Ana"), new Usuario(2L, "Bia"));
List<Long> ids = mapear(usuarios, Usuario::id);Esse tipo de utilitário é uma forma prática de construir APIs expressivas sem perder segurança de tipos.
Bounded type parameters: limites com extends e múltiplas restrições
Limites servem para dizer ao compilador quais operações são válidas sobre T.
Exemplo: máximo de uma lista de comparáveis
public static <T extends Comparable<? super T>> T maximo(List<T> itens) {
if (itens.isEmpty()) throw new IllegalArgumentException("lista vazia");
T max = itens.get(0);
for (T item : itens) {
if (item.compareTo(max) > 0) max = item;
}
return max;
}Por que Comparable<? super T>? Para aceitar tipos que implementam Comparable de si mesmos ou de um supertipo (padrão comum em hierarquias).
Múltiplos limites
Você pode exigir que T implemente mais de um contrato:
public static <T extends Identificavel<Long> & Serializable> Long idSerializavel(T obj) {
return obj.getId();
}Regra: se houver uma classe como limite, ela deve vir primeiro; depois, interfaces.
Curingas (wildcards): ? extends e ? super em cenários comuns
Wildcards são úteis quando você quer flexibilidade na entrada/saída sem “travar” o tipo exato.
Regra prática: PECS
- Producer Extends: se a estrutura produz valores para você ler, use
? extends T. - Consumer Super: se a estrutura consome valores que você vai inserir, use
? super T.
? extends: ler com segurança
Se você quer somar números, você só precisa ler:
public static double somar(List<? extends Number> nums) {
double total = 0;
for (Number n : nums) total += n.doubleValue();
// nums.add(1); // não compila: não sabemos o subtipo exato
return total;
}
List<Integer> ints = List.of(1, 2, 3);
List<Double> doubles = List.of(1.5, 2.5);
somar(ints);
somar(doubles);Com ? extends Number, você aceita List<Integer>, List<Double> etc., mas perde a capacidade de inserir (exceto null), porque o compilador não sabe qual subtipo exato a lista mantém.
? super: inserir com segurança
Se você quer copiar itens para uma lista destino, o destino é um consumidor:
public static <T> void copiar(List<T> origem, List<? super T> destino) {
for (T item : origem) {
destino.add(item);
}
}
List<Integer> origem = List.of(1, 2, 3);
List<Number> destino = new ArrayList<>();
copiar(origem, destino);O destino List<? super Integer> pode ser List<Integer>, List<Number> ou List<Object>. Ao ler do destino, o tipo mais seguro é Object (porque pode ser uma lista de supertipo).
Quando preferir <T> em vez de wildcard
Se o tipo precisa ser “amarrado” entre parâmetros (mesmo T em mais de um lugar), prefira um método genérico:
// Melhor do que usar wildcards soltos quando os tipos precisam se relacionar
public static <T> void trocar(List<T> lista, int i, int j) {
T tmp = lista.get(i);
lista.set(i, lista.get(j));
lista.set(j, tmp);
}Type erasure: o que acontece com Generics em runtime
Em Java, Generics são implementados via type erasure: em tempo de execução, o parâmetro de tipo (T) é “apagado” e substituído por seu limite (ou Object se não houver limite). Isso mantém compatibilidade com bytecode antigo, mas traz implicações práticas.
Implicações comuns
- Não existe
List<String>.class. Você só temList.class. - Você não pode criar
new T()dentro de código genérico (sem passar uma fábrica/constructor reference). - Você não pode criar arrays de tipos parametrizados:
new List<String>[10]não compila. - Overload não pode diferir apenas por parâmetro genérico (após erasure, assinaturas colidem).
Exemplos rápidos
// 1) instanceof com tipo parametrizado não compila
if (obj instanceof List<String>) { } // erro
// 2) new T() não compila
public class Fabrica<T> {
public T criar() {
// return new T(); // erro
return null;
}
}
// 3) Solução comum: passar Supplier
public class Fabrica<T> {
private final Supplier<T> supplier;
public Fabrica(Supplier<T> supplier) { this.supplier = supplier; }
public T criar() { return supplier.get(); }
}
Fabrica<ArrayList<String>> f = new Fabrica<>(ArrayList::new);
ArrayList<String> lista = f.criar();Sobre arrays: prefira List<T> ou, quando realmente precisar de array, use Array.newInstance com Class<T> (passando o token de classe).
Erros comuns e como evitá-los (legibilidade + segurança)
1) Usar raw types
List lista = new ArrayList(); // evita
List<String> lista = new ArrayList<>();2) “Resolver” com cast em vez de ajustar o tipo
// Evite
Object x = "abc";
String s = (String) x;
// Prefira modelar com generics
Optional<String> s2 = Optional.of("abc");3) Wildcards demais na API pública
Wildcards são ótimos em parâmetros de métodos, mas podem reduzir legibilidade quando aparecem em muitos lugares. Uma regra prática: use wildcards para flexibilizar entradas e use tipos concretos/parametrizados para saídas claras.
Exercícios práticos (com foco em repositórios e utilitários genéricos)
Exercício 1: Repositório em memória com busca por predicado
Estenda RepositorioEmMemoria<T, ID>:
- Adicionar método
List<T> buscar(Predicate<? super T> filtro). - Garantir que não há casts.
- Escrever um exemplo com
Usuariofiltrando por nome.
Exercício 2: Paginação genérica
Crie um utilitário:
public static <T> List<T> pagina(List<T> itens, int offset, int limit)- Tratar limites inválidos.
- Não modificar a lista original.
- Testar com
List<Produto>eList<Integer>.
Exercício 3: Copiador com PECS
Implemente e demonstre:
public static <T> void copiarTodos(List<? extends T> origem, List<? super T> destino)- Copiar de
List<Integer>paraList<Number>. - Copiar de
List<Usuario>paraList<Identificavel<Long>>(ajuste os tipos conforme sua modelagem).
Exercício 4: Repositório com fábrica (contornando type erasure)
Crie uma classe RepositorioComFabrica<T> que recebe Supplier<T> para criar instâncias “vazias” (por exemplo, para testes ou placeholders). Mostre por que new T() não funciona e como o Supplier resolve.