Advanced C# A 03 13 features

Delegates and Lambdas

A delegate is an object that holds a method to call later. A lambda is a short way to write one. Most bugs come from what a lambda quietly captures.

Delegates power LINQ, events, callbacks, and async continuations. This page covers how they work and where they cost memory. It also covers the lambda features added from C# 9 to C# 14. Each feature has a tested sample, traps, and interview questions.

Contents

  1. Func, Action, custom delegates
  2. Multicast delegates
  3. Closures and capture
  4. Static lambdas
  5. Local functions vs lambdas
  6. Natural type and attributes
  7. Default lambda parameters
  8. params collections
  9. Modifiers without types
  10. Events and EventHandler
  11. Weak events and leaks
  12. Function pointers
  13. Method groups and allocation
  14. Recap
The one rule. A delegate holds two things: a method and a target object. A lambda that uses outer variables needs a hidden object to hold them. That object is shared, lives on the heap, and lives as long as the delegate does.

1. Func, Action, and custom delegates C# 1.0 / 3.0

What it is

A delegate type describes a method signature. delegate int Op(int a, int b); declares one. The BCL ships generic ones, so you rarely need your own:

Why and when

Sample

/// <summary>A custom delegate: needed because Func cannot express an out parameter.</summary>
public delegate bool TryParser<T>(string text, out T value);

/// <summary>A named delegate type for readability.</summary>
public delegate int BinaryOp(int a, int b);

public static class Delegates
{
    /// <summary>Applies an operation to fold a list from left to right.</summary>
    /// <param name="items">Values to fold. Must not be empty.</param>
    /// <param name="op">How to combine two values.</param>
    /// <returns>The folded result.</returns>
    /// <example>Delegates.Fold([1, 2, 3], (a, b) =&gt; a * b) returns 6.</example>
    public static int Fold(int[] items, BinaryOp op)
    {
        // [0] seeds the fold because there is no neutral value for an unknown op.
        int acc = items[0];
        // i is the next item to fold in. Start at 1 since item 0 is the seed.
        for (int i = 1; i < items.Length; i++) acc = op(acc, items[i]);
        return acc;
    }

    /// <summary>Parses every string with the given parser and keeps the good ones.</summary>
    /// <param name="texts">Inputs.</param>
    /// <param name="parse">A TryParse-shaped method, such as int.TryParse.</param>
    /// <returns>Only the values that parsed.</returns>
    /// <example>Delegates.ParseAll(["1", "x", "3"], int.TryParse) returns [1, 3].</example>
    public static List<T> ParseAll<T>(string[] texts, TryParser<T> parse)
    {
        var result = new List<T>();
        foreach (var t in texts)
        {
            if (parse(t, out T v)) result.Add(v);
        }
        return result;
    }

    /// <summary>Shows that Predicate and Func with the same shape do not convert.</summary>
    /// <returns>Matches found using a Func wrapped as a Predicate.</returns>
    /// <example>Delegates.PredicateVsFunc() returns [2, 4].</example>
    public static List<int> PredicateVsFunc()
    {
        Func<int, bool> isEven = x => x % 2 == 0;   // % 2 == 0 means divisible by 2
        var list = new List<int> { 1, 2, 3, 4 };
        // list.FindAll(isEven) does not compile. Wrap with new Predicate or a lambda.
        return list.FindAll(new Predicate<int>(isEven));
    }
}

Pitfalls

Interview questions

2. Multicast delegates C# 1.0

What it is

Every delegate can hold a list of methods. a + b or a += b makes a new delegate that calls both, in order. -= removes the last matching entry. Delegates are immutable, so each += builds a new object.

Why and when

Sample

public static class Multicast
{
    /// <summary>Calls a multicast Func. Only the last result comes back.</summary>
    /// <returns>The value returned by invoking the combined delegate.</returns>
    /// <example>Multicast.LastWins() returns 3.</example>
    public static int LastWins()
    {
        // 1, 2, 3 are labels so you can see which handler's result survives.
        Func<int> all = () => 1;
        all += () => 2;
        all += () => 3;
        return all();                   // runs all three, keeps only the last result
    }

