Delegates, events and lambdas
Use Func and Action, capture variables without surprises, raise events correctly, and know when an expression tree is the right tool.
Delegates, Func and Action
// Func<...> returns a value; Action<...> returns void
Func<int, int, int> add = (a, b) => a + b;
Action<string> log = message => Console.WriteLine(message);
Predicate<int> isEven = n => n % 2 == 0;
// a named method group converts to a delegate without an explicit new
static int Square(int x) => x * x;
Func<int, int> f = Square;
// multicast: invocation calls every target in order
Action<string> pipeline = null!;
pipeline += Console.WriteLine;
pipeline += m => Console.Error.WriteLine(m);
pipeline("started"); // both run; the return value would be the last one only
// remove a handler you added
pipeline -= Console.WriteLine;
// passing behaviour as a parameter
static IEnumerable<T> Filter<T>(IEnumerable<T> src, Func<T, bool> predicate)
{
foreach (T item in src)
if (predicate(item)) yield return item;
}
var evens = Filter(new[] { 1, 2, 3, 4 }, isEven);- A multicast delegate with a non-void return type discards every result except the last. Use it for notification, not for aggregation.
- Invoke a possibly-empty delegate with the null-conditional operator:
handler?.Invoke(args). - Method groups convert to delegates by signature. A mismatch produces an overload-resolution error that mentions the delegate type, not the method.
Funcwith more than four parameters is allowed up to sixteen; beyond that define a named delegate type, which also documents intent.
Closures and captured variables
// a closure captures the VARIABLE, not the value
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
actions.Add(() => Console.WriteLine(i)); // C# 5+ for-loop: i is per-iteration
foreach (Action a in actions) a(); // prints 0, 1, 2
// a while loop has one shared variable, so this prints 3, 3, 3
var shared = new List<Action>();
int j = 0;
while (j < 3)
{
int captured = j; // copy inside the loop body to fix it
shared.Add(() => Console.WriteLine(captured));
j++;
}
// the capture keeps the closure's target alive: a long-lived cache of lambdas
// holding a big object prevents that object from being collected
static Func<int> MakeCounter()
{
int count = 0;
return () => ++count; // count lives as long as the returned delegate
}| Construct | Captures | Gotcha |
|---|---|---|
() => x | The variable x | Later reassignment is visible inside |
(x) => x | The parameter copy | Safe; no shared state |
static () => 1 | Nothing | Guarantees no hidden allocation, cannot capture |
[x] => ... not applicable | C# captures implicitly | Use a local copy to freeze a value |
| Expression tree | The shape of the expression | Cannot contain statements or await in old versions |
A closure allocates a display class on the heap the first time the enclosing method runs the capture. Marking a lambda static tells the compiler it captures nothing, which is a useful way to catch an accidental capture during a refactor.
Events and the standard pattern
// the conventional pattern: EventHandler<T> with a sender and derived args
public sealed class OrderShippedEventArgs : EventArgs
{
public OrderShippedEventArgs(int orderId, string tracking) => (OrderId, Tracking) = (orderId, tracking);
public int OrderId { get; }
public string Tracking { get; }
}
public class OrderService
{
// an event is a delegate field with restricted access: only this class can raise it
public event EventHandler<OrderShippedEventArgs>? Shipped;
protected virtual void OnShipped(OrderShippedEventArgs e) => Shipped?.Invoke(this, e);
public void Ship(int orderId, string tracking) => OnShipped(new OrderShippedEventArgs(orderId, tracking));
}
class Program
{
static void Main()
{
var svc = new OrderService();
EventHandler<OrderShippedEventArgs> handler = (sender, e) =>
Console.WriteLine($"order {e.OrderId} -> {e.Tracking}");
svc.Shipped += handler;
svc.Ship(42, "TRACK123");
svc.Shipped -= handler; // unsubscribe or the publisher keeps this object alive
}
}- An event subscription is a strong reference from the publisher to the subscriber. Long-lived publishers plus short-lived subscribers equal a memory leak unless you unsubscribe.
- Never raise an event from a constructor: subscribers may not be attached yet and virtual dispatch on a partly initialised object is a genuine bug.
eventoutside its declaring type only allows+=and-=. A plain public delegate field would let any caller overwrite the whole list.- For weak subscription semantics, or to avoid the leak entirely, prefer an explicit callback list or a message bus with a
Disposethat detaches.
💡
Expression trees (
Expression<Func<T, bool>>) let a library inspect the code as data, which is how LINQ providers translate a predicate into SQL. They cost compile-time and run-time overhead, so only reach for them when something must read the expression rather than call it.FAQ
Is a lambda cached?
A non-capturing lambda can be cached by the compiler as a static field, so it allocates nothing per call. A capturing lambda allocates a closure object each time the capture is created, which is a real cost inside a hot loop.
What is the difference between a delegate and an interface?
A delegate carries one operation and composes with
+=. An interface carries a set of related operations and is the better choice when the contract has more than one member or needs state.Related
LINQ and async/await Classes and interfaces
Last refreshed 2026-09-18.