Advanced C# A 02 10 features

Generics

C# generics are real at run time. The type argument survives compilation. So the code can ask what T is, make a T[], and add two T values.

You already use List<T> and Dictionary<TKey, TValue>. This page covers writing generic code well. Constraints say what T can do. Variance says when one generic type can stand in for another. Static abstract members and generic math let T bring its own operators. Each feature has a tested sample, traps, and interview questions.

Contents

  1. Constraints
  2. allows ref struct
  3. Covariance and contravariance
  4. Static abstract and virtual members
  5. Generic math with INumber
  6. Generic attributes
  7. Static generic class caching
  8. default and default(T)
  9. Reified generics vs Java erasure
  10. nameof on unbound generics
  11. Recap
The one rule. Inside a generic method you may only do to T what its constraints allow. Every feature here adds a new kind of constraint. Or it follows from T being known at run time.

1. Constraints C# 2.0 / 7.3 / 8

What it is

A where clause limits which types can fill T. In return, the code may use what the constraint promises.

Why and when

Sample

/// <summary>A base class for the base-class constraint demo.</summary>
public abstract class Entity
{
    public int Id { get; init; }
}

/// <summary>A sample entity.</summary>
public sealed class Customer : Entity
{
    public string Name { get; init; } = "";
}

/// <summary>A sample enum for the Enum constraint.</summary>
public enum Color { Red, Green, Blue }

/// <summary>A store that can only hold entities. The constraint lets it read Id.</summary>
public sealed class Repo<TEntity> where TEntity : Entity
{
    private readonly Dictionary<int, TEntity> _byId = [];

    /// <summary>Adds or replaces an entity by its Id.</summary>
    /// <param name="item">The entity.</param>
    /// <example>repo.Save(new Customer { Id = 1 }) stores it under key 1.</example>
    public void Save(TEntity item) => _byId[item.Id] = item;   // Id is visible via Entity

    /// <summary>Finds an entity by Id.</summary>
    /// <param name="id">The key.</param>
    /// <returns>The entity, or null when missing.</returns>
    /// <example>repo.Find(1) returns the customer saved with Id 1.</example>
    public TEntity? Find(int id) => _byId.GetValueOrDefault(id);
}

public static class Constraints
{
    /// <summary>Largest item. IComparable lets the code call CompareTo.</summary>
    /// <param name="items">A non-empty sequence.</param>
    /// <returns>The maximum.</returns>
    /// <example>Constraints.Largest(["pear", "fig", "apple"]) returns "pear".</example>
    public static T Largest<T>(IEnumerable<T> items) where T : IComparable<T>
    {
        using var e = items.GetEnumerator();
        if (!e.MoveNext()) throw new InvalidOperationException("empty sequence");
        T best = e.Current;
        // Invariant: best is the max of the items seen so far.
        while (e.MoveNext())
        {
            // > 0 means e.Current sorts after best.
            if (e.Current.CompareTo(best) > 0) best = e.Current;
        }
        return best;
    }

    /// <summary>Makes n fresh objects. new() lets the code call the constructor.</summary>
    /// <param name="n">How many to make.</param>
    /// <returns>A list of n new instances.</returns>
    /// <example>Constraints.MakeMany&lt;List&lt;int&gt;&gt;(2).Count returns 2.</example>
    public static List<T> MakeMany<T>(int n) where T : new()
    {
        var list = new List<T>(n);      // capacity n, the exact final size
        for (int i = 0; i < n; i++) list.Add(new T());
        return list;
    }

    /// <summary>Parses text to a value type, or null. struct lets T? mean Nullable.</summary>
    /// <param name="text">The text to parse.</param>
    /// <returns>The value, or null when parsing fails.</returns>
    /// <example>Constraints.ParseOrNull&lt;int&gt;("x") returns null.</example>
    public static T? ParseOrNull<T>(string text) where T : struct, IParsable<T> =>
        T.TryParse(text, null, out T value) ? value : null;   // null: no format provider

    /// <summary>Size in bytes of an unmanaged type. Needs unsafe for sizeof(T).</summary>
    /// <returns>The size of T.</returns>
    /// <example>Constraints.SizeOf&lt;long&gt;() returns 8.</example>
    public static unsafe int SizeOf<T>() where T : unmanaged => sizeof(T);