    /// <summary>Collects every handler's result through the invocation list.</summary>
    /// <returns>All results in call order.</returns>
    /// <example>Multicast.AllResults() returns [1, 2, 3].</example>
    public static List<int> AllResults()
    {
        Func<int> all = () => 1;
        all += () => 2;
        all += () => 3;
        return all.GetInvocationList().Cast<Func<int>>().Select(f => f()).ToList();
    }

    /// <summary>Shows that one throwing handler stops the rest.</summary>
    /// <returns>The handlers that ran before the exception.</returns>
    /// <example>Multicast.ExceptionStopsChain() returns ["a"].</example>
    public static List<string> ExceptionStopsChain()
    {
        var ran = new List<string>();
        Action chain = () => ran.Add("a");
        chain += () => throw new InvalidOperationException("boom");
        chain += () => ran.Add("c");    // never runs
        try { chain(); } catch (InvalidOperationException) { }
        return ran;
    }

    /// <summary>Removing a lambda with -= fails because each lambda is a new object.</summary>
    /// <returns>How many handlers remain after the failed and the real removal.</returns>
    /// <example>Multicast.RemoveDemo() returns "2 1".</example>
    public static string RemoveDemo()
    {
        var log = new List<int>();
        Action<int> keep = x => log.Add(x);
        Action<int>? all = keep;
        all += x => log.Add(-x);        // -x: a second, different handler
        all -= x => log.Add(-x);        // a new lambda object, so nothing matches
        int afterLambda = all!.GetInvocationList().Length;
        all -= keep;                    // the same object, so this one is removed
        return $"{afterLambda} {all!.GetInvocationList().Length}";
    }
}

Pitfalls

Interview questions

3. Closures and capture semantics C# 2.0 / fix in C# 5

What it is

A lambda that uses a variable from an outer scope captures the variable, not its value. The compiler moves that variable into a hidden heap object, often called the display class. The method and every lambda then share that one object. Later changes are visible to all of them.

