Delegates and Events
By the end of this lesson
Pass behaviour as a value and publish notifications to subscribers.
You already pass numbers and objects into methods. A delegate lets you pass a method the same way. It is a variable whose value is a method — you can store one, hand it to another method, keep a list of them, and call whatever is in it without knowing what it is.
Why would you want that? Because sometimes the caller knows the rule and the callee knows the process. A checkout knows how to total an order; it does not know which discount your marketing team invented this week. Handing the discount in as a value keeps the checkout out of that argument.
// A delegate type describes a shape: what goes in, what comes out.
public delegate decimal DiscountRule(decimal orderTotal);
public class Checkout
{
public decimal FinalTotal(decimal total, DiscountRule rule) => rule(total);
}
public static class DiscountRules
{
public static decimal None(decimal total) => total;
public static decimal TenPercentOverFiveThousand(decimal total) =>
total > 5_000m ? total * 0.9m : total;
}
Checkout checkout = new Checkout();
// Note: no parentheses. This stores the method, it does not call it.
DiscountRule rule = DiscountRules.TenPercentOverFiveThousand;
Console.WriteLine(checkout.FinalTotal(6_000m, rule)); // 5400
Console.WriteLine(checkout.FinalTotal(6_000m, DiscountRules.None)); // 6000
// In practice you would use the built-in type instead of declaring your own.
Func<decimal, decimal> sameThing = DiscountRules.TenPercentOverFiveThousand;
Console.WriteLine(sameThing(6_000m)); // 5400- public delegate decimal DiscountRule(decimal orderTotal); declares a type, not a method. It says: any method taking one decimal and returning a decimal fits here.
- DiscountRules.TenPercentOverFiveThousand with no parentheses is the method itself. Adding parentheses would call it and store the result instead, which is a common early slip.
- rule(total) inside FinalTotal calls whatever was passed in. The checkout has no idea which rule it is running.
- Func<decimal, decimal> is a delegate type the libraries already provide. The last type argument is the return type; the ones before it are the parameters. Because of Func and Action, writing your own delegate type is now uncommon — do it when the name genuinely helps, as DiscountRule arguably does.
- Compare this with defining an IDiscountRule interface with one method. Both work. The delegate is lighter when the abstraction is one operation; the interface is better when implementations need state or several related methods.
The vocabulary, now that you have seen the mechanism:
- Func<...>
- A delegate that returns a value. Func<Order, decimal> takes an order and returns a decimal.
- Action<...>
- A delegate that returns nothing. Action<string> takes a string and returns void.
- Predicate<T>
- An older name for a method returning bool. Func<T, bool> means the same and is more common now.
- Multicast delegate
- One delegate variable can hold several methods. += adds one, -= removes one, and invoking it calls them all in the order they were added.
- Invocation list
- The list of methods inside a delegate. This is what += and -= modify.
- event
- A keyword that wraps a delegate and restricts outside code to += and -=. Callers cannot invoke it, and cannot replace the whole list with =. Only the declaring type can raise it.
- EventHandler<TEventArgs>
- The conventional delegate shape for events: (object? sender, TEventArgs e). Following it means your events look like every other event in .NET.
public class OrderSubmittedEventArgs : EventArgs
{
public OrderSubmittedEventArgs(string reference, decimal total)
{
Reference = reference;
Total = total;
}
public string Reference { get; }
public decimal Total { get; }
}
public class OrderService
{
// Outside code may only += and -=. It cannot raise this or clear it.
public event EventHandler<OrderSubmittedEventArgs>? OrderSubmitted;
public void Submit(string reference, decimal total)
{
// ... save the order first ...
// ?. because the event is null until somebody subscribes.
OrderSubmitted?.Invoke(this, new OrderSubmittedEventArgs(reference, total));
}
}
public class WarehouseNotifier
{
public void Watch(OrderService service)
{
service.OrderSubmitted += OnOrderSubmitted;
}
private void OnOrderSubmitted(object? sender, OrderSubmittedEventArgs e)
{
Console.WriteLine($"Picking list requested for {e.Reference}");
}
}- The event is declared nullable because a delegate with no subscribers is null, not an empty list. That is the single most common source of crashes in event code.
- ?.Invoke(...) reads the event once and calls it only if it is not null. Writing if (OrderSubmitted != null) OrderSubmitted(...) looks equivalent and is not: with more than one thread, the last subscriber can detach between the check and the call.
- this is passed as the sender so a handler can tell which object raised the event. The details go in the EventArgs subclass, where they are read-only.
- The subscriber attaches its own private method. Nothing in OrderService knows that WarehouseNotifier exists, which is the point — the publisher announces, and who listens is somebody else's decision.
- Invocation is synchronous. Submit does not return until every handler has finished, so a slow handler slows down order submission.
public sealed class OrderAuditLog : IDisposable
{
private readonly OrderService _service;
public OrderAuditLog(OrderService service)
{
_service = service;
_service.OrderSubmitted += Record; // subscribe
}
private void Record(object? sender, OrderSubmittedEventArgs e)
{
Console.WriteLine($"Audit: {e.Reference} for {e.Total:N2}");
}
public void Dispose()
{
_service.OrderSubmitted -= Record; // and, equally important, unsubscribe
}
}
// The trap. These two lambdas look identical and are two different methods.
service.OrderSubmitted += (sender, e) => Console.WriteLine(e.Reference);
service.OrderSubmitted -= (sender, e) => Console.WriteLine(e.Reference); // removes nothing
// Keep a reference if a lambda subscription needs to be removed later.
EventHandler<OrderSubmittedEventArgs> handler = (sender, e) => Console.WriteLine(e.Reference);
service.OrderSubmitted += handler;
service.OrderSubmitted -= handler; // this one works- Holding the publisher in a field is what makes unsubscribing possible. A subscriber that cannot reach the publisher later has no way to detach.
- IDisposable gives the subscription an explicit end. In a using block or a framework that disposes its components, Dispose is where the -= belongs.
- -= matches on the target object and the method. Two separately written lambdas compile to two distinct methods, so removal silently does nothing — no exception, no warning, and the subscription stays.
- Assigning the lambda to a variable first gives you the one thing -= needs: the same delegate instance to remove.
- If you cannot control the subscriber's lifetime at all, the remaining options are a weak-reference event pattern or an explicit subscription object handed back by the publisher. Both are more machinery than most code needs, so reach for them when the ownership problem is real rather than by default.
Summary
- A delegate is a variable holding a method, which lets a caller pass in behaviour rather than data
- Func and Action cover almost every shape, so declaring your own delegate type is now occasional
- An event wraps a delegate and limits outside code to += and -=, keeping the right to raise it with the publisher
- A subscription is a reference: it keeps the subscriber alive for as long as the publisher lives, and the handler keeps running
- Every += needs a matching -=, and a lambda can only be removed if you kept the delegate in a variable
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Write a StockService that raises a StockRanLow event when a product's quantity falls below its reorder level. Give the event args the SKU and the remaining quantity.
Then write two subscribers: one that writes to the console and one that counts how many times it fired. Attach both, trigger the event, and detach one.
Show solution
The event is raised inside the method that changes the quantity, after the change has been made. Raising it before would announce something that has not happened yet, and a handler reading the service would see stale values.
Two subscribers on one event is a multicast delegate. Both are called, in the order they attached, on the thread that called Dispatch.
The counter subscriber is written as a class with a method rather than a lambda for one reason: the method can be passed to -=. Had it been a lambda, detaching it would need the delegate stored in a variable first. That constraint on lambdas is worth internalising before you write event code you will later need to unwind.
Note what the service does not know. It has no reference to either subscriber type, no interface for them, and no list to maintain. Adding a third listener requires no change to StockService at all.
public class StockRanLowEventArgs : EventArgs
{
public StockRanLowEventArgs(string sku, int remaining)
{
Sku = sku;
Remaining = remaining;
}
public string Sku { get; }
public int Remaining { get; }
}
public class StockService
{
public event EventHandler<StockRanLowEventArgs>? StockRanLow;
public void Dispatch(Product product, int quantity)
{
product.Dispatch(quantity);
if (product.QuantityInStock < product.ReorderLevel)
{
StockRanLow?.Invoke(this, new StockRanLowEventArgs(product.Sku, product.QuantityInStock));
}
}
}
public class LowStockCounter
{
public int Alerts { get; private set; }
public void OnStockRanLow(object? sender, StockRanLowEventArgs e) => Alerts++;
}
StockService service = new StockService();
LowStockCounter counter = new LowStockCounter();
void WriteAlert(object? sender, StockRanLowEventArgs e) =>
Console.WriteLine($"{e.Sku} down to {e.Remaining}");
service.StockRanLow += WriteAlert;
service.StockRanLow += counter.OnStockRanLow;
service.Dispatch(deskLamp, 10); // both handlers run
service.StockRanLow -= WriteAlert; // a named method, so this works
service.Dispatch(deskLamp, 1); // only the counter runs nowThink about it
Think about it
An application has a single long-lived NotificationHub with a public event. Screens subscribe when they open. Nobody unsubscribes, on the assumption that closing a screen is the end of its life.
Describe what the memory graph looks like after a user has opened and closed forty screens, and what else goes wrong besides memory use.
Show solution
The hub's event holds forty handler references, each pointing at a screen object. Nothing else in the program refers to those screens, but the hub does, and one live reference is enough. All forty stay in memory, along with whatever each one loaded — a list of orders, a cached image, a reference back to the hub. The graph is a hub with forty dead branches that cannot be pruned.
The second problem is behavioural, and it usually surfaces first. Every notification is delivered to all forty handlers. Screens that a user closed an hour ago are still reacting: updating fields nobody can see, and in the worst case writing to a database or showing a message box from a screen that no longer exists.
Performance degrades in proportion to use, which makes the symptom confusing. The application is fine for a few minutes and slow after an hour, and restarting it clears the evidence.
The fix is for each screen to detach in whatever teardown method it has — Dispose, an unload handler, a closing event. Because the discipline is easy to forget, it helps to make the subscription return something disposable, so the compiler and the code review both have something to point at.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.