Advanced C# A 09 11 features

Metaprogramming

Code that reads, builds or writes other code. C# can do it at run time with reflection and expression trees, or at build time with source generators. Build time wins when you can choose.

The page moves from run time to build time. Reflection, attributes, dynamic and expression trees inspect and create code while the program runs. Source generators, interceptors and module initializers act at compile time or load time. Partial members, the field keyword and extension members come from C# 13 and 14. They help generated code and hand-written code fit together.

Contents

  1. Reflection
  2. Custom and generic attributes
  3. Caller info and nameof
  4. dynamic and the DLR
  5. Expression trees
  6. Source generators
  7. Interceptors
  8. Module initializers
  9. Partial members
  10. The field keyword
  11. Extension members
  12. Recap

1. Reflection C# 1, .NET 1.0

What it is

Every type carries metadata. System.Reflection reads it at run time. From a Type you can list properties, methods, fields and attributes. You can read and write values, call methods, and create instances, all by name.

Why and when

Sample

using System.Reflection;

public sealed class Widget
{
    public string Name { get; set; } = "";
    public int Size { get; private set; }

    /// <summary>Grows the widget.</summary>
    /// <param name="by">Amount to add.</param>
    /// <returns>The new size.</returns>
    /// <example>new Widget().Grow(3) returns 3.</example>
    public int Grow(int by) => Size += by;

    private string Secret() => "hidden";
}

public static class Reflect
{
    /// <summary>Lists public instance property names, sorted.</summary>
    /// <param name="t">The type to inspect.</param>
    /// <returns>Property names in order.</returns>
    /// <example>Reflect.PropertyNames(typeof(Widget)) returns ["Name", "Size"].</example>
    public static List<string> PropertyNames(Type t)
        => t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
            .Select(p => p.Name)
            .Order()
            .ToList();

    /// <summary>Calls Grow by name through MethodInfo.Invoke.</summary>
    /// <param name="w">The target widget.</param>
    /// <param name="by">Amount to add.</param>
    /// <returns>The new size.</returns>
    /// <example>Reflect.InvokeGrow(new Widget(), 4) returns 4.</example>
    public static int InvokeGrow(Widget w, int by)
    {
        MethodInfo m = typeof(Widget).GetMethod(nameof(Widget.Grow))!;
        // Invoke takes object?[] and returns object?, so by is boxed and the result unboxed.
        return (int)m.Invoke(w, [by])!;
    }

    /// <summary>Calls a private method, which reflection allows.</summary>
    /// <param name="w">The target widget.</param>
    /// <returns>The private method's result.</returns>
    /// <example>Reflect.CallPrivate(new Widget()) returns "hidden".</example>
    public static string CallPrivate(Widget w)
        => (string)typeof(Widget)
            .GetMethod("Secret", BindingFlags.NonPublic | BindingFlags.Instance)!
            .Invoke(w, null)!;

    /// <summary>Sets any property by name.</summary>
    /// <param name="target">The object.</param>
    /// <param name="property">The property name.</param>
    /// <param name="value">The new value.</param>
    /// <returns>The value read back through reflection.</returns>
    /// <example>Reflect.SetByName(new Widget(), "Name", "gear") returns "gear".</example>
    public static object? SetByName(object target, string property, object? value)
    {
        PropertyInfo p = target.GetType().GetProperty(property)
            ?? throw new ArgumentException($"No property {property}.", nameof(property));
        p.SetValue(target, value);
        return p.GetValue(target);
    }

    /// <summary>Turns Grow into a typed delegate once, for fast repeated calls.</summary>
    /// <returns>An open-instance delegate: the first argument is the target.</returns>
    /// <example>Reflect.FastGrow()(new Widget(), 5) returns 5.</example>
    public static Func<Widget, int, int> FastGrow()
        => typeof(Widget).GetMethod(nameof(Widget.Grow))!
            .CreateDelegate<Func<Widget, int, int>>();

    /// <summary>Creates an instance from a Type with a parameterless constructor.</summary>
    /// <param name="t">The type.</param>
    /// <returns>The new object's type name.</returns>
    /// <example>Reflect.CreateName(typeof(Widget)) returns "Widget".</example>
    public static string CreateName(Type t) => Activator.CreateInstance(t)!.GetType().Name;
}

