03 · Delegates & Events¶
A delegate is a type-safe reference to a method — a "function pointer" that can be stored in a variable, passed as an argument, and invoked later. Events build on delegates to give objects a publish/subscribe mechanism.
Declaring and using a delegate¶
public delegate int Operation(int a, int b);
int Add(int a, int b) => a + b;
int Multiply(int a, int b) => a * b;
Operation op = Add;
Console.WriteLine(op(3, 4)); // 7
op = Multiply;
Console.WriteLine(op(3, 4)); // 12
op can hold a reference to any method matching the signature
int (int, int). Reassigning op changes which method actually runs when
you call op(...).
Func, Action, Predicate — built-in generic delegates¶
You rarely declare custom delegate types today; the BCL provides generic ones for almost every shape:
Func<int, int, int> add = (a, b) => a + b; // returns a value
Action<string> log = msg => Console.WriteLine(msg); // returns void
Predicate<int> isEven = n => n % 2 == 0; // returns bool
Console.WriteLine(add(2, 3)); // 5
log("hello"); // hello
Console.WriteLine(isEven(4)); // True
Func<T1, ..., TResult> — last type parameter is the return type.
Action<T1, ...> — no return value. Predicate<T> — shorthand for
Func<T, bool>, mainly seen in older APIs like List<T>.Find.
Multicast delegates¶
A delegate can hold more than one method — invoking it calls all of them in order:
Action<string> notify = msg => Console.WriteLine($"Email: {msg}");
notify += msg => Console.WriteLine($"SMS: {msg}");
notify += msg => Console.WriteLine($"Push: {msg}");
notify("Order shipped");
// Email: Order shipped
// SMS: Order shipped
// Push: Order shipped
Func<T, TResult> delegates can also be multicast, but if you invoke one
directly you only get the last method's return value — the others still
run, their results are just discarded. This is why multicasting is used
almost exclusively with Action/void delegates.
Events¶
An event is a controlled wrapper around a multicast delegate: only the
declaring class can raise it, while any external code can subscribe
(+=) or unsubscribe (-=), but never call it directly or replace the
whole subscriber list with =.
public class Order
{
public event Action<string>? Shipped;
public void MarkShipped()
{
Console.WriteLine("Order marked as shipped.");
Shipped?.Invoke("Order #1024"); // raise the event, if anyone subscribed
}
}
var order = new Order();
order.Shipped += trackingId => Console.WriteLine($"Notify customer: {trackingId}");
order.Shipped += trackingId => Console.WriteLine($"Update warehouse: {trackingId}");
order.MarkShipped();
// Order marked as shipped.
// Notify customer: Order #1024
// Update warehouse: Order #1024
Shipped?.Invoke(...) guards against the case where nobody has subscribed
yet (Shipped would be null); the ?. skips the call entirely rather
than throwing NullReferenceException.
The standard EventHandler pattern¶
.NET's own APIs (WinForms, WPF, ASP.NET) conventionally use
EventHandler/EventHandler<TEventArgs> with a sender and an event-args
object:
public class TemperatureChangedEventArgs : EventArgs
{
public double NewTemperature { get; }
public TemperatureChangedEventArgs(double newTemperature) => NewTemperature = newTemperature;
}
public class Thermostat
{
public event EventHandler<TemperatureChangedEventArgs>? TemperatureChanged;
public void SetTemperature(double value)
{
TemperatureChanged?.Invoke(this, new TemperatureChangedEventArgs(value));
}
}
var thermostat = new Thermostat();
thermostat.TemperatureChanged += (sender, e) =>
Console.WriteLine($"New temperature: {e.NewTemperature}");
thermostat.SetTemperature(21.5);
// New temperature: 21.5
| Concept | Meaning |
|---|---|
delegate |
Type-safe reference to a method matching a signature |
Func<..., TResult> |
Built-in delegate that returns a value |
Action<...> |
Built-in delegate that returns void |
| Multicast delegate | Holds a chain of methods, invoked in order |
event |
Restricted delegate field — subscribe/unsubscribe only from outside |
?.Invoke(...) |
Null-safe way to raise an event |
How It Actually Works¶
- A delegate instance is a small object holding a method pointer and a
target reference.
Operation op = Addallocates aDelegate-derived object on the heap with two key fields: the method's compiled-code entry point, and (for instance methods) a reference to the target object the method should run against — this is howop(3, 4)knows both what code to jump to and whichthisto use, if any. Astaticmethod target likeAddleaves that second field null. Assigningop = Multiplyisn't mutating the delegate — it creates a brand-new delegate object and rebinds the variable to it, since delegates are immutable once created. - Multicasting is implemented as a linked list of single-cast delegates,
wrapped in a
MulticastDelegate.notify += ...doesn't append to some internal array — it allocates a newMulticastDelegatewhose invocation list is the old list plus the new delegate, and reassignsnotifyto point at it (delegates being immutable, exactly likestringconcatenation building a new string rather than mutating in place). Invoking a multicast delegate walks that list and calls each entry in order, synchronously, on the calling thread — which is exactly why aFunc<T,TResult>multicast only surfaces the last method's return value: the CLR's generated invoke loop simply discards every intermediate result except the final one. eventis a language-level access restriction over an ordinary delegate field, enforced by the compiler, not the CLR. Underpublic event Action<string>? Shipped;, the compiler generates a private backing delegate field plusadd_Shipped/remove_Shippedaccessor methods (again thespecialnamepattern from properties). Code insideOrdercan useShippedas a plain field (including callingInvokeon it directly); code outside the class can only call+=/-=, which the compiler routes through those accessor methods — there is no way to reach the raw field or call=to replace the whole list from outside, because the compiler simply refuses to emit that access, not because the CLR blocks it.?.Invoke(...)reads the delegate field into a local exactly once before checking null and invoking — this null-safety pattern exists specifically because a multi-threaded unsubscribe (-=) between the null check and the call could otherwise race and null out the field mid-call; capturing it into a temporary first (which is what?.compiles to) avoids that particularNullReferenceExceptionrace.
Exercise¶
Build a Stopwatch-like Timer class with an event Action<int> Tick that
fires once per second (simulate this with a loop calling a private method,
not System.Threading.Timer) and an event Action Finished that fires
after 5 ticks. Subscribe to both events from outside the class and print
each tick count plus a "Done!" message when it finishes.