    /// <summary>All names of an enum, with no typeof or casts.</summary>
    /// <returns>The member names in declared order.</returns>
    /// <example>Constraints.Names&lt;Color&gt;() returns "Red,Green,Blue".</example>
    public static string Names<T>() where T : struct, Enum =>
        string.Join(",", Enum.GetValues<T>());

    /// <summary>Groups words by a key that must not be null.</summary>
    /// <param name="words">The words.</param>
    /// <param name="key">Picks the group key. notnull matches Dictionary's own rule.</param>
    /// <returns>A count per key.</returns>
    /// <example>Constraints.CountBy(["a", "bb", "cc"], w =&gt; w.Length)[2] returns 2.</example>
    public static Dictionary<TKey, int> CountBy<TKey>(IEnumerable<string> words,
        Func<string, TKey> key) where TKey : notnull
    {
        var counts = new Dictionary<TKey, int>();
        foreach (var w in words)
        {
            var k = key(w);
            counts[k] = counts.GetValueOrDefault(k) + 1;   // + 1 for this word, 0 if new
        }
        return counts;
    }
}

Pitfalls

Interview questions

2. allows ref struct C# 13

What it is

Before C# 13, a ref struct such as Span<T> could never be a type argument. where T : allows ref struct is an anti-constraint. It widens the set of allowed types to include ref structs. In exchange, the method must treat T by ref struct rules. No boxing, no class fields, and no capture in lambdas.

C# 13 also lets a ref struct implement interfaces. Together, these let generic code run over stack-only types.

Why and when

Sample

/// <summary>Anything with a length. A ref struct may implement it in C# 13.</summary>
public interface IHasLength
{
    int Length { get; }
}

/// <summary>A stack-only window over text that implements an interface.</summary>
public ref struct TextWindow : IHasLength
{
    private readonly ReadOnlySpan<char> _text;

    public TextWindow(ReadOnlySpan<char> text) => _text = text;

    public int Length => _text.Length;
}

public static class RefStructGenerics
{
    /// <summary>Reads Length from any T, including ref structs.</summary>
    /// <param name="value">The value. It is never boxed.</param>
    /// <returns>value.Length.</returns>
    /// <example>RefStructGenerics.LengthOf(new TextWindow("abc")) returns 3.</example>
    public static int LengthOf<T>(T value) where T : IHasLength, allows ref struct =>
        value.Length;

    /// <summary>Applies a function to a value that may be a ref struct.</summary>
    /// <param name="value">The input.</param>
    /// <param name="f">Func is marked allows ref struct in .NET 9.</param>
    /// <returns>f(value).</returns>
    /// <example>RefStructGenerics.Apply("hey".AsSpan(), s =&gt; s.Length) returns 3.</example>
    public static TResult Apply<T, TResult>(T value, Func<T, TResult> f)
        where T : allows ref struct => f(value);

    /// <summary>Runs both helpers on stack-only values.</summary>
    /// <returns>The two lengths.</returns>
    /// <example>RefStructGenerics.Demo() returns "5 2".</example>
    public static string Demo()
    {
        int a = LengthOf(new TextWindow("hello"));
        // Span<char> as a type argument: illegal before C# 13.
        int b = Apply("hi".AsSpan(), s => s.Length);
        return $"{a} {b}";
    }
}

Pitfalls

Interview questions

3. Covariance and contravariance C# 4.0

What it is

Variance says when a generic type built from Derived can be used where one built from Base is expected. Only interfaces and delegates can be variant, and only for reference type arguments.

Covariant: out T Contravariant: in T Invariant object string IEnumerable<object> IEnumerable<string> object string IComparer<object> IComparer<string> object string List<object> List<string> arrow = “converts to”
Figure A2.1 — Covariance keeps the arrow, contravariance flips it, invariance drops it.

Reading the figure. Grey boxes are the element types. The grey arrow says a string converts to object. Green is covariant: the generic arrow points the same way. Violet is contravariant: the arrow points down. A comparer that handles any object can surely compare strings. Red is invariant, with no arrow. A List<object> would let you add an int to a list of strings.

Why and when

Sample

/// <summary>Produces T values. T only comes out, so the interface is covariant.</summary>
public interface IProducer<out T>
{
    T Make();
}

/// <summary>Consumes T values. T only goes in, so the interface is contravariant.</summary>
public interface IConsumer<in T>
{
    void Take(T item);
}

