A delegate is a C# type that describes a callable method signature. A delegate value pairs that signature with a method and, for a closed instance method, its target object. The value can then be stored, passed around, combined with other delegates, and invoked later.
This makes delegates the common mechanism behind callbacks, LINQ operators, strategy-style APIs, and events. Any static or instance method with a compatible signature can be assigned to the delegate type.
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.97Built-in Generic Delegates
Action<T...>: returnsvoidFunc<T..., TResult>: returns a valuePredicate<T>: returnsboolfor one input. Its signature matchesFunc<T, bool>, but it remains a different delegate 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
A delegate can represent more than one method. += combines invocation lists, -= removes an entry, and invoking the result calls its handlers in registration order.
Action pipeline = () => Console.WriteLine("Step 1");
pipeline += () => Console.WriteLine("Step 2");
pipeline += () => Console.WriteLine("Step 3");
pipeline();Three details matter at runtime:
- If one handler throws, later handlers are not invoked.
- For non-
voiddelegates, only the last handler’s return value is returned. -=removes the last matching handler from the invocation list.
Variance
Delegate compatibility supports covariance for return values and contravariance for parameters:
- Covariance: a method may return a more derived type.
- Contravariance: a method may accept a less derived parameter 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; // contravarianceAnonymous Methods and Lambdas
Anonymous methods (delegate(...) { ... }) and lambdas ((...) => ...) can be converted to delegate instances. Both forms may capture local variables.
int threshold = 10;
Func<int, bool> greaterThanThreshold = x => x > threshold;The captured variable becomes shared closure state. It is not copied when the delegate is created.
Closures
A closure holds variables captured from an outer scope by a lambda or anonymous method. Every delegate that closes over the same variable sees the same storage, including later updates.
var handlers = new List<Action>();
for (int i = 0; i < 3; i++)
{
handlers.Add(() => Console.WriteLine(i));
}
handlers.ForEach(h => h()); // 3, 3, 3The lambda captures the single for variable i, not its value during each 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, 2The extra local gives each iteration separate storage. This is needed whenever later mutation of shared captured state would change the delegate’s meaning, with for loop indices being the usual example.
Allocation, Captures, and Function Pointers
- Delegates are immutable.
+=and-=create a new multicast delegate with an updated invocation list throughDelegate.CombineorDelegate.Remove. Frequent subscription changes on a hot path therefore allocate. - A capturing lambda needs closure state. A
staticlambda (C# 9) cannot capture locals, so the compiler rejects accidental captures and may reuse the delegate instance. - Static method-group conversions can be cached by the compiler. C# 11 added cached static method-group conversions, avoiding a fresh delegate allocation when the same conversion is evaluated repeatedly.
DynamicInvokeuses late-bound invocation. It performs runtime checks and can box arguments, so a typed call is preferable. A closed delegate binds its target instance. An open delegate receives the target as an argument.- Function pointers (
delegate*, C# 9) remove the delegate object. They fit narrowunsafeor interop paths where the calling convention and lifetime are controlled. They give up delegate safety and composition to get there.
Pitfalls
- One exception stops a multicast call. Later handlers are skipped. When handlers must be isolated, enumerate
GetInvocationList()and invoke each one inside its owntry/catch. - A non-
voidmulticast call exposes only the last return value. Enumerate the handlers and collect results when every result matters. - Captured loop state can drift. Closures capture variables rather than snapshots, so several handlers may observe the same final value. A per-iteration local creates the intended snapshot.
- The legacy asynchronous delegate pattern is unsupported on modern .NET. Calling delegate
BeginInvokeorEndInvokethrowsPlatformNotSupportedException.Task-based APIs are the replacement.
Questions
How is a delegate represented at runtime?
A delegate declaration creates a sealed type derived from
System.MulticastDelegate. ItsInvokemethod describes the signature that compatible methods must match. A delegate instance stores a method pointer and, for an instance method, the target object; a multicast delegate also stores an invocation list. The type still hasBeginInvokeandEndInvokemetadata, but calling those methods on modern .NET (6+) throwsPlatformNotSupportedException.