Test

T.Check("props", Reflect.PropertyNames(typeof(Widget)), ["Name", "Size"]);
T.Check("invoke", Reflect.InvokeGrow(new Widget(), 4), 4);
T.Check("private", Reflect.CallPrivate(new Widget()), "hidden");
T.Check("set by name", Reflect.SetByName(new Widget(), "Name", "gear"), "gear");
T.Check("fast grow", Reflect.FastGrow()(new Widget(), 5), 5);

Pitfalls

Interview questions

2. Custom attributes and generic attributes attributes: C# 1 generic attributes: C# 11

What it is

An attribute is a class that derives from Attribute. Placing [Name(args)] on code stores the arguments in metadata. Nothing runs until some code asks for the attribute through reflection, or a source generator reads it at build time. [AttributeUsage] limits where it may go.

Since C# 11, an attribute can be generic: [Validator<SignupValidator>] instead of [Validator(typeof(SignupValidator))]. The compiler then checks constraints on the type argument.

Why and when

Sample

using System.Reflection;

[AttributeUsage(AttributeTargets.Property)]
public sealed class MaxLengthAttribute(int length) : Attribute
{
    public int Length { get; } = length;
}

public interface IValidator
{
    bool IsValid(object value);
}

// Non-generic view of the generic attribute, so callers can find it without knowing T.
public interface IValidatorSource
{
    IValidator Create();
}

// C# 11 generic attribute. The constraint is checked at the place it is used.
[AttributeUsage(AttributeTargets.Class)]
public sealed class ValidatorAttribute<TValidator> : Attribute, IValidatorSource
    where TValidator : IValidator, new()
{
    public IValidator Create() => new TValidator();
}

public sealed class SignupValidator : IValidator
{
    public bool IsValid(object value) => value is Signup s && s.Email.Contains('@');
}

[Validator<SignupValidator>]
public sealed class Signup
{
    [MaxLength(5)]   // 5: a short limit so the demo can break it easily
    public string User { get; set; } = "";
    public string Email { get; set; } = "";
}

public static class Validation
{
    /// <summary>Checks MaxLength on each property, then runs the class-level validator.</summary>
    /// <param name="obj">The object to validate.</param>
    /// <returns>A list of error messages, empty when valid.</returns>
    /// <example>Validation.Errors(new Signup { User = "ada", Email = "a@b" }) returns [].</example>
    public static List<string> Errors(object obj)
    {
        List<string> errors = [];
        // p is the current property. Invariant: errors covers every earlier property.
        foreach (PropertyInfo p in obj.GetType().GetProperties())
        {
            var max = p.GetCustomAttribute<MaxLengthAttribute>();
            if (max is not null && p.GetValue(obj) is string s && s.Length > max.Length)
            {
                errors.Add($"{p.Name} too long");
            }
        }
        // Find the generic attribute through its non-generic interface.
        foreach (IValidatorSource source in obj.GetType().GetCustomAttributes()
                     .OfType<IValidatorSource>())
        {
            if (!source.Create().IsValid(obj))
            {
                errors.Add("invalid");
            }
        }
        return errors;
    }
}

Test

T.Check("valid", Validation.Errors(new Signup { User = "ada", Email = "a@b" }), []);
T.Check("too long", Validation.Errors(new Signup { User = "adalovelace", Email = "a@b" }),
    ["User too long"]);
T.Check("bad email", Validation.Errors(new Signup { User = "ada", Email = "x" }), ["invalid"]);

Pitfalls

Interview questions

3. Caller info attributes and nameof caller info: C# 5 nameof: C# 6

What it is

Why and when

Logging and tracing helpers use caller info to record where they were called. INotifyPropertyChanged uses [CallerMemberName] so setters need no string. Use nameof anywhere a member name appears in a string: exceptions, attributes, logs.

Sample

using System.ComponentModel;
using System.Runtime.CompilerServices;

