Generics: Constraints and Variance
Objective
Generics let one piece of code work with many types without giving up type
safety or paying for boxing. In .NET they are reified: the type arguments exist
at run time, typeof(T) works, and the runtime decides how much native code to
share between instantiations. This concept covers why generics replaced the
object-based collections, the constraint list from class to
allows ref struct (C# 13), how the runtime specializes code for value types
and shares it for reference types, how a constraint avoids boxing on interface
calls, covariance and contravariance with out and in, why arrays are
covariant in an unsafe way, and generic attributes (C# 11).
Use Cases
- A
Max<T>orCache<TKey, TValue>written once and used forint,stringand your own types. - Accepting an
IEnumerable<Animal>and receiving aList<Dog>without copying. - Passing a general
Action<object>logger where anAction<string>is expected. - A per-type static cache (
Counter<T>.Hits) that needs no dictionary. [ValidateWith<EmailRule>]instead of[ValidateWith(typeof(EmailRule))].
Deep Dive
What generics fixed: boxing and casts
ArrayList and Hashtable store object, so every value type is boxed on the
way in and cast on the way out. Measured with
GC.GetAllocatedBytesForCurrentThread on .NET 10.0.0 (Apple M4, arm64,
Release), adding 100,000 ints to pre-sized collections:
csharpvar legacy = new ArrayList(N);
for (int i = 0; i < N; i++) legacy.Add(i); // 2401488 bytes allocated
var generic = new List<int>(N);
for (int i = 0; i < N; i++) generic.Add(i); // 0 bytes allocatedThat is 24 bytes per boxed int on this 64-bit runtime, and garbage for the GC
to collect. A generic collection also catches list.Add("oops") at compile time
instead of failing at the cast.
Reified generics
Unlike Java, where type arguments are erased, .NET keeps them:
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; }Each closed type has its own static fields, which is a cheap per-type cache.
default(T) is 0 for numbers and null for references.
Constraints
A constraint tells the compiler what it may assume about T, and it rejects
type arguments that do not fit:
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 | Means |
|---|---|
class / class? |
reference type (non-nullable / nullable) |
struct |
non-nullable value type; implies new() |
notnull |
any non-nullable type, value or reference |
unmanaged |
value type with no references at any depth; implies struct |
new() |
public parameterless constructor; must come last |
BaseClass, IInterface |
must derive from or implement it |
allows ref struct |
C# 13 anti-constraint: Span<T> and other ref structs are accepted |
Violations are compile errors, for example:
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 ... (same NameOf without allows ref struct)
How the runtime shares code
The C# guide on generics at run time says the runtime creates a specialized
version for each value type used as a type argument, and one shared version for
all reference types, because every reference has the same size. You can watch it
happen. Asking the JIT for its compilation summary (DOTNET_JitDisasmSummary=1)
while calling a generic Identity<T> with int, long, string and object
listed exactly three compilations:
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 is the runtime's placeholder for "any reference type": string and
object ran the same machine code. The consequence is that value-type
instantiations get code tuned for their exact layout, at the price of one copy
per value type, while reference-type instantiations cost no extra code but must
look up type-specific details at run time when the code needs them.
Constraints avoid boxing on interface calls
Passing an int as IComparable<int> boxes it. Calling the same method through
a constrained type parameter does not, because the code is specialized for
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 calls each, measured three times:
// interface 240000 bytes, constraint 0 bytesSame environment as above. When you write an API that takes structs, prefer
void M<T>(T x) where T : IFoo over void M(IFoo x).
Variance: out and in
Variance lets a generic interface or delegate convert along the inheritance of its type argument, but only when that is safe:
csharpIEnumerable<string> names = ["ana", "bia"];
IEnumerable<object> objects = names; // covariance: IEnumerable<out T>
Action<object> logAny = o => Console.WriteLine($"log {o}");
Action<string> logText = logAny; // contravariance: Action<in T>
IProducer<Dog> kennel = new Kennel();
IProducer<Animal> shelter = kennel; // your own covariant interface
interface IProducer<out T> { T Next(); }
class Animal;
class Dog : Animal;
class Kennel : IProducer<Dog> { public Dog Next() => new(); }out T means T only comes out (return values), so a producer of Dog is a
producer of Animal. in T means T only goes in (parameters), so something
that accepts any object accepts a string; IComparer<in T> works the same
way. The compiler enforces it: interface IBad<out T> { void Put(T item); }
fails with CS1961 ("must be contravariantly valid").
Three limits:
csharpList<object> a = new List<string>(); // CS0029: classes are never variant
IEnumerable<object> b = new List<int>(); // CS0266: variance only for reference typesVariance is declared only on interfaces and delegates, and the official docs
state it applies only to reference type arguments, since a boxed int is a
different representation, not just a different static type.
Arrays: covariance that can throw
Arrays of reference types are covariant too, and the C# docs call it out as not type safe: the compiler accepts the conversion, so the check moves to run time:
csharpobject[] boxes = new string[1];
boxes[0] = 42; // ArrayTypeMismatchExceptionIReadOnlyList<T> and IEnumerable<T> give you covariance that cannot fail,
because they have no way to store an element.
Generic attributes (C# 11)
csharp[ValidateWith<EmailRule>] public string Email { get; set; } = "";
sealed class ValidateWithAttribute<TRule> : Attribute where TRule : IRule, new();The constraint now checks at compile time what typeof(EmailRule) used to leave
to reflection. The type argument must be fully known: an attribute cannot use a
type parameter of the enclosing type.
Trade-offs
- Value-type instantiations multiply code. Each struct you plug in compiles
a new copy, which is the price of no boxing: in the experiment above
intandlongwere two separate JIT compilations of the same method, so many struct instantiations mean more JIT work at startup. - Shared code for references has to look things up. Since one
__Canonversion serves every reference type, anything type-specific (typeof(T),new T(), static fields ofGeneric<T>) cannot be baked into it and is resolved at run time. Measure before assuming it costs the same as in a value-type instantiation. - Constraints are a public contract. Adding one later breaks callers whose
types do not satisfy it; removing one can break code inside the method.csharp
public static T Parse<T>(string s) where T : IParsable<T> => T.Parse(s, null); // hard to loosen later - Variance only works for interfaces and delegates. If your API returns
List<Dog>, callers cannot treat it asList<Animal>; returnIReadOnlyList<Dog>and they can. - Covariant arrays are a legacy trap. Prefer
IReadOnlyList<T>parameters overobject[]orBase[]when you only read. - Generic is not always simpler. A method that only ever handles one type,
or that switches on
typeof(T), is clearer as overloads.
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