Lesson 5: Generics in depth
Sections
Reading settings
Natural voice recorded for this course, the same in every browser.
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
You use generics every day, and lesson 1 already showed one thing they do under the hood: a call through a constraint is emitted with the constrained. prefix, and doesn’t box. This lesson goes further. It looks at what a generic type is at run time, what each constraint writes into the metadata, what default(T) and T? really mean, how static abstract members let an interface describe a type rather than an object, and when the JIT gives two instantiations the same machine code. Each point is printed by a program, compiled, disassembled or measured.
The running example is a family of interfaces from Guitar Alchemist. PitchClass, Fret, IntervalClass and a dozen other small structs implement IStaticValueObjectList<TSelf>, which uses nearly every feature of this lesson at once: a type parameter that refers to the implementing type, a struct constraint, static abstract members, and a static generic class used as a cache.
GA links point to commit a826864; runtime links point to commit 4271d88 of dotnet/runtime, tagged v10.0.12.
Running the lesson’s program
Section titled “Running the lesson’s program”bash code/csharp-advanced/check.sh # every lesson, compared with expected/dotnet run --project code/csharp-advanced/Advanced -c Release -- l5 # this lesson only, after check.shcd code/csharp-advanceddotnet run -c Release --project Benchmarks -- --filter "*GenericMathBenchmarks*" # one benchmark classThe program is Advanced/Lesson5.cs; the four methods whose IL the lesson shows are in Snippets/Generics.cs; the rejected snippets are the l5_*.cs files of CompileFail/snippets. check.sh compares all of it with expected/ on three OSes. The timings come from Benchmarks/GenericBenchmarks.cs, run on the author’s machine, the one described in lesson 4.
Reified generics: a type per instantiation
Section titled “Reified generics: a type per instantiation”In .NET, a generic type is reified: List<int> and List<string> are two distinct types at run time, each with its own Type object, and a generic method can ask for typeof(T) or create a new T[n]. The runtime builds each closed type from the open definition List<T> the first time it’s needed (Generics in the runtime). For a value type, that means the elements are stored inline, without boxes (Lesson5.cs#L33-L62):
== Reified generics: every instantiation is a type of its owntypeof(List<int>) == typeof(List<string>): Falsetypeof(List<PitchClass>): System.Collections.Generic.List`1[GA.Domain.Core.Theory.Atonal.PitchClass]new List<PitchClass>() is List<int>: FalseNameOf<PitchClass>(): PitchClass; new T[3] is GA.Domain.Core.Theory.Atonal.PitchClass[]12 pitch classes in a List<PitchClass>: 104 bytes12 pitch classes in a List<object>: 440 bytesThe 104 bytes are the List object, 32 bytes, and an array of 12 four-byte PitchClass values, 24 + 48. The List<object> has the same 32 bytes, an array of 12 references, 24 + 96, and twelve boxes of 24 bytes each: 440 bytes, more than four times as much, and thirteen objects for the garbage collector instead of two. That second list is what every generic collection of numbers looks like in Java, as the last section shows.
Constraints: what the compiler checks, and what the runtime checks
Section titled “Constraints: what the compiler checks, and what the runtime checks”A constraint does two jobs: it restricts the type arguments a caller may pass, and it lets the generic code use what the constraint promises, a constructor, an operator, a method. The C# reference lists them all (Constraints on type parameters). Without a constraint, T offers only what object has, and the compiler says so:
// expect: CS0304public static class Factory{ public static T Create<T>() => new T();}l5_new_without_constraint.cs(4,36): error CS0304: Cannot create an instance of the variable type 'T' because it does not have the new() constraint// expect: CS0019public static class Arithmetic{ public static T Add<T>(T left, T right) => left + right;}l5_operator_without_constraint.cs(4,48): error CS0019: Operator '+' cannot be applied to operands of type 'T' and 'T'The second error was the classic limit of C# generics until C# 11; generic math, below, removes it. First, what do constraints become once compiled? The program declares one empty method per constraint and reads its type parameter back with reflection, through GenericParameterAttributes (Lesson5.cs#L66-L92):
== Constraints in the metadata: what the runtime seesClass ReferenceTypeConstraint; types []; attributes []Struct NotNullableValueTypeConstraint, DefaultConstructorConstraint; types [ValueType]; attributes []Unmanaged NotNullableValueTypeConstraint, DefaultConstructorConstraint; types [ValueType]; attributes [IsUnmanagedAttribute]NotNull None; types []; attributes []New DefaultConstructorConstraint; types []; attributes []Enum NotNullableValueTypeConstraint, DefaultConstructorConstraint; types [Enum, ValueType]; attributes []AllowsRefStruct AllowByRefLike; types []; attributes []structis written as two flags, “not a nullable value type” and “has a default constructor”, plus the base typeValueType: a struct always has a parameterless constructor as far as the runtime is concerned.unmanagedis written exactly likestruct, plus anIsUnmanagedAttributethat only the compiler reads.notnullleaves nothing the runtime reads: it is a nullable annotation, checked by the compiler as a warning.allows ref struct, new in C# 13, is a flag of its own, and it’s an anti-constraint: it widens what the caller may pass instead of restricting it.
So the runtime can check some constraints and not others. MethodInfo.MakeGenericMethod instantiates a method at run time, without the compiler:
== Constraints the compiler checks and the runtime doesn'tMakeGenericMethod((int, string)) on 'where T : unmanaged': acceptedMakeGenericMethod(string) on 'where T : unmanaged': ArgumentExceptionMakeGenericMethod(int?) on 'where T : struct': ArgumentExceptionsizeof(T) with T : unmanaged: PitchClass 4, (byte, long) 16IsReferenceOrContainsReferences: PitchClass False, (int, string) Truenew T() with T : new(): StringBuilder, Str 0The runtime accepts (int, string) for unmanaged, because what it checks is “a non-nullable value type”, and a tuple containing a string is one. The compiler rejects the same thing, because unmanaged also means “no references at any depth”, which is what makes sizeof(T) and pointers to T safe:
// expect: CS8377public struct Voicing{ public int Root; public string Name;}
public static class Buffers{ public static unsafe int SizeOf<T>() where T : unmanaged => sizeof(T);
public static int VoicingSize() => SizeOf<Voicing>();}l5_unmanaged_reference_field.cs(12,40): error CS8377: The type 'Voicing' must be a non-nullable value type, along with all fields at any level of nesting, in order to use it as parameter 'T' in the generic type or method 'Buffers.SizeOf<T>()'When code must decide at run time whether a T holds references, for example to know whether a buffer must be cleared for the garbage collector, RuntimeHelpers.IsReferenceOrContainsReferences<T>() answers, and the JIT turns it into a constant for each value type. The struct constraint, on the other hand, excludes Nullable<T> both in the compiler and in the runtime:
// expect: CS0453public static class Options{ public static T? Find<T>(T[] items) where T : struct => items.Length > 0 ? items[0] : null;
public static int? First(int?[] items) => Find(items);}l5_struct_nullable_argument.cs(6,47): error CS0453: The type 'int?' must be a non-nullable value type in order to use it as parameter 'T' in the generic type or method 'Options.Find<T>(T[])'And notnull, which exists only in the compiler, gives only a warning; Dictionary<TKey, TValue> declares its key notnull:
// expect: CS8714public static class Chords{ public static Dictionary<string?, int> ByName = new();}l5_notnull_nullable_key.cs(4,44): warning CS8714: The type 'string?' cannot be used as type parameter 'TKey' in the generic type or method 'Dictionary<TKey, TValue>'. Nullability of type argument 'string?' doesn't match 'notnull' constraint.Finally, what does the compiler emit for new T()? Not a constructor call: it can’t know which constructor. The IL of Generics.Create calls Activator.CreateInstance<T>(), and for a struct that returns the zero value without running any validation. GA’s Str, a guitar string numbered 1 to 26, comes out as string 0 in the last line above:
.method public hidebysig static !!T Create<.ctor T> () cil managed{ // Header size: 1 // Code size: 6 (0x6) .maxstack 8
IL_0000: call !!0 [System.Runtime]System.Activator::CreateInstance<!!T>() IL_0005: ret} // end of method Generics::Createdefault(T), and what T? means
Section titled “default(T), and what T? means”default(T) is all bits zero: null for a reference type, 0 for a number, and for a struct, every field zeroed. The IL is initobj !!T, whatever T is. The surprise is T?. On an unconstrained T, T? only says “may be the default”; it doesn’t turn int into int?. With where T : struct, T? is Nullable<T> (Lesson5.cs#L113-L141):
static T? FirstOrDefault<T>(IEnumerable<T> items, Func<T, bool> predicate){ foreach (var item in items) { if (predicate(item)) return item; } return default;}
static T? FirstOrNull<T>(IEnumerable<T> items, Func<T, bool> predicate) where T : struct{ foreach (var item in items) { if (predicate(item)) return item; } return null;}== default(T), and what T? meansFirstOrDefault(1 2 3, > 5): 0FirstOrDefault("C" "G", empty): nullFirstOrNull(1 2 3, > 5): nullreturn type of FirstOrDefault<int>: Int32; of FirstOrNull<int>: Nullable`1default(PitchClass): 0, the pitch class Cdefault(Str).Value: 0; Str.FromValue(0): ArgumentOutOfRangeExceptionnew Str[6]: 0 0 0 0 0 0FirstOrDefault<int> can’t tell “not found” from “found 0”: its return type is plain Int32. That’s how LINQ’s FirstOrDefault behaves too. The GA lines show the same trap on domain types. default(PitchClass) is 0, a perfectly valid C, so a missing pitch class looks like a C. default(Str) is string 0, which Str refuses to create: its init accessor checks the range, but default, new Str[6] and new T() never run it. A struct with an invariant can’t enforce it against zeroed memory; GA’s value objects with a minimum of 1, Str and the scale degrees, can all appear as 0 this way.
Static abstract members
Section titled “Static abstract members”An interface method describes what an object can do. A static abstract member, since C# 11, describes what a type can do: create itself from an int, give its minimum, add two of its values. Generic code calls it on the type parameter, T.FromValue(3). GA’s IValueObject<TSelf> and IRangeValueObject<TSelf> are built that way:
public interface IValueObject<TSelf> : IValueObject, IComparable<TSelf>, IEquatable<TSelf> where TSelf : IValueObject<TSelf>{ static abstract TSelf FromValue(int value);
static abstract implicit operator TSelf(int value); static abstract implicit operator int(TSelf fret);}
public interface IRangeValueObject<TSelf> : IValueObject<TSelf> where TSelf : IRangeValueObject<TSelf>{ static abstract TSelf Min { get; }
static abstract TSelf Max { get; }}The constraint where TSelf : IRangeValueObject<TSelf> is the self-referencing pattern: PitchClass implements IRangeValueObject<PitchClass>, so inside the interface, TSelf means “the implementing type”, and FromValue can return a PitchClass rather than an interface. With that, one generic method works for every GA value object (Lesson5.cs#L160-L177):
static string Range<T>() where T : IRangeValueObject<T> => $"{typeof(T).Name,-13} {T.Min,2} to {T.Max,-2} {T.Max.Value - T.Min.Value + 1,2} values";
static IEnumerable<T> AllOf<T>() where T : IRangeValueObject<T> => Enumerable.Range(T.Min.Value, T.Max.Value - T.Min.Value + 1).Select(v => T.FromValue(v));== Static abstract members: GA's IRangeValueObject<TSelf>PitchClass 0 to E 12 valuesIntervalClass 0 (Unison) to 6 (A4, d5; Tritone) 7 valuesFret x to 36 38 valuesStr 1 to 26 26 valuesAllOf<IntervalClass>(): 0 (Unison) 1 (m2, M7) 2 (M2, m7) 3 (m3, M6) 4 (M3, m6) 5 (P4, P5) 6 (A4, d5; Tritone)CountItems<PitchClass>() through IStaticReadonlyCollection<T>.Items: 12In the IL, a call to a static abstract member is a call with the same constrained. prefix as in lesson 1, on the interface’s method. It has no object to dispatch on: the runtime resolves it from the type argument, and in code compiled for a value type, the JIT resolves it once and can inline it (Generics.FromValue):
IL_0000: ldarg.0IL_0001: constrained. !!TIL_0007: call !0 class Snippets.IFromValue`1<!!T>::FromValue(int32)IL_000c: retThe price of static abstract members is that the interface is no longer a type you can hold a value of in generic code. Its static members have no implementation to call, so it can’t be a type argument:
// expect: CS8920public interface IFromValue<TSelf> where TSelf : IFromValue<TSelf>{ static abstract TSelf FromValue(int value);}
public readonly record struct PitchClass(int Value) : IFromValue<PitchClass>{ public static PitchClass FromValue(int value) => new(value % 12);}
public static class Registry{ public static List<IFromValue<PitchClass>> Factories = [];}l5_static_abstract_type_argument.cs(14,48): error CS8920: The interface 'IFromValue<PitchClass>' cannot be used as type argument. Static member 'IFromValue<PitchClass>.FromValue(int)' does not have a most specific implementation in the interface.Generic math
Section titled “Generic math”.NET 7 used static abstract members to give every number type a set of interfaces: INumber<T> and its parts, IAdditionOperators<TSelf, TOther, TResult>, INumberBase<T> with Zero and One, and the rest (Generic math). An operator is a static method, so left + right on T : IAdditionOperators<T, T, T> compiles to a constrained call to op_Addition, which is the IL of Generics.Add:
IL_0000: ldarg.0IL_0001: ldarg.1IL_0002: constrained. !!TIL_0008: call !2 class [System.Runtime]System.Numerics.IAdditionOperators`3<!!T, !!T, !!T>::op_Addition(!0, !1)IL_000d: retOne Sum and one Mean then serve every number type (Lesson5.cs#L181-L205):
public static T Sum<T>(ReadOnlySpan<T> values) where T : INumberBase<T>{ var sum = T.Zero; foreach (var value in values) sum += value; return sum;}
static T Mean<T>(ReadOnlySpan<T> values) where T : INumber<T> => Sum(values) / T.CreateChecked(values.Length);
static T AddChecked<T>(T left, T right) where T : IAdditionOperators<T, T, T> => checked(left + right);== Generic math: one Sum and one Mean for every number typeint sum 10, mean 2double sum 10, mean 2.5decimal sum 0.3, double sum 0.30000000000000004byte 250 + 10: unchecked 4, checked OverflowExceptionint MaxValue + 1: unchecked -2147483648, checked OverflowExceptiondouble MaxValue + MaxValue: checked Infinitybyte.CreateChecked(300) OverflowException, CreateSaturating 255, CreateTruncating 44int.CreateSaturating(double.NaN) 0, int.CreateSaturating(1e10) 2147483647Generic code keeps each type’s semantics. The integer mean of 1, 2, 3 and 4 is 2, the double mean is 2.5; decimal adds 0.1 and 0.2 exactly, double doesn’t. A checked expression in generic code calls the type’s checked operator, op_CheckedAddition, a C# 11 addition too: byte and int throw, and double, which has no overflow, gives infinity. Converting between number types has three explicit flavours: CreateChecked throws when the value doesn’t fit, CreateSaturating clamps it, and CreateTruncating keeps the low bits: 300 is 0x12C, and 0x2C is 44.
Is Sum<int> as fast as a loop written for int? GenericMathBenchmarks sums 1,024 values:
| Method | Mean | Error | StdDev | Ratio | RatioSD |
|---|---|---|---|---|---|
| IntLoop | 205.7 ns | 1.60 ns | 1.50 ns | 1.00 | 0.01 |
| IntGeneric | 208.7 ns | 1.46 ns | 2.81 ns | 1.01 | 0.02 |
| DoubleLoop | 363.3 ns | 4.44 ns | 4.16 ns | 1.77 | 0.02 |
| DoubleGeneric | 360.3 ns | 6.87 ns | 6.43 ns | 1.75 | 0.03 |
The generic version and the hand-written loop run in the same time, for int and for double, within their error margins: the JIT compiles a Sum<int> of its own, in which T.Zero and += are the int ones. That is only true because int and double are value types, which is the next section.
How the JIT shares generic code
Section titled “How the JIT shares generic code”The runtime can’t compile one body of machine code for every T: a Describe<int> receives 4 bytes in a register, a Describe<long> 8 bytes, a Describe<PitchClass> a struct. It can share code between reference types, because every reference is a pointer of the same size, and the garbage collector treats them all alike. So the JIT compiles one version per value type, and one version for all reference types, instantiated over a placeholder type called System.__Canon (Shared generics design).
You don’t have to take that on faith. The JIT prints the list of methods it compiles when DOTNET_JitDisasmSummary=1 is set, and writes it to a file with DOTNET_JitStdOutFile; both work in the shipped runtime (Viewing JIT dumps). The program calls Shared<T>.Describe for six type arguments, and check.sh keeps the lines of that list that mention it (Lesson5.cs#L291-L348, check.sh):
public static class Shared<T>{ public static int Calls;
[MethodImpl(MethodImplOptions.NoInlining)] public static string Describe(T value) { Calls++; return typeof(T).Name; }}== How the JIT shares generic code: one Describe per value type, one for all reference typesShared<Int32>.Describe: Calls 1Shared<Int64>.Describe: Calls 1Shared<PitchClass>.Describe: Calls 1Shared<String>.Describe: Calls 1Shared<Object>.Describe: Calls 1Shared<List`1>.Describe: Calls 1ReadStatic<int>(10) 10, ReadStatic<string>(10) 10JIT summary:Lesson5+Shared`1[GA.Domain.Core.Theory.Atonal.PitchClass]:Describe(GA.Domain.Core.Theory.Atonal.PitchClass)Lesson5+Shared`1[System.__Canon]:Describe(System.__Canon)Lesson5+Shared`1[int]:Describe(int)Lesson5+Shared`1[long]:Describe(long)Six instantiations, four compilations: int, long and PitchClass each get their own, and string, object and List<int> share the __Canon one. The program also counts the methods the JIT compiled on its thread during each first call, with JitInfo.GetCompiledMethodCount. The count depends on the runtime, so it’s printed on machine-dependent lines; on the author’s machine, it agrees:
# Shared<String>.Describe: 1 methods compiled on this thread by the first call# Shared<Object>.Describe: 0 methods compiled on this thread by the first call# Shared<List`1>.Describe: 0 methods compiled on this thread by the first callYet each instantiation still has its own Calls field, and typeof(T) still returns String or Object. The shared code finds them through a hidden argument, the generic context, which points to the instantiation’s generic dictionary: a table of the type handles, statics and methods that depend on T. That lookup is the cost of sharing. Here is ReadStatic<T>, which adds a static field of Counter<T> in a loop. The program’s l5-tiers mode calls it 5,000 times for int and for string, and DOTNET_JitDisasm=ReadStatic prints its machine code at each tier; these are the final, tier-1 versions, on x64, cut down to the loop:
public static int ReadStatic<T>(int times){ var sum = 0; for (var i = 0; i < times; i++) sum += Counter<T>.Value; return sum;}; Assembly listing for method Advanced.Lesson5:ReadStatic[int](int):int (Tier1)G_M000_IG03: mov edx, dword ptr [(reloc 0x7ffdbe39b0e0)]G_M000_IG04: add eax, edx dec ecx jne SHORT G_M000_IG04
; Assembly listing for method Advanced.Lesson5:ReadStatic[System.__Canon](int):int (Tier1)G_M000_IG03: mov rdx, qword ptr [rcx+0x18] mov rdi, qword ptr [rdx+0x10] test rdi, rdi je SHORT G_M000_IG07G_M000_IG04: mov rcx, rdi call CORINFO_HELP_GET_NONGCSTATIC_BASE add esi, dword ptr [rax] dec ebx jne SHORT G_M000_IG04For int, the JIT knows the address of Counter<int>.Value, and even reads the field once, before the loop: the loop is three instructions. For __Canon, it reads the class handle from the dictionary, through the generic context passed in rcx, and then calls a runtime helper to find the static field’s address on every iteration. Compiled with tiered compilation off, the __Canon loop is the same. SharedGenericBenchmarks measures 1,000 reads:
| Method | Mean | Error | StdDev | Ratio | RatioSD |
|---|---|---|---|---|---|
| ValueTypeInstantiation | 199.8 ns | 2.61 ns | 2.44 ns | 1.00 | 0.02 |
| ReferenceTypeInstantiation | 292.2 ns | 5.79 ns | 5.42 ns | 1.46 | 0.03 |
The shared code is 1.46 times slower, about 0.09 ns more per read. That is less than I expected for a call per iteration; I haven’t measured why it costs so little, to verify. This is a worst case, a loop around a lookup and nothing else; most shared generic code does real work between lookups. The lesson to keep is the direction: over value types, generic code is compiled as if you had written it by hand; over reference types, it is shared and pays for what depends on T.
A static field per closed type: generic caches
Section titled “A static field per closed type: generic caches”Since each closed type has its own static fields, a static generic class is a dictionary keyed by T that the runtime maintains for you, with no lookup and no lock after initialization. The program’s TypeCache<T> has an explicit static constructor, so that the runtime initializes it exactly at its first use rather than at a time of its choosing (static constructors) (Lesson5.cs#L263-L287):
== A static field per closed type: a cache the runtime keys by T TypeCache<String> initialized TypeCache<Object> initialized TypeCache<Int32> initializedId: string 1, object 2, int 3; initializations 3Hits: string 3, object 1, int 1EqualityComparer<T>.Default: int GenericEqualityComparer`1, string StringEqualityComparer, PitchClass GenericEqualityComparer`1 int? NullableEqualityComparer`1, object ObjectEqualityComparer`1, DayOfWeek EnumEqualityComparer`1the same instance each time: TrueTypeCache<string> and TypeCache<object> share their code but not their statics. The base library uses the pattern everywhere: EqualityComparer<T>.Default picks the best comparer for T once, a comparer that calls IEquatable<T>.Equals directly for PitchClass, one that unwraps Nullable<T>, one for enums, and caches it in a static field of EqualityComparer<T>.
GA’s ValueObjectCache<T> is the same idea, keyed by value-object type, and it calls the static abstract members to fill itself:
internal static class ValueObjectCache<T> where T : IRangeValueObject<T>{ internal static readonly int Min = T.Min.Value; internal static readonly int Max = T.Max.Value; private static readonly int _count = Max - Min + 1; internal static readonly T[] AllItems = CreateItems(); internal static FrozenSet<T> ItemsSet { get; } = FrozenSet.Create<T>(AllItems); internal static readonly ImmutableArray<int> AllValues = CreateValues(); internal static FrozenSet<int> ValuesSet { get; } = [..AllValues]; internal static ReadOnlySpan<T> ItemsSpan => AllItems;}All the static fields of a class are initialized together, in the order they’re written. A first read of ItemsSpan for GA’s 26 strings therefore also builds two FrozenSets, whether anyone uses them or not. The program measures that first access on a machine-dependent line:
# first access to ValueObjectUtils<Str>.ItemsSpan (26 items) allocated 4592 bytesAn array of 26 Str is 128 bytes; most of the 4,592 bytes are the two frozen sets and the immutable array. It’s paid once per type, and it’s small, but it’s worth knowing that a generic cache initializes everything it declares at once. In the three GA projects the course fetches, nothing but ValueObjectUtils<TSelf> reads those two sets, and ValueObjectUtils only exposes them.
Case study: GA’s IStaticValueObjectList<TSelf>
Section titled “Case study: GA’s IStaticValueObjectList<TSelf>”The interface is short:
public interface IStaticValueObjectList<TSelf> : IStaticReadonlyCollectionFromValues<TSelf> where TSelf : struct, IRangeValueObject<TSelf>{ public static abstract IReadOnlyList<int> Values { get; }}It inherits static abstract IReadOnlyCollection<TSelf> Items from IStaticReadonlyCollection<out TSelf>, and each implementer forwards to ValueObjectUtils<TSelf>, as PitchClass does:
public static IReadOnlyCollection<PitchClass> Items => ValueObjectUtils<PitchClass>.Items;public static IReadOnlyList<int> Values => ValueObjectUtils<PitchClass>.Values;public static ReadOnlySpan<PitchClass> ItemsSpan => ValueObjectUtils<PitchClass>.ItemsSpan;The cache is computed once, but what does each access cost? The program measures the bytes allocated by one read, after a first read has run the JIT and the static constructors (Lesson5.cs#L352-L406):
== GA: what IStaticValueObjectList<TSelf> costs per accessPitchClass.Items: 32 bytes, the same object twice: Falseforeach over PitchClass.Items: 72 bytesPitchClass.Values[3]: 24 bytes, Values is ImmutableArray`1ValueObjectUtils<PitchClass>.Values[3]: 0 bytesPitchClass.ItemsSpan[3]: 0 bytesSumAll<PitchClass>() over ItemsSpan: 66, 0 bytesItemsallocates a wrapper on every read.ValueObjectUtils<TSelf>.ItemscallsValueObjectCollection<TSelf>.Create(), which returns anew32-byte collection over the cached array. Its documentation says “automatically memoized”; the array is, the collection isn’t.- A
foreachover it allocates 72 bytes: the wrapper, and its struct enumerator boxed toIEnumerator<PitchClass>, 40 bytes, becauseItemsis typed as the interface. Valuesboxes anImmutableArray<int>on every read.ValueObjectUtils<TSelf>.Valuesreturns anImmutableArray<int>, a struct;IStaticValueObjectList<TSelf>.Valuesis declaredIReadOnlyList<int>, so each implementer’s property converts the struct to the interface: a 24-byte box, and an interface call for each index. Read directly, the sameImmutableArraycosts nothing.ItemsSpan, which the interface only mentions in a comment, is free, and so is a generic method that readsValueObjectUtils<T>.ItemsSpan:SumAll<T>works for every GA value object without any interface call.
ValueObjectListBenchmarks puts times on those bytes:
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|
| ItemsForeach | 14.473 ns | 0.2975 ns | 0.2783 ns | 1.00 | 0.03 | 0.0021 | 40 B | 1.00 |
| ItemsSpanForeach | 3.871 ns | 0.0757 ns | 0.0708 ns | 0.27 | 0.01 | - | - | 0.00 |
| GenericSumAll | 3.720 ns | 0.0655 ns | 0.0613 ns | 0.26 | 0.01 | - | - | 0.00 |
| ValuesIndexer | 41.796 ns | 0.8563 ns | 1.8796 ns | 2.89 | 0.14 | 0.0153 | 288 B | 7.20 |
| ImmutableArrayValues | 3.134 ns | 0.0705 ns | 0.0659 ns | 0.22 | 0.01 | - | - | 0.00 |
Summing the twelve pitch classes through Items takes 14.5 ns; through ItemsSpan, or the generic SumAll<PitchClass>, 3.8 ns. Reading the twelve values through PitchClass.Values takes 41.8 ns and allocates 288 bytes, twelve boxes of 24 bytes; through the ImmutableArray itself, 3.1 ns and nothing: 13 times faster. The benchmark also shows the JIT at work once more: optimized with dynamic PGO, the foreach over Items allocates 40 bytes instead of the 72 that the program’s first calls measured. 40 bytes is the size of the boxed enumerator, so the wrapper is probably the object the JIT no longer allocates; I haven’t checked which one it is, to verify.
The pattern is common and easy to miss: a static abstract property typed as an interface turns a cached struct or array into an allocation per call, even though the generic machinery around it costs nothing.
Two more things the program found in these interfaces. First, the out on IStaticReadonlyCollection<out TSelf> is covariance, which the next section explains; the program looked for implementers in the two GA assemblies it loads:
== GA: who implements IStaticReadonlyCollection<TSelf>13 structs, 4 classes: ModalFamily, PitchClassSet, Scale, SetClassSecond, GA has two range checks that normalize a value into its range, and they disagree. IRangeValueObject<TSelf>.EnsureValueInRange, a static method with a body on the generic interface, takes the remainder modulo max - min, one less than the number of values, then adds 1; ValueObjectUtils<TSelf>.EnsureValueRange uses max - min + 1:
== GA: the two range checks with normalize: true -1: IRangeValueObject<PitchClass>.EnsureValueInRange 11, ValueObjectUtils<PitchClass>.EnsureValueRange 11 12: IRangeValueObject<PitchClass>.EnsureValueInRange 2, ValueObjectUtils<PitchClass>.EnsureValueRange 0 13: IRangeValueObject<PitchClass>.EnsureValueInRange 3, ValueObjectUtils<PitchClass>.EnsureValueRange 1Pitch class 12 is 0; the interface’s version says 2. PitchClass uses the correct one, and the value objects that call the interface’s version, like Fret and Finger, don’t pass normalize: true, so the bug is latent: no GA code in the fetched projects reaches it.
Variance: in, out, and why List<T> has neither
Section titled “Variance: in, out, and why List<T> has neither”A string is an object; is a sequence of strings a sequence of objects? For reading, yes: everything that comes out of an IEnumerable<string> is an object. For writing, no: a List<object> accepts a PitchClass, which a List<string> mustn’t. C# lets an interface or a delegate say which applies to each type parameter (Covariance and contravariance): out T if T only comes out, which makes the interface covariant, in T if T only goes in, contravariant (Lesson5.cs#L209-L242):
== Variance: in, out, and why List<T> has neitherIEnumerable<string> as IEnumerable<object>: TrueList<string> is IEnumerable<object>: True; is IList<object>: FalseList<int> is IEnumerable<object>: FalseAction<object> as Action<string>: Trueobject[] array = new string[1]; array[0] = PitchClass.C: ArrayTypeMismatchException IEnumerable`1 Covariant IReadOnlyList`1 Covariant IList`1 None Action`1 Contravariant Func`2 Contravariant, Covariant IComparer`1 Contravariant IStaticReadonlyCollection`1 CovariantGA ModalFamily (class) is IStaticReadonlyCollection<object>: TrueGA PitchClass (struct) is IStaticReadonlyCollection<object>: False- The conversion is a reference conversion:
IEnumerable<object>is the same object as theIEnumerable<string>, no copy. IList<T>both returns and acceptsT, so it can’t be variant, and classes can never be:List<T>is invariant, and the compiler refuses the assignment outright.- Variance only works with reference types. A
List<int>is not anIEnumerable<object>: eachintwould need a box, and a reference conversion can’t create boxes. The same rule makes GA’soutonIStaticReadonlyCollection<out TSelf>useless for its 13 struct implementers, and harmless for the 4 classes, since an interface with only static members has nothing to call through a converted reference. - Arrays are covariant too, since .NET 1.0, but writing into them is unsafe, so every write of a reference into an array is checked at run time, and fails with
ArrayTypeMismatchException.
The compiler checks variance on both sides. The assignment of an invariant class:
// expect: CS0029public static class Names{ public static List<object> All = new List<string> { "C", "G" };}l5_invariant_list.cs(4,38): error CS0029: Cannot implicitly convert type 'System.Collections.Generic.List<string>' to 'System.Collections.Generic.List<object>'And a covariant parameter used as input:
// expect: CS1961public interface IProducer<out T>{ T Next();
void Accept(T item);}l5_unsafe_variance.cs(6,17): error CS1961: Invalid variance: The type parameter 'T' must be contravariantly valid on 'IProducer<T>.Accept(T)'. 'T' is covariant.allows ref struct
Section titled “allows ref struct”Lesson 1 showed that List<Span<int>> doesn’t compile: a ref struct can’t be a type argument unless the type parameter says allows ref struct, added in C# 13 (ref struct anti-constraint). .NET 9 added it where it was safe, so the program asks the runtime which type parameters carry the flag (Lesson5.cs#L246-L257):
== allows ref struct: which generic parameters accept a Span<T> Func`2 yes, yes Action`1 yes IEquatable`1 yes IComparable`1 yes IEnumerable`1 yes IEqualityComparer`1 yes List`1 no Dictionary`2 no, no Span`1 no Nullable`1 noApply("x 3 2 0 1 0".AsSpan(), span => span.Count(' ')): 5The delegates and the small interfaces allow it; the collections, which store their items in arrays on the heap, don’t. A generic method can opt in too:
static TResult Apply<T, TResult>(T value, Func<T, TResult> func) where T : allows ref struct => func(value);The program’s call passes it a ReadOnlySpan<char> over a voicing string, which compiles because both Apply’s T and Func’s T allow it. In exchange, the method’s body must treat T as possibly a ref struct: no boxing, no field in a class, no capture in a lambda.
// expect: CS0029public static class Boxes{ public static object Box<T>(T value) where T : allows ref struct => value;}l5_ref_struct_boxing.cs(4,73): error CS0029: Cannot implicitly convert type 'T' to 'object'If you know Spring and Reactor
Section titled “If you know Spring and Reactor”Java generics look the same on the page and are the opposite underneath: the compiler checks them, then erases them. The Java for C# developers course shows the consequences from the Java side, with javac’s error messages; here is the same list from the C# side, row by row with this lesson.
| C# (.NET 10) | Java, Spring and Reactor |
|---|---|
typeof(List<int>) != typeof(List<string>); typeof(T) works inside a generic method |
one ArrayList class for every List<…> (JLS §4.6); a method that needs the type takes a Class<T>, and Spring recovers declared type arguments from signatures with ResolvableType, or from an anonymous subclass with ParameterizedTypeReference |
List<PitchClass>: the values inline, one array |
List<Integer> only: type arguments must be reference types, each element a boxed object; IntStream and its siblings exist to avoid it, Reactor’s Flux has no such variant, and JEP 218, generics over primitive types, is still a candidate |
where T : struct, unmanaged, new(), notnull, allows ref struct |
only bounds: T extends Comparable<T>, with & for several; no constructor constraint (pass a Supplier<T>), nothing about value types, since there are none yet (JEP 401 value classes are a preview targeted at JDK 28) |
default(T): zero bits, 0 for int, null for string |
always null, since every T is a reference |
static abstract members: T.FromValue(3), T.Zero, left + right on T : INumber<T> |
no static members through a type variable; the usual answer is a factory or a strategy object passed in, as Comparator<T> is |
declaration-site variance: IEnumerable<out T>, Action<in T>, checked by the compiler (CS1961) |
use-site variance with wildcards: List<? extends Number> to read, List<? super Integer> to write, the “in” and “out” variables of the wildcard guidelines |
arrays are covariant and checked at run time: ArrayTypeMismatchException |
the same design and the same failure, ArrayStoreException |
one static field per closed type: TypeCache<string> and TypeCache<object> are two caches |
one static field for the whole class, whatever the type argument; a per-type cache is a ClassValue or a Map<Class<?>, …> |
value-type instantiations get their own machine code; reference types share __Canon code |
every instantiation runs the same bytecode, like .NET’s shared code for reference types, and the JIT specializes from the profile instead |
The last row is the closest the two platforms come. HotSpot never sees List<String>, only List, and relies on its profile to inline and devirtualize, much as .NET’s dynamic PGO devirtualized an interface call in lesson 4. What .NET adds is the value-type path: the code for Sum<int> is compiled for int, which Java only gets today from a class written for int, like IntStream.
Exercises
Section titled “Exercises”-
GA normalizes values with a modulo that never returns a negative number. Write
Wrap<T>(T value, T size)once for every number type, and check it on-1modulo12,-13Lmodulo12L,-0.5modulo12.0and25modulo12. -
GA’s
PitchClassimplementsIParsable<TSelf>, a static abstract interface from the base library. WriteParseAll<T>(string text), which splits a space-separated list and parses each part withT.Parse, and use it for"0 4 7 T"as pitch classes and"0 4 7 10"as integers. -
Which of these fields compile, and why?
public static class Quiz{public static IReadOnlyList<object> A = new List<string>();public static IList<object> B = new List<string>();public static IEnumerable<object> C = new List<int>();public static Func<string, object> D = (Func<object, string>)(o => o.ToString()!);public static Action<object> E = (Action<string>)(s => { });}
Solutions
-
INumber<T>gives%,+, the comparison andT.Zero(Lesson5.cs#L411-L415):public static T Wrap<T>(T value, T size) where T : INumber<T>{var remainder = value % size;return remainder < T.Zero ? remainder + size : remainder;}1. Wrap(-1, 12) 11, Wrap(-13L, 12L) 11, Wrap(-0.5, 12.0) 11.5, Wrap(25, 12) 1%in C# keeps the sign of the dividend, so-1 % 12is-1, and adding the size once brings it into range. The same code works fordouble, where%is defined too. -
IParsable<T>declaresstatic abstract T Parse(string s, IFormatProvider? provider), so the generic method calls it onT(Lesson5.cs#L418-L419):public static List<T> ParseAll<T>(string text) where T : IParsable<T> =>[.. text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(s => T.Parse(s, null))];2. ParseAll<PitchClass>("0 4 7 T") [0 4 7 T], ParseAll<int>("0 4 7 10") sum 21 -
AandDcompile;B,CandEdon’t.IReadOnlyList<out T>is covariant, so a list of strings is a read-only list of objects.IList<T>is invariant (B).List<int>holds values, and variance only converts references (C).Func<in T, out TResult>accepts a function fromobjecttostringwhere one fromstringtoobjectis expected: it takes more and returns less (D).Action<in T>goes the other way: an action on strings can’t accept every object (E). The compiler checked it (l5_exercise_variance.cs):l5_exercise_variance.cs(5,37): error CS0266: Cannot implicitly convert type 'System.Collections.Generic.List<string>' to 'System.Collections.Generic.IList<object>'. An explicit conversion exists (are you missing a cast?)l5_exercise_variance.cs(6,43): error CS0266: Cannot implicitly convert type 'System.Collections.Generic.List<int>' to 'System.Collections.Generic.IEnumerable<object>'. An explicit conversion exists (are you missing a cast?)l5_exercise_variance.cs(8,38): error CS0266: Cannot implicitly convert type 'System.Action<string>' to 'System.Action<object>'. An explicit conversion exists (are you missing a cast?)“An explicit conversion exists” because a
List<string>could, in theory, be cast to an interface at run time; the cast would throwInvalidCastException.
Key takeaways
Section titled “Key takeaways”- .NET generics are reified:
List<PitchClass>stores its values inline, 104 bytes for twelve, where a list of objects takes 440. - Constraints are metadata. The runtime checks
class,structandnew();unmanagedandnotnullare checked only by the compiler, andallows ref structwidens instead of restricting. default(T)andnew T()produce zeroed structs without running their validation. On an unconstrainedT,T?isn’tNullable<T>.- Static abstract members describe a type, not an object:
T.FromValue,T.Zero,left + right. An interface that has them can’t be a type argument. - Generic math keeps each type’s semantics, including checked operators, and
Sum<int>ran as fast as a loop written forint. - The JIT compiles generic code once per value type and once for all reference types, over
__Canon. Shared code finds what depends onTthrough a generic dictionary, and paid a helper call per iteration to read a static field: 1.46 times slower in that loop. - A static generic class is a per-type cache, initialized all at once. GA’s cache is sound, but its interfaces allocate on every access: 32 bytes for
Items, 24 forValues, nothing forItemsSpan. - Variance is for interfaces and delegates over reference types:
outto read,into write, never both.
Sources
Section titled “Sources”- Microsoft Learn: Constraints on type parameters, Generics in the runtime,
defaultoperator, Static virtual interface members, Generic math, Covariance and contravariance,GenericParameterAttributes,EqualityComparer<T>.Default, Static constructors. - dotnet/runtime at
v10.0.12(commit4271d88): Shared generics design, Viewing JIT dumps. - Java: Type erasure, Restrictions on generics and Wildcard guidelines in the Java tutorials, JLS §4.6, JEP 218, JEP 401.
- Guitar Alchemist at
a826864:IStaticValueObjectList.cs,IValueObject.cs,IRangeValueObject.cs,ValueObjectCache.cs,ValueObjectUtils.cs,PitchClass.cs.