<< Home

Chapter 11: Delegates, Events, and Lambdas

Delegates, events, and lambda expressions form a core trio in modern C#. Delegates provide type-safe references to methods; events build on delegates to implement the publish and subscribe model; lambda expressions make delegate instances concise to write and easy to compose. You will meet these features across .NET APIs such as EventHandler, Task continuations, Timer callbacks, and LINQ.

Delegates and type safety

A delegate is a type-safe object that represents a method signature. You can store a reference to any method whose parameters and return type match the delegate definition; at call time C# enforces that the target method fits the signature. Delegates are reference types, support invocation, and can target static or instance methods.

Declaring and instantiating a delegate

Declare a delegate with the delegate keyword. Construct an instance by providing a compatible method group or lambda. Invocation uses normal call syntax.

using System;

public delegate int BinaryOp(int x, int y);

class Calculator
{
  public static int Add(int a, int b) { return a + b; }
  public int Multiply(int a, int b) { return a * b; }
}

class Program
{
  static void Main()
  {
    BinaryOp op1 = Calculator.Add;             // static method group
    BinaryOp op2 = new Calculator().Multiply;  // instance method group

    int s = op1(3, 4);  // 7
    int p = op2(3, 4);  // 12
    Console.WriteLine($"{s}, {p}");
  }
}
💡 Prefer method groups or lambdas when creating delegates; the older new DelegateType(target, method) syntax is rarely needed.

Variance with delegate type parameters

Generic delegates support covariance in return types and contravariance in parameter types. This enables flexible assignment as long as the direction matches what is safe for substitution.

delegate Animal AnimalFactory();           // return type is covariant
delegate void AnimalHandler(Mammal m);     // parameter is contravariant

class Animal { }
class Mammal : Animal { }
class Cat : Mammal { }

Animal MakeAnimal() { return new Cat(); }  // OK: Cat → Animal
void HandleAnimal(Animal a) { /* ... */ }

void Demo()
{
  AnimalFactory f = MakeAnimal;            // covariance
  AnimalHandler h = HandleAnimal;          // contravariance
  h(new Cat());
}
⚠️ Variance applies at the delegate type boundary; inside the target method your parameter and return types are the ones you declared in that method’s signature.

Anonymous methods and lambda expressions

Anonymous methods and lambda expressions create delegates without a named method. Lambdas are the idiomatic approach in modern C#. A lambda can be an expression-bodied form that returns its expression, or a statement-bodied form enclosed in braces which can contain multiple statements.

Lambda syntax and expression forms

The compiler infers parameter and return types from the target delegate. Use parentheses for zero or multiple parameters; omit them for a single parameter when no type annotation is present. Statement-bodied lambdas use braces and must return explicitly if the delegate has a non-void return type.

Func square = x => x * x;              // expression-bodied
Action log = (msg) => { Console.WriteLine(msg); };  // statement-bodied
Func add = (a, b) => a + b;

int nine = square(3);
log($"3² = {nine}");
int seven = add(3, 4);
💡 Use _ as a discard parameter when you do not need a value in multi-parameter lambdas. Example: (_, value) => Console.WriteLine(value).

Anonymous methods with delegate

The older anonymous method form uses the delegate keyword. It can still be useful in rare cases, such as when you want to omit parameter names or use unsafe code blocks; lambdas are otherwise preferred.

Action hello = delegate { Console.WriteLine("Hello"); };
Func max = delegate(int a, int b) { return a > b ? a : b; };

hello();
Console.WriteLine(max(5, 9));
⚠️ A static lambda does not capture outer variables. Write static x => { … } to enforce no captures and enable small runtime optimizations.

Events and event patterns

Events implement the publish and subscribe model on top of delegates. An event exposes += and -= for subscriber management. Only the declaring type can raise the event. .NET libraries standardize events with the EventHandler and EventArgs pattern.

Declaring and raising an event

Declare an event with a delegate type. Use a protected virtual method named OnEventName to raise it. The null-conditional operator helps avoid a race condition where the invocation list changes between a null check and the call.

using System;

class TickEventArgs : EventArgs
{
  public DateTime When { get; }
  public TickEventArgs(DateTime when) { When = when; }
}

class Ticker
{
  public event EventHandler<TickEventArgs>? Ticked;

  protected virtual void OnTicked(DateTime when)
  {
    Ticked?.Invoke(this, new TickEventArgs(when));
  }

  public void Tick() { OnTicked(DateTime.UtcNow); }
}

class Program
{
  static void Main()
  {
    var t = new Ticker();
    t.Ticked += (sender, e) => Console.WriteLine($"Tick at {e.When:O}");
    t.Tick();
  }
}
💡 Expose events using EventHandler or EventHandler<TEventArgs>. This matches .NET conventions and works smoothly with UI frameworks, Timer, and many libraries.

