Delegates, Lambda Expressions and Events
A delegate is a type-safe reference to a method (or several methods).
Concept
A delegate is a type-safe reference to a method (or several methods). Declaring delegate double Converter(double value); creates a type; any method with a matching signature can be assigned to it, including via method-group conversion (Converter c = Twice;). Invoke with c(10) or c.Invoke(10).
Multicast: += adds methods to the invocation list, -= removes them; invoking calls all in order (the return value is the last one's).
Built-in delegates cover almost every need: Action/Action<T…> (no return), Func<T…, TResult> (returns a value), Predicate<T> (returns bool), Comparison<T>, EventHandler/EventHandler<TEventArgs>.
A lambda is an anonymous function: x => x * x (expression lambda) or x => { ...; return ...; } (statement lambda).
Captured variables / closures: a lambda can use local variables from the enclosing scope; it captures the *variable*, not its value at creation time.
Static lambdas (static x => -x) cannot capture — useful to avoid accidental allocations.
Natural type: var parse = (string s) => int.Parse(s); infers Func<string, int>.
Async lambdas: async () => { await ...; return 42; }.
Lambdas are how you pass behaviour to LINQ (numbers.Select(x => x * x)).
An event is a delegate field with restricted access: outside code can only subscribe (+=) and unsubscribe (-=); only the declaring class can raise it. This is the publisher/subscriber (observer) pattern.
Standard signature: event EventHandler<TEventArgs>? Name; with (object? sender, TEventArgs e) handlers.
Raise safely with Name?.Invoke(this, args), typically from a protected virtual void OnName(...) method.
Custom accessors (add { } remove { }) control how subscriptions are stored.
Unsubscribe when a short-lived subscriber listens to a long-lived publisher — otherwise the publisher keeps the subscriber alive (a classic memory leak).
Why It Matters
Delegates and lambdas are how C# passes behaviour around: callbacks, LINQ, event handlers, strategies, retry policies, ASP.NET minimal APIs. Events drive UIs, device drivers ("frame received") and domain events.
Syntax
delegate TResult Name(TArg arg);
Func<int, int> square = x => x * x;
Action<string> log = s => Console.WriteLine(s);
public event EventHandler<MyEventArgs>? Changed;
Changed?.Invoke(this, new MyEventArgs(...));
publisher.Changed += OnChanged; publisher.Changed -= OnChanged;Example
19.1 Delegates and lambdas
// Delegate declaration and invocation
Converter toKmh = mps => mps * 3.6;
Console.WriteLine($"toKmh(25) = {toKmh(25)}, via Invoke: {toKmh.Invoke(10)}");
// Methods as delegate targets (method group conversion)
Converter doubler = Twice;
Console.WriteLine($"doubler(21) = {doubler(21)}");
// Multicast: += adds targets; all are called in order
Action<string> log = s => Console.WriteLine($" [console] {s}");
log += s => Console.WriteLine($" [file] {s}");
log += Audit;
log("ignition on");
Console.WriteLine($"invocation list length: {log.GetInvocationList().Length}");
log -= Audit;
log("after removing Audit");
// Built-in delegates: Action, Func, Predicate
Func<int, int, int> add = (a, b) => a + b;
Predicate<int> isEven = n => n % 2 == 0;
Func<double> random = () => 4; // chosen by fair dice roll
Console.WriteLine($"add(2, 3) = {add(2, 3)}, isEven(7) = {isEven(7)}, random() = {random()}");
// Lambdas: expression vs statement bodies, captured variables (closures)
int threshold = 50;
Func<int, bool> aboveThreshold = v => v > threshold; // captures 'threshold'
threshold = 80; // closures see the CURRENT value
Console.
…var sensor = new TemperatureSensor();
var dashboard = new Dashboard("Cluster");
var logger = new FileLogger();
sensor.ThresholdExceeded += dashboard.OnOverheat; // subscribe
sensor.ThresholdExceeded += logger.OnOverheat;
sensor.Reading += (_, t) => Console.WriteLine($" reading {t} C");
sensor.Report(85);
sensor.Report(112);
sensor.ThresholdExceeded -= logger.OnOverheat; // unsubscribe (important to avoid leaks)
sensor.Report(120);
// Custom event accessors
var bus = new CanBus();
EventHandler<string> h = (_, msg) => Console.WriteLine($" frame: {msg}");
bus.FrameReceived += h;
bus.Raise("0x100 01 02");
bus.FrameReceived -= h;
public sealed class OverheatEventArgs(double celsius) : EventArgs
{
public double Celsius { get; } = celsius;
}
public class TemperatureSensor
{
public double Limit { get; init; } = 105;
public event EventHandler<OverheatEventArgs>? ThresholdExceeded; // standard pattern
public event EventHandler<double>? Reading;
public void Report(double celsius)
{
Reading?.Invoke(this, celsius);
if (celsius > Limit)
OnThresholdExceeded(new OverheatEventArgs(celsius));
}
protected virtual voi
…Expected Result
Delegates and lambdas:
aboveThreshold(60) is False because the closure reads threshold when it runs (80), not when the lambda was created (50).
After -=, the file logger no longer received the 120 °C event.
toKmh(25) = 90, via Invoke: 36
doubler(21) = 42
[console] ignition on
[file] ignition on
[audit] ignition on
invocation list length: 3
[console] after removing Audit
[file] after removing Audit
add(2, 3) = 5, isEven(7) = False, random() = 4
aboveThreshold(60) = False
classify(-4) = negative
0 1 2
squares: 1,4,9,16
parse("12") + negate(2) = 10
async lambda result: 42reading 85 C
reading 112 C
[Cluster] WARNING: 112 C from TemperatureSensor
[log] overheat 112
reading 120 C
[Cluster] WARNING: 120 C from TemperatureSensor
(subscriber added)
frame: 0x100 01 02
(subscriber removed)Common Mistakes
Raising or assigning an event from outside its class — real compiler error:
Capturing a loop variable that later changes (copy it into a local, as the example does; foreach variables are safe since C# 5).
Forgetting to unsubscribe from long-lived publishers (memory leaks).
Invoking an event without a null check (Changed(this, e) throws when there are no subscribers).
Relying on the return value of a multicast delegate (only the last one is returned).
async void event handlers that throw — exceptions cannot be caught by the publisher.
var s = new Sensor();
s.Changed?.Invoke(s, EventArgs.Empty); // only the declaring class may raise an event
s.Changed = null; // or assign it
class Sensor
{
public event EventHandler? Changed;
}m19_err.cs(2,3): error CS0070: The event 'Sensor.Changed' can only appear on the left hand side of += or -= (except when used from within the type 'Sensor')
m19_err.cs(3,3): error CS0070: The event 'Sensor.Changed' can only appear on the left hand side of += or -= (except when used from within the type 'Sensor')Best Practices
Use Func/Action instead of declaring custom delegate types, except for public APIs that benefit from a descriptive name.
Follow the EventHandler<TEventArgs> pattern for events.
Keep lambdas short; extract a named method when a lambda grows.
Use static lambdas in hot paths to prevent accidental captures.
Interview Relevance
Very high: "delegate vs event", "what is a closure?", "Func vs Action vs Predicate", "multicast delegates", "how can events cause memory leaks?".
Practice Questions
Write a Retry(Func<bool> operation, int attempts) helper.
Create a Pipeline that holds a list of Func<double, double> steps and applies them in order.
Add an Alarm event to a BatteryPack raised when the state of charge drops below 10%.
Demonstrate a closure capturing a counter to create a "next ID" generator.
Replace a switch on operation names with a Dictionary<string, Func<double, double, double>>.
Quick Quiz
Q1. (Multiple choice) Which built-in delegate returns a value? A) Action<T> B) Func<T, TResult> C) EventHandler D) Action Answer: B.
Q2. (Predict the output) int t = 50; Func<int, bool> f = v => v > t; t = 80; Console.WriteLine(f(60));
Answer: False — closures read the current value (verified).
Q3. (True/False) Code outside a class can raise that class's event.
Answer: False. Only +=/-= are allowed outside (CS0070, verified).
Q4. (Predict the output) How many handlers run for log("ignition on") in the example?
Answer: 3 (console, file, audit) — the invocation list length is 3 (verified).
Coding Challenge
Create a CanBusSimulator with an event FrameReceived (EventHandler<CanFrameEventArgs>), a method Inject(uint id, byte[] data), and a subscriber that counts frames per ID using a lambda.
var bus = new CanBusSimulator();
var counts = new Dictionary<uint, int>();
bus.FrameReceived += (_, e) => counts[e.Id] = counts.GetValueOrDefault(e.Id) + 1;
bus.Inject(0x100, [1]); bus.Inject(0x200, [2]); bus.Inject(0x100, [3]);
foreach (var (id, n) in counts) Console.WriteLine($"0x{id:X3}: {n}");
class CanFrameEventArgs(uint id, byte[] data) : EventArgs
{
public uint Id { get; } = id;
public byte[] Data { get; } = data;
}
class CanBusSimulator
{
public event EventHandler<CanFrameEventArgs>? FrameReceived;
public void Inject(uint id, byte[] data) => FrameReceived?.Invoke(this, new CanFrameEventArgs(id, data));
}