public static class Trace
{
    /// <summary>Reports which member called it and whether a line number was supplied.</summary>
    /// <param name="member">Filled in by the compiler.</param>
    /// <param name="line">Filled in by the compiler.</param>
    /// <returns>"member@line-known".</returns>
    /// <example>Trace.Here() called from Run returns "Run@line-known".</example>
    public static string Here([CallerMemberName] string member = "",
        [CallerLineNumber] int line = 0)
        // 0 is the default, so a positive number proves the compiler filled it in.
        => $"{member}@{(line > 0 ? "line-known" : "no-line")}";

    /// <summary>Calls Here so the reported member is this method.</summary>
    /// <returns>"Run@line-known".</returns>
    /// <example>Trace.Run() returns "Run@line-known".</example>
    public static string Run() => Here();
}

public sealed class Profile : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    public string Name
    {
        get;
        set => SetField(ref field, value);   // C# 14 field keyword, see section 10
    } = "";

    public int Age
    {
        get;
        set => SetField(ref field, value);
    }

    // [CallerMemberName] fills in "Name" or "Age" with no string literal.
    private void SetField<TValue>(ref TValue field, TValue value,
        [CallerMemberName] string? name = null)
    {
        if (EqualityComparer<TValue>.Default.Equals(field, value))
        {
            return;
        }
        field = value;
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

public static class Notify
{
    /// <summary>Changes properties and records which names were raised.</summary>
    /// <returns>The raised property names.</returns>
    /// <example>Notify.Changes() returns ["Name", "Age"].</example>
    public static List<string> Changes()
    {
        var p = new Profile();
        List<string> raised = [];
        p.PropertyChanged += (_, e) => raised.Add(e.PropertyName ?? "?");
        p.Name = "Ada";
        p.Age = 36;   // 36: sample age
        p.Age = 36;   // same value again: no event
        return raised;
    }

    /// <summary>Shows nameof on a type, a member and a generic type.</summary>
    /// <returns>The three names.</returns>
    /// <example>Notify.Names() returns ["Profile", "Age", "List"].</example>
    public static List<string> Names() =>
        [nameof(Profile), nameof(Profile.Age), nameof(List<int>)];
}

Test

T.Check("caller member", Trace.Run(), "Run@line-known");
T.Check("notify", Notify.Changes(), ["Name", "Age"]);
T.Check("nameof", Notify.Names(), ["Profile", "Age", "List"]);

Pitfalls

Interview questions

4. dynamic and the DLR C# 4

What it is

A dynamic value skips compile-time checking. Every member access, call and operator on it is bound at run time by the Dynamic Language Runtime. The DLR caches each call site, so repeat calls with the same types are fairly quick. ExpandoObject is a bag you add members to at run time. DynamicObject lets you intercept member access yourself.

Why and when

Sample

using System.Dynamic;

public static class Dyn
{
    /// <summary>Adds two values of any types that support +, chosen at run time.</summary>
    /// <param name="x">Any value.</param>
    /// <returns>x + x, as bound at run time.</returns>
    /// <example>Dyn.Twice(2) returns 4, Dyn.Twice("ab") returns "abab".</example>
    public static object Twice(dynamic x) => x + x;

    /// <summary>Adds members to an ExpandoObject at run time.</summary>
    /// <returns>The name plus the member count.</returns>
    /// <example>Dyn.Expando() returns "Ada 2".</example>
    public static string Expando()
    {
        dynamic bag = new ExpandoObject();
        bag.Name = "Ada";
        bag.Age = 36;   // 36: sample age
        // ExpandoObject is also a dictionary of its members.
        var members = (IDictionary<string, object?>)bag;
        string name = bag.Name;
        return $"{name} {members.Count}";
    }

    /// <summary>Calls a method that does not exist on string.</summary>
    /// <returns>Never returns. Throws RuntimeBinderException.</returns>
    /// <example>Dyn.CallMissing() throws.</example>
    public static object CallMissing()
    {
        dynamic s = "text";
        return s.NoSuchMethod();
    }

    /// <summary>Records calls on a DynamicObject.</summary>
    /// <returns>The recorded call names.</returns>
    /// <example>Dyn.Record() returns ["Open", "Close"].</example>
    public static List<string> Record()
    {
        dynamic r = new CallRecorder();
        r.Open(1);   // 1: any argument
        r.Close();
        return ((CallRecorder)r).Calls;
    }
}

public sealed class CallRecorder : DynamicObject
{
    public List<string> Calls { get; } = [];

    // Runs for every method call the compiler could not bind.
    public override bool TryInvokeMember(InvokeMemberBinder binder, object?[]? args,
        out object? result)
    {
        Calls.Add(binder.Name);
        result = args?.Length ?? 0;   // 0 when there are no arguments
        return true;
    }
}

Test

T.Check("twice int", Dyn.Twice(2), 4);
T.Check("twice string", Dyn.Twice("ab"), "abab");
T.Check("expando", Dyn.Expando(), "Ada 2");
T.Throws<Microsoft.CSharp.RuntimeBinder.RuntimeBinderException>("missing", () => Dyn.CallMissing());
T.Check("recorder", Dyn.Record(), ["Open", "Close"]);

Pitfalls

Interview questions

5. Expression trees built and compiled C# 3

What it is

An Expression<Func<...>> is code as data. The compiler turns a lambda into a tree of nodes instead of IL. You can inspect the tree, translate it, as Entity Framework does into SQL, or call Compile() to get a real delegate. You can also build trees by hand with the Expression factory methods.

Lambda (x) => Add Multiply Constant 1 Parameter x Constant 2 ToString(): x => ((x * 2) + 1) Compile() turns the tree into a delegate
Figure A9.1 — A lambda stored as an expression tree is a data structure you can walk, rewrite or compile.

Reading the figure. Violet is the lambda root. Amber nodes are operators. Blue is the parameter and green are constants. A LINQ provider walks this tree from the top and emits SQL. Compile() walks it and emits IL.

Why and when

Sample

using System.Linq.Expressions;

public static class Exprs
{
    /// <summary>Builds x => x * a + b by hand and compiles it.</summary>
    /// <param name="a">The multiplier.</param>
    /// <param name="b">The offset.</param>
    /// <returns>A compiled delegate.</returns>
    /// <example>Exprs.Linear(2, 1)(5) returns 11.</example>
    public static Func<int, int> Linear(int a, int b)
    {
        ParameterExpression x = Expression.Parameter(typeof(int), "x");
        // Build bottom-up: (x * a), then (x * a) + b.
        BinaryExpression body = Expression.Add(
            Expression.Multiply(x, Expression.Constant(a)),
            Expression.Constant(b));
        return Expression.Lambda<Func<int, int>>(body, x).Compile();
    }

    /// <summary>Prints a lambda's expression tree.</summary>
    /// <param name="e">A lambda the compiler turned into a tree.</param>
    /// <returns>The tree as text.</returns>
    /// <example>Exprs.Show(x => x * 2 + 1) returns "x => ((x * 2) + 1)".</example>
    public static string Show(Expression<Func<int, int>> e) => e.ToString();

    /// <summary>Compiles a property getter by name. Faster than PropertyInfo.GetValue.</summary>
    /// <param name="property">The property name.</param>
    /// <returns>A getter delegate that boxes the value to object.</returns>
    /// <example>Exprs.Getter<Widget>("Name")(new Widget { Name = "a" }) returns "a".</example>
    public static Func<TObj, object?> Getter<TObj>(string property)
    {
        ParameterExpression o = Expression.Parameter(typeof(TObj), "o");
        // Convert to object so one delegate type fits any property type.
        UnaryExpression body = Expression.Convert(Expression.Property(o, property), typeof(object));
        return Expression.Lambda<Func<TObj, object?>>(body, o).Compile();
    }

    /// <summary>Extracts the member name from a selector like x => x.Size.</summary>
    /// <param name="selector">A member access lambda.</param>
    /// <returns>The member name.</returns>
    /// <example>Exprs.MemberName((Widget w) => w.Size) returns "Size".</example>
    public static string MemberName<TObj, TProp>(Expression<Func<TObj, TProp>> selector)
        => selector.Body is MemberExpression m
            ? m.Member.Name
            : throw new ArgumentException("Expected a member access.", nameof(selector));
}

Test

T.Check("linear", Exprs.Linear(2, 1)(5), 11);
T.Check("show", Exprs.Show(x => x * 2 + 1), "x => ((x * 2) + 1)");
T.Check("getter", Exprs.Getter<Widget>("Name")(new Widget { Name = "a" }), "a");
T.Check("member", Exprs.MemberName((Widget w) => w.Size), "Size");

Pitfalls

Interview questions

6. Source generators C# 9, .NET 5 incremental: .NET 6

What it is

A source generator is a compiler plug-in. It runs during the build, reads your code through the Roslyn APIs, and adds new C# files to the compilation. It can only add code. It cannot change what you wrote. The incremental API, IIncrementalGenerator, builds a pipeline of cached steps so the IDE stays fast as you type.

your code [AutoToString] filter by attribute transform to a model emit Point.g.cs compile one assembly each amber step is cached: unchanged input means the step is skipped
Figure A9.2 — An incremental generator is a cached pipeline from marked syntax to added source files.

Reading the figure. Blue is code you write. Amber steps are cached pipeline stages. Green is the generated file. Violet is the normal compile, which sees your code and the generated code together.

Why and when

Sample

This block does not compile in the site’s harness. A generator lives in its own netstandard2.0 project that references Microsoft.CodeAnalysis.CSharp, and the app references it as an analyzer.

using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

[Generator]
public sealed class AutoToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Step 1: add the marker attribute to every compilation that uses the generator.
        context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
            "AutoToStringAttribute.g.cs",
            "[System.AttributeUsage(System.AttributeTargets.Class)]\n" +
            "internal sealed class AutoToStringAttribute : System.Attribute { }"));

        // Step 2: find partial classes marked [AutoToString], keep only a small model.
        var models = context.SyntaxProvider.ForAttributeWithMetadataName(
            "AutoToStringAttribute",
            predicate: static (node, _) => node is ClassDeclarationSyntax,
            transform: static (ctx, _) =>
            {
                var type = (INamedTypeSymbol)ctx.TargetSymbol;
                var props = type.GetMembers().OfType<IPropertySymbol>()
                    .Select(p => p.Name).ToArray();
                return (Ns: type.ContainingNamespace.ToDisplayString(),
                        Name: type.Name, Props: string.Join(",", props));
            });

        // Step 3: emit one file per model. Cached models skip this step.
        context.RegisterSourceOutput(models, static (spc, m) =>
        {
            var parts = m.Props.Split(',', StringSplitOptions.RemoveEmptyEntries)
                .Select(p => $"{p} = {{{p}}}");
            string code = $$"""
                namespace {{m.Ns}};
                partial class {{m.Name}}
                {
                    public override string ToString()
                        => $"{{m.Name}} { {{string.Join(", ", parts)}} }";
                }
                """;
            spc.AddSource($"{m.Name}.g.cs", SourceText.From(code, Encoding.UTF8));
        });
    }
}

