A delegate is a type-safe function pointer in C#. It lets you treat methods as values: store them in variables, pass them to other methods, compose invocation lists, and invoke them later. Delegates are foundational for callbacks, LINQ, strategy-style APIs, and events.

A delegate type defines a method signature. Any method with a compatible signature (static or instance) can be assigned to that delegate variable.

public delegate decimal PriceCalculator(int quantity, decimal unitPrice);
 
public static decimal StandardPrice(int q, decimal p) => q * p;
 
PriceCalculator calc = StandardPrice;
var total = calc(3, 19.99m); // 59.97

Built-in generic delegates

  • Action<T...>: returns void
  • Func<T..., TResult>: returns a value
  • Predicate<T>: predefined delegate with signature T -> bool (similar to Func<T, bool>, but a different type)
Func<int, int, int> add = (a, b) => a + b;
Action<string> log = s => Console.WriteLine(s);
Predicate<int> isEven = n => n % 2 == 0;

Multicast Delegates

Delegates can hold an invocation list (+=, -=). Calling the delegate invokes handlers in registration order.

Action pipeline = () => Console.WriteLine("Step 1");
pipeline += () => Console.WriteLine("Step 2");
pipeline += () => Console.WriteLine("Step 3");
 
pipeline();

Important runtime behavior:

  • If one handler throws, later handlers are not invoked.
  • For non-void delegates, only the last handler’s return value is returned.
  • -= removes the last matching handler from the invocation list.

Variance

Delegates support covariance and contravariance:

  • Covariance (returns): method may return a more derived type.
  • Contravariance (parameters): method may accept a less derived type.
class Animal { }
class Dog : Animal { }
 
Func<Dog> dogFactory = () => new Dog();
Func<Animal> animalFactory = dogFactory; // covariance
 
Action<Animal> inspectAnimal = a => Console.WriteLine(a.GetType().Name);
Action<Dog> inspectDog = inspectAnimal;   // contravariance

Anonymous Methods and Lambdas

Anonymous methods (delegate(...) { ... }) and lambdas ((...) => ...) compile to delegate instances. Both can capture local variables (closures).

int threshold = 10;
Func<int, bool> greaterThanThreshold = x => x > threshold;

Captured variables are references to closure state, not a one-time value copy.

Closures

A closure is the runtime state created when a lambda or anonymous method captures variables from an outer scope. The captured variable is shared, so updates to that variable are observed by all delegates that close over it.

var handlers = new List<Action>();
 
for (int i = 0; i < 3; i++)
{
    handlers.Add(() => Console.WriteLine(i));
}
 
handlers.ForEach(h => h()); // 3, 3, 3

Why this happens: the lambda captures the variable i, not its value per iteration.

var handlers = new List<Action>();
 
for (int i = 0; i < 3; i++)
{
    int copy = i; // capture per-iteration value
    handlers.Add(() => Console.WriteLine(copy));
}
 
handlers.ForEach(h => h()); // 0, 1, 2

Use this pattern when the captured variable would otherwise be shared and mutated after handler creation (most commonly for loop indices).

Allocation, Captures, and Function Pointers

  • Delegates are immutable. += and -= don’t mutate the delegate in place — they allocate a new multicast delegate with an updated invocation list (Delegate.Combine/Remove under the hood). Frequent subscribe/unsubscribe on a hot path therefore allocates.
  • A capturing lambda allocates a closure object plus the delegate. To prevent accidental captures, mark a lambda static (C# 9) — the compiler then errors if it tries to capture state, and can cache the delegate instance.
  • Method-group conversions are cached (.NET 7+): Func<int,int> f = Square; no longer allocates a fresh delegate on each conversion.
  • DynamicInvoke invokes a delegate via reflection (slow, boxes args) — avoid it in favor of a typed invoke. Delegates are also “open” or “closed”: a closed delegate binds a target instance; an open delegate leaves the receiver as the first argument.
  • Function pointers delegate* (C# 9) are the zero-allocation alternative for unsafe/interop hot paths — a raw managed function pointer with no delegate object at all (delegate*<int,int>), optionally unmanaged.

Pitfalls

  • Multicast stops at first exception: direct invocation of a multicast delegate exits when one handler throws, so later handlers are skipped. Mitigation: iterate GetInvocationList(), invoke each handler in its own try/catch, and log or aggregate failures.
  • Non-void multicast returns only one value: only the last handler’s return value is surfaced, so earlier results are lost. Mitigation: enumerate handlers explicitly and collect results into a list.
  • Loop variable capture can produce wrong values: closures capture variables, not snapshots, so all handlers can see a final mutated value (for example for loop index). Mitigation: create a per-iteration local copy before capturing.
  • Legacy async delegate pattern is unsupported on modern .NET: calling delegate BeginInvoke/EndInvoke throws PlatformNotSupportedException on modern runtimes. Mitigation: use Task-based async APIs.

Questions

References