1. Introdução
Imagine que você tem uma caixa universal, onde é possível colocar qualquer coisa: uma maçã, um livro ou até um brinquedo. Em Java, essa “caixa universal” é uma classe que armazena dados do tipo mais geral, Object. Esse tipo é o pai de todas as outras classes em Java, portanto é possível colocar absolutamente qualquer objeto em uma variável do tipo Object.
Agora imagine um depósito com essas caixas. Se as caixas não têm rótulos, você pode colocar qualquer coisa dentro, mas quando chegar a hora de tirar algo — será preciso abrir a caixa e adivinhar o que há dentro. Com generics, a situação se parece com um depósito com rótulos organizados: “Apenas maçãs”, “Apenas livros”, “Apenas ferramentas”. Agora você sempre sabe o que está em cada caixa e não consegue, por engano, colocar um livro na caixa de maçãs.
À primeira vista, armazenar em Object parece conveniente: não é preciso criar classes separadas para diferentes tipos de dados. Mas na prática essa “universalidade” se transforma em problemas:
- É fácil cometer um erro. Você pode, sem querer, colocar na caixa um objeto diferente do que esperava.
- O compilador não verá o problema. Ele simplesmente permitirá colocar qualquer coisa, porque o tipo Object permite isso.
- É preciso “desempacotar” manualmente. Quando você tira um objeto de uma caixa assim, ele volta a ser do tipo Object, e você precisa convertê-lo para o tipo desejado (isso se chama conversão de tipos ou cast). E se você errar o tipo, o programa simplesmente vai falhar com um erro!
Vamos ver isso em um exemplo simples.
class Box {
private Object value;
public void set(Object value) {
this.value = value;
}
public Object get() {
return value;
}
}
Agora vamos usar essa caixa:
Box box = new Box();
box.set("Olá"); // Colocamos uma string
String s = (String) box.get(); // Recuperamos a string, tudo certo
box.set(123); // Colocamos um número
// O compilador não vê problema...
String t = (String) box.get(); // Erro em tempo de execução!
Como você pode ver, o compilador deixou passar tranquilamente o código que acabou levando a um erro. Só soubemos do problema quando o programa foi executado e “caiu”.
2. A solução — generics
Generics (genéricos) — são uma maneira de resolver esse problema. É uma sintaxe especial que permite vincular uma classe ou um método a um tipo de dado específico já na etapa de compilação. Simplificando, é como um adesivo na caixa que diz: “Nesta caixa podem ficar apenas strings” ou “Nesta caixa podem ficar apenas números”.
Dessa forma, obtemos segurança de tipos: o compilador não vai permitir que coloquemos o objeto errado na “caixa”. Ele verifica nosso código e aponta o erro antes de executarmos o programa.
A mesma classe Box, mas agora com generics:
class Box<Type> {
private Type value;
public void set(Type value) {
this.value = value;
}
public Type get() {
return value;
}
}
Aqui, Type é um parâmetro de tipo. É um nome convencional que escolhemos (costuma-se usar T, E, K, V), e ele diz: “Quando formos criar o Box, indicaremos com qual tipo ele vai trabalhar, e eu vou usar esse tipo em todos os lugares onde no código está escrito Type”.
3. Uso de generics
Quando criamos um objeto de uma classe com generics, indicamos o tipo específico entre os sinais de menor e maior <...>.
// Criamos uma caixa que funciona apenas com strings
Box<String> stringBox = new Box<>();
stringBox.set("Olá, mundo!"); // OK, colocamos uma string
String s = stringBox.get(); // Recuperamos a string sem conversão de tipos
stringBox.set(123); // Erro de compilação! O compilador não permitirá isso.
Se criarmos um Box<Integer>, o compilador vai garantir que apenas números sejam colocados nele:
// Criamos uma caixa que funciona apenas com números
Box<Integer> intBox = new Box<>();
intBox.set(42); // OK, colocamos um número
Integer number = intBox.get(); // Recuperamos o número sem conversão de tipos
intBox.set("Olá"); // Erro de compilação!
Agora o compilador sabe exatamente qual tipo de dado deve estar em cada caixa e nos protege de erros com segurança.
4. Como isso funciona em exemplos
Generics podem ser usados não apenas em classes, mas também em métodos. Isso permite escrever um código muito flexível e universal.
Exemplo 1 — classe com generics
Suponha que precisamos de uma classe Pair que armazene dois objetos do mesmo tipo. Com generics, isso fica assim:
class Pair<T> {
private T first;
private T second;
public Pair(T first, T second) {
this.first = first;
this.second = second;
}
public T getFirst() {
return first;
}
public T getSecond() {
return second;
}
}
Uso:
Pair<String> greetings = new Pair<String>("Olá", "Mundo");
System.out.println(greetings.getFirst() + " " + greetings.getSecond());
Pair<Integer> numbers = new Pair<Integer>(10, 20);
System.out.println(numbers.getFirst() + numbers.getSecond());
No primeiro caso, o compilador tem certeza de que em greetings há strings; no segundo — números.
Exemplo 2 — método genérico
Podemos escrever um método que funciona com qualquer tipo de dado.
class Utils {
// <T> antes de void indica que o método trabalhará com um parâmetro de tipo T
public static <T> void printTwice(T value) {
System.out.println(value);
System.out.println(value);
}
}
Agora podemos chamar esse método com qualquer tipo de dado, e ele funcionará da mesma forma:
Utils.printTwice("Java");
Utils.printTwice(123);
Utils.printTwice(3.14);
5. Vantagens dos generics
Generics não são apenas açúcar sintático, mas uma ferramenta poderosa que resolve problemas reais no desenvolvimento. Vamos considerar três vantagens principais.
Segurança de tipos — é a principal vantagem dos generics. Sem eles, o compilador não consegue verificar se você está usando corretamente os tipos de dados em suas classes e métodos “universais”. Erros como tentar colocar uma string em uma “caixa de números” serão detectados apenas durante a execução do programa, quando ele de repente falhar com a exceção ClassCastException. Com generics, o compilador controla rigidamente os tipos. Ele verifica para garantir que você coloque apenas strings em Box<String> e apenas números em Box<Integer>. Se você tentar fazer algo incorreto, ele apontará o erro imediatamente, e você poderá corrigi-lo antes de executar o programa. Isso torna seu código muito mais confiável e previsível.
Código mais limpo (sem conversões de tipos desnecessárias). Lembre-se de como era o código com nossa “caixa universal” sem generics: Box box = new Box(); box.set("Olá"); String s = (String) box.get(); Toda vez que você retirava um objeto da caixa, precisava escrever (String), (Integer) e assim por diante. Com generics, essa necessidade desaparece. O compilador já sabe qual tipo está dentro e o converte automaticamente para você: Box<String> stringBox = new Box<>(); stringBox.set("Olá"); String s = stringBox.get(); Isso não apenas deixa o código mais curto, como também melhora significativamente sua legibilidade e praticidade.
Flexibilidade e reutilização de código. Generics permitem criar classes e métodos realmente universais que funcionam com diferentes tipos de dados, sem perder a segurança de tipos. Por exemplo, a classe Box<T> pode ser usada para armazenar strings, números, suas próprias classes (Student, Car) — para qualquer coisa! Você não precisa escrever uma classe separada StringBox, IntegerBox e StudentBox. Você escreve uma classe universal Box<T> e apenas indica o tipo necessário ao criar o objeto. Isso permite reduzir a quantidade de código, evitar duplicação e tornar seu programa mais modular e flexível.
6. Limitações dos generics
Apesar de todas as vantagens, os generics têm algumas limitações importantes que você precisa conhecer.
Primitivos (primitives). Você não pode usar tipos primitivos (como int, double, boolean etc.) como parâmetro de tipo. Por exemplo, o código Box<int> intBox = new Box<>(); causará um erro de compilação. Em vez disso, é preciso usar suas “classes wrapper” (Integer, Double, Boolean). Box<Integer> intBox = new Box<>();. O compilador Java sabe converter automaticamente primitivos para classes wrapper e vice-versa (int em Integer e o contrário) — esse mecanismo é chamado de autoboxing/unboxing.
Apagamento de tipos (Type Erasure). Essa é a característica central de como os generics são implementados em Java. A ideia é que o compilador Java usa as informações dos generics apenas durante a compilação. Depois que ele verifica seu código e garante sua segurança de tipos, ele apaga todas as informações sobre os parâmetros de tipo. Por exemplo, Box<String> e Box<Integer> no código compilado (no bytecode) vão parecer simplesmente Box. Isso significa que, para a máquina virtual Java (JVM), eles se tornam o mesmo tipo.
O que isso significa para você? Você não pode criar um array de genéricos, por exemplo new Box<String>[10], e não pode usar instanceof com generics para verificar se um objeto é instanceof Box<String>. Esse é um tema mais complexo, mas seu entendimento é importante para um estudo mais profundo de Java. Nesta etapa, basta lembrar que generics são, na essência, um “adesivo” para o compilador que o ajuda a verificar o código, mas esse adesivo é removido quando o programa está pronto para ser executado.
GO TO FULL VERSION