Delegates as type-safe method references: anatomy (target plus method), creation and invocation, allocation and caching, equality, and choosing delegates versus interfaces or function pointers.
A delegate is a type-safe reference to a method. The delegate type defines the signature, and a delegate instance is an object that holds the method and, for instance methods, the target object, so calling Invoke calls that method on that object. Delegates are immutable reference types that derive from MulticastDelegate, and two delegates are equal if they point at the same method on the same target. Creating one allocates, though the compiler caches non-capturing lambdas. I use Func and Action for most callbacks, and an interface when the contract has more than one method.
A delegate is a type-safe reference to a method — an object you can store, pass and invoke later, like a function pointer that also knows its target object and is checked by the type system. Delegates power callbacks, LINQ, events, Task.Run, ASP.NET Core middleware and minimal APIs. I'd explain what a delegate object contains, creation and invocation, equality, static vs instance targets, allocations, and how delegates compare with interfaces and function pointers.
public delegate decimal DiscountRule(Order order); // a delegate TYPE (a class deriving from MulticastDelegate)
decimal LoyaltyDiscount(Order o) => o.Total * 0.05m;
DiscountRule rule = LoyaltyDiscount; // method group conversion
DiscountRule rule2 = o => o.Total > 1000m ? 50m : 0m; // lambda
DiscountRule rule3 = new DiscountRule(LoyaltyDiscount); // explicit (old style)
decimal d = rule(order); // invoke (same as rule.Invoke(order))
In practice you usually use the built-in generic delegates: Func<Order, decimal>, Action<string>, Predicate<T>.
A delegate instance is a heap object (a class derived from System.MulticastDelegate) holding:
_target — the object to call the method on (this for instance methods; for static methods, the delegate itself or null depending on the invocation stub);_methodPtr — a pointer to the code to call (or to a stub);_methodPtrAux — extra pointer used for static methods (shuffle thunks) and other cases;_invocationList / _invocationCount — for multicast delegates, the array of combined delegates.Method and Target properties expose the method (MethodInfo) and target object via reflection.
_target = the object; invocation calls the method with _target as this. The delegate keeps the target alive — important for memory leaks (event handlers holding subscribers).this, so the runtime uses a small shuffle thunk to rearrange arguments (the delegate's own this slot is dropped). Historically this made static-method delegates slightly slower than instance-method delegates; modern runtimes have reduced the difference. Lambdas that capture nothing are compiled as instance methods on a cached singleton (<>c class) partly for this reason.Two delegates are equal (==/Equals) when they have the same type, the same target and the same method (and for multicast, equal invocation lists in order). That's how -= finds the handler to remove:
button.Click += OnClick;
button.Click -= OnClick; // works: new delegate equal to the stored one
button.Click += (s, e) => Log();
button.Click -= (s, e) => Log(); // does NOT remove: different lambda = different method (and maybe target)
Store a lambda in a variable if you'll need to unsubscribe it.
+=) allocates a new delegate with a new invocation list array (delegates are immutable).Hot-path tips: use static lambdas, state-passing overloads (GetOrAdd(key, static (k, arg) => ..., arg)), cache delegates in fields.
| Delegate | Interface | Function pointer (delegate*<int, int>, C# 9) |
|
|---|---|---|---|
| Represents | One method (+ optional target) | A set of methods + object state | Raw pointer to a static method |
| Allocation | Delegate object (+ closure) | Object implementing it | None |
| Call cost | Indirect call via delegate | Interface dispatch | Direct indirect call, cheapest |
| Multicast | Yes | No | No |
| Can capture state | Yes (closures) | Yes (fields) | No (static methods only) |
| Safe code | Yes | Yes | Requires unsafe |
| Best for | Callbacks, LINQ, events, small strategies | Rich contracts, DI, multiple operations | Interop, extreme hot paths |
Use delegates for single-operation customisation (Func<Order, bool> filter), interfaces when there are several related operations or you want DI and naming.
A rules engine stored thousands of capturing lambdas created per request (rules.Select(r => (Func<Order,bool>)(o => r.Matches(o, ctx)))), allocating closures and delegates on every call. Profiling showed them in the top allocations. We compiled each rule once into a cached delegate that took the context as a parameter (Func<Order, RuleContext, bool>), using static lambdas — per-request allocations dropped to zero for the rules path.
Checks the fundamentals behind events, LINQ and middleware.
Green flags: target plus method; immutability; keeps target alive; allocation and caching; knows Func/Action versus custom delegates; mentions BeginInvoke is unsupported on .NET Core.
Red flags: "a delegate is a pointer" with nothing about the target; cannot explain why event subscriptions leak; thinks delegates are value types.
public delegate decimal DiscountRule(Order o); compiles to:
public sealed class DiscountRule : MulticastDelegate
{
public DiscountRule(object target, IntPtr method);
public virtual decimal Invoke(Order o); // implemented by the runtime
public virtual IAsyncResult BeginInvoke(Order o, AsyncCallback cb, object state); // not supported on .NET Core
public virtual decimal EndInvoke(IAsyncResult result);
}
Invoke has no IL body — the runtime provides it. BeginInvoke/EndInvoke throw PlatformNotSupportedException on .NET Core; use Task.Run instead.
Invoke loads _target into the this register and jumps to _methodPtr._methodPtr points to a shuffle thunk that shifts arguments, then jumps to _methodPtrAux (the real method).Invoke iterates the invocation list and calls each.ldvirtftn).<>c class; the delegate is cached in a static field on first use.Func<int,int> f = Math.Abs; caches the delegate in a static field too.unsafe
{
delegate*<int, int, int> add = &Add;
int r = add(1, 2);
}
static int Add(int a, int b) => a + b;
delegate* managed<...> for managed static methods; delegate* unmanaged[Cdecl]<...> for native callbacks ([UnmanagedCallersOnly]).LibraryImport, CsWin32) and runtime internals.Delegate calls are indirect and normally not inlined. With dynamic PGO (.NET 8+), the JIT can perform guarded devirtualisation for delegates: if a call site almost always invokes the same method, it checks _methodPtr and calls/inlines that method directly. This reduces the cost of LINQ predicates and callbacks in hot loops.
A delegate holds a strong reference to its target. Storing delegates in long-lived places (static events, singletons, caches) keeps targets alive — the classic event-handler leak. Use weak event patterns, unsubscribe in Dispose, or avoid capturing large objects.
rule.Method and rule.Target for instance, static and lambda delegates.-= using an identical-looking lambda.namespace Delegates.Basics;
public sealed record Order(decimal Total, bool IsLoyaltyMember);
public delegate decimal PriceRule(Order order, decimal currentPrice);
public sealed class LoyaltyDiscount(decimal rate)
{
public decimal Apply(Order order, decimal price) => order.IsLoyaltyMember ? price * (1 - rate) : price;
}
public static class Rules
{
public static decimal AddGst(Order order, decimal price) => price * 1.10m;
}
public sealed class PricingEngine(IReadOnlyList<PriceRule> rules)
{
public decimal Price(Order order)
{
var price = order.Total;
foreach (var rule in rules) price = rule(order, price);
return decimal.Round(price, 2);
}
}
public static class Program
{
public static void Main()
{
var loyalty = new LoyaltyDiscount(0.05m);
PriceRule discount = loyalty.Apply; // instance target captured
PriceRule gst = Rules.AddGst; // static, cached since C# 11
PriceRule promo = (o, p) => o.Total > 100m ? p - 10m : p;
var engine = new PricingEngine([discount, promo, gst]);
Console.WriteLine(engine.Price(new Order(150m, IsLoyaltyMember: true)));
Console.WriteLine(discount.Target == loyalty); // True: delegate keeps loyalty alive
Console.WriteLine(gst.Target is null); // True: static method
Console.WriteLine(discount.Method.Name); // Apply
PriceRule again = loyalty.Apply;
Console.WriteLine(discount == again); // True: same target and method
Console.WriteLine(ReferenceEquals(discount, again)); // False: two objects
}
}
What happens: x.Changed -= (s, e) => Handle();.
Why it is wrong: a new delegate isn't equal to the original; nothing is removed.
Do instead: store the delegate in a field and use it for both += and -=.
What happens: delegates created per call that capture locals. Why it is wrong: closure and delegate allocations per call. Do instead: static lambdas, state-passing overloads, cached delegates.
BeginInvoke on .NET CoreWhat happens: old async delegate pattern ported from .NET Framework.
Why it is wrong: throws PlatformNotSupportedException.
Do instead: Task.Run or async methods.
What happens: passing five Func<> parameters to configure behaviour.
Why it is wrong: unreadable and hard to test.
Do instead: use an interface or options object.
What happens: long-lived delegates referencing short-lived objects. Why it is wrong: memory leaks. Do instead: unsubscribe, avoid capturing large objects, use weak references where appropriate.
What happens: invoking a possibly null delegate field directly.
Why it is wrong: NullReferenceException.
Do instead: callback?.Invoke(args).
What happens: many custom delegate declarations for simple signatures.
Why it is wrong: extra types without benefit.
Do instead: use Func/Action unless names, ref/out parameters or documentation justify a custom type.
What happens: unsafe function pointers in ordinary application code. Why it is wrong: unsafe code for negligible gains. Do instead: reserve them for interop and measured hot paths.
Delegates are the backbone of LINQ, events, middleware (RequestDelegate) and minimal APIs, so seniors understand their cost model: one object per creation, a strong reference to the target, an indirect call that is rarely inlined. In application code that cost is irrelevant; in hot paths I check allocations with BenchmarkDotNet and use static lambdas or cached delegates. For extensibility I choose delegates for single operations and interfaces for multi-method contracts that need DI and mocking.
Any delegate can hold several methods. += calls Delegate.Combine, which creates a new immutable delegate with an invocation list, and invoking it calls each method synchronously in subscription order. For a delegate with a return value you only get the last handler's result, and if one handler throws, the rest are skipped. -= removes the last matching entry, which only works for lambdas if you kept the same instance. When I need every result or want one failing handler not to stop the others, I loop over GetInvocationList().
A lambda is an anonymous function, either an expression like x => x * 2 or a statement block. It has no type until it is converted, either to a delegate, which is compiled code, or to an Expression<Func<...>>, which is a data structure EF Core can turn into SQL. Since C# 10 a lambda can have a natural type, so var f = (int x) => x * 2 is a Func<int,int>. When it captures variables, the compiler hoists them into a closure class, which allocates. static lambdas forbid capture. Newer additions are attributes and return types (C# 10), default parameters (C# 12), and C# 14 parameter modifiers like out without writing types.
Func is a delegate that returns a value, so Func<int, string> takes an int and returns a string. Action returns void, and Predicate<T> is a delegate that takes a T and returns bool. Older APIs like List<T>.FindAll use it, while LINQ uses Func<T, bool>. Even with identical signatures they are different delegate types, so you cannot pass a Func<T, bool> variable as a Predicate<T> without wrapping it. A lambda converts to either. I declare my own delegate only when I need ref or out parameters or a meaningful name.
Last updated 30 Oct 2026.