Advanced C# Generics: How the CLR Shares Generic Code
Every List<string> and every List<Customer> you've ever created shares the exact same compiled machine code. Every List<int> gets its own. That single fact explains more about generics performance than anything else in this lesson.
You spent Intermediate learning how to write generic types and methods, constrain them, and use covariance/contravariance on generic interfaces. That's the "how to write it" layer. This lesson is about what actually happens once your generic code is compiled and run — because C# generics are implemented in a way that's genuinely different from, say, Java's generics, and that difference has real consequences for both performance and what's possible in the language.
Here's the fact that anchors the whole lesson: List<string> and List<Customer>, despite being "different types," run on the exact same compiled native code at the CLR level. List<int> and List<double>, by contrast, each get their own, separately compiled, specialized native code. Understanding why unlocks a much clearer picture of generic code-size and performance trade-offs — and sets up two genuinely new C# 11+ capabilities: static abstract interface members, which enable "generic math," and self-referencing generic constraints.
In this lesson: how the CLR shares compiled code across reference-type generic instantiations but specializes for each value type, generic virtual/interface method dispatch, static abstract members and generic math, and a first look at self-referencing generic constraints.
What Is It?
Code Sharing — The Core Idea
When you write class Box<T> { T Value; } and use it as Box<string>, Box<Customer>, and Box<int>, the CLR (Common Language Runtime) doesn't treat all three the same way underneath. It groups generic instantiations into two categories based on a simple fact: every reference type is stored the same way — a pointer, always the same size (8 bytes on 64-bit). A value type is stored inline, and different value types have genuinely different sizes and layouts.
Reference-type instantiations
Box<string>,Box<Customer>,Box<object>, ...- All share one compiled native implementation
- The JIT compiles
Box<T>'s code once, treatingTgenerically as "a pointer" - No extra native code generated per reference type used
Value-type instantiations
Box<int>,Box<double>,Box<Guid>, ...- Each gets its own, specialized compiled native implementation
- The JIT compiles a genuinely distinct version for every distinct value type
T - More native code generated, but no boxing and full inlining opportunities
Why Does It Exist?
The Problem
A generic system has two competing goals that are hard to satisfy at once: (1) avoid boxing value types — the whole reason generics were added to C# in the first place, replacing pre-generic collections like ArrayList that stored everything as object — and (2) avoid generating a mountain of nearly-identical native code for every generic type used with every reference type, which would bloat assemblies and slow down JIT compilation for no real benefit.
The Need
Value types need genuinely specialized code, because an int and a Guid are laid out completely differently in memory and can't share a single implementation without boxing — which defeats the point. Reference types, by contrast, are all interchangeable at the machine level (a pointer is a pointer), so specializing per reference type would be pure waste.
The Solution — Code Sharing by Category
The CLR's answer: generate one shared native implementation for all reference-type instantiations of a generic type (often called "canonical" code, using object/pointer-shaped storage underneath, with the runtime supplying the exact type information needed for casts and type checks at each call site), and generate a distinct, fully specialized native implementation for each value-type instantiation. This gets you both goals at once: no boxing for value types, and no code bloat for reference types.
Big Picture
List<string>List<Customer>List<object>
List<int>List<double>List<Guid>
How It Works
- The C# compiler compiles
class Box<T>to a single block of IL (Intermediate Language) withTleft as an open placeholder — this happens exactly once, regardless of how many types you'll eventually useBox<T>with.
- The first time
Box<Customer>is actually used, the JIT compiles native code for it — and becauseCustomeris a reference type, that native code is written generically enough to be reused for any reference-typeT. The next time a never-before-seen reference type likeBox<Invoice>is used, the JIT can typically reuse that same compiled code rather than compiling fresh. - The first time
Box<int>is used, the JIT compiles a fresh, specialized native implementation, laid out specifically for a 4-byteint.Box<double>gets its own separate specialized compilation, laid out for an 8-bytedouble. Neither shares code with the other, or with the reference-type version.
- A generic type used with many different value types genuinely generates more native code in total (more JIT work, larger compiled-code footprint) than the same type used with many different reference types, which converge onto shared code. This is a real, measurable effect in large applications with generics instantiated over many distinct value types (enums, small structs), and part of why AOT-compiled/trimmed applications pay close attention to generic instantiation counts.
T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
a.CompareTo(b)is a call through theIComparable<T>constraint — for a reference-typeT, this resolves via the same kind of virtual dispatch (a vtable lookup) you already know from ordinary polymorphism.- For a value-type
Tin a specialized (non-shared) instantiation, the JIT knows the concrete type at compile time and can callCompareTodirectly — often even inlining it — with no virtual dispatch overhead and no boxing at all. This is a direct, concrete performance benefit of the value-type specialization described above.
Simple Example
Static abstract members and generic math (C# 11+)
Before C# 11, an interface could declare instance members but not static ones — so you could never write a generic method constrained to "any type that supports +," because operators are static. static abstract (and static virtual) interface members remove that restriction, and .NET's System.Numerics namespace ships a family of interfaces (INumber<T>, IAdditionOperators<T,T,T>, and others) built on top of it — collectively called "generic math."
// One generic method that works for int, double, decimal, and any other INumber —
// no need to write a separate Sum for each numeric type.
static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
T total = T.Zero; // T.Zero — a STATIC member accessed through the type parameter itself
foreach (var value in values)
total += value; // uses T's own + operator, via INumber<T>'s static abstract operator members
return total;
}
Sum(new[] { 1, 2, 3 }); // 6 (int)
Sum(new[] { 1.5, 2.5 }); // 4.0 (double)
Sum(new[] { 10m, 20m, 30m }); // 60 (decimal)
Meaning: T.Zero and total += value are only possible because INumber<T> declares Zero and the arithmetic operators as static abstract members — the constraint where T : INumber<T> guarantees every candidate T supplies them. Before this feature, writing one generic Sum across every numeric type simply wasn't expressible in C#; you'd have needed a separate overload per numeric type, or to fall back on dynamic and lose compile-time checking entirely.
Real-World Example
A generic statistics helper for a reporting service is a realistic place generic math replaces what used to be several near-duplicate methods:
public static class Stats
{
public static T Average<T>(IReadOnlyCollection<T> values) where T : INumber<T>
{
T total = T.Zero;
foreach (var v in values) total += v;
return total / T.CreateChecked(values.Count);
}
public static T Max<T>(IEnumerable<T> values) where T : INumber<T>
{
T? max = null;
foreach (var v in values)
if (max is null || v > max) max = v; // INumber<T> also brings comparison operators
return max ?? T.Zero;
}
}
decimal revenueAverage = Stats.Average(new[] { 1200m, 950m, 1800m }); // works for decimal...
double scoreAverage = Stats.Average(new[] { 88.5, 92.1, 79.4 }); // ...and double, same code
Before generic math, Stats either needed a separate Average(int[])/Average(decimal[])/Average(double[]) per numeric type, or fell back on dynamic/reflection-based arithmetic (slow, and giving up compile-time type checking entirely). One generic method constrained to INumber<T> now covers every current and future numeric type that implements it.
Analogy
A reference-type generic instantiation is like a universal TV remote — the exact same physical device works for a Sony, a Samsung, or an LG, because from the remote's point of view they're all "just a TV" (a pointer to something). One design, manufactured once, reused everywhere.
A value-type generic instantiation is like a custom-molded phone case — a case molded for one specific phone model won't fit a different one, even a similarly-sized one, because the actual dimensions differ. You need a genuinely separate mold (specialized compiled code) for every distinct phone (value type), because the internal shape actually differs, not just the label on the box.
Under the Hood
public interface IComparableTo<T> where T : IComparableTo<T>
{
int CompareTo(T other);
}
public class Money : IComparableTo<Money> // Money "closes the loop" — T is itself
{
public decimal Amount { get; init; }
public int CompareTo(Money other) => Amount.CompareTo(other.Amount);
}
This pattern — sometimes called the Curiously Recurring Generic Pattern — guarantees at compile time that Money.CompareTo only ever accepts another Money, never some unrelated type that happens to implement the same interface shape. It's exactly what IComparable<T> and IEquatable<T> in the BCL rely on when a type implements IComparable<Money> or IEquatable<Money> on itself — the type parameter and the implementing type are the same thing, closing the loop so the comparison is always type-safe and never accidentally cross-type. static abstract members lean on this same self-referencing idea heavily — INumber<T>'s operators are declared in terms of T operating on T, which only makes sense because the implementing type supplies itself as T.
Common Confusion
1. "Generics are erased, like Java's" — no, this is a genuinely different model
Java generics are type-erased: at runtime, all generic instantiations collapse to essentially the same bytecode, and value-like primitives (via autoboxing) always end up boxed. C# generics are reified — the runtime retains full type information for each instantiation, which is exactly what makes value-type specialization (and boxing avoidance) possible in the first place. "Code sharing" in this lesson is a deliberate optimization the CLR chooses for reference types specifically because it's safe to do so, not the same thing as erasure.
2. static abstract members are not the same as ordinary static methods on a class
A static method on a concrete class is called directly, by name, with no polymorphism involved. A static abstract interface member is called through a generic type parameter constrained to that interface (like T.Zero inside a method generic over T) — the actual implementation used depends on which concrete type T is at the call site, which is a genuinely new capability, not a renaming of something you already had.
3. Code sharing doesn't mean the types are "the same" — they're still distinct types
List<string> and List<Customer> sharing compiled native code is purely an implementation detail of how the CLR generates machine code. As far as the type system, casting rules, and everything you've learned about generics are concerned, they remain two completely distinct, unrelated types — you still can't assign one to the other, exactly as invariance (covered in the previous module) would lead you to expect.
Common Mistakes
Mistake 1 — Assuming a generic method constrained to an interface always avoids boxing for value types
Believing where T : IComparable<T> is automatically boxing-free for all value types in all contexts. In a genuinely specialized (per-value-type) instantiation it is — but casting the value type to the interface explicitly, or using it in a non-generic context that expects IComparable<T> as a reference, still boxes, exactly as covered in the value-vs-reference-types lesson.
Keep value-typed generic code concrete and generic all the way through; boxing sneaks back in the moment a value type is forced into a non-generic interface-typed variable or parameter.
Mistake 2 — Reaching for many distinct small value-type generic instantiations without considering code size
A library that instantiates a large generic type over dozens of small structs/enums, each triggering its own specialized native compilation, in an application that cares about startup time or binary size (e.g. AOT/trimmed deployments).
This is rarely worth avoiding for ordinary application code — it's a genuine consideration mainly for library authors and AOT-sensitive scenarios. Know the trade-off exists; don't over-engineer around it prematurely.
Mistake 3 — Writing a self-referencing constraint without actually needing type-safety across a family
Reaching for the self-referencing pattern (where T : IThing<T>) purely out of habit for a one-off type with no real family of implementers to guard against cross-type mixing.
It earns its complexity specifically when you need to prevent, at compile time, an unrelated type from satisfying an interface meant only for comparisons/operations within one family (exactly what IComparable<T>/IEquatable<T>/INumber<T> use it for) — not as a default habit for every generic interface you write.
When Should I Use It?
- Reach for static abstract interface members / generic math when you're writing numeric algorithms (statistics, aggregation, financial calculations) that should work identically across
int,double,decimal, and custom numeric types, without duplicating the method per type. - Reach for a self-referencing constraint when you're defining a family of types (comparable-to-each-other, combinable-with-each-other) and need to guarantee at compile time that operations stay within that family.
- Keep the code-sharing model in mind mainly as a mental tool for reasoning about generic performance and code size — it rarely needs to change how you write everyday application code, but it explains real, observable behavior (JIT time, assembly size) when it comes up.
Mental Model
Value-type generics = a fresh, specialized compiled implementation per distinct value type — layouts genuinely differ.
static abstract members = "every implementer of this interface must supply its own version of this static member" — the mechanism behind generic math.
Self-referencing constraint = "T must be able to operate on itself" — keeps comparisons and operations inside one type family, compile-time enforced.
Remember: C# generics are reified, not erased — the runtime always knows the real type, which is exactly what makes both the code-sharing optimization and boxing avoidance possible at the same time.
Key Takeaway
- All reference-type instantiations of a generic type share one compiled native implementation; every value-type instantiation gets its own specialized native code — a real code-size/performance trade-off, rooted in reference types always being pointer-sized while value types vary.
- Generic method calls through interface constraints resolve via the same virtual-dispatch machinery as ordinary polymorphism for reference types, but can be direct/inlined for specialized value-type instantiations — no boxing, no virtual call overhead.
static abstract/static virtualinterface members (C# 11+) let an interface require static members from every implementer, enabling "generic math" viaSystem.Numericsinterfaces likeINumber<T>.- Self-referencing generic constraints (
where T : IThing<T>) keep operations within one family of types, enforced at compile time — the same patternIComparable<T>/IEquatable<T>and generic math both rely on. - C# generics are reified, not type-erased — the runtime always retains real type information, which is what makes both of the above possible.
Check Your Understanding
You've seen how the CLR compiles generic code differently for reference types versus value types, and two genuinely new capabilities this unlocks. Let's check your understanding.
1. Why do List<string> and List<Customer> share the exact same compiled native code, while List<int> and List<double> each get their own?
Show answer
Correct: B
Why B is correct: This is the core fact anchoring the lesson — reference types are uniformly pointer-sized, so one native implementation handles all of them, while value types differ in actual layout and size, requiring the JIT to generate specialized code per distinct value type.
Why A is incorrect: Size similarity between string and Customer is irrelevant — the sharing happens because both are reference types (pointers), not because of any coincidental size match.
Why C is incorrect: Assembly boundaries have nothing to do with this — the sharing behavior is based purely on whether the type argument is a reference type or a value type.
Why D is incorrect: This behavior applies to generic types generally, not something unique to List<T> specifically.
Reinforcement: "Pointer-sized and uniform" vs. "genuinely different layouts" is the dividing line the CLR uses to decide whether to share compiled code.
2. What capability do static abstract interface members (C# 11+) add that wasn't possible before?
Show answer
Correct: B
Why B is correct: Before C# 11, interfaces couldn't declare static members at all, which made it impossible to write one generic method constrained to "any type supporting the + operator." static abstract members close that gap — this is exactly what powers generic math via INumber<T>.
Why A is incorrect: This feature is about static interface members, not private fields — an unrelated capability.
Why C is incorrect: Interfaces still cannot be instantiated with new — a concrete implementing type is still required.
Why D is incorrect: The feature is specifically about interface members, not adding virtual-ness to ordinary class static methods (which was never the limitation being solved).
Reinforcement: static abstract interface members exist specifically to let generic code use static functionality (like operators) polymorphically across implementing types — this is the mechanism behind generic math.
3. Why is C# generics described as "reified" rather than "type-erased" (unlike Java)?
Show answer
Correct: B
Why B is correct: Reification means the runtime keeps genuine type information per instantiation, rather than collapsing everything to one erased representation. That's precisely why the CLR can specialize compiled code for value types and avoid boxing — capabilities type erasure would preclude.
Why A is incorrect: Reification is actually a performance advantage in this respect (avoiding boxing), not a source of slowdown.
Why C is incorrect: The opposite is true — reification is specifically what allows C# generics to work efficiently with value types, unlike Java's erasure-plus-autoboxing approach.
Why D is incorrect: They describe genuinely different runtime models with different consequences, as explained in "Common Confusion."
Reinforcement: Reification is the underlying reason the code-sharing-vs-specialization split from this lesson is even possible — the runtime has to know the real type to make that decision.
4. What does declaring interface IComparableTo<T> where T : IComparableTo<T>, then having Money : IComparableTo<Money>, actually guarantee?
Show answer
Correct: B
Why B is correct: The self-referencing constraint ties the interface's type parameter to the implementing type itself, guaranteeing CompareTo's parameter type matches Money specifically — the exact mechanism IComparable<T>/IEquatable<T> use to stay type-safe.
Why A is incorrect: This is the opposite of what the pattern guarantees — it narrows comparisons to within the family, not opens them to everything.
Why C is incorrect: It has a real, compiler-enforced effect: it would be a compile error for Money to implement IComparableTo<SomeOtherType> instead of IComparableTo<Money>.
Why D is incorrect: Whether Money is a class or struct is an entirely separate decision from implementing this interface — the constraint says nothing about value-vs-reference semantics.
Reinforcement: Self-referencing constraints are a compile-time guardrail keeping a family of related operations type-safe within that family.
You now understand what actually happens to generic code at the CLR level, and you've seen two genuinely new C# 11+ capabilities that build directly on it.