Começamos agora a abrir o capô do LINQ, e a primeira peça a examinar é uma que você já vem usando desde a Fase 4, sem que a nomeássemos: os genéricos. Toda vez que você escreveu List<int>, Dictionary<string, int> ou HashSet<string>, aqueles sinais de menor e maior com um tipo dentro eram genéricos em ação. Nesta aula, entenderemos o que eles realmente são, por que existem, e como você mesmo pode criar código genérico. A ideia que os define é poderosa e elegante: escrever um pedaço de código uma única vez, deixando um ou mais tipos "em aberto", de modo que ele funcione com qualquer tipo concreto — com total segurança. É um mecanismo que evita repetição em larga escala, e compreendê-lo aprofundará seu entendimento das coleções e preparará o terreno para as lambdas da próxima aula.
O problema que os genéricos resolvem
Sintamos o problema. Imagine que você queira criar uma classe simples, uma "caixa" que guarda um valor e o devolve. Se você quisesse uma caixa para inteiros, escreveria uma classe assim:
class CaixaDeInt
{
public int Valor { get; set; }
}
Mas e se precisasse de uma caixa para textos? Teria de escrever outra classe, idêntica, trocando apenas o tipo:
class CaixaDeString
{
public string Valor { get; set; }
}
E para Produto? Outra classe. Para Pessoa? Mais uma. Você acabaria com dezenas de classes praticamente idênticas, diferindo apenas no tipo do valor — uma repetição absurda, que viola tudo o que aprendemos sobre não repetir código (Aula #16). O problema é claro: queremos uma caixa que funcione com qualquer tipo, sem reescrevê-la para cada um. Os genéricos são exatamente a solução para isso.
A classe genérica: um tipo em aberto
Um genérico permite deixar um tipo "em aberto" ao escrever uma classe, para ser definido apenas quando alguém a usar. Fazemos isso declarando um parâmetro de tipo entre os sinais de menor e maior, logo após o nome da classe. Por convenção, esse parâmetro de tipo chama-se T (de type, "tipo"):
// T é um "tipo em aberto", definido por quem usar a Caixa.
class Caixa<T>
{
public T Valor { get; set; }
}
Dentro da classe, T funciona como um tipo qualquer — mas qual tipo ele será é decidido só na hora do uso. Quando você cria uma Caixa, especifica o tipo concreto que T assumirá:
Caixa<int> caixaInt = new Caixa<int>(); // aqui, T vira int
caixaInt.Valor = 42;
Caixa<string> caixaTexto = new Caixa<string>(); // aqui, T vira string
caixaTexto.Valor = "olá";
Caixa<Pessoa> caixaPessoa = new Caixa<Pessoa>(); // aqui, T vira Pessoa
Uma única classe Caixa<T> serve para todos os tipos: inteiros, textos, pessoas, qualquer coisa. Ao escrever Caixa<int>, o T "vira" int, e a caixa passa a guardar inteiros; ao escrever Caixa<string>, o T vira string. Não há repetição: você escreveu a caixa uma vez, e ela se adapta a qualquer tipo. É isso que as coleções fazem — List<T>, Dictionary<TKey, TValue> são classes genéricas, escritas uma vez e usáveis com qualquer tipo. Agora você entende o que aqueles sinais < > sempre significaram: eles preenchem o "tipo em aberto" de uma classe genérica.
A segurança dos genéricos
Aqui está um ponto crucial que revela por que os genéricos são tão valiosos, e não apenas convenientes. Quando você cria uma Caixa<int>, o C# sabe que o Valor dela é um int — e garante isso. Você não pode guardar um texto numa Caixa<int>, nem tirar dela um valor tratando-o como texto; o compilador barra qualquer uso incoerente com o tipo escolhido, antes de o programa rodar:
Caixa<int> c = new Caixa<int>();
c.Valor = 42; // OK
// c.Valor = "texto"; // ERRO DE COMPILAÇÃO: a caixa é de int, não de texto
int valor = c.Valor; // OK: o C# sabe que é int, sem necessidade de conversão
Essa é a grande virtude: os genéricos combinam reutilização (uma classe para todos os tipos) com segurança de tipo (cada uso é verificado). Sem genéricos, a alternativa para uma "caixa universal" seria guardar tudo como o tipo mais genérico possível (object, o ancestral de todos os tipos), mas isso perderia a segurança — o compilador não saberia o tipo real e não poderia impedir erros, que só apareceriam durante a execução. Os genéricos dão o melhor dos dois mundos: um código, todos os tipos, e cada uso verificado em tempo de compilação. É por isso que as coleções do C# são genéricas — uma List<int> garante que só inteiros entrem, e que o que sai seja tratado como inteiro, tudo verificado antes de rodar. A repetição foi eliminada sem sacrificar a segurança.
Métodos genéricos
Não é só classes que podem ser genéricas; um método também pode ter seu próprio tipo em aberto, útil para operações que fazem a mesma coisa independentemente do tipo. Um exemplo clássico é trocar dois valores de lugar:
// O <T> após o nome do método declara o tipo em aberto do método.
static void Trocar<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
int x = 1, y = 2;
Trocar(ref x, ref y); // funciona com int
Console.WriteLine($"{x}, {y}"); // 2, 1
string s1 = "primeiro", s2 = "segundo";
Trocar(ref s1, ref s2); // funciona também com string, sem reescrever
O método Trocar<T> troca dois valores de qualquer tipo, escrito uma única vez. (O ref é um detalhe que faz o método alterar as variáveis originais do chamador — um recurso que você encontrará ocasionalmente; não é o foco aqui.) Note que, ao chamá-lo, nem precisamos escrever Trocar<int>: o C# infere o tipo a partir dos argumentos, como já vimos a inferência funcionar com o var (Aula #06). Essa inferência automática é, aliás, o que torna o uso do LINQ tão fluido — quando você escreve numeros.Select(n => n * 2), o C# infere os tipos envolvidos sem que você precise anotá-los, graças aos métodos genéricos por baixo.
Por que isto importa para o LINQ
Fecho conectando ao propósito da fase. Os operadores do LINQ — Where, Select, OrderBy — são métodos genéricos. É por isso que eles funcionam sobre uma coleção de qualquer tipo: uma lista de inteiros, de produtos, de pessoas. O Select, por exemplo, é escrito genericamente para transformar elementos de qualquer tipo em resultados de qualquer tipo, e o C# infere quais tipos são a cada uso. Sem os genéricos, o LINQ precisaria de uma versão para cada tipo de dado imaginável — impossível. Os genéricos são, portanto, uma das peças fundamentais que tornam o LINQ (e as coleções, e tanto mais) possíveis. Você acabou de compreender uma das colunas que sustentam a elegância do C#.
A duplicação de código que só variava no tipo ficou resolvida. Um tipo genérico deixa o tipo em aberto, definido por quem usa, o que permite escrever a estrutura ou o algoritmo uma única vez e aplicá-lo com segurança a qualquer tipo concreto.
A segurança é o ponto que merece ênfase: diferente de tratar tudo como um tipo genérico sem forma, os genéricos preservam a verificação em tempo de compilação, e o compilador recusa de imediato um uso incoerente. Esse mecanismo também esclareceu retroativamente algo já familiar — a notação com colchetes angulares das coleções sempre foi exatamente isto, e é ela que sustenta as consultas sobre dados tipados.
Fontes e leituras recomendadas
- Genéricos em C# — a introdução oficial a classes e métodos genéricos; a base desta aula.
- Classes genéricas — como definir uma classe com um tipo em aberto.
- Métodos genéricos — a definição e a inferência de tipos em métodos genéricos.
- Por que usar genéricos — os benefícios de reutilização e segurança de tipo.
- Genéricos e coleções — como as coleções do C# usam genéricos, o que você já vinha usando.
Exercícios
Exercício 1
Crie uma classe genérica Par<T> que guarde dois valores do mesmo tipo T (chame-os Primeiro e Segundo). Crie um Par<int> e um Par<string>, preencha-os e exiba seus valores. Explique como uma única classe pôde servir aos dois tipos.
Ver resposta
✓ Resposta: Exemplo:
class Par<T>
{
public T Primeiro { get; set; }
public T Segundo { get; set; }
}
Par<int> pi = new Par<int> { Primeiro = 1, Segundo = 2 };
Par<string> ps = new Par<string> { Primeiro = "a", Segundo = "b" };
Console.WriteLine($"{pi.Primeiro}, {pi.Segundo}"); // 1, 2
Console.WriteLine($"{ps.Primeiro}, {ps.Segundo}"); // a, b
Uma única classe Par<T> serviu aos dois tipos porque o T é um tipo em aberto, preenchido na criação: em Par<int>, o T vira int; em Par<string>, vira string. A estrutura da classe é a mesma; apenas o tipo dos valores muda conforme o que se especifica no uso.
Exercício 2
Explique, com suas palavras, o problema que os genéricos resolvem, usando o exemplo das classes CaixaDeInt e CaixaDeString. O que aconteceria se você precisasse de caixas para dez tipos diferentes sem usar genéricos?
Ver resposta
✓ Resposta: Os genéricos resolvem o problema da repetição de código quando se precisa da mesma estrutura para tipos diferentes. Sem eles, para ter uma "caixa" de inteiros e uma de textos, seria preciso escrever duas classes idênticas (CaixaDeInt e CaixaDeString), diferindo apenas no tipo do valor. Se você precisasse de caixas para dez tipos diferentes, teria de escrever dez classes praticamente iguais — uma repetição enorme e difícil de manter, pois qualquer mudança na estrutura da caixa teria de ser replicada em todas as dez. Com genéricos, escreve-se uma única Caixa<T> que serve a todos os dez tipos, eliminando a repetição.
Exercício 3
Tente, numa Caixa<int>, atribuir um texto ao Valor (c.Valor = "abc";) e observe o erro de compilação. Explique por que esse erro é uma vantagem, relacionando-o com a "segurança de tipo" dos genéricos.
Ver resposta
✓ Resposta: Ao tentar c.Valor = "abc"; numa Caixa<int>, o compilador acusa erro, pois o Valor de uma Caixa<int> é um int, e um texto não pode ser guardado ali. Esse erro é uma vantagem porque representa a segurança de tipo dos genéricos: o C# sabe, em tempo de compilação, o tipo exato que a caixa guarda, e impede usos incoerentes antes de o programa rodar. Assim, um erro que, de outra forma, poderia passar despercebido e causar falhas durante a execução é capturado imediatamente, na tela do programador. A verificação antecipada evita uma classe inteira de bugs.
Exercício 4
Escreva um método genérico T PrimeiroDe<T>(List<T> lista) que devolva o primeiro elemento de uma lista de qualquer tipo. Teste-o com uma List<int> e uma List<string>. Explique por que não foi preciso escrever o tipo explicitamente ao chamá-lo (a inferência).
Ver resposta
✓ Resposta: Exemplo:
static T PrimeiroDe<T>(List<T> lista)
{
return lista[0];
}
Console.WriteLine(PrimeiroDe(new List<int> { 10, 20 })); // 10
Console.WriteLine(PrimeiroDe(new List<string> { "x", "y" })); // x
Não foi preciso escrever o tipo explicitamente (por exemplo, PrimeiroDe<int>) porque o C# infere o tipo a partir do argumento: ao receber uma List<int>, ele deduz que T é int; ao receber uma List<string>, deduz que é string. Essa inferência automática torna a chamada de métodos genéricos fluida e concisa, dispensando anotações de tipo que o compilador consegue descobrir sozinho.
Exercício 5
Sem código: explique por que os genéricos são fundamentais para o funcionamento do LINQ e das coleções. Como uma List<T> e um operador como Select se beneficiam de serem genéricos, em vez de terem uma versão para cada tipo?
Ver resposta
✓ Resposta: Os genéricos são fundamentais para o LINQ e as coleções porque permitem que uma mesma estrutura ou operação funcione com qualquer tipo de dado, sem repetição. Uma List<T> se beneficia por ser uma única classe que serve para listas de inteiros, textos, produtos, ou o que for — em vez de existir uma ListaDeInt, uma ListaDeString, e assim por diante, o que seria impraticável. Um operador como Select se beneficia por ser um método genérico que transforma elementos de qualquer tipo em resultados de qualquer tipo, aplicando-se a coleções de qualquer natureza; sem genéricos, o LINQ precisaria de uma versão de cada operador para cada tipo de dado imaginável, o que seria impossível de escrever e manter. Os genéricos, portanto, são o que permite ao LINQ e às coleções serem universais — escritos uma vez, usáveis com tudo — mantendo a segurança de tipo em cada uso.