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: 42
reading 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));
}