Generics em Java OOP: tipos parametrizados, segurança de tipos e coleções

Capítulo 9

Tempo estimado de leitura: 9 minutos

+ Exercício

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.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

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 var apenas quando o tipo genérico ainda ficar óbvio no contexto.
  • Evite tipos crus (raw types) como List ou Map sem parâmetros.
  • Use interfaces no tipo da variável: List<T>, Map<K,V>, e não ArrayList<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ó tem List.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 Usuario filtrando 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> e List<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> para List<Number>.
  • Copiar de List<Usuario> para List<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.

Agora responda o exercício sobre o conteúdo:

Ao implementar um método que copia elementos de uma lista de origem para uma lista de destino, qual assinatura usa Generics corretamente para permitir copiar de List para List com segurança de tipos (PECS)?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Pela regra PECS: a origem apenas produz valores, então usa ? extends T. O destino consome valores inseridos, então usa ? super T, permitindo copiar List<Integer> para List<Number> sem casts.

Próximo capitúlo

Imutabilidade em Java OOP: objetos seguros, final, cópias defensivas e value objects

Arrow Right Icon
Capa do Ebook gratuito Java Orientado a Objetos: Do Conceito ao Código com Padrões de Projeto Básicos
53%

Java Orientado a Objetos: Do Conceito ao Código com Padrões de Projeto Básicos

Novo curso

17 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.