/// <summary>Makes customers.</summary>
public sealed class CustomerMaker : IProducer<Customer>
{
    public Customer Make() => new() { Id = 7, Name = "Ann" };   // 7 is a sample id
}

/// <summary>Logs the id of any entity.</summary>
public sealed class EntityLogger : IConsumer<Entity>
{
    public List<int> Seen { get; } = [];

    public void Take(Entity item) => Seen.Add(item.Id);
}

public static class Variance
{
    /// <summary>Uses covariance and contravariance once each.</summary>
    /// <returns>The id produced and the ids logged.</returns>
    /// <example>Variance.Demo() returns "7 7".</example>
    public static string Demo()
    {
        // Covariance: a producer of Customer is a producer of Entity.
        IProducer<Entity> producer = new CustomerMaker();
        // Contravariance: a consumer of Entity can consume Customers.
        var logger = new EntityLogger();
        IConsumer<Customer> consumer = logger;
        consumer.Take((Customer)producer.Make());
        return $"{producer.Make().Id} {string.Join(",", logger.Seen)}";
    }

    /// <summary>Sorts strings with a comparer written for object.</summary>
    /// <returns>The strings sorted by length.</returns>
    /// <example>Variance.SortWithObjectComparer() returns ["b", "aa", "ccc"].</example>
    public static List<string> SortWithObjectComparer()
    {
        // ToString()! is safe here because every item is a non-null string.
        IComparer<object> byLength = Comparer<object>.Create(
            (x, y) => x.ToString()!.Length.CompareTo(y.ToString()!.Length));
        var words = new List<string> { "ccc", "b", "aa" };
        words.Sort(byLength);           // IComparer<object> used as IComparer<string>
        return words;
    }

    /// <summary>Array covariance compiles but fails at run time.</summary>
    /// <example>Variance.ArrayCovarianceTrap() throws ArrayTypeMismatchException.</example>
    public static void ArrayCovarianceTrap()
    {
        object[] items = new string[1]; // 1 slot is enough to show the problem
        items[0] = 42;                  // the real array holds strings, so this throws
    }
}

Pitfalls

Interview questions

Say this out loud: “Out means T only comes out, so it is safe to widen. In means T only goes in, so it is safe to narrow. Lists do both, so they are invariant.”

4. Static abstract and static virtual interface members C# 11

What it is

An interface can now declare static abstract members: static methods, properties, and operators that each implementing type must supply. static virtual members give a default the type may replace. Generic code then calls them on the type parameter, as in T.Zero or a + b.

The usual shape is the curiously recurring pattern: interface IShape<TSelf> where TSelf : IShape<TSelf>. It lets a static member return the implementing type itself.

Why and when

Sample

/// <summary>A shape type that can describe itself without an instance.</summary>
public interface IShape<TSelf> where TSelf : IShape<TSelf>
{
    static abstract string Name { get; }
    static abstract TSelf Unit();
    static virtual int Corners => 0;    // 0: the default for shapes with no corners
    double Area { get; }
}

/// <summary>A square implements every static member, and overrides Corners.</summary>
public sealed record Square(double Side) : IShape<Square>
{
    public static string Name => "square";
    public static Square Unit() => new(1);      // 1: a unit side length
    public static int Corners => 4;             // 4: a square has four corners
    public double Area => Side * Side;
}

/// <summary>A circle keeps the default Corners.</summary>
public sealed record Circle(double Radius) : IShape<Circle>
{
    public static string Name => "circle";
    public static Circle Unit() => new(1);      // 1: a unit radius
    public double Area => Math.PI * Radius * Radius;
}

/// <summary>Types that can be added and have a zero. Operators in an interface.</summary>
public interface IAddable<TSelf> where TSelf : IAddable<TSelf>
{
    static abstract TSelf Zero { get; }
    static abstract TSelf operator +(TSelf a, TSelf b);
}

/// <summary>A 2D vector that supports + and Zero.</summary>
public readonly record struct Vec2(int X, int Y) : IAddable<Vec2>
{
    public static Vec2 Zero => new(0, 0);       // (0, 0): adding it changes nothing
    public static Vec2 operator +(Vec2 a, Vec2 b) => new(a.X + b.X, a.Y + b.Y);
}

