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}");
}
}
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());
}
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);
_ 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));
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();
}
}
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);
}
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);
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
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
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);
}
}