C# Generics

Você já escreveu List<int>, List<string> e Dictionary<string, decimal>. Uma única classe List serve para guardar números, textos, alunos ou qualquer outra coisa, e mesmo assim o compilador sabe exatamente o que está dentro de cada lista. Isso é generics (em português, "genéricos"): escrever uma classe ou um método uma vez, deixando um tipo "em branco", para ser preenchido por quem for usar. Nesta aula você vai criar as suas próprias classes e métodos genéricos.

Você vai aprender
  • Por que generics existem: o problema de guardar tudo como object.
  • Criar uma classe genérica: Caixa<T>.
  • Os nomes dos parâmetros de tipo (T, TKey, TValue…) e classes com dois tipos.
  • Criar métodos genéricos e deixar o compilador deduzir o tipo.
  • O default: o valor "vazio" de qualquer tipo.
  • Os erros reais: CS1503, CS0305, CS0411 e CS0019 (o que T não sabe fazer).

Todos os programas desta aula rodaram com o .NET 10 na máquina da Ana (usuária ana, computador notebook), na pasta ~/cs, com dotnet run arquivo.cs. Você pode repetir cada um no editor do guia.


A ideia: a caixa com etiqueta

Na oficina da Ana há caixas de plástico iguaizinhas. A fábrica fez um só modelo de caixa. Quando a Ana compra uma, ela cola uma etiqueta: "PARAFUSOS", "PREGOS", "BOTÕES". A partir daí, aquela caixa é de parafusos, e ninguém guarda pregos nela.

Uma classe genérica é o modelo da fábrica, com um espaço para a etiqueta. Esse espaço é o parâmetro de tipo, escrito entre < >, normalmente com a letra T (de type, "tipo"):

  class Caixa<T>          « o modelo: T é a etiqueta em branco
  new Caixa<int>(…)       « uma caixa com a etiqueta "int"
  new Caixa<string>(…)    « outra caixa, com a etiqueta "string"

Quando você escreve Caixa<int>, o compilador troca o T por int em toda a classe. É como se você tivesse escrito uma classe só para números, e outra só para textos, sem ter escrito nenhuma das duas.


1. A vida sem generics

