Genéricos: Um Código, Muitos Tipos, Nenhuma Conversão

Genéricos: Um Código, Muitos Tipos, Nenhuma Conversão

Genéricos em C#: métodos e classes com parâmetro de tipo, restrições com where, uma página genérica para paginação, o repositório genérico em memória, genéricos reificados contra o type erasure do Java, covariância em IEnumerable e matemática genérica com INumber.
Linguagem C#

• • 16 min de leitura

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 fazer new T().
  • where T : Produto: T é Produto ou 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, IEntidade exige um tipo de referência com Id, e é por isso que entidade.Id compila.
  • O retorno T?. Como T é uma classe, ObterPorId pode devolver nulo quando o identificador não existe.
  • TryAdd. Ele adiciona só se a chave não existir e devolve false em 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

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).

Comentários

Mais em Linguagem C#

Classes e Objetos: Dando Forma aos Dados da Livraria
Classes e Objetos: Dando Forma aos Dados da Livraria

Classes e objetos em C#: campos, objetos como tipos de referência…

Dapper: SQL Seu, Sem a Cerimônia
Dapper: SQL Seu, Sem a Cerimônia

Como o Dapper elimina a cerimônia do ADO.NET sem esconder o SQL: QueryAsync…

Do Arquivo Único à Solução: Namespaces, Projetos e Pacotes NuGet
Do Arquivo Único à Solução: Namespaces, Projetos e Pacotes NuGet

Namespaces, projetos e NuGet em C#: namespace de arquivo e global using…