public static class StaticAbstracts
{
    /// <summary>Describes a shape type using only static members.</summary>
    /// <returns>Name, corner count, and the area of the unit shape, rounded.</returns>
    /// <example>StaticAbstracts.Describe&lt;Square&gt;() returns "square 4 1".</example>
    public static string Describe<T>() where T : IShape<T> =>
        $"{T.Name} {T.Corners} {Math.Round(T.Unit().Area, 2)}";   // 2 decimal places

    /// <summary>Sums any IAddable type, starting from its own Zero.</summary>
    /// <param name="items">The values.</param>
    /// <returns>The total. Zero for an empty list.</returns>
    /// <example>StaticAbstracts.SumAll([new Vec2(1, 2), new Vec2(3, 4)]) is (4, 6).</example>
    public static T SumAll<T>(IEnumerable<T> items) where T : IAddable<T>
    {
        T total = T.Zero;
        foreach (var item in items) total += item;   // operator + from the interface
        return total;
    }
}

Pitfalls

Interview questions

5. Generic math with INumber<T> C# 11 / .NET 7

What it is

.NET 7 used static abstract members to give every numeric type a set of interfaces. INumber<T> bundles the common ones: + - * /, comparison, T.Zero, T.One, parsing, and conversion. int, long, double, decimal, and BigInteger all implement it. Finer interfaces exist too, such as IAdditionOperators and IBinaryInteger<T>.

Why and when

Sample

using System.Numerics;

public static class GenericMath
{
    /// <summary>Sums any numeric type, throwing on integer overflow.</summary>
    /// <param name="values">The numbers.</param>
    /// <returns>The total. T.Zero for an empty span.</returns>
    /// <example>GenericMath.Sum&lt;int&gt;([1, 2, 3]) returns 6.</example>
    public static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
    {
        T total = T.Zero;
        // checked makes int and long throw on overflow. double has no checked form.
        foreach (T v in values) total = checked(total + v);
        return total;
    }

    /// <summary>Mean of the values, in the same numeric type.</summary>
    /// <param name="values">A non-empty span.</param>
    /// <returns>Sum divided by count. Integer types truncate.</returns>
    /// <example>GenericMath.Mean&lt;double&gt;([1, 2]) returns 1.5.</example>
    public static T Mean<T>(ReadOnlySpan<T> values) where T : INumber<T>
    {
        // 0 items would divide by zero, so reject it up front.
        if (values.Length == 0) throw new ArgumentException("empty", nameof(values));
        // CreateChecked turns the int count into a T, throwing if it does not fit.
        return Sum(values) / T.CreateChecked(values.Length);
    }

    /// <summary>Greatest common divisor for any integer type.</summary>
    /// <param name="a">First number.</param>
    /// <param name="b">Second number.</param>
    /// <returns>gcd(a, b), always non-negative.</returns>
    /// <example>GenericMath.Gcd(12L, 18L) returns 6.</example>
    public static T Gcd<T>(T a, T b) where T : IBinaryInteger<T>
    {
        // Invariant: gcd(a, b) never changes. b shrinks each pass, so the loop ends.
        while (b != T.Zero) (a, b) = (b, a % b);
        return T.Abs(a);
    }
}

Pitfalls

Interview questions

6. Generic attributes C# 11

What it is

An attribute class can now be generic: class ValidatorAttribute<T> : Attribute. You apply it as [Validator<NotEmpty>]. Before C# 11 you had to pass typeof(NotEmpty) to the constructor, with no compile-time check on what the type was.

Why and when

Sample

using System.Reflection;

/// <summary>Checks one value.</summary>
public interface IValidator
{
    bool IsValid(object? value);
}

/// <summary>Rejects null and empty strings.</summary>
public sealed class NotEmpty : IValidator
{
    public bool IsValid(object? value) => value is string { Length: > 0 };   // > 0 chars
}

/// <summary>Accepts only strings that contain an at sign.</summary>
public sealed class HasAt : IValidator
{
    public bool IsValid(object? value) => value is string s && s.Contains('@');
}

/// <summary>Non-generic face of the attribute, so reflection can find all of them.</summary>
public interface IValidatorSource
{
    IValidator Create();
}

/// <summary>Names a validator type. The constraint is checked at compile time.</summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public sealed class ValidatorAttribute<TValidator> : Attribute, IValidatorSource
    where TValidator : IValidator, new()
{
    public IValidator Create() => new TValidator();
}