// In the app project:
// [AutoToString]
// public partial class Point { public int X { get; set; } public int Y { get; set; } }
// new Point { X = 1, Y = 2 }.ToString() returns "Point { X = 1, Y = 2 }"

Pitfalls

Interview questions

7. Interceptors C# 12 preview

What it is

An interceptor is a method marked [InterceptsLocation(...)]. It tells the compiler: “at this exact call site, call me instead”. It shipped as a C# 12 preview and is still an opt-in feature. A project must list allowed namespaces in <InterceptorsNamespaces>. Since the .NET 9 SDK the location is an opaque hash from the Roslyn API, not a file path, line and column.

Why and when

Sample

This block does not compile in the site’s harness. It needs the preview opt-in in the project file, and the location data must come from a generator.

// Program.cs (hand-written)
var greeter = new Greeter();
greeter.Hello("Ada");   // the generator intercepts this exact call

public sealed class Greeter
{
    public string Hello(string name) => $"Hello {name}";
}

// Generated.g.cs (emitted by a source generator)
namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
    file sealed class InterceptsLocationAttribute(int version, string data) : Attribute { }
}

namespace MyApp.Generated
{
    static file class Interceptors
    {
        // version 1 and data come from SemanticModel.GetInterceptableLocation(...)
        [System.Runtime.CompilerServices.InterceptsLocation(1, "base64-hash-of-call-site")]
        public static string FastHello(this Greeter g, string name) => "Hi " + name;
    }
}