Antes dos generics (que chegaram no C# 2, em 2005), o jeito de fazer uma caixa "para qualquer coisa" era usar o tipo object, que aceita qualquer valor (veja A Classe object):

var caixa = new CaixaDeQualquerCoisa();
caixa.Guardar("parafusos");

int quantidade = (int)caixa.Abrir();
Console.WriteLine(quantidade);

class CaixaDeQualquerCoisa
{
    private object conteudo = "nada";
    public void Guardar(object coisa) => conteudo = coisa;
    public object Abrir() => conteudo;
}
$ dotnet run semgenerics.cs
Unhandled exception. System.InvalidCastException: Unable to cast object of type 'System.String' to type 'System.Int32'.
   at Program.<Main>$(String[] args) in /home/ana/cs/semgenerics.cs:line 4
$ echo $?
134

(No editor do guia, a mesma exceção sai com uma mensagem mais curta, Specified cast is not valid., "a conversão indicada não é válida". O erro é o mesmo.)

O programa compilou: para o compilador, a caixa guarda object, e um (int) (um cast) de object é permitido. Só na hora de rodar o .NET descobriu que dentro havia um texto, e lançou a InvalidCastException: "não é possível converter um objeto do tipo String no tipo Int32". Com object, você:

  • tem de converter ((int), (string)) toda vez que tira algo da caixa;
  • só descobre o erro quando o programa roda, talvez na máquina do cliente;
  • perde desempenho com números: cada int guardado num object é "embrulhado" num objeto (o boxing).

2. A primeira classe genérica: Caixa<T>

var caixaDeNumero = new Caixa<int>(42);
int numero = caixaDeNumero.Abrir();
Console.WriteLine(numero + 1);

var caixaDeTexto = new Caixa<string>("parafusos");
string texto = caixaDeTexto.Abrir();
Console.WriteLine(texto.ToUpper());

caixaDeTexto.Guardar("pregos");
Console.WriteLine(caixaDeNumero);
Console.WriteLine(caixaDeTexto);

class Caixa<T>
{
    private T conteudo;

    public Caixa(T conteudo)
    {
        this.conteudo = conteudo;
    }

    public void Guardar(T coisa) => conteudo = coisa;

    public T Abrir() => conteudo;

    public override string ToString() => $"Caixa<{typeof(T).Name}> com {conteudo}";
}
$ dotnet run caixa.cs
43                               « o Abrir devolveu um int de verdade: dá para somar
PARAFUSOS                        « e aqui um string: dá para usar ToUpper
Caixa<Int32> com 42
Caixa<String> com pregos
class Caixa<T>
"Uma caixa de T". O <T> logo depois do nome declara o parâmetro de tipo. Dentro da classe, T pode ser usado em qualquer lugar onde caberia um tipo.
private T conteudo;
Um campo do tipo T. Numa Caixa<int>, ele é um int; numa Caixa<string>, um string.
public Caixa(T conteudo)
O construtor: a caixa já nasce com algo dentro. Repare que o nome do construtor é só Caixa, sem o <T>.
public void Guardar(T coisa)
Só aceita T. Numa Caixa<int>, tentar guardar um texto é erro de compilação (você vai ver logo abaixo).
public T Abrir()
Devolve um T. Não precisa de (int) nenhum: o compilador já sabe o tipo.
typeof(T).Name
O nome do tipo que preencheu o T, já com o programa rodando: Int32 é o nome do int no .NET, e String, o do string. No C#, o tipo de verdade continua conhecido durante a execução.

O erro agora aparece antes de rodar

Com a caixa genérica, a mesma confusão da seção 1 nem chega a virar programa:

var caixa = new Caixa<int>(42);
caixa.Guardar("parafusos");
$ dotnet run errado.cs
/home/ana/cs/errado.cs(2,15): error CS1503: Argumento 1: não é possível converter de "string" para "int"

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

Esse é o grande ganho: o erro que antes era uma exceção na máquina do cliente agora é uma linha vermelha no seu editor, com o lugar exato (linha 2, coluna 15). É a chamada segurança de tipos.

Os nomes dos parâmetros de tipo

NomeCostumeExemplo do .NET
TUm tipo só, sem papel especialList<T>, HashSet<T>
TKey, TValue"T" + o papel do tipo, quando há mais de umDictionary<TKey, TValue>
TElement, TPriorityIdemPriorityQueue<TElement, TPriority>
TResultO tipo do resultadoFunc<T, TResult> (aula Func e Action)

Os nomes são só convenção (o compilador aceitaria <Banana>), mas siga-a: quem lê o seu código reconhece na hora que TKey é um tipo, e não uma variável.

Dois parâmetros de tipo

var produto = new Par<string, decimal>("café", 18.90m);
var aluno = new Par<string, int>("Ana", 29);
Console.WriteLine($"{produto.Primeiro}: {produto.Segundo:C}");
Console.WriteLine($"{aluno.Primeiro} tem {aluno.Segundo} anos");
Console.WriteLine(produto);

record Par<TPrimeiro, TSegundo>(TPrimeiro Primeiro, TSegundo Segundo);
$ dotnet run par.cs
café: R$ 18,90
Ana tem 29 anos
Par { Primeiro = café, Segundo = 18,90 }

Os parâmetros de tipo vão separados por vírgula, como no Dictionary. Um record também pode ser genérico: aqui, uma linha basta para um par de qualquer coisa com qualquer coisa. (Para pares rápidos, o C# já tem as tuplas; o Par é só para ver a ideia.)


3. Métodos genéricos

Não é só classe que pode ter <T>: um método também. O <T> vem logo depois do nome do método:

int a = 1, b = 2;
Trocar(ref a, ref b);
Console.WriteLine($"a = {a}, b = {b}");

string x = "Ana", y = "Bruno";
Trocar(ref x, ref y);
Console.WriteLine($"x = {x}, y = {y}");

Console.WriteLine(Primeiro(new List<int> { 7, 8, 9 }));
Console.WriteLine(Primeiro<string>(["pão", "leite"]));

void Trocar<T>(ref T primeiro, ref T segundo)
{
    T guardado = primeiro;
    primeiro = segundo;
    segundo = guardado;
}

T Primeiro<T>(List<T> itens) => itens[0];
$ dotnet run metodos.cs
a = 2, b = 1
x = Bruno, y = Ana
7
pão
void Trocar<T>(ref T primeiro, ref T segundo)
Um método que troca o valor de duas variáveis de qualquer tipo, desde que as duas sejam do mesmo tipo T. O ref (veja ref, out e in) deixa o método mudar as variáveis de quem chamou.
T guardado = primeiro;
Uma variável local do tipo T, para não perder o primeiro valor no meio da troca.
Trocar(ref a, ref b)
Repare: sem <int>. O compilador olha os argumentos (a e b são int) e deduz que T é int. Isso se chama inferência de tipo.
Primeiro<string>(["pão", "leite"])
Aqui o tipo foi escrito à mão. É preciso quando o compilador não tem como deduzir: os colchetes [ … ] sozinhos não dizem que coleção é (veja o erro CS0411 em Quando dá errado).

Você já usou métodos genéricos sem perceber: o Sum, o Max e o ToList do LINQ, o Array.IndexOf, o Comparer<T>.Create… Por isso eles funcionam com qualquer tipo.

Missão: o método Ultimo só aceita List<int>, por isso a chamada com a lista de nomes não compila. Torne-o genérico (T Ultimo<T>(List<T> itens)) para as duas chamadas funcionarem.

List<int> numeros = [3, 5, 9];
List<string> nomes = ["Ana", "Bruno", "Carla"];

Console.WriteLine($"Último número: {Ultimo(numeros)}");
Console.WriteLine($"Último nome: {Ultimo(nomes)}");

int Ultimo(List<int> itens) => itens[itens.Count - 1];
List<int> numeros = [3, 5, 9];
List<string> nomes = ["Ana", "Bruno", "Carla"];

Console.WriteLine($"Último número: {Ultimo(numeros)}");
Console.WriteLine($"Último nome: {Ultimo(nomes)}");

T Ultimo<T>(List<T> itens) => itens[itens.Count - 1];

4. default: o "vazio" de qualquer tipo

Às vezes um método genérico precisa devolver "nada". Mas o que é "nada" para um T que pode ser int, string ou bool? Para isso existe o default, o valor padrão do tipo:

Console.WriteLine(PrimeiroOuPadrao(new List<int>()));
Console.WriteLine(PrimeiroOuPadrao(new List<string>()) is null);
Console.WriteLine(PrimeiroOuPadrao(new List<decimal> { 2.5m }));
Console.WriteLine(default(int));
Console.WriteLine(default(bool));

T? PrimeiroOuPadrao<T>(List<T> itens)
{
    if (itens.Count == 0)
        return default;
    return itens[0];
}
$ dotnet run padrao.cs
0                « lista de int vazia: default de int é 0
True             « lista de string vazia: default de string é null
2,5
0
False            « default de bool é false
return default;
"Devolva o valor padrão do T, seja ele qual for": 0 para números, false para bool, null para classes e string.
T?
Avisa quem chama que o resultado pode ser o "vazio" (com string, pode ser null). Sem o ?, o compilador reclamaria de devolver um possível null.
default(int)
A forma com o tipo escrito, para pedir o padrão de um tipo específico.

É exatamente o que fazem o GetValueOrDefault do Dictionary e o FirstOrDefault do LINQ.


5. O que o T não sabe fazer

Dentro de um método genérico, o compilador não sabe que tipo vai chegar no T. Por isso, ele só deixa você fazer com T o que qualquer tipo sabe: guardar, devolver, passar adiante, comparar com null, chamar ToString() e Equals(). Somar ou comparar com >, não:

Console.WriteLine(Maior(3, 7));

T Maior<T>(T a, T b)
{
    if (a > b)
        return a;
    return b;
}
$ dotnet run operador.cs
/home/ana/cs/operador.cs(5,9): error CS0019: O operador ">" não pode ser aplicado a operandos dos tipos "T" e "T"

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

Faz sentido: e se alguém chamasse Maior com dois Aluno? Qual aluno é "maior"? Para dizer ao compilador "este T só pode ser um tipo que sabe se comparar", você põe uma restrição: where T : IComparable<T>. Esse é o assunto da próxima aula, Restrições (where).


Generics no C# × em outras linguagens

C#Java
Tipos como intList<int> funciona direto, sem embrulhar cada númeroSó List<Integer>: cada número vira objeto
O tipo durante a execuçãoConhecido: typeof(T).Name dá Int32Apagado depois da compilação

Se você vem do Java, essa é a principal diferença: no C#, List<int> e List<string> são tipos realmente diferentes também enquanto o programa roda (new List<int>().GetType() mostra System.Collections.Generic.List`1[System.Int32]).


Quando dá errado

error CS1503: Argumento 1: não é possível converter de "string" para "int"

O que significa: você passou um valor de outro tipo para um método de uma classe genérica já "etiquetada". Numa Caixa<int>, o Guardar só aceita int.

Como consertar: guarde o tipo certo, ou crie a caixa com a etiqueta certa (new Caixa<string>(…)).

error CS0305: Usar o genérico tipo "Caixa<T>" requer 1 argumentos de tipo

O que significa: você escreveu new Caixa(42), sem a etiqueta. Diferente dos métodos, uma classe genérica não deduz o tipo pelo construtor.

$ dotnet run semtipo.cs
/home/ana/cs/semtipo.cs(1,17): error CS0305: Usar o genérico tipo "Caixa<T>" requer 1 argumentos de tipo

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

Como consertar: escreva o tipo: new Caixa<int>(42). Ou declare a variável com o tipo e use new(42): Caixa<int> caixa = new(42);.

error CS0411: Os argumentos de tipo do método "Criar<T>()" não podem ser inferidos com base no uso. Tente especificar explicitamente os argumentos de tipo.

O que significa: o compilador não conseguiu deduzir o T. Com var vazio = Criar();, não há argumento nenhum para olhar, e o var também não diz o tipo.

Como consertar: escreva o tipo na chamada: Criar<int>().

error CS0019: O operador ">" não pode ser aplicado a operandos dos tipos "T" e "T"

O que significa: você usou com T uma operação que nem todo tipo tem (>, +, <…). Em inglês: Operator '>' cannot be applied to operands of type 'T' and 'T'.

Como consertar: restrinja o T (por exemplo, where T : IComparable<T>) e use o método que a restrição garante: a.CompareTo(b) > 0. Veja Restrições (where).

InvalidCastException: Unable to cast object of type 'System.String' to type 'System.Int32'.

O que significa: um código antigo guarda tudo como object e converteu para o tipo errado (seção 1).

Como consertar: troque o object por um parâmetro de tipo T; aí o erro passa a aparecer na compilação.


Referência rápida

EscritaO que faz
class Caixa<T> { … }Classe genérica com um parâmetro de tipo
class Par<TA, TB> / record Par<TA, TB>(TA A, TB B);Dois parâmetros de tipo
new Caixa<int>(42)Usa a classe com T = int
T Primeiro<T>(List<T> itens)Método genérico
Primeiro(lista) / Primeiro<int>(lista)Tipo deduzido / tipo escrito
default / default(T)Valor padrão: 0, false, null…
typeof(T).NameNome do tipo que preencheu o T
where T : …Restrição (próxima aula)

Exercícios

1. A etiqueta

Complete para criar uma caixa que guarda decimal:

var cofre = new Caixa<>(10.50m);

2. Declarar uma classe genérica

Complete a declaração de uma classe Sacola com um parâmetro de tipo:

class Sacola

3. Qual a principal vantagem de Caixa<T> sobre uma caixa que guarda object?

4. Com T Primeiro<T>(List<T> itens), o que a chamada Primeiro(new List<double> { 1.5 }) faz?

5. O que default vale quando T é string?

Missão final: a sacola genérica

Missão: a classe SacolaDeTexto só guarda textos, e a Ana quer uma sacola de números também. Transforme-a numa classe genérica Sacola<T> (troque string por T onde for o tipo do item) e ajuste as duas criações para new Sacola<string>() e new Sacola<int>().

Frutas: 2
Tem uva? True
Números: 3
Tem 8? False
var frutas = new SacolaDeTexto();
frutas.Colocar("uva");
frutas.Colocar("maçã");
Console.WriteLine($"Frutas: {frutas.Quantidade}");
Console.WriteLine($"Tem uva? {frutas.Tem("uva")}");

var numeros = new SacolaDeTexto();
numeros.Colocar(4);
numeros.Colocar(7);
numeros.Colocar(9);
Console.WriteLine($"Números: {numeros.Quantidade}");
Console.WriteLine($"Tem 8? {numeros.Tem(8)}");

class SacolaDeTexto
{
    private readonly List<string> itens = [];

    public void Colocar(string item) => itens.Add(item);

    public bool Tem(string item) => itens.Contains(item);

    public int Quantidade => itens.Count;
}
var frutas = new Sacola<string>();
frutas.Colocar("uva");
frutas.Colocar("maçã");
Console.WriteLine($"Frutas: {frutas.Quantidade}");
Console.WriteLine($"Tem uva? {frutas.Tem("uva")}");

var numeros = new Sacola<int>();
numeros.Colocar(4);
numeros.Colocar(7);
numeros.Colocar(9);
Console.WriteLine($"Números: {numeros.Quantidade}");
Console.WriteLine($"Tem 8? {numeros.Tem(8)}");

class Sacola<T>
{
    private readonly List<T> itens = [];

    public void Colocar(T item) => itens.Add(item);

    public bool Tem(T item) => itens.Contains(item);

    public int Quantidade => itens.Count;
}

Repare que dentro da Sacola<T> há uma List<T>: um genérico usando outro. O T da sacola "passa" para a lista. É assim que as coleções do .NET são construídas.


Resumo

  • Generics deixam um tipo "em branco" (<T>) numa classe ou método, preenchido por quem usa: Caixa<int>, Caixa<string>.
  • Comparados com object, eles dão segurança de tipos (erro na compilação, e não InvalidCastException), dispensam conversões e não embrulham números.
  • Convenção de nomes: T, ou T + papel (TKey, TValue). Vários parâmetros vão separados por vírgula.
  • Métodos genéricos (T Primeiro<T>(…)) costumam ter o tipo deduzido pelos argumentos; quando não dá, escreva-o (CS0411). Classes genéricas sempre precisam do tipo (CS0305).
  • default é o valor padrão de qualquer T: 0, false, null.
  • Sem restrições, T não tem operadores como > e + (CS0019); a próxima aula ensina o where.