/// <summary>A sample model with validated properties.</summary>
public sealed class Signup
{
    [Validator<NotEmpty>]
    [Validator<HasAt>]
    public string Email { get; set; } = "";

    [Validator<NotEmpty>]
    public string Name { get; set; } = "";
}

public static class Validation
{
    /// <summary>Runs every validator attribute on every property.</summary>
    /// <param name="model">The object to check.</param>
    /// <returns>"Property:Validator" for each failure, sorted.</returns>
    /// <example>Validation.Errors(new Signup { Email = "x", Name = "A" }) is ["Email:HasAt"]
    /// </example>
    public static List<string> Errors(object model)
    {
        var errors = new List<string>();
        foreach (PropertyInfo prop in model.GetType().GetProperties())
        {
            object? value = prop.GetValue(model);
            // OfType finds every closed generic attribute through the shared interface.
            foreach (var source in prop.GetCustomAttributes(true).OfType<IValidatorSource>())
            {
                IValidator v = source.Create();
                if (!v.IsValid(value)) errors.Add($"{prop.Name}:{v.GetType().Name}");
            }
        }
        errors.Sort(StringComparer.Ordinal);   // property order from reflection is not fixed
        return errors;
    }
}

Pitfalls

Interview questions

7. Static generic class caching C# 2.0

What it is

Each closed generic type is a separate type at run time. Cache<int> and Cache<string> have separate static fields and separate static constructors. The runtime runs each static constructor once, safely across threads. So a static generic class is a free, lock-free, per-type cache.

Why and when

Sample

/// <summary>Counts how many per-type caches have been built.</summary>
public static class CacheStats
{
    public static int Builds;
}

/// <summary>One cached description per closed type T.</summary>
public static class TypeInfoCache<T>
{
    public static readonly string Description;

    // Runs once per T, the first time TypeInfoCache<T> is touched.
    static TypeInfoCache()
    {
        Interlocked.Increment(ref CacheStats.Builds);   // thread-safe + 1
        Description = $"{typeof(T).Name}:{(typeof(T).IsValueType ? "value" : "ref")}";
    }
}

/// <summary>A static counter in a generic class: one counter per T.</summary>
public static class PerTypeCounter<T>
{
    public static int Count;
}

/// <summary>Marker types used only by the demo below, so its count is predictable.</summary>
public sealed class DemoA;
public sealed class DemoB;

public static class StaticCaching
{
    /// <summary>Reads the cache three times for two types.</summary>
    /// <returns>How many builds happened. 2 on the first call, 0 after.</returns>
    /// <example>StaticCaching.BuildsForDemo() returns 2 the first time.</example>
    public static int BuildsForDemo()
    {
        int before = CacheStats.Builds;
        _ = TypeInfoCache<DemoA>.Description;   // builds the DemoA cache
        _ = TypeInfoCache<DemoA>.Description;   // already built, no new build
        _ = TypeInfoCache<DemoB>.Description;   // builds the DemoB cache
        return CacheStats.Builds - before;
    }

    /// <summary>Shows that each T has its own static field.</summary>
    /// <returns>The counts for int and string after bumping int twice.</returns>
    /// <example>StaticCaching.SeparateStatics() returns "2 0" on a fresh run.</example>
    public static string SeparateStatics()
    {
        PerTypeCounter<int>.Count++;    // + 1 for int only
        PerTypeCounter<int>.Count++;    // + 1 again, int is now 2
        return $"{PerTypeCounter<int>.Count} {PerTypeCounter<string>.Count}";
    }
}

Pitfalls

Interview questions

8. default and default(T) C# 2.0 / 7.1 / 9

What it is

Why and when

Sample

public static class Defaults
{
    /// <summary>First match, or default(T) when nothing matches.</summary>
    /// <param name="items">Items to search.</param>
    /// <param name="match">The test.</param>
    /// <returns>The match, or default. For int that is 0, not null.</returns>
    /// <example>Defaults.FindOrDefault([1, 2], x =&gt; x &gt; 5) returns 0.</example>
    public static T? FindOrDefault<T>(IEnumerable<T> items, Func<T, bool> match)
    {
        foreach (var item in items)
        {
            if (match(item)) return item;
        }
        return default;
    }