// MyApp.csproj
// <InterceptorsNamespaces>$(InterceptorsNamespaces);MyApp.Generated</InterceptorsNamespaces>

Pitfalls

Interview questions

8. Module initializers C# 9

What it is

A method marked [ModuleInitializer] runs once when the assembly loads, before any other code in it. It must be static, return void, take no parameters, and be internal or public.

Why and when

Sample

using System.Runtime.CompilerServices;

public static class Registry
{
    public static List<string> Codecs { get; } = [];

    // Runs once, when this assembly loads, before Tests.Run touches anything.
    [ModuleInitializer]
    internal static void RegisterDefaults()
    {
        Codecs.Add("json");
        Codecs.Add("csv");
    }

    /// <summary>Lists the codecs registered at load time.</summary>
    /// <returns>The codec names.</returns>
    /// <example>Registry.Snapshot() returns ["json", "csv"].</example>
    public static List<string> Snapshot() => [.. Codecs];
}

Test

T.Check("module init", Registry.Snapshot(), ["json", "csv"]);

Pitfalls

Interview questions

9. Partial methods, properties, indexers, constructors and events partial methods: C# 3, extended C# 9 properties and indexers: C# 13 constructors and events: C# 14

What it is

A partial member has a defining declaration with no body and an implementing declaration with the body. The two parts can sit in different files of the same partial type. This is how hand-written and generated code meet. You declare the shape, and a generator writes the body, or the reverse.