for (int i = 0; i < 3; i++) foreach (var x in [0, 1, 2]) () => i () => i () => i closure i = 3 all three return 3 () => x () => x () => x closure x = 0 closure x = 1 closure x = 2 returns 0, 1, 2 (since C# 5)
Figure A3.1 — A for loop has one variable, so one closure. A foreach loop has a fresh variable per pass.

Reading the figure. Blue boxes are the delegates. Red is a single shared closure object. The loop kept bumping its i until the exit test failed at 3. Green closures are one per pass, each holding its own x. Notice the fix for for: copy i into a local inside the body. That local is fresh per pass, just like the foreach variable.

Why and when

Sample

public static class Closures
{
    /// <summary>The for-loop capture bug, still present in C# 14.</summary>
    /// <returns>What each lambda returns when called after the loop.</returns>
    /// <example>Closures.ForLoopBug() returns [3, 3, 3].</example>
    public static List<int> ForLoopBug()
    {
        var fs = new List<Func<int>>();
        // 3 lambdas. i is declared once for the whole loop and ends at 3.
        for (int i = 0; i < 3; i++) fs.Add(() => i);
        return fs.Select(f => f()).ToList();
    }

    /// <summary>The fix: copy the loop variable into a fresh local each pass.</summary>
    /// <returns>Each lambda's own value.</returns>
    /// <example>Closures.ForLoopFixed() returns [0, 1, 2].</example>
    public static List<int> ForLoopFixed()
    {
        var fs = new List<Func<int>>();
        // 3 passes again. copy is a new variable each pass, so each lambda gets its own.
        for (int i = 0; i < 3; i++)
        {
            int copy = i;
            fs.Add(() => copy);
        }
        return fs.Select(f => f()).ToList();
    }

    /// <summary>foreach makes a fresh variable per pass (since C# 5).</summary>
    /// <returns>Each lambda's own value.</returns>
    /// <example>Closures.ForeachIsFine() returns [0, 1, 2].</example>
    public static List<int> ForeachIsFine()
    {
        var fs = new List<Func<int>>();
        foreach (int x in new[] { 0, 1, 2 }) fs.Add(() => x);   // 0, 1, 2 are sample items
        return fs.Select(f => f()).ToList();
    }

    /// <summary>Two lambdas share one captured variable.</summary>
    /// <returns>The value read after two increments.</returns>
    /// <example>Closures.SharedCounter() returns 2.</example>
    public static int SharedCounter()
    {
        int count = 0;                  // 0: nothing counted yet
        Action bump = () => count++;
        Func<int> read = () => count;
        bump();
        bump();
        return read();                  // both lambdas see the same count variable
    }

    /// <summary>Makes a counter. The local outlives the method because it is captured.</summary>
    /// <returns>A function that returns 1, 2, 3, ... on each call.</returns>
    /// <example>var next = Closures.MakeCounter(); next(); next() returns 2.</example>
    public static Func<int> MakeCounter()
    {
        int n = 0;                      // 0 so the first call returns 1
        return () => ++n;
    }
}

Pitfalls

Interview questions

Say this out loud: “Lambdas capture variables, not values. A for loop has one variable for all passes, so I copy it into a local inside the body.”

4. Static lambdas C# 9

What it is

Put static before a lambda, as in static x => x * 2. The compiler then forbids capturing locals, parameters, or this. Any accidental capture becomes error CS8820.

Why and when

Sample

using System.Collections.Concurrent;

public static class StaticLambdas
{
    /// <summary>Squares every number with a static lambda. No closure is possible.</summary>
    /// <param name="items">Input numbers.</param>
    /// <returns>The squares.</returns>
    /// <example>StaticLambdas.Squares([1, 2, 3]) returns [1, 4, 9].</example>
    public static List<int> Squares(int[] items) => items.Select(static x => x * x).ToList();

    /// <summary>Passes outside data as state instead of capturing it.</summary>
    /// <param name="cache">The cache.</param>
    /// <param name="key">Key to look up.</param>
    /// <param name="suffix">Extra data the factory needs.</param>
    /// <returns>The cached or new value.</returns>
    /// <example>StaticLambdas.GetOrAdd(cache, "a", "!") returns "a!".</example>
    public static string GetOrAdd(ConcurrentDictionary<string, string> cache, string key,
        string suffix) =>
        // suffix travels as the state argument, so the lambda captures nothing.
        cache.GetOrAdd(key, static (k, s) => k + s, suffix);

    /// <summary>string.Create fills a new string in place, with state passed in.</summary>
    /// <param name="c">The character to repeat.</param>
    /// <param name="n">How many times.</param>
    /// <returns>A string of n copies of c.</returns>
    /// <example>StaticLambdas.Repeat('z', 3) returns "zzz".</example>
    public static string Repeat(char c, int n) =>
        string.Create(n, c, static (span, ch) => span.Fill(ch));
}

Pitfalls

Interview questions

5. Local functions vs lambdas C# 7.0 / static in C# 8

What it is

A local function is a named method declared inside another method. It can capture outer variables like a lambda. But it is not a delegate unless you convert it to one.

Why and when

Sample

public static class LocalFunctions
{
    /// <summary>Counts paths in a grid with a memoised recursive local function.</summary>
    /// <param name="rows">Grid rows, at least 1.</param>
    /// <param name="cols">Grid columns, at least 1.</param>
    /// <returns>Paths from top-left to bottom-right moving only right or down.</returns>
    /// <example>LocalFunctions.GridPaths(3, 3) returns 6.</example>
    public static long GridPaths(int rows, int cols)
    {
        var memo = new Dictionary<(int, int), long>();
        return Paths(rows - 1, cols - 1);   // - 1: index of the last row and column

        // A local function can call itself. A lambda would need a pre-declared variable.
        long Paths(int r, int c)
        {
            // 0 in either index means a single straight line remains: exactly 1 path.
            if (r == 0 || c == 0) return 1;
            if (memo.TryGetValue((r, c), out long hit)) return hit;
            // - 1 steps back one row (came from above) or one column (came from the left).
            long total = Paths(r - 1, c) + Paths(r, c - 1);
            memo[(r, c)] = total;
            return total;
        }
    }

    /// <summary>An iterator that validates eagerly, thanks to a local function.</summary>
    /// <param name="start">First value.</param>
    /// <param name="count">How many values. Must not be negative.</param>
    /// <returns>start, start + 1, ...</returns>
    /// <example>LocalFunctions.Range(5, 3) yields 5, 6, 7.</example>
    public static IEnumerable<int> Range(int start, int count)
    {
        // Runs at call time, not at first MoveNext, because this method has no yield.
        ArgumentOutOfRangeException.ThrowIfNegative(count);
        return Iterate();

        IEnumerable<int> Iterate()
        {
            // i counts values produced. Invariant: start .. start + i - 1 are done.
            for (int i = 0; i < count; i++) yield return start + i;
        }
    }

    /// <summary>A generic static local function. Static means it captures nothing.</summary>
    /// <returns>The larger of two pairs, compared by local helper.</returns>
    /// <example>LocalFunctions.GenericLocal() returns "b 9".</example>
    public static string GenericLocal()
    {
        // "a"/"b" and 4/9 are sample values. 9 wins, so the answer is "b 9".
        return $"{Max("a", "b")} {Max(4, 9)}";

        static T Max<T>(T x, T y) where T : IComparable<T> => x.CompareTo(y) >= 0 ? x : y;
    }
}

Pitfalls

Interview questions

6. Lambda natural type and attributes C# 10

What it is

Why and when

Sample

using System.ComponentModel;
using System.Reflection;

public static class NaturalTypes
{
    /// <summary>A plain method, used as a method group.</summary>
    /// <param name="x">Input.</param>
    /// <returns>x times 2.</returns>
    /// <example>NaturalTypes.Twice(4) returns 8.</example>
    public static int Twice(int x) => 2 * x;   // 2: the doubling factor

    /// <summary>Shows inferred delegate types for a lambda and a method group.</summary>
    /// <returns>The two inferred type names.</returns>
    /// <example>NaturalTypes.InferredTypes() returns "Func`2 Func`2".</example>
    public static string InferredTypes()
    {
        var square = (int x) => x * x;  // inferred Func<int, int>
        var twice = Twice;              // works because Twice has exactly one overload
        return $"{square.GetType().Name} {twice.GetType().Name}";
    }

    /// <summary>An explicit return type lets two branches return different types.</summary>
    /// <returns>The results for true and false.</returns>
    /// <example>NaturalTypes.ExplicitReturn() returns "1 one".</example>
    public static string ExplicitReturn()
    {
        var pick = object (bool b) => b ? 1 : "one";   // 1 and "one" are sample values
        return $"{pick(true)} {pick(false)}";
    }

    /// <summary>Reads an attribute placed on a lambda.</summary>
    /// <returns>The Description text found on the lambda's method.</returns>
    /// <example>NaturalTypes.LambdaAttribute() returns "doubles a number".</example>
    public static string LambdaAttribute()
    {
        var f = [Description("doubles a number")] (int x) => x * 2;   // 2: doubling factor
        return f.Method.GetCustomAttribute<DescriptionAttribute>()!.Description;
    }
}

Pitfalls

Interview questions

7. Default lambda parameters C# 12

What it is

Lambda parameters can now have default values and a params array, just like methods: var inc = (int x, int by = 1) => x + by;. The natural type cannot be Func, so the compiler makes an anonymous delegate type that keeps the default.

Why and when

Sample

public static class DefaultLambdaParams
{
    /// <summary>Calls a lambda with and without its optional argument.</summary>
    /// <returns>Both results.</returns>
    /// <example>DefaultLambdaParams.Demo() returns "6 15".</example>
    public static string Demo()
    {
        var inc = (int x, int by = 1) => x + by;   // by = 1: step by one unless told otherwise
        return $"{inc(5)} {inc(5, 10)}";           // 5 and 10 are sample inputs
    }

    /// <summary>A params array on a lambda.</summary>
    /// <returns>The sum of the arguments passed loosely.</returns>
    /// <example>DefaultLambdaParams.ParamsDemo() returns 6.</example>
    public static int ParamsDemo()
    {
        var sum = (params int[] xs) => xs.Sum();
        return sum(1, 2, 3);            // 1, 2, 3 are sample values
    }

    /// <summary>Shows the natural type is an anonymous delegate, not Func.</summary>
    /// <returns>True when the inferred type is not a Func.</returns>
    /// <example>DefaultLambdaParams.IsAnonymousDelegate() returns true.</example>
    public static bool IsAnonymousDelegate()
    {
        var inc = (int x, int by = 1) => x + by;   // same default step as above
        return !inc.GetType().Name.StartsWith("Func");
    }
}

Pitfalls

Interview questions

8. params collections C# 13

What it is

params used to work only with arrays. C# 13 allows any collection type that supports collection expressions. That includes Span<T>, ReadOnlySpan<T>, List<T>, IEnumerable<T>, and IReadOnlyList<T>.

Why and when

Sample

public static class ParamsCollections
{
    /// <summary>Sums loose arguments with no heap array.</summary>
    /// <param name="values">Arguments, built on the stack by the compiler.</param>
    /// <returns>The total.</returns>
    /// <example>ParamsCollections.Sum(1, 2, 3) returns 6.</example>
    public static int Sum(params ReadOnlySpan<int> values)
    {
        int total = 0;                  // 0 is the empty sum
        foreach (int v in values) total += v;
        return total;
    }

    /// <summary>Joins loose values or any sequence.</summary>
    /// <param name="parts">Strings to join.</param>
    /// <returns>The parts joined by dashes.</returns>
    /// <example>ParamsCollections.Dash("a", "b") returns "a-b".</example>
    public static string Dash(params IEnumerable<string> parts) => string.Join("-", parts);

    /// <summary>Calls each overload in the loose and the collection form.</summary>
    /// <returns>A summary of all the results.</returns>
    /// <example>ParamsCollections.Demo() returns "6 0 a-b x-y".</example>
    public static string Demo()
    {
        int a = Sum(1, 2, 3);           // loose ints, sample values
        int none = Sum();               // empty span, so 0
        string b = Dash("a", "b");
        string c = Dash(new List<string> { "x", "y" });   // an existing sequence works too
        return $"{a} {none} {b} {c}";
    }
}

Pitfalls

Interview questions

9. Lambda parameter modifiers without types C# 14

What it is

Before C# 14, using ref, out, in, or scoped on a lambda parameter forced you to write every type. C# 14 lets you keep the modifier and drop the types. The delegate supplies them.

Why and when

Sample

/// <summary>A callback that changes a value in place.</summary>
public delegate void RefAction<T>(ref T value);

public static class TypelessModifiers
{
    /// <summary>Uses out and ref on lambda parameters with no types written.</summary>
    /// <returns>Parse result, parsed value, and the doubled variable.</returns>
    /// <example>TypelessModifiers.Demo() returns "True 42 10".</example>
    public static string Demo()
    {
        // C# 14: out with no type. Before, this needed (string text, out int result).
        TryParser<int> parse = (text, out result) => int.TryParse(text, out result);
        bool ok = parse("42", out int n);   // "42" is a sample number

        // ref with no type: the delegate says value is an int.
        RefAction<int> twice = (ref value) => value *= 2;   // * 2: doubles in place
        int x = 5;                      // 5 is a sample start value
        twice(ref x);
        return $"{ok} {n} {x}";
    }

    /// <summary>Applies a ref callback to every element in place.</summary>
    /// <param name="items">The array. It is changed.</param>
    /// <param name="f">The in-place change.</param>
    /// <returns>The same array.</returns>
    /// <example>TypelessModifiers.ForEachRef([1, 2], (ref v) =&gt; v++) returns [2, 3].</example>
    public static int[] ForEachRef(int[] items, RefAction<int> f)
    {
        // i walks the array. Invariant: items[0..i-1] have been changed.
        for (int i = 0; i < items.Length; i++) f(ref items[i]);
        return items;
    }
}

Pitfalls

Interview questions

10. Events and the EventHandler pattern C# 1.0

What it is

An event is a delegate field with restricted access. Outside code may only += and -=. Only the owning class may invoke it or set it to null. The standard shape is event EventHandler<TArgs>, raised from a protected virtual OnXxx method.

Why and when

Sample

/// <summary>Data for a price change.</summary>
public sealed class PriceChangedEventArgs(decimal oldPrice, decimal newPrice) : EventArgs
{
    public decimal OldPrice { get; } = oldPrice;
    public decimal NewPrice { get; } = newPrice;
}

/// <summary>A stock that raises an event when its price changes.</summary>
public class Stock(string symbol)
{
    private decimal _price;

    public string Symbol { get; } = symbol;

    public event EventHandler<PriceChangedEventArgs>? PriceChanged;

    public decimal Price
    {
        get => _price;
        set
        {
            if (value == _price) return;    // no change, no event
            var args = new PriceChangedEventArgs(_price, value);
            _price = value;
            OnPriceChanged(args);
        }
    }

    // ?.Invoke reads the field once, so a handler leaving on another thread is safe.
    protected virtual void OnPriceChanged(PriceChangedEventArgs e) => PriceChanged?.Invoke(this, e);
}

public static class EventsDemo
{
    /// <summary>Subscribes, changes the price twice, unsubscribes, changes it again.</summary>
    /// <returns>The log of changes the handler saw.</returns>
    /// <example>EventsDemo.Run() returns ["ACME 0-&gt;10", "ACME 10-&gt;12"].</example>
    public static List<string> Run()
    {
        var log = new List<string>();
        var stock = new Stock("ACME");
        EventHandler<PriceChangedEventArgs> handler = (sender, e) =>
            log.Add($"{((Stock)sender!).Symbol} {e.OldPrice}->{e.NewPrice}");
        stock.PriceChanged += handler;
        stock.Price = 10;               // 10 and 12 are sample prices
        stock.Price = 12;
        stock.Price = 12;               // same value, so no event
        stock.PriceChanged -= handler;  // the same delegate object, so removal works
        stock.Price = 20;               // nobody listening now
        return log;
    }
}

Pitfalls

Interview questions

11. Weak events and leaks pattern, no keyword

What it is

Subscribing stores a delegate in the publisher. The delegate's Target is the subscriber. So the publisher keeps the subscriber alive. If the publisher lives long, such as a static or app-wide service, every forgotten subscriber leaks.

A weak event stores the subscriber through a WeakReference. The GC may then collect the subscriber, and the publisher drops dead entries when it next raises.

Strong event: leak Publisherlives all app long delegateTarget, Method View (closed)cannot be freed Weak event: collectable Publisherlives all app long WeakReference+ MethodInfo View (closed)GC may free it dashed = weak, the GC ignores it
Figure A3.2 — A strong subscription chains the subscriber to the publisher. A weak one lets it go.

Reading the figure. Solid red arrows are strong references. The GC follows them, so the closed view stays alive as long as the publisher does. The dashed green arrow is a weak reference that the GC does not follow. Notice the leak is not in the view at all. It is in the long-lived publisher holding the list.

Why and when

Sample

using System.Reflection;
using System.Runtime.CompilerServices;

/// <summary>A minimal weak event. Holds subscribers weakly and prunes dead ones.</summary>
public sealed class WeakEvent<TArgs>
{
    private readonly List<(WeakReference<object> Target, MethodInfo Method)> _subs = [];

    /// <summary>Adds an instance-method handler without keeping its target alive.</summary>
    /// <param name="handler">Must be an instance method, not a static or lambda.</param>
    /// <example>weak.Subscribe(view.OnTick) keeps view collectable.</example>
    public void Subscribe(EventHandler<TArgs> handler)
    {
        if (handler.Target is null) throw new ArgumentException("static handlers not supported");
        _subs.Add((new WeakReference<object>(handler.Target), handler.Method));
    }

    /// <summary>Calls every live handler and drops the dead ones.</summary>
    /// <param name="sender">The publisher.</param>
    /// <param name="args">Event data.</param>
    /// <returns>How many handlers ran.</returns>
    /// <example>weak.Raise(this, 5) returns 1 when one subscriber is alive.</example>
    public int Raise(object sender, TArgs args)
    {
        int delivered = 0;              // 0 handlers run so far
        // Walk backwards so RemoveAt does not shift entries not yet visited.
        // - 1 because the last valid index is Count - 1. Stop after index 0.
        for (int i = _subs.Count - 1; i >= 0; i--)
        {
            if (_subs[i].Target.TryGetTarget(out object? target))
            {
                _subs[i].Method.Invoke(target, [sender, args]);
                delivered++;
            }
            else
            {
                _subs.RemoveAt(i);      // the subscriber was collected
            }
        }
        return delivered;
    }

    public int Count => _subs.Count;
}

/// <summary>A long-lived publisher with a normal, strong event.</summary>
public sealed class Ticker
{
    public event EventHandler<int>? Tick;
    public WeakEvent<int> WeakTick { get; } = new();

    public void Fire(int n)
    {
        Tick?.Invoke(this, n);
        WeakTick.Raise(this, n);
    }
}

/// <summary>A subscriber that counts ticks.</summary>
public sealed class TickView
{
    public int Seen;

    public void OnTick(object? sender, int n) => Seen += n;
}

public static class LeakDemo
{
    /// <summary>Checks whether a subscriber survives a full GC.</summary>
    /// <param name="weak">True to subscribe weakly, false for a normal event.</param>
    /// <returns>True when the subscriber is still alive, which means it leaked.</returns>
    /// <example>LeakDemo.SubscriberSurvivesGc(false) returns true.</example>
    public static bool SubscriberSurvivesGc(bool weak)
    {
        var ticker = new Ticker();
        WeakReference probe = Subscribe(ticker, weak);
        // Collect twice with a finalizer pass between, so the GC finishes its work.
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        bool alive = probe.IsAlive;
        GC.KeepAlive(ticker);           // the publisher must outlive the check
        return alive;
    }

    // In its own method, so no local in the caller keeps the view alive.
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static WeakReference Subscribe(Ticker ticker, bool weak)
    {
        var view = new TickView();
        if (weak) ticker.WeakTick.Subscribe(view.OnTick);
        else ticker.Tick += view.OnTick;
        return new WeakReference(view);
    }

    /// <summary>A live weak subscriber still receives events.</summary>
    /// <returns>The sum of ticks the view saw.</returns>
    /// <example>LeakDemo.WeakStillDelivers() returns 5.</example>
    public static int WeakStillDelivers()
    {
        var ticker = new Ticker();
        var view = new TickView();
        ticker.WeakTick.Subscribe(view.OnTick);
        ticker.Fire(2);                 // 2 and 3 are sample tick sizes
        ticker.Fire(3);
        return view.Seen;
    }
}

Pitfalls

Interview questions

12. Function pointers: delegate* C# 9

What it is

A function pointer, delegate*<int, int, int>, is a raw address of a static method. It has no target object, no invocation list, and no heap allocation. You take it with &Method and call it like a delegate. It needs an unsafe context. delegate* unmanaged points at native code for interop.

Why and when

Sample

public static unsafe class FunctionPointers
{
    private static int Add(int a, int b) => a + b;
    private static int Mul(int a, int b) => a * b;

    /// <summary>Picks an operation by symbol and calls it through a function pointer.</summary>
    /// <param name="op">'+' or '*'.</param>
    /// <param name="a">Left operand.</param>
    /// <param name="b">Right operand.</param>
    /// <returns>The result.</returns>
    /// <example>FunctionPointers.Apply('*', 6, 7) returns 42.</example>
    public static int Apply(char op, int a, int b)
    {
        delegate*<int, int, int> f = op switch
        {
            '+' => &Add,
            '*' => &Mul,
            _ => throw new ArgumentException($"unknown op {op}", nameof(op)),
        };
        return f(a, b);
    }

    /// <summary>A table of function pointers, indexed like an array.</summary>
    /// <returns>Each table entry applied to 3 and 4.</returns>
    /// <example>FunctionPointers.Table() returns [7, 12].</example>
    public static List<int> Table()
    {
        // stackalloc of 2 pointer slots: one per operation. No heap at all.
        delegate*<int, int, int>* table = stackalloc delegate*<int, int, int>[2];
        table[0] = &Add;                // slot 0 is +
        table[1] = &Mul;                // slot 1 is *
        // 3 and 4 are sample operands.
        return [table[0](3, 4), table[1](3, 4)];
    }
}

Pitfalls

Interview questions

13. Method group conversion and allocation C# 2.0 / caching in C# 11

What it is

A method group is a method name used without calling it, as in items.Select(Parse). The compiler turns it into a delegate. Whether that allocates depends on the case:

Why and when

Sample

public static class MethodGroups
{
    /// <summary>A static method used as a method group.</summary>
    /// <param name="x">Input.</param>
    /// <returns>x + 1.</returns>
    /// <example>MethodGroups.Inc(1) returns 2.</example>
    public static int Inc(int x) => x + 1;   // + 1: the increment

    private sealed class Box(int v)
    {
        public int Get() => v;
    }

    /// <summary>Converts the same method group twice at one site and compares instances.</summary>
    /// <returns>Whether the static and instance conversions reused one delegate.</returns>
    /// <example>MethodGroups.Reuse() returns "static=True instance=False".</example>
    public static string Reuse()
    {
        var statics = new List<Func<int, int>>();
        var instances = new List<Func<int>>();
        var box = new Box(5);           // 5 is a sample stored value
        // 2 passes, so each site converts twice.
        for (int i = 0; i < 2; i++)
        {
            statics.Add(Inc);           // C# 11+: cached, same object both times
            instances.Add(box.Get);     // binds box, so a new delegate each time
        }
        // [0] and [1] are the delegates from the first and second pass.
        return $"static={ReferenceEquals(statics[0], statics[1])} "
            + $"instance={ReferenceEquals(instances[0], instances[1])}";
    }

    /// <summary>Measures heap bytes for converting each kind n times.</summary>
    /// <param name="n">Number of conversions.</param>
    /// <returns>True when instance groups allocate and static groups do not.</returns>
    /// <example>MethodGroups.AllocationGap(1000) returns true.</example>
    public static bool AllocationGap(int n)
    {
        var box = new Box(1);           // 1 is a sample stored value
        long sink = 0;                  // 0: running total to keep the calls alive
        // Warm up both paths so JIT work is not counted.
        sink += ConvertStatic(1) + ConvertInstance(box, 1);   // 1 pass each
        long before = GC.GetAllocatedBytesForCurrentThread();
        sink += ConvertStatic(n);
        long staticBytes = GC.GetAllocatedBytesForCurrentThread() - before;
        before = GC.GetAllocatedBytesForCurrentThread();
        sink += ConvertInstance(box, n);
        long instanceBytes = GC.GetAllocatedBytesForCurrentThread() - before;
        GC.KeepAlive(sink);
        // 0 bytes for cached statics. Instance groups pay one delegate per pass.
        return staticBytes == 0 && instanceBytes > 0;
    }

    private static long ConvertStatic(int n)
    {
        long total = 0;                 // 0 is the empty sum
        for (int i = 0; i < n; i++)
        {
            Func<int, int> f = Inc;     // cached since C# 11
            total += f(i);
        }
        return total;
    }

    private static long ConvertInstance(Box box, int n)
    {
        long total = 0;                 // 0 is the empty sum
        for (int i = 0; i < n; i++)
        {
            Func<int> f = box.Get;      // new delegate every pass
            total += f();
        }
        return total;
    }
}

Pitfalls

Interview questions

Recap

Things to carry forward

Where this goes next

The next page, LINQ and Iterators, is where delegates do most of their work. It covers deferred execution, yield, and what LINQ costs.


← A 02 — Generics A 04 — LINQ and Iterators →