Generics: constraints e variância
Objective
Generics permitem que um mesmo código funcione com muitos tipos sem abrir mão de
type safety nem pagar por boxing. No .NET eles são reificados: os argumentos de
tipo existem em runtime, typeof(T) funciona, e o runtime decide quanto código
nativo compartilhar entre instanciações. Este conceito cobre por que generics
substituíram as coleções baseadas em object, a lista de constraints de class
até allows ref struct (C# 13), como o runtime especializa código para value
types e compartilha para reference types, como uma constraint evita boxing em
chamadas de interface, covariância e contravariância com out e in, por que
arrays são covariantes de um jeito inseguro, e generic attributes (C# 11).
Use Cases
- Um
Max<T>ouCache<TKey, TValue>escrito uma vez e usado paraint,stringe os seus próprios tipos. - Aceitar um
IEnumerable<Animal>e receber umaList<Dog>sem copiar. - Passar um logger genérico
Action<object>onde se espera umAction<string>. - Um cache estático por tipo (
Counter<T>.Hits) que dispensa dicionário. [ValidateWith<EmailRule>]em vez de[ValidateWith(typeof(EmailRule))].
Deep Dive
O que generics resolveram: boxing e casts
ArrayList e Hashtable guardam object, então todo value type sofre boxing na
entrada e cast na saída. Medindo com GC.GetAllocatedBytesForCurrentThread no
.NET 10.0.0 (Apple M4, arm64, Release), ao adicionar 100.000 ints em coleções
já dimensionadas:
csharpvar legacy = new ArrayList(N);
for (int i = 0; i < N; i++) legacy.Add(i); // 2401488 bytes alocados
var generic = new List<int>(N);
for (int i = 0; i < N; i++) generic.Add(i); // 0 bytes alocadosSão 24 bytes por int encaixotado neste runtime de 64 bits, e lixo para o GC
recolher. Uma coleção genérica também pega list.Add("oops") em tempo de
compilação, em vez de falhar no cast.
Generics reificados
Diferente do Java, em que os argumentos de tipo são apagados, o .NET os mantém:
csharpConsole.WriteLine(typeof(List<int>) == typeof(List<string>)); // False
Console.WriteLine(new List<string>().GetType()); // System.Collections.Generic.List`1[System.String]
static string Describe<T>() => $"{typeof(T).Name}:{default(T)?.ToString() ?? "null"}";
// Describe<int>() -> "Int32:0", Describe<string>() -> "String:null"
Counter<int>.Hits++; Counter<int>.Hits++; Counter<string>.Hits++;
Console.WriteLine($"{Counter<int>.Hits} {Counter<string>.Hits}"); // 2 1
static class Counter<T> { public static int Hits; }Cada tipo fechado tem seus próprios campos estáticos, o que dá um cache por tipo
barato. default(T) é 0 para números e null para referências.
Constraints
Uma constraint diz ao compilador o que ele pode assumir sobre T, e ele recusa
argumentos de tipo que não se encaixam:
csharpstatic T Max<T>(IEnumerable<T> items) where T : IComparable<T>
{
using var e = items.GetEnumerator();
if (!e.MoveNext()) throw new InvalidOperationException("empty");
T best = e.Current;
while (e.MoveNext()) if (e.Current.CompareTo(best) > 0) best = e.Current;
return best;
}
static T Create<T>() where T : class, new() => new T();
static int ByteCount<T>(T value) where T : unmanaged
=> MemoryMarshal.AsBytes(new ReadOnlySpan<T>(in value)).Length; // ByteCount(42L) -> 8
static string NameOf<T>(T value) where T : allows ref struct => typeof(T).Name;
// NameOf("hello".AsSpan()) -> "ReadOnlySpan`1"| Constraint | Significa |
|---|---|
class / class? |
reference type (não anulável / anulável) |
struct |
value type não anulável; implica new() |
notnull |
qualquer tipo não anulável, valor ou referência |
unmanaged |
value type sem referências em nenhum nível; implica struct |
new() |
construtor público sem parâmetros; vem por último |
BaseClass, IInterface |
precisa derivar dela ou implementá-la |
allows ref struct |
anti-constraint do C# 13: aceita Span<T> e outros ref structs |
Violações são erros de compilação, por exemplo:
texterror CS0310: 'string' must be a non-abstract type with a public parameterless constructor ... 'Create<T>()' error CS8377: The type 'string' must be a non-nullable value type, along with all fields at any level of nesting ... 'ByteCount<T>(T)' error CS9244: The type 'ReadOnlySpan<char>' may not be a ref struct ... (o mesmo NameOf sem allows ref struct)
Como o runtime compartilha código
O guia de C# sobre generics em runtime diz que o runtime cria uma versão
especializada para cada value type usado como argumento de tipo, e uma versão
compartilhada para todos os reference types, porque toda referência tem o mesmo
tamanho. Dá para ver acontecendo. Pedindo ao JIT o resumo de compilações
(DOTNET_JitDisasmSummary=1) enquanto um Identity<T> genérico era chamado
com int, long, string e object, apareceram exatamente três compilações:
textProgram:<<Main>$>g__Identity|0_2[int](int) Program:<<Main>$>g__Identity|0_2[long](long) Program:<<Main>$>g__Identity|0_2[System.__Canon](System.__Canon)
__Canon é o marcador do runtime para "qualquer reference type": string e
object rodaram o mesmo código de máquina. A consequência é que instanciações
com value types ganham código ajustado ao layout exato, ao preço de uma cópia
por value type, enquanto instanciações com reference types não custam código
extra, mas precisam buscar em runtime os detalhes específicos do tipo quando o
código precisa deles.
Constraints evitam boxing em chamadas de interface
Passar um int como IComparable<int> faz boxing. Chamar o mesmo método por um
parâmetro de tipo com constraint não faz, porque o código é especializado para
int:
csharpstatic int ViaInterface(IComparable<int> value, int other) => value.CompareTo(other);
static int ViaConstraint<T>(T value, T other) where T : IComparable<T> => value.CompareTo(other);
// 10.000 chamadas de cada, medido três vezes:
// interface 240000 bytes, constraint 0 bytesMesmo ambiente de antes. Ao escrever uma API que recebe structs, prefira
void M<T>(T x) where T : IFoo a void M(IFoo x).
Variância: out e in
Variância deixa uma interface ou um delegate genérico converter seguindo a herança do argumento de tipo, mas só quando isso é seguro:
csharpIEnumerable<string> names = ["ana", "bia"];
IEnumerable<object> objects = names; // covariância: IEnumerable<out T>
Action<object> logAny = o => Console.WriteLine($"log {o}");
Action<string> logText = logAny; // contravariância: Action<in T>
IProducer<Dog> kennel = new Kennel();
IProducer<Animal> shelter = kennel; // a sua própria interface covariante
interface IProducer<out T> { T Next(); }
class Animal;
class Dog : Animal;
class Kennel : IProducer<Dog> { public Dog Next() => new(); }out T significa que T só sai (valores de retorno), então um produtor de
Dog é um produtor de Animal. in T significa que T só entra
(parâmetros), então algo que aceita qualquer object aceita uma string;
IComparer<in T> funciona do mesmo jeito. O compilador garante isso:
interface IBad<out T> { void Put(T item); } falha com CS1961 ("must be
contravariantly valid").
Três limites:
csharpList<object> a = new List<string>(); // CS0029: classes nunca são variantes
IEnumerable<object> b = new List<int>(); // CS0266: variância só para reference typesVariância só é declarada em interfaces e delegates, e a documentação oficial
afirma que ela vale apenas para argumentos que são reference types, já que um
int encaixotado é outra representação, não só outro tipo estático.
Arrays: covariância que pode lançar exceção
Arrays de reference types também são covariantes, e a documentação de C# avisa que isso não é type safe: o compilador aceita a conversão, então a checagem vai para o runtime:
csharpobject[] boxes = new string[1];
boxes[0] = 42; // ArrayTypeMismatchExceptionIReadOnlyList<T> e IEnumerable<T> dão covariância que não tem como falhar,
porque não oferecem jeito de gravar um elemento.
Generic attributes (C# 11)
csharp[ValidateWith<EmailRule>] public string Email { get; set; } = "";
sealed class ValidateWithAttribute<TRule> : Attribute where TRule : IRule, new();A constraint agora confere em tempo de compilação o que typeof(EmailRule)
deixava para a reflection. O argumento de tipo precisa ser totalmente conhecido:
um atributo não pode usar um parâmetro de tipo do tipo que o contém.
Trade-offs
- Instanciações com value types multiplicam código. Cada struct que você usa
compila uma cópia nova, que é o preço de não ter boxing: no experimento acima
intelongforam duas compilações separadas do mesmo método, então muitas instanciações com structs significam mais trabalho de JIT na inicialização. - Código compartilhado para referências precisa buscar coisas. Como uma
única versão
__Canonatende todo reference type, nada específico do tipo (typeof(T),new T(), campos estáticos deGeneric<T>) pode ficar embutido nela, e isso é resolvido em runtime. Meça antes de supor que custa o mesmo que numa instanciação com value type. - Constraints são contrato público. Adicionar uma depois quebra quem chama
com tipos que não a satisfazem; remover uma pode quebrar o código dentro do
método.csharp
public static T Parse<T>(string s) where T : IParsable<T> => T.Parse(s, null); // difícil de afrouxar depois - Variância só funciona em interfaces e delegates. Se a sua API retorna
List<Dog>, quem chama não consegue tratá-la comoList<Animal>; retorneIReadOnlyList<Dog>e consegue. - Arrays covariantes são uma armadilha legada. Prefira parâmetros
IReadOnlyList<T>aobject[]ouBase[]quando você só lê. - Genérico nem sempre é mais simples. Um método que só lida com um tipo, ou
que faz switch em
typeof(T), fica mais claro como sobrecargas.
Documentation Links
- Generic classes and methods, C# fundamentals, Microsoft Learn
- Constraints on type parameters, Microsoft Learn
- Generics in the runtime, Microsoft Learn
- Covariance and contravariance in generics, .NET, Microsoft Learn
- Covariance and contravariance, C#, Microsoft Learn
- What's new in C# 13 (allows ref struct), Microsoft Learn
- C# version history, C# 11 (generic attributes), Microsoft Learn
- Generics and attributes, Microsoft Learn