A essência dos generics em Java é a parametrização de tipos, onde os tipos de dados com os quais se opera são especificados como um parâmetro. Esta abordagem promove a reutilização de código e a segurança de tipos.
Em Java, os generics podem ser aplicados de três maneiras principais: em classes, interfaces e métodos.
Símbolos de Parâmetros de Tipo
Símbolos comuns usados como placeholders para tipos incluem:
- E - Element (utilizado em coleções)
- T - Type (para uma classe Java genérica)
- K - Key (para chaves em um Map)
- V - Value (para valores em um Map)
- N - Number (para tipos numéricos)
- ? - Wildcard, representando um tipo desconhecido ou qualquer tipo.
É possível utilizar nomes personalizados, como no exemplo a seguir:
class RespostaApi<T> implements Serializable {
private T conteudo;
// getters e setters
}
O nome do parâmetro de tipo é apenas um identificador.
Métodos Genéricos
Um método genérico define seus próprios parâmetros de tipo, independentemente de sua classe conter generics ou não. A declaração do parâmetro de tipo deve preceder o tipo de retorno.
public <E> void processarItens(E[] itens) {
// lógica que usa o array de tipo genérico E
}
Em uma classe que já possui um parâmetro de tipo (por exemplo, Classe<T>), definir um novo <T> em um método cria um parâmetro de tipo separado para aquele método, sem conflito com o parâmetro da classe.
public class Gerenciador<T> {
private T item;
public T getItem() {
return item;
}
public void setItem(T item) {
this.item = item;
}
// Este método define seu próprio parâmetro de tipo 'U'
public <U> void inspecionar(U elemento) {
System.out.println("Elemento inspecionado: " + elemento);
}
}
Parâmetros de Tipo Delimitados (Bounded Type Parameters)
Para restringir os tipos que podem ser usados como argumento de tipo, utiliza-se a palavra-chave extends. Isso é útil para garantir que o tipo possua certas capacidades, como implementar uma interface.
public static <T extends Comparable<T>> T encontrarMaximo(T x, T y, T z) {
T maximo = x;
if (y.compareTo(maximo) > 0) {
maximo = y;
}
if (z.compareTo(maximo) > 0) {
maximo = z;
}
return maximo;
}
Existe também o conceito de limite inferior com ? super T, que indica que o tipo pode ser T ou uma superclasse de T, sendo útil para operações de escrita seguras em coleções.
Classes e Interfaces Genéricas
A declaração de uma classe genérica segue o padrão das classes normais, mas adiciona uma seção de parâmetros de tipo após o nome da classe.
public class Container<T> {
private T conteudo;
public Container(T conteudo) {
this.conteudo = conteudo;
}
public T getConteudo() {
return conteudo;
}
}
Type Erasure (Apagamento de Tipo)
A implementação de generics em Java ocorre através do type erasure. Isso significa que as informações de tipo genérico existem apenas no código fonte e durante a compilação. Após a compilação, elas são substituídas pelos seus tipos limitantes (Object ou a classe delimitadora superior). Isso é conhecido como generics "falsos".
Isso contrasta com a implementação de generics "reais", como em C#, onde List<Integer> e List<String> permanecem tipos distintos em tempo de execução (type expansion).
Tipo Bruto (Raw Type)
Após o type erasure, o compilador gera um único tipo bruto para todos os parâmetros de tipo. Por exemplo, tanto List<String> quanto List<Integer> tornam-se simplesmente List no bytecode. O tipo bruto representa o ancestral comum de todas as suas parametrizações. Ao acessar elementos, o compilador insere automaticamente casts (conversões de tipo) no bytecode resultante.
Consequências importantes desta implementação:
- A verificação de tipos é realizada pelo compilador antes do type erasure.
- Conversões de tipo explícitas são inseridas automaticamente pelo compilador no código de máquina para acessar os elementos.
Conflitos de Polimorfismo e Bridge Methods
O type erasure pode causar conflitos aparentes com o polimorfismo. Considere a sobrescrita de um método em uma sbuclasse genérica.
public class Pai<T> {
public void processar(T valor) {
System.out.println("Valor do Pai: " + valor);
}
}
public class Filha extends Pai<String> {
@Override
public void processar(String valor) {
System.out.println("Valor da Filha: " + valor);
}
}
Após o type erasure, o método na classe Pai terá a assinatura processar(Object), enquanto o método na Filha terá processar(String). Java exige que métodos sobrescritos tenham assinaturas idênticas, o que não ocorre aqui.
Para resolver isso, o compilador gera um bridge method (método ponte) na classe filha. Este método tem a assinatura processar(Object), igual à do método da classe pai (após o erasure), e internamente faz um cast e delega para o método correto.
// Método ponte gerado pelo compilador (sinalizado com ACC_BRIDGE)
public void processar(Object valor) {
processar((String) valor); // Delega para o método 'real'
}
// Método com a assinatura desejada pelo programador
public void processar(String valor) {
System.out.println("Valor da Filha: " + valor);
}
Este bridge method garante que a sobrescrita funcione corretamente na JVM. Note que ferramentas de descompilação geralmente ocultam métodos ponte. Além disso, devido ao erasure, tentar sobrecarregar (overload) métodos que se diferenciam apenas por seus parâmetros de tipo genérico não é permitido, pois após o erasure suas assinaturas colidiriam.