    /// <summary>Shows default for several kinds of T.</summary>
    /// <returns>The printed defaults.</returns>
    /// <example>Defaults.Show() returns "0|False|null|null|(0, 0)".</example>
    public static string Show()
    {
        int? maybe = default;           // Nullable<int>: default is null, not 0
        string? text = default;
        return $"{default(int)}|{default(bool)}|{maybe?.ToString() ?? "null"}|"
            + $"{text ?? "null"}|({default(Vec2).X}, {default(Vec2).Y})";
    }

    /// <summary>True when value is the default of its type.</summary>
    /// <param name="value">Any value.</param>
    /// <returns>True for null, 0, false, or an all-zero struct.</returns>
    /// <example>Defaults.IsDefault(0) returns true.</example>
    public static bool IsDefault<T>(T value) =>
        EqualityComparer<T>.Default.Equals(value, default);
}

Pitfalls

Interview questions

9. Reified generics vs Java erasure C# 2.0

What it is

Java erases generics. List<String> becomes a raw List of objects after compiling. C# generics are reified. The type argument is kept in metadata and is real at run time.

List<T>.Add one IL body JIT shared code: Add<__Canon> used by string, Customer, object, ... specialised: Add<int> 4-byte slots, no boxing specialised: Add<double> 8-byte slots, no boxing
Figure A2.2 — One IL body, shared machine code for references, a private copy per value type.

Reading the figure. The grey box is the compiled IL, which exists once. Blue is the single machine-code body every reference type shares. All references are pointer sized, so one body works for all. Green bodies are made per value type, because an int and a double differ in size. Notice no path boxes anything.

Why and when

Sample

public static class Reified
{
    /// <summary>Makes a typed array. Impossible with erased generics.</summary>
    /// <param name="n">Length.</param>
    /// <returns>A T[] of length n.</returns>
    /// <example>Reified.MakeArray&lt;int&gt;(3).GetType().Name returns "Int32[]".</example>
    public static T[] MakeArray<T>(int n) => new T[n];

    /// <summary>Names T at run time.</summary>
    /// <returns>typeof(T).Name.</returns>
    /// <example>Reified.NameOf&lt;Guid&gt;() returns "Guid".</example>
    public static string NameOf<T>() => typeof(T).Name;

    /// <summary>A per-type fast path. The JIT deletes the dead branch for value types.</summary>
    /// <param name="value">The value to describe.</param>
    /// <returns>"int:N" for ints, "other" for anything else.</returns>
    /// <example>Reified.Describe(5) returns "int:5".</example>
    public static string Describe<T>(T value)
    {
        if (typeof(T) == typeof(int)) return $"int:{(int)(object)value!}";
        return "other";
    }

    /// <summary>Run-time checks on closed generic types.</summary>
    /// <returns>Results of three is-checks on a List of int.</returns>
    /// <example>Reified.TypeChecks() returns [true, false, true].</example>
    public static List<bool> TypeChecks()
    {
        object list = new List<int>();
        return
        [
            list is List<int>,
            list is List<long>,         // a different closed type, so false
            typeof(List<int>) != typeof(List<string>),
        ];
    }
}

Pitfalls

Interview questions

10. nameof on unbound generic types C# 14

What it is

nameof turns a symbol into its name as a string at compile time. Before C# 14, a generic type needed a dummy argument: nameof(List<int>). C# 14 accepts the unbound form: nameof(List<>) and nameof(Dictionary<,>). You can reach members too: nameof(List<>.Count).

Why and when

Sample

public static class NameofUnbound
{
    /// <summary>Names generic types and members without a type argument.</summary>
    /// <returns>The three names, joined by spaces.</returns>
    /// <example>NameofUnbound.Demo() returns "List Dictionary Count".</example>
    public static string Demo() =>
        $"{nameof(List<>)} {nameof(Dictionary<,>)} {nameof(List<>.Count)}";

    /// <summary>A typical use: a clear error message.</summary>
    /// <param name="index">Index that was too big.</param>
    /// <returns>The message text.</returns>
    /// <example>NameofUnbound.Message(9) returns "Repo: index 9 out of range".</example>
    public static string Message(int index) => $"{nameof(Repo<>)}: index {index} out of range";
}

Pitfalls

Interview questions

Recap

Things to carry forward

Where this goes next

The next page, Delegates and Lambdas, puts Func<in T, out TResult> to work. It covers closures, capture bugs, events, and function pointers.


← A 01 — Types and Memory A 03 — Delegates and Lambdas →