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.
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.
Box<string>, Box<Customer>, Box<object>, ...Box<T>'s code once, treating T generically as "a pointer"Box<int>, Box<double>, Box<Guid>, ...TA 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.
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 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.
List<string>List<Customer>List<object>
List<int>List<double>List<Guid>
class Box<T> to a single block of IL (Intermediate Language) with T left as an open placeholder — this happens exactly once, regardless of how many types you'll eventually use Box<T> with.Box<Customer> is actually used, the JIT compiles native code for it — and because Customer is a reference type, that native code is written generically enough to be reused for any reference-type T. The next time a never-before-seen reference type like Box<Invoice> is used, the JIT can typically reuse that same compiled code rather than compiling fresh.Box<int> is used, the JIT compiles a fresh, specialized native implementation, laid out specifically for a 4-byte int. Box<double> gets its own separate specialized compilation, laid out for an 8-byte double. Neither shares code with the other, or with the reference-type version.T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
a.CompareTo(b) is a call through the IComparable<T> constraint — for a reference-type T, this resolves via the same kind of virtual dispatch (a vtable lookup) you already know from ordinary polymorphism.T in a specialized (non-shared) instantiation, the JIT knows the concrete type at compile time and can call CompareTo directly — 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.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.
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.
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.
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.
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.
static abstract members are not the same as ordinary static methods on a classA 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.
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.
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.
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.
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.
int, double, decimal, and custom numeric types, without duplicating the method per type.static abstract/static virtual interface members (C# 11+) let an interface require static members from every implementer, enabling "generic math" via System.Numerics interfaces like INumber<T>.where T : IThing<T>) keep operations within one family of types, enforced at compile time — the same pattern IComparable<T>/IEquatable<T> and generic math both rely on.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?
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?
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)?
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?
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.