A mesma List<T> guarda inteiros, livros ou pedidos sem nenhuma conversão e sem perder a verificação do compilador: esse é o trabalho dos genéricos.
A Fase 2 terminou com um convite: desde "Guardando Muitos Valores" usamos List<T>, Dictionary<TKey, TValue> e IReadOnlyList<T>, lendo o <T> como "de quê". Hoje abrimos a Fase 3 entendendo esse mecanismo por dentro e aprendendo a escrever nossos próprios tipos e métodos genéricos. Na aula de história vimos o problema que eles resolveram: no C# 1.0, o ArrayList guardava tudo como object, exigia conversões a cada leitura e só descobria erros de tipo com o programa rodando. Os genéricos, que chegaram no C# 2.0, trocaram esse problema de execução por uma verificação na compilação.
O problema: o mesmo algoritmo para tipos diferentes
Imagine um método que devolve o maior de dois valores. Para inteiros:
static int Maior(int a, int b) => a >= b ? a : b;
Agora você precisa dele para decimal, para string, para DateTime. Há duas saídas ruins. A primeira é duplicar o método para cada tipo, multiplicando código idêntico. A segunda é usar object, que aceita qualquer coisa, mas perde a verificação do compilador e obriga conversões na volta: int x = (int)Maior(3, 5);. O genérico é a terceira saída, que não tem nenhum desses custos.
Métodos genéricos
Um método genérico declara um parâmetro de tipo, por convenção T, entre sinais de menor e maior:
static T Primeiro<T>(T[] valores) => valores[0];
int n = Primeiro([10, 20, 30]); // T = int
string s = Primeiro(["Dom", "Casmurro"]); // T = string
O T é um lugar reservado para um tipo que será escolhido em cada chamada. Na primeira linha, o compilador deduz pelos argumentos que T é int, e o método se comporta como se tivesse sido escrito para int. Na segunda, T é string. Não há conversão, não há object e não há perda de verificação: int x = Primeiro(["a"]); é erro de compilação, porque ali T é string.
Quando o compilador não consegue deduzir, você informa explicitamente: Primeiro<decimal>([...]).
Restrições: o que o T precisa saber fazer
Voltemos ao Maior. A tentativa óbvia não compila:
static T Maior<T>(T a, T b) => a >= b ? a : b; // erro CS0019
O compilador não sabe se T suporta >=. T pode ser qualquer tipo, inclusive um que não tem noção de ordem, como um Pedido. Para usar uma capacidade, você precisa exigi-la com uma restrição where:
static T Maior<T>(T a, T b) where T : IComparable<T> =>
a.CompareTo(b) >= 0 ? a : b;
Console.WriteLine(Maior(3, 7)); // 7
Console.WriteLine(Maior("banana", "maçã")); // maçã
Console.WriteLine(Maior(59.90m, 42.50m)); // 59,90
where T : IComparable<T> diz: "este método só aceita tipos que saibam se comparar com outro do mesmo tipo". Com essa garantia, a.CompareTo(b) compila. E quem tentar chamar Maior com um tipo que não implementa a interface recebe um erro de compilação, e não uma surpresa em produção. É a interface de "Interfaces: Contratos Que Qualquer Tipo Pode Assinar" funcionando como exigência.
As restrições disponíveis:
where T : class: T é um tipo de referência.where T : struct: T é um tipo de valor (e não pode ser anulável).where T : notnull: T não é anulável.where T : new(): T tem construtor sem parâmetros, então o código pode fazernew T().where T : Produto: T éProdutoou uma derivada.where T : IEntidade: T implementa a interface.where T : unmanaged: T é um tipo de valor sem nenhuma referência dentro. Útil em interop e código de baixo nível, que veremos na Fase 4.
Restrições se combinam (where T : class, IEntidade, new()), e cada parâmetro de tipo tem as suas.
Classes genéricas
Tipos também podem ser genéricos. Uma necessidade que vai aparecer na API e no site é a paginação: devolver a página 2 de um catálogo com 10 itens por página, junto com o total de páginas. A estrutura é a mesma para livros, pedidos ou clientes, e isso pede um genérico:
public sealed record Pagina<T>(IReadOnlyList<T> Itens, int Numero, int Tamanho, int TotalItens)
{
public int TotalPaginas => (TotalItens + Tamanho - 1) / Tamanho;
public bool TemAnterior => Numero > 1;
public bool TemProxima => Numero < TotalPaginas;
}
O TotalPaginas usa um truque clássico de divisão inteira para arredondar para cima: com 23 itens e páginas de 10, (23 + 9) / 10 = 3. Uma Pagina<Produto> tem Itens do tipo IReadOnlyList<Produto>. Uma Pagina<Pedido> tem IReadOnlyList<Pedido>. O tipo é escrito uma vez e serve a todos, com verificação completa.
Um método que monta a página a partir de uma lista qualquer:
static Pagina<T> Paginar<T>(IReadOnlyList<T> todos, int numero, int tamanho)
{
ArgumentOutOfRangeException.ThrowIfLessThan(numero, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(tamanho, 1);
var itens = new List<T>();
int inicio = (numero - 1) * tamanho;
for (int i = inicio; i < todos.Count && i < inicio + tamanho; i++)
itens.Add(todos[i]);
return new Pagina<T>(itens, numero, tamanho, todos.Count);
}
Dentro de código genérico, às vezes você precisa do "valor vazio" de T, seja ele qual for. A expressão default(T), ou simplesmente default, devolve zero para números, false para bool e null para tipos de referência.
Interfaces genéricas: o repositório
Genéricos e interfaces juntos produzem uma das abstrações mais usadas em sistemas .NET. Todo tipo com identidade (produto, pedido, cliente) precisa ser guardado, buscado por identificador, listado e removido. Primeiro, um contrato mínimo para "ter identidade":
public interface IEntidade
{
int Id { get; }
}
Produto já tem um Id, então basta declarar public abstract class Produto : IEntidade. Agora, um repositório genérico:
public interface IRepositorio<T> where T : class, IEntidade
{
void Adicionar(T entidade);
T? ObterPorId(int id);
IReadOnlyList<T> Listar();
bool Remover(int id);
}
public sealed class RepositorioEmMemoria<T> : IRepositorio<T> where T : class, IEntidade
{
private readonly Dictionary<int, T> _dados = new();
public void Adicionar(T entidade)
{
ArgumentNullException.ThrowIfNull(entidade);
if (!_dados.TryAdd(entidade.Id, entidade))
throw new InvalidOperationException($"Já existe um registro com Id {entidade.Id}.");
}
public T? ObterPorId(int id) => _dados.GetValueOrDefault(id);
public IReadOnlyList<T> Listar() => [.. _dados.Values];
public bool Remover(int id) => _dados.Remove(id);
}
Leia com calma, porque tudo o que aprendemos até aqui aparece junto:
- A restrição.
where T : class, IEntidadeexige um tipo de referência comId, e é por isso queentidade.Idcompila. - O retorno
T?. ComoTé uma classe,ObterPorIdpode devolver nulo quando o identificador não existe. TryAdd. Ele adiciona só se a chave não existir e devolvefalseem vez de lançar exceção, no padrão Try.- A cópia na listagem.
[.. _dados.Values]é uma expressão de coleção com espalhamento: cria uma lista nova com todos os valores. Assim, quem recebe a lista não consegue alterar o dicionário interno.
Um RepositorioEmMemoria<Produto> guarda produtos. Um RepositorioEmMemoria<Pedido> guarda pedidos, desde que Pedido implemente IEntidade. E a interface IRepositorio<T> é o ponto de virada que antecipamos em "Do Arquivo Único à Solução": na Fase 5, escreveremos implementações que gravam em PostgreSQL, MySQL e MongoDB, e o resto do sistema, que depende só de IRepositorio<T>, não vai precisar mudar.
Por dentro: genéricos "reais"
Uma diferença importante em relação ao Java. Lá, os genéricos existem só na compilação: em execução, List<Integer> e List<String> são a mesma classe, e os inteiros são guardados como objetos (type erasure). No .NET, os genéricos são reificados: o runtime sabe que List<int> é uma lista de inteiros.
Isso tem uma consequência de desempenho decisiva. Para cada tipo de valor usado como argumento, o JIT gera código especializado: uma List<int> guarda inteiros de verdade, lado a lado num array de int, sem converter cada um em objeto no heap (o boxing que mencionamos em "Classe, Struct ou Record"). Para tipos de referência, o código é compartilhado, porque todas as referências têm o mesmo tamanho. É o melhor dos dois mundos: um só código-fonte, e desempenho de código escrito à mão para cada tipo.
Variância: quando IEnumerable<LivroImpresso> serve como IEnumerable<Produto>
Todo LivroImpresso é um Produto. Então uma lista de livros impressos é uma lista de produtos? Surpreendentemente, não:
List<LivroImpresso> impressos = [ /* ... */ ];
List<Produto> produtos = impressos; // erro de compilação
E há uma boa razão. Se fosse permitido, alguém poderia fazer produtos.Add(new Ebook(...)), e um e-book entraria numa lista que deveria conter apenas livros impressos. Mas, para interfaces que só devolvem itens e nunca os recebem, o risco não existe:
IEnumerable<Produto> leitura = impressos; // permitido
IReadOnlyList<Produto> leitura2 = impressos; // permitido
Essas interfaces são declaradas covariantes (IEnumerable<out T>). O out promete que T só sai, nunca entra, e o compilador verifica essa promessa. É mais um motivo para os métodos receberem IEnumerable<T> ou IReadOnlyList<T> quando só precisam ler: eles passam a aceitar coleções de qualquer tipo derivado.
Matemática genérica
Por anos, um método genérico que somasse números era impossível, porque não havia como exigir "T suporta +". O C# 11 resolveu isso com membros estáticos abstratos em interfaces, e o .NET passou a oferecer interfaces como INumber<T>:
using System.Numerics;
static T Somar<T>(IEnumerable<T> valores) where T : INumber<T>
{
T soma = T.Zero;
foreach (T v in valores)
soma += v;
return soma;
}
Console.WriteLine(Somar([1, 2, 3])); // 6
Console.WriteLine(Somar([59.90m, 42.50m])); // 102,40
Console.WriteLine(Somar([0.5, 0.25])); // 0,75
T.Zero é um membro estático exigido pela interface: cada tipo numérico diz qual é o seu zero. Você não vai escrever matemática genérica todo dia, mas ela explica como a própria biblioteca do .NET implementa operações numéricas uma vez só para todos os tipos.
Juntando tudo: um catálogo paginado sobre um repositório
using System.Globalization;
CultureInfo.CurrentCulture = new CultureInfo("pt-BR");
IRepositorio<Produto> repositorio = new RepositorioEmMemoria<Produto>();
repositorio.Adicionar(new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 10));
repositorio.Adicionar(new Ebook("Vidas Secas", "Graciliano Ramos", 24.90m, tamanhoMb: 1.8));
repositorio.Adicionar(new LivroImpresso("Grande Sertão: Veredas", "Guimarães Rosa", 89.00m, pesoGramas: 1200, estoque: 3));
repositorio.Adicionar(new Ebook("São Bernardo", "Graciliano Ramos", 22.90m, tamanhoMb: 1.6));
repositorio.Adicionar(new LivroImpresso("O Cortiço", "Aluísio Azevedo", 42.50m, pesoGramas: 400, estoque: 5));
IReadOnlyList<Produto> todos = repositorio.Listar();
for (int numero = 1; ; numero++)
{
Pagina<Produto> pagina = Paginar(todos, numero, tamanho: 2);
Console.WriteLine($"--- Página {pagina.Numero} de {pagina.TotalPaginas} ---");
foreach (Produto p in pagina.Itens)
Console.WriteLine($" {p.Titulo,-25}{p.Preco,10:C}");
if (!pagina.TemProxima)
break;
}
Produto? procurado = repositorio.ObterPorId(3);
Console.WriteLine(procurado is not null ? $"Id 3: {procurado.Titulo}" : "Id 3 não existe");
Com cinco produtos e páginas de dois, o programa mostra três páginas: 2, 2 e 1 itens. O laço for sem condição (for (int numero = 1; ; numero++)) repete até o break, que é disparado quando a página diz que não há próxima. Uma ressalva: ObterPorId(3) só encontra algo se os identificadores gerados pelos construtores começarem em 1 neste programa, o que depende de quantos produtos já foram criados antes. É uma fragilidade do nosso gerador estático de Id, e ela será resolvida quando o banco de dados passar a atribuir os identificadores.
Repare no método Paginar: ele percorre a lista com um laço, copia um intervalo de itens e conta o total. Filtrar, ordenar, somar e copiar trechos de coleções são operações que escrevemos à mão desde a Fase 1, sempre com um laço, uma variável acumuladora e um if. Elas mudam de forma a cada situação, mas o esqueleto é sempre o mesmo. O que varia é só um pedaço de comportamento: qual critério usar para filtrar, que propriedade usar para ordenar. Se pudéssemos passar esse pedaço como argumento, da mesma forma que hoje passamos tipos, o esqueleto poderia ser escrito uma única vez. Em C#, comportamento também pode ser passado como dado.
Fontes e leituras recomendadas
- Genéricos (Microsoft Learn): https://learn.microsoft.com/dotnet/csharp/fundamentals/types/generics. É a introdução oficial a métodos, classes e interfaces genéricas.
- Restrições em parâmetros de tipo: https://learn.microsoft.com/dotnet/csharp/programming-guide/generics/constraints-on-type-parameters. É a lista completa de restrições, com exemplos de cada uma.
- Genéricos em tempo de execução: https://learn.microsoft.com/dotnet/csharp/programming-guide/generics/generics-in-the-run-time. Explica a especialização para tipos de valor e o compartilhamento para tipos de referência.
- Covariância e contravariância: https://learn.microsoft.com/dotnet/standard/generics/covariance-and-contravariance. Cobre
ineoutem interfaces e delegates genéricos. - Matemática genérica: https://learn.microsoft.com/dotnet/standard/generics/math. Apresenta as interfaces numéricas (
INumber<T>e família) e os membros estáticos abstratos. - Jon Skeet, C# in Depth, 4ª edição, capítulo 2: conta a história e os detalhes dos genéricos do C# 2.0, incluindo a comparação com o modelo do Java.
Exercícios
Exercício 1
Escreva um método genérico Trocar<T> que troque o conteúdo de duas variáveis de qualquer tipo e teste-o com inteiros e com strings.
Ver resposta
✓ Resposta:
int a = 1, b = 2;
Trocar(ref a, ref b);
Console.WriteLine($"{a} {b}"); // 2 1
string x = "Dom", y = "Casmurro";
Trocar(ref x, ref y);
Console.WriteLine($"{x} {y}"); // Casmurro Dom
static void Trocar<T>(ref T primeiro, ref T segundo)
{
T temporario = primeiro;
primeiro = segundo;
segundo = temporario;
}
É o Trocar de "Métodos: Dividindo o Problema em Partes Que Conversam", agora sem estar amarrado a int. Nenhuma restrição é necessária: o método só guarda e atribui valores, operações que qualquer tipo suporta. O compilador deduz T pelas variáveis passadas e garante que as duas sejam do mesmo tipo: Trocar(ref a, ref x), com um int e uma string, não compila.
Exercício 2
Explique por que o método abaixo não compila e corrija-o sem limitar a um único tipo:
static T Menor<T>(T a, T b) => a < b ? a : b;
Ver resposta
✓ Resposta: Sem restrições, T pode ser qualquer tipo, inclusive tipos sem noção de ordem, e o compilador não pode assumir que o operador < existe para ele (erro CS0019). A correção é exigir a capacidade de comparação:
static T Menor<T>(T a, T b) where T : IComparable<T> =>
a.CompareTo(b) <= 0 ? a : b;
Funciona com int, decimal, string, DateTime e qualquer tipo seu que implemente IComparable<T>. Uma alternativa moderna, só para números, seria where T : INumber<T>, que libera os operadores < e > diretamente. Mas ela excluiria strings e datas. A restrição escolhida define exatamente quais tipos o método aceita, então escolha a mais ampla que ainda dê o que o código precisa.
Exercício 3
Implemente uma classe genérica Pilha<T> usando um array interno que dobra de tamanho quando enche, com os métodos Empilhar, Desempilhar e Topo e a propriedade Quantidade. Desempilhar uma pilha vazia deve lançar InvalidOperationException.
Ver resposta
✓ Resposta:
public sealed class Pilha<T>
{
private T[] _itens = new T[4];
public int Quantidade { get; private set; }
public void Empilhar(T item)
{
if (Quantidade == _itens.Length)
Array.Resize(ref _itens, _itens.Length * 2);
_itens[Quantidade++] = item;
}
public T Desempilhar()
{
if (Quantidade == 0)
throw new InvalidOperationException("A pilha está vazia.");
T item = _itens[--Quantidade];
_itens[Quantidade] = default!; // libera a referência para o coletor de lixo
return item;
}
public T Topo() => Quantidade > 0
? _itens[Quantidade - 1]
: throw new InvalidOperationException("A pilha está vazia.");
}
É o mesmo mecanismo da List<T> descrito em "Guardando Muitos Valores": um array que dobra de tamanho quando enche. Array.Resize cria um array maior e copia os elementos, por isso recebe ref, já que troca o array para o qual a variável aponta. O detalhe mais sutil está em Desempilhar: se T for uma classe, o array continuaria guardando uma referência ao objeto desempilhado, impedindo o coletor de lixo de recolhê-lo. Atribuir default à posição liberada resolve. O ! avisa ao compilador que sabemos que default pode ser nulo para T. Repare também no throw usado como expressão dentro do operador condicional em Topo, uma forma compacta permitida desde o C# 7.
Exercício 4
Por que a chamada abaixo não compila? Mude apenas a assinatura do método para que ela funcione, e explique por que a mudança é segura.
static decimal SomarPrecos(List<Produto> produtos) { /* soma os preços */ }
List<LivroImpresso> impressos = [ /* ... */ ];
decimal total = SomarPrecos(impressos);
Ver resposta
✓ Resposta: List<T> é invariante: uma List<LivroImpresso> não é uma List<Produto>, mesmo que todo livro impresso seja um produto. Se fosse, o método poderia chamar produtos.Add(new Ebook(...)) e colocar um e-book numa lista que só deveria conter impressos. A correção é declarar que o método só precisa ler:
static decimal SomarPrecos(IEnumerable<Produto> produtos)
{
decimal soma = 0;
foreach (Produto p in produtos)
soma += p.Preco;
return soma;
}
IEnumerable<out T> é covariante: como ela só permite obter itens, nunca inserir, é impossível colocar um e-book numa lista de impressos através dela. O compilador aceita a conversão porque ela não abre nenhuma brecha. A lição de desenho: nos parâmetros, peça a interface mais restrita que o método precisa. Isso comunica a intenção ("só vou ler") e amplia os tipos aceitos.
Exercício 5
Escreva um método Media<T> que funcione para qualquer tipo numérico, usando INumber<T>. O que acontece quando você o chama com inteiros como [1, 2]?
Ver resposta
✓ Resposta:
using System.Numerics;
static T Media<T>(IReadOnlyList<T> valores) where T : INumber<T>
{
if (valores.Count == 0)
throw new ArgumentException("É preciso ao menos um valor.", nameof(valores));
T soma = T.Zero;
foreach (T v in valores)
soma += v;
return soma / T.CreateChecked(valores.Count);
}
Console.WriteLine(Media([1.0, 2.0])); // 1,5
Console.WriteLine(Media([1, 2])); // 1
T.CreateChecked(valores.Count) converte o int da contagem para T, lançando exceção se o valor não couber (é o checked de "Cada Valor Tem um Tipo", em versão genérica). Com double, a média de 1 e 2 é 1,5. Com int, é 1: a divisão de T por T, quando T é inteiro, é a divisão inteira de "Operadores, Texto e Conversa com o Usuário", que trunca. O método está correto, mas o resultado pode surpreender. Um código genérico herda as regras de cada tipo concreto. Se a intenção é sempre obter uma média fracionária, a assinatura deveria refletir isso, por exemplo devolvendo double, com cada valor convertido por double.CreateChecked(v).