Custom accessors and thread safety

Events can define custom add and remove accessors to control subscription storage or implement weak references. For thread safety, treat the invocation list as immutable during a raise by copying the delegate to a local variable, or use ?.Invoke which reads it once.

private EventHandler? _changed;

public event EventHandler Changed
{
  add { _changed += value; }
  remove { _changed -= value; }
}

void RaiseChanged()
{
  var handler = _changed;  // local copy
  handler?.Invoke(this, EventArgs.Empty);
}
⚠️ In long-lived publishers such as UI controls, strong event references can keep subscribers alive. Consider patterns like weak events in UI frameworks or explicit Dispose unsubscription to prevent memory leaks.

Action, Func, and Predicate

.NET ships general-purpose delegate types so you do not need to declare your own for common shapes. Action represents a method that returns void; Func represents a method that returns a value; Predicate<T> represents a method that returns bool for a single input.

Built-in delegates overview

The following table summarizes the most frequently used built-in delegates. The arity column indicates the number of input parameters.

Delegate Arity Signature Example
Action 0 () => void Action a = () => Console.WriteLine("Hi");
Action<T1, …> 1..16 (T1, …) => void Action<int,string> a = (i,s) => { … };
Func<TResult> 0 () => TResult Func<DateTime> now = () => DateTime.UtcNow;
Func<T1, …, TResult> 1..16 (T1, …) => TResult Func<int,int,int> add = (a,b) => a + b;
Predicate<T> 1 (T) => bool Predicate<int> even = n => n % 2 == 0;

Predicate<T> is equivalent to Func<T, bool>. Many .NET APIs prefer Func; older ones may still accept Predicate for readability.

Using built-in delegates with tasks and timers

Many .NET APIs accept Action or Func. For example, you can attach a continuation to a task or provide a callback to a timer using a lambda.

using System;
using System.Threading;
using System.Threading.Tasks;

Task.Run(() => 21)
  .ContinueWith(t => Console.WriteLine(t.Result * 2));  // Action<Task<int>>

using var timer = new Timer(_ => Console.WriteLine("Tick"), null, 0, 500); // Action<object?>
// keep process alive briefly
Thread.Sleep(1200);
💡 When you see generic parameters ending with TResult in Func, the last generic type is the return type. All previous types are inputs.

Closures and captured variables

A lambda or anonymous method can capture variables from its enclosing scope. The captured variables are stored in a hidden closure object so the lambda can read or modify them even after the original scope has returned. This feature enables factories and deferred execution; it can also surprise you if you capture a loop variable incorrectly.

Capturing and mutating outer variables

Captured variables retain identity and changes are visible to all lambdas that close over the same variable. The lifetime extends to the last live delegate that references it.

Func Counter()
{
  int n = 0;                        // captured
  return () => { n++; return n; };  // closes over n
}

var next = Counter();
Console.WriteLine(next()); // 1
Console.WriteLine(next()); // 2
⚠️ Captures can extend object lifetimes. If the captured variable refers to a disposable or large object, be deliberate about when the delegate becomes unreachable.

Loop variable capture

Each foreach iteration variable is distinct. In a for loop the control variable is shared; create a local copy inside the loop when you need per-iteration capture.

var actions = new Action[3];

for (int i = 0; i < 3; i++)
{
  int copy = i;                  // capture the copy
  actions[i] = () => Console.WriteLine(copy);
}

foreach (var a in actions) a();  // prints 0 then 1 then 2
💡 If you add static to a lambda it cannot capture outer variables; this guards against accidental captures and clarifies intent.

Multicast delegates

Delegates can reference multiple targets. When you combine delegates with + or subscribe multiple handlers to an event, you create a multicast delegate whose invocation list runs in order. If a target throws an exception and you invoke with normal call syntax, execution stops and the exception propagates.

Combining and removing delegates

Use + or += to combine, and - or -= to remove. The return value of a multicast delegate call is the return of the last invocation; do not rely on return values in multicast scenarios unless you control the list and design for it.

void A() { Console.Write("A"); }
void B() { Console.Write("B"); }

Action m = A;
m += B;
m();     // prints "AB"
m -= A;
m();     // prints "B"

Safely iterating the invocation list

To invoke all targets even if some throw exceptions, iterate GetInvocationList() and invoke each handler inside a try block. This is common in logging and notification pipelines.

Action handlers = () => Console.WriteLine("one");
handlers += () => throw new InvalidOperationException("boom");
handlers += () => Console.WriteLine("three");

foreach (var d in handlers.GetInvocationList())
{
  try
  {
    ((Action)d)();
  }
  catch (Exception ex)
  {
    Console.Error.WriteLine(ex.Message);
  }
}
💡 Events naturally use multicast delegates. The pattern above is useful when you need best-effort delivery across many handlers in a server or plugin system.