← Open in the full interactive course (progress tracking, search & more)

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

Value-type instantiations

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

ONE List<T> SOURCE, TWO VERY DIFFERENT COMPILATION OUTCOMES
Reference types
List<string>
List<Customer>
List<object>
↓ all share ↓
ONE compiled implementation
Value types
List<int>
List<double>
List<Guid>
↓ each gets ↓
ITS OWN compiled implementation

How It Works

FROM SOURCE TO SHARED/SPECIALIZED NATIVE CODE — STEP BY STEP
1. THE COMPILER EMITS ONE SET OF GENERIC IL
2. AT JIT TIME, THE RUNTIME DECIDES: SHARE OR SPECIALIZE
3. THE CONSEQUENCE FOR CODE SIZE AND STARTUP
GENERIC VIRTUAL / INTERFACE METHOD DISPATCH
CALLING A METHOD THROUGH A GENERIC INTERFACE CONSTRAINT
T Max<T>(T a, T b) where T : IComparable<T>
    => a.CompareTo(b) >= 0 ? a : b;

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

One universal remote vs. a custom-molded case

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

SELF-REFERENCING GENERIC CONSTRAINTS — A FIRST LOOK
A TYPE CONSTRAINING ITS OWN TYPE PARAMETER TO ITSELF
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?

Mental Model

Reference-type generics = one compiled implementation, reused for every reference type — pointers all look alike.
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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.