Why and when

Use them when a generator must fill in a member you name: regex, logging, P/Invoke, view-model properties, weak events. Outside generators, splitting a class across files this way is rarely worth it.

Sample

// Part 1: the shape. A generator would usually read or write one of these parts.
public partial class Thermostat
{
    // Optional hook: removed by the compiler if nobody implements it.
    partial void OnChanged(double celsius);

    public partial double Celsius { get; set; }            // C# 13 partial property
    public partial string this[char unit] { get; }        // C# 13 partial indexer
    public partial Thermostat(double celsius);             // C# 14 partial constructor
    public partial event Action<double>? Changed;          // C# 14 partial event
}

// Part 2: the bodies.
public partial class Thermostat
{
    private double _celsius;
    private Action<double>? _changed;

    public List<double> History { get; } = [];

    public partial Thermostat(double celsius) => _celsius = celsius;

    public partial double Celsius
    {
        get => _celsius;
        set
        {
            _celsius = value;
            OnChanged(value);
            _changed?.Invoke(value);
        }
    }

    public partial string this[char unit] => unit switch
    {
        // * 9 / 5 + 32 converts Celsius to Fahrenheit.
        'F' => $"{_celsius * 9 / 5 + 32}",
        // + 273.15 converts Celsius to Kelvin.
        'K' => $"{_celsius + 273.15}",
        _ => $"{_celsius}",
    };

    // The implementing event must spell out add and remove.
    public partial event Action<double>? Changed
    {
        add => _changed += value;
        remove => _changed -= value;
    }

    partial void OnChanged(double celsius) => History.Add(celsius);
}

public static class PartialDemo
{
    /// <summary>Exercises every partial member of Thermostat.</summary>
    /// <returns>Readings in F and K, plus history and event counts.</returns>
    /// <example>PartialDemo.Run() returns "77 298.15 history=2 events=2".</example>
    public static string Run()
    {
        var t = new Thermostat(20);   // 20 C: a starting room temperature
        int events = 0;   // 0: no events yet
        t.Changed += _ => events++;
        t.Celsius = 21;   // 21 C: first change
        t.Celsius = 25;   // 25 C: second change, 77 F
        return $"{t['F']} {t['K']} history={t.History.Count} events={events}";
    }
}

Test

T.Check("partial", PartialDemo.Run(), "77 298.15 history=2 events=2");

Pitfalls

Interview questions

10. The field keyword in property accessors C# 14

What it is

Inside a property accessor, field refers to a backing field the compiler creates. You can write one accessor by hand and leave the other automatic. Before C# 14, adding any logic to a setter meant declaring a private field yourself and writing both accessors.

Why and when

Sample

public sealed class Person
{
    // The setter trims and validates. The getter stays automatic.
    public string Name
    {
        get;
        set => field = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Name is required.", nameof(value))
            : value.Trim();
    } = "unknown";   // initializer writes the backing field directly, no setter call

