Como escrever classes e métodos que funcionam com qualquer tipo sem abrir mão da verificação do compilador, e como construir a sua própria lista genérica.
A Fase 2 terminou com uma promessa: explicar os sinais de menor e maior que apareceram em HashSet<Ponto> e EnumMap<Dia, Integer>. Eles marcam os genéricos, e esta aula abre a Fase 3 com eles. Você vai ver o problema que os genéricos resolvem, escrever classes e métodos genéricos, aprender a restringir os tipos aceitos, entender como os tipos primitivos entram nessa história e, no fim, construir uma lista que cresce sozinha e funciona com qualquer tipo de elemento, uma versão simplificada de uma das classes mais usadas do Java.
O problema: código que serve para tudo e não protege nada
Suponha que você queira uma classe Caixa, capaz de guardar um objeto qualquer. Antes dos genéricos, a única forma de aceitar "qualquer coisa" era usar Object, a raiz de todos os tipos, de "Do Geral ao Específico":
class Caixa {
private Object conteudo;
void guardar(Object o) { conteudo = o; }
Object abrir() { return conteudo; }
}
Caixa caixa = new Caixa();
caixa.guardar("um presente");
String presente = (String) caixa.abrir();
IO.println(presente.toUpperCase()); // UM PRESENTE
caixa.guardar(42);
String outro = (String) caixa.abrir(); // compila...
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
A classe aceita qualquer coisa, e por isso não garante nada. Quem abre a caixa precisa lembrar o que guardou e fazer um cast, e um erro só aparece na execução, possivelmente muito longe de onde o objeto errado entrou. É exatamente o tipo de erro que a tipagem estática de "Cada Valor em sua Caixa" deveria evitar. Até o Java 5, de 2004, todas as coleções da biblioteca funcionavam assim.
Classes genéricas
Um parâmetro de tipo transforma a classe num molde que recebe um tipo como argumento:
class Caixa<T> {
private T conteudo;
void guardar(T conteudo) {
this.conteudo = conteudo;
}
T abrir() {
return conteudo;
}
}
T é uma variável que representa um tipo. Dentro da classe, ele é usado como qualquer outro tipo. O valor de T é escolhido por quem usa a classe:
Caixa<String> caixa = new Caixa<>();
caixa.guardar("um presente");
String presente = caixa.abrir(); // sem cast
IO.println(presente.toUpperCase()); // UM PRESENTE
Caixa<Integer> numeros = new Caixa<>();
numeros.guardar(42);
int n = numeros.abrir();
IO.println(n + 1); // 43
Caixa<String> é uma caixa que só aceita textos. O cast desapareceu, e o erro que antes aparecia na execução agora é pego pelo compilador:
Erro.java:8: error: incompatible types: int cannot be converted to String
caixa.guardar(42);
^
O <> vazio do lado direito é o operador diamante: o compilador deduz o tipo pela declaração à esquerda, e não é preciso repetir new Caixa<String>().
Por convenção, os parâmetros de tipo têm nomes de uma letra maiúscula, o que os distingue de nomes de classe: T para um tipo qualquer, E para elementos de coleções, K e V para chaves e valores de mapas, e R para tipos de retorno. Uma classe pode ter vários parâmetros. Records também podem ser genéricos:
record Par<A, B>(A primeiro, B segundo) { }
Par<String, Integer> idade = new Par<>("Ana", 30);
IO.println(idade); // Par[primeiro=Ana, segundo=30]
IO.println(idade.primeiro().length() + idade.segundo()); // 33
O compilador sabe que primeiro() devolve um String, e por isso permite chamar length(). E sabe que segundo() devolve um Integer, e por isso permite a soma. Um Par é útil, por exemplo, para um método que precisa devolver dois valores:
Par<Integer, Integer> minMax(int[] valores) {
int min = valores[0];
int max = valores[0];
for (int v : valores) {
min = Math.min(min, v);
max = Math.max(max, v);
}
return new Par<>(min, max);
}
var extremos = minMax(new int[] {7, 3, 9, 1});
IO.println("mínimo " + extremos.primeiro() + ", máximo " + extremos.segundo());
// mínimo 1, máximo 9
Um aviso de projeto: um Par genérico é conveniente, mas nomes como primeiro e segundo não dizem nada. Num código real, um record próprio, como record Extremos(int minimo, int maximo), custa uma linha e é muito mais claro. Os genéricos são para código que realmente não se importa com o que está guardando, como caixas, listas e mapas.
Métodos genéricos
Um método também pode ter parâmetros de tipo próprios, declarados antes do tipo de retorno:
<T> T primeiroOuPadrao(T[] valores, T padrao) {
return valores.length > 0 ? valores[0] : padrao;
}
String[] nomes = {"Bia", "Caio"};
IO.println(primeiroOuPadrao(nomes, "ninguém")); // Bia
IO.println(primeiroOuPadrao(new String[0], "ninguém")); // ninguém
Na chamada, o compilador deduz T a partir dos argumentos. Essa é a inferência de tipos. O método é escrito uma vez e funciona com arrays de qualquer tipo de objeto, sempre com a garantia de que o valor devolvido tem o tipo dos elementos. Um record ou uma classe também podem ter métodos genéricos com parâmetros próprios, independentes dos da classe:
record Par<A, B>(A primeiro, B segundo) {
<C> Par<A, C> comSegundo(C novo) {
return new Par<>(primeiro, novo);
}
}
idade.comSegundo(true) devolve um Par<String, Boolean>.
Limitando o tipo: bounded types
Às vezes o método precisa de algo do tipo além de "ser um objeto". Para encontrar o maior elemento de um array, é preciso saber comparar os elementos. A biblioteca tem uma interface para isso, Comparable<T>, a interface "sei me comparar" mencionada em "Contratos sem Implementação". O método compareTo devolve um número negativo, zero ou positivo, conforme o objeto seja menor, igual ou maior que o outro:
IO.println("pera".compareTo("uva")); // -5: "pera" vem antes
IO.println(Integer.valueOf(3).compareTo(17)); // -1: 3 é menor
Um limite superior (upper bound) restringe os tipos aceitos por T:
<T extends Comparable<T>> T maior(T[] valores) {
T resultado = valores[0];
for (T v : valores) {
if (v.compareTo(resultado) > 0) {
resultado = v;
}
}
return resultado;
}
IO.println(maior(new Integer[] {3, 17, 8})); // 17
IO.println(maior(new String[] {"pera", "uva", "abacaxi"})); // uva
<T extends Comparable<T>> se lê: "T é qualquer tipo que saiba se comparar com outro T". Dentro do método, o compilador permite chamar compareTo porque todo T o tem. Fora, ele recusa tipos que não se comparam: um array de Object dá o erro inference variable T has incompatible bounds. Repare que, nos limites, extends vale tanto para classes quanto para interfaces. Um limite com uma classe funciona do mesmo jeito: <T extends Number> aceita Integer, Double e os demais números, e permite chamar doubleValue():
<T extends Number> double somar(T[] valores) {
double total = 0;
for (T v : valores) {
total += v.doubleValue();
}
return total;
}
A comparação de objetos, com Comparable e com a sua irmã Comparator, tem uma aula dedicada, "Ordem e Critério".
Genéricos e tipos primitivos
Há uma restrição importante: parâmetros de tipo só aceitam tipos de referência. Caixa<int> não compila:
Prim.java:3: error: unexpected type
Caixa<int> c = new Caixa<>();
^
required: reference
O motivo está na próxima aula: por baixo, um T é tratado como Object, e um int não é um objeto. Para cada tipo primitivo existe uma classe embrulho (wrapper) correspondente: Integer para int, Long para long, Double para double, Character para char, Boolean para boolean, e assim por diante. Você já conhece algumas delas pelos métodos estáticos Integer.parseInt e Double.parseDouble.
A conversão entre primitivo e embrulho é automática. De primitivo para objeto é o autoboxing, e de objeto para primitivo é o unboxing:
Integer embrulhado = 5; // autoboxing: int -> Integer
int desembrulhado = embrulhado; // unboxing: Integer -> int
Foi o autoboxing que permitiu Object numero = 42 em "Do Geral ao Específico" e Caixa<Integer> acima. A conversão é conveniente, mas não é gratuita, e esconde três armadilhas.
A identidade. Um Integer é um objeto, e == compara referências. O cache de −128 a 127 de "Iguais ou o Mesmo?" faz o == funcionar com números pequenos e falhar com números maiores. Com embrulhos, use sempre equals.
O unboxing de null. Um Integer pode ser null. Um int não pode. Desembrulhar um null lança exceção, numa linha que parece não ter nenhum objeto:
Integer nada = null;
int x = nada;
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<local3>" is null
A mensagem revela o que o compilador escreveu por baixo: uma chamada a intValue().
O custo. Cada autoboxing de um valor fora do cache cria um objeto no heap. Num laço intenso, a diferença é grande:
long soma = 0;
for (int i = 0; i < 100_000_000; i++) soma += i; // primitivo
Long somaObj = 0L;
for (int i = 0; i < 100_000_000; i++) somaObj += i; // embrulho
Na máquina em que este artigo foi testado, a versão primitiva levou cerca de 50 ms, e a versão com Long levou quase 700 ms, mais de dez vezes mais. Cada somaObj += i desembrulha, soma e embrulha um objeto novo. Use primitivos em cálculos e embrulhos só onde objetos são necessários, como dentro de coleções genéricas. O Projeto Valhalla, em desenvolvimento no OpenJDK, trabalha para reduzir esse custo no futuro, e você vai ouvir falar dele em "O Preço da Compatibilidade".
Construindo uma lista genérica
Agora, o exemplo principal. Em "O que Fica Dentro, o que Fica Fora", a ContaBancaria guardava os movimentos num array que dobrava de tamanho quando enchia. Em "Contratos sem Implementação", o NotificadorRegistrador repetiu o mesmo código para textos. Com genéricos, dá para escrever essa lógica uma vez, para qualquer tipo:
import java.util.Arrays;
public class Lista<E> {
private Object[] elementos = new Object[4];
private int tamanho = 0;
public void adicionar(E elemento) {
if (tamanho == elementos.length) {
elementos = Arrays.copyOf(elementos, elementos.length * 2);
}
elementos[tamanho++] = elemento;
}
@SuppressWarnings("unchecked")
public E obter(int indice) {
if (indice < 0 || indice >= tamanho) {
throw new IndexOutOfBoundsException("Índice " + indice + ", tamanho " + tamanho);
}
return (E) elementos[indice];
}
public int tamanho() {
return tamanho;
}
public boolean contem(E procurado) {
for (int i = 0; i < tamanho; i++) {
if (elementos[i].equals(procurado)) {
return true;
}
}
return false;
}
@Override
public String toString() {
return Arrays.toString(Arrays.copyOf(elementos, tamanho));
}
}
Alguns detalhes:
- O array interno é
Object[], e nãoE[]. Não é possível criar um array de um tipo genérico comnew E[4], por uma razão que a próxima aula explica. O array guardaObject, e a classe garante que só entram elementos do tipoE, porqueadicionarsó aceitaE. - O cast em
obteré seguro por essa razão, mas o compilador não consegue provar isso e emitiria um aviso de "unchecked cast". A anotação@SuppressWarnings("unchecked")silencia o aviso. Ela deve ficar no menor escopo possível, aqui só no método, e deve ser usada só quando você tem certeza de que o cast é seguro. - A validação do índice usa a exceção padrão da biblioteca para esse caso,
IndexOutOfBoundsException, com uma mensagem clara. contemusaequals, e não==, pela lição de "Iguais ou o Mesmo?". Por isso, procurar umPontocom as mesmas coordenadas funciona, desde que oPontotenhaequals.
public class TestaLista {
public static void main(String[] args) {
Lista<String> nomes = new Lista<>();
for (String n : new String[] {"Ana", "Bia", "Caio", "Davi", "Eva"}) {
nomes.adicionar(n);
}
IO.println(nomes + " tamanho " + nomes.tamanho());
IO.println(nomes.obter(4).toUpperCase());
IO.println(nomes.contem("Caio") + " " + nomes.contem("Zeca"));
Lista<Integer> quadrados = new Lista<>();
for (int i = 1; i <= 5; i++) {
quadrados.adicionar(i * i);
}
int soma = 0;
for (int i = 0; i < quadrados.tamanho(); i++) {
soma += quadrados.obter(i);
}
IO.println(quadrados + " soma " + soma);
}
}
[Ana, Bia, Caio, Davi, Eva] tamanho 5
EVA
true false
[1, 4, 9, 16, 25] soma 55
O quinto nome passou da capacidade inicial de 4, e o array interno dobrou sem que ninguém de fora percebesse. nomes.obter(4).toUpperCase() compila sem cast, porque o compilador sabe que obter devolve String numa Lista<String>. Na lista de inteiros, i * i é embrulhado automaticamente ao entrar, e soma += quadrados.obter(i) é desembrulhado ao sair. nomes.obter(5) lança IndexOutOfBoundsException: Índice 5, tamanho 5.
Você acabou de escrever uma versão simplificada do ArrayList, a lista mais usada da biblioteca padrão. Ela é, essencialmente, isto: um array de Object que cresce, com uma interface genérica por cima. A versão real tem muito mais métodos e cuidados, mas o princípio é o mesmo.
Tipos crus: o legado de 2004
Por compatibilidade com o código anterior ao Java 5, ainda é possível usar uma classe genérica sem o parâmetro de tipo, como ArrayList lista = new ArrayList();. Isso é um tipo cru (raw type), que desliga a verificação de tipos e volta ao comportamento da Caixa com Object. O compilador avisa:
Note: Raw.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
Tipos crus existem só para que o código antigo continue compilando. Nunca os use em código novo. E não ignore esse aviso quando ele aparecer: ele significa que algum ponto do programa pode ter um ClassCastException escondido.
Os genéricos parecem simples na superfície, mas escondem duas perguntas incômodas. A primeira: se Integer é um Number, uma Lista<Integer> é uma Lista<Number>? A intuição diz que sim, e o compilador diz que não. A segunda: por que não é possível criar um new E[4], se E é um tipo como outro qualquer? As duas respostas vêm de uma decisão tomada em 2004 para que o código antigo continuasse funcionando.
Fontes e leituras recomendadas
- Oracle, The Java Tutorials, "Generics": https://docs.oracle.com/javase/tutorial/java/generics/index.html. A trilha completa do tutorial, de classes genéricas a limites e inferência.
- Gilad Bracha, "Generics in the Java Programming Language" (2004): https://www.oracle.com/technetwork/java/javase/generics-tutorial-159168.pdf. O tutorial original de um dos projetistas dos genéricos, escrito no lançamento do Java 5. Continua sendo uma das melhores introduções.
- Joshua Bloch, Effective Java, 3ª edição, itens 26, 29, 30 e 61: "Don't use raw types", "Favor generic types", "Favor generic methods" e "Prefer primitive types to boxed primitives". O item 29 constrói uma pilha genérica com a mesma técnica do array de
Objectdesta aula. - Documentação da classe
Integer(Java SE 25): https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Integer.html. Os métodos da classe embrulho, incluindovalueOf, que implementa o cache. - Código-fonte do
ArrayListno OpenJDK: https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/ArrayList.java. Vale abrir e comparar com aListadesta aula: o campoelementDataé umObject[], e o métodogrowfaz o crescimento.
Exercícios
Exercício 1
Na versão da Caixa com Object, o erro aparecia na linha do cast, durante a execução. Na versão genérica, onde e quando o mesmo erro aparece? Por que essa mudança de lugar é tão valiosa?
Ver resposta
✓ Resposta: Na versão genérica, o erro aparece na linha caixa.guardar(42), e na compilação: o compilador recusa guardar um Integer numa Caixa<String>. Na versão com Object, o erro aparecia só na execução, na linha que abre a caixa, e só se aquele caminho do programa fosse executado. O valor disso está em duas coisas. A primeira é o momento: um erro de compilação aparece para o programador, antes do programa existir, e não para o usuário, em produção. A segunda é o lugar: o erro aponta onde o objeto errado entrou, que é onde está o defeito, e não onde ele foi usado, que pode ser em outro arquivo, dias depois. Encontrar a origem de um ClassCastException exige descobrir quem colocou o objeto errado ali, e o genérico elimina essa investigação.
Exercício 2
Escreva um método genérico <T> void trocar(T[] valores, int i, int j) que troque dois elementos de um array de posição. Teste com um array de textos e outro de inteiros. Por que o método pode ser void?
Ver resposta
✓ Resposta:
<T> void trocar(T[] valores, int i, int j) {
T temp = valores[i];
valores[i] = valores[j];
valores[j] = temp;
}
Com String[] s = {"a", "b", "c"}, trocar(s, 0, 2) deixa [c, b, a]. Com Integer[] n = {1, 2, 3}, trocar(n, 0, 1) deixa [2, 1, 3]. A variável temporária é do tipo T, e o compilador garante que só elementos do mesmo tipo são trocados entre si. O método pode ser void pela razão de "Muitos Valores sob um Nome": ele recebe uma cópia da referência que aponta para o mesmo array de quem chamou, e as trocas acontecem nesse array. Não funciona com int[], porque T só aceita tipos de referência, e seria preciso um array Integer[] ou uma versão específica para int. É por isso que a classe Arrays tem versões de sort e fill para cada tipo primitivo.
Exercício 3
Escreva um método <T extends Comparable<T>> boolean estaOrdenado(T[] valores) que diga se o array está em ordem crescente. Teste com {1, 3, 3, 7}, {5, 2} e {"ana", "bia", "caio"}.
Ver resposta
✓ Resposta:
<T extends Comparable<T>> boolean estaOrdenado(T[] valores) {
for (int i = 1; i < valores.length; i++) {
if (valores[i - 1].compareTo(valores[i]) > 0) {
return false;
}
}
return true;
}
Os resultados são true, false e true. O laço compara cada elemento com o anterior e devolve false no primeiro par fora de ordem, com retorno antecipado. Elementos iguais, como o 3 repetido, não violam a ordem, porque o teste é > 0, e não >= 0. Arrays vazios ou de um elemento estão ordenados por definição: o laço não executa nenhuma vez e o método devolve true. O limite extends Comparable<T> é o que permite chamar compareTo. Sem ele, o compilador não saberia que os elementos se comparam.
Exercício 4
O método contem da Lista usa elementos[i].equals(procurado). Explique o que aconteceria numa Lista<Integer> se ele usasse ==, e o que acontece hoje se a lista contiver um elemento null.
Ver resposta
✓ Resposta: Com ==, a comparação seria de referências. Numa Lista<Integer>, contem(100) funcionaria por coincidência, porque os valores entre −128 e 127 vêm do cache e o objeto guardado e o procurado seriam o mesmo. contem(1000) devolveria false mesmo com 1000 na lista, porque o autoboxing criaria objetos diferentes. É a armadilha de "Iguais ou o Mesmo?". Com equals, o problema não existe. Quanto ao null: se a lista contiver um elemento null, a chamada elementos[i].equals(...) sobre ele lança NullPointerException. A correção é usar Objects.equals(elementos[i], procurado), que trata null dos dois lados com segurança e ainda permite procurar o próprio null. É o que o ArrayList real faz.
Exercício 5
Acrescente à Lista um método E remover(int indice) que remova o elemento da posição, desloque os seguintes uma posição para trás e devolva o elemento removido. Por que é importante atribuir null à última posição ocupada depois do deslocamento?
Ver resposta
✓ Resposta:
@SuppressWarnings("unchecked")
public E remover(int indice) {
E removido = obter(indice); // reaproveita a validação do índice
for (int i = indice; i < tamanho - 1; i++) {
elementos[i] = elementos[i + 1];
}
elementos[--tamanho] = null;
return removido;
}
Numa lista [Ana, Bia, Caio], remover(0) devolve "Ana" e deixa [Bia, Caio]. O null na última posição é importante por causa do coletor de lixo. Depois do deslocamento, a última posição ocupada ainda guarda uma referência ao último elemento, que agora também está uma posição antes. A lista nunca mais vai ler essa posição, porque tamanho diminuiu, mas o array continua segurando a referência. Se o elemento removido fosse o último da lista, ele seria um objeto que ninguém mais usa, e que o coletor não pode recolher, porque ainda existe uma referência a ele. Numa lista que recebe e remove muitos objetos grandes, isso vira um vazamento de memória. Essas referências esquecidas são um dos poucos jeitos de vazar memória numa linguagem com coletor de lixo, e o tema volta em "Quem Recolhe o Lixo". Repare também no reaproveitamento de obter: a validação do índice fica num lugar só.