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.
  • Func with 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
}
ConstructCapturesGotcha
() => xThe variable xLater reassignment is visible inside
(x) => xThe parameter copySafe; no shared state
static () => 1NothingGuarantees no hidden allocation, cannot capture
[x] => ... not applicableC# captures implicitlyUse a local copy to freeze a value
Expression treeThe shape of the expressionCannot 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.
  • event outside 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 Dispose that 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.

LINQ and async/await Classes and interfaces

Last refreshed 2026-09-18.