    // Clamps to 0..150, a generous range for a human age.
    public int Age
    {
        get;
        set => field = Math.Clamp(value, 0, 150);
    }

    public int LoadCount { get; private set; }

    // Created on first read, then reused.
    public List<string> Tags => field ??= LoadTags();

    private List<string> LoadTags()
    {
        LoadCount++;
        return ["new"];
    }
}

public static class FieldDemo
{
    /// <summary>Exercises the trimming setter, the clamp and the lazy getter.</summary>
    /// <returns>A summary of the resulting state.</returns>
    /// <example>FieldDemo.Run() returns "Ada 150 tags=1 loads=1".</example>
    public static string Run()
    {
        var p = new Person { Name = "  Ada ", Age = 200 };   // 200: above the clamp limit
        _ = p.Tags;
        _ = p.Tags;   // second read reuses the same list
        return $"{p.Name} {p.Age} tags={p.Tags.Count} loads={p.LoadCount}";
    }
}

Test

T.Check("field", FieldDemo.Run(), "Ada 150 tags=1 loads=1");
T.Check("default name", new Person().Name, "unknown");
T.Throws<ArgumentException>("blank name", () => new Person { Name = " " });

Pitfalls

Interview questions

11. Extension members C# 14

What it is

Extension methods (C# 3) let a static method look like an instance method. C# 14 generalizes them with an extension block inside a static class. The block names the receiver once. Inside it you can declare instance methods, properties, and static members that appear on the extended type itself, such as int.Squared(3). Old this-parameter extension methods still work and can sit beside the new form.

Why and when

Sample

public static class TextExtensions
{
    // One receiver, many members. s is the string the member is called on.
    extension(string s)
    {
        /// <summary>True when the text is null, empty or whitespace.</summary>
        /// <example>"  ".IsBlank returns true.</example>
        public bool IsBlank => string.IsNullOrWhiteSpace(s);

        /// <summary>Counts words split on spaces.</summary>
        /// <example>"to be  or".WordCount returns 3.</example>
        public int WordCount => s.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;

        /// <summary>Upper-cases and adds an exclamation mark.</summary>
        /// <returns>The shouted text.</returns>
        /// <example>"hi".Shout() returns "HI!".</example>
        public string Shout() => s.ToUpperInvariant() + "!";
    }

    // Generic receiver: works for any sequence.
    extension<TItem>(IEnumerable<TItem> source)
    {
        /// <summary>True when the sequence has no items.</summary>
        /// <example>new List<int>().IsEmpty returns true.</example>
        public bool IsEmpty => !source.Any();
    }

    // No receiver name: only static members, called on the type itself.
    extension(int)
    {
        /// <summary>Squares a number. Called as int.Squared(x).</summary>
        /// <param name="x">The number.</param>
        /// <returns>x times x.</returns>
        /// <example>int.Squared(7) returns 49.</example>
        public static int Squared(int x) => x * x;
    }
}

public static class ExtensionDemo
{
    /// <summary>Uses each kind of extension member once.</summary>
    /// <returns>The results joined with spaces.</returns>
    /// <example>ExtensionDemo.Run() returns "True 3 HI! True 49".</example>
    public static string Run()
    {
        List<int> none = [];
        // 7: any sample number for the static extension.
        return $"{"  ".IsBlank} {"to be  or".WordCount} {"hi".Shout()} "
            + $"{none.IsEmpty} {int.Squared(7)}";
    }
}

Test

T.Check("extensions", ExtensionDemo.Run(), "True 3 HI! True 49");
T.Check("not blank", "x".IsBlank, false);
T.Check("not empty", new[] { 1 }.IsEmpty, false);

Pitfalls

Interview questions

Recap

The things to carry forward

Say this out loud: “I prefer build-time over run-time metaprogramming. A source generator or an extension member costs nothing at startup and works with AOT. I use reflection only at the edges, and I cache delegates when I do.”

Where this goes next

Next is code that runs at the same time as other code. Concurrency covers locks, the new Lock type, Interlocked and concurrent collections.


← A 08 — Performance and Memory A 10 — Concurrency →