Delegates, Events & Weak Events
Key Points
- A delegate is a typed function pointer;
Action/Funcare pre-defined. Multicast delegates chain via+=; invocation runs handlers in subscription order. - Events are syntax sugar over delegates with restricted access (only
+=/-=outside the declaring type) and the canonicalEventHandler<TEventArgs>signature. - The #1 event bug: subscribing a long-lived publisher to a short-lived subscriber. The publisher's delegate roots the subscriber → memory leak. Always
-=symmetrically. - Weak event pattern breaks the strong reference: store handlers via
WeakReferenceso subscribers can be GC'd. UseConditionalWeakTable<TKey,TValue>for table-backed associations. - Modern alternatives:
IObservable<T>/IAsyncEnumerable<T>,Channel<T>,IMessageBuspatterns. Events are still right for in-process notifications with no async or backpressure needs.
Concepts (deep dive)
Delegate types
delegate int BinaryOp(int a, int b); // declared
Action<string> log = Console.WriteLine; // void return, params
Func<int, int, int> add = (a, b) => a + b; // generic
Predicate<int> isEven = n => n % 2 == 0;
Action / Func cover 99% of cases; declare a custom delegate only for clarity (e.g., EventHandler<T>).
Variance
Func<object> objFactory = () => "hi"; // covariant return
Action<string> stringSink = (object o) => { }; // contravariant param — assignment from Action<object>
In/out variance applies on interface and delegate generic parameters. Action<in T> is contravariant in T; Func<out T> is covariant in T.
Multicast invocation
If a handler throws mid-list, subsequent handlers don't run by default. Iterate GetInvocationList() to swallow per-handler:
foreach (var d in a.GetInvocationList())
try { ((Action)d)(); } catch (Exception ex) { _log.Error(ex, "handler failed"); }
The event keyword
public event EventHandler<OrderSubmittedEventArgs>? OrderSubmitted;
protected virtual void OnOrderSubmitted(OrderSubmittedEventArgs e)
=> OrderSubmitted?.Invoke(this, e);
event only allows += and -= from outside the declaring type — callers can't Invoke() directly or null it.
Why events leak
Publisher (singleton, lives forever)
│ m_OrderSubmitted ──► [handler1, handler2, handler3]
│
▼
Subscriber (window, supposed to GC when closed)
✗ rooted by handler delegate ✗
Closing the window doesn't unsubscribe; the publisher's delegate list still holds a reference. Subscriber survives until the publisher dies.
Weak event pattern
public sealed class WeakEvent<TArgs>
{
private readonly List<WeakReference<EventHandler<TArgs>>> _handlers = new();
public void Subscribe(EventHandler<TArgs> h) => _handlers.Add(new(h));
public void Raise(object sender, TArgs args)
{
for (int i = _handlers.Count - 1; i >= 0; i--)
{
if (_handlers[i].TryGetTarget(out var h)) h(sender, args);
else _handlers.RemoveAt(i);
}
}
}
Caveat: lambdas often capture local variables and live as long as the closure object — usually the subscriber's owner. Use method group subscriptions or pin the delegate in the subscriber.
ConditionalWeakTable<TKey,TValue>
Hash table where keys are weakly held — when the key is collected, the entry vanishes. Used for attaching ad-hoc state to objects without rooting them (XAML uses this for attached properties).
Code: correct vs wrong
❌ Wrong: leak via lambda capture
There's no way to remove this handler — the lambda has no name.
✅ Correct: method group, symmetric unsubscribe
public void Show()
{
publisher.OrderSubmitted += OnOrderSubmitted;
}
public void Close()
{
publisher.OrderSubmitted -= OnOrderSubmitted; // exact same delegate
}
private void OnOrderSubmitted(object? s, OrderSubmittedEventArgs e) => Refresh();
❌ Wrong: race condition on raise
✅ Correct: null-conditional invoke
Design patterns for this topic
Pattern 1 — EventHandler<TEventArgs> convention
Intent: uniform shape (object? sender, TArgs args) so generic dispatch (data binding, logging) works.
public class OrderSubmittedEventArgs : EventArgs { public Guid OrderId { get; init; } }
public event EventHandler<OrderSubmittedEventArgs>? OrderSubmitted;
Pattern 2 — Disposable subscription
Intent: make unsubscribe automatic.
public IDisposable Subscribe(Action<TArgs> h)
{
OnRaised += h;
return new Subscription(() => OnRaised -= h);
}
Caller: using var s = bus.Subscribe(OnEvent);
Pattern 3 — Weak event manager (WPF-style)
Intent: subscribers can be collected without explicit unsubscribe. Cost: slight per-raise overhead.
Pattern 4 — Channel/Observable replacement
Intent: when handlers are async or need backpressure, drop events for Channel<T> or IObservable<T> (Rx).
private readonly Channel<OrderSubmitted> _bus = Channel.CreateBounded<OrderSubmitted>(1024);
public ChannelReader<OrderSubmitted> Reader => _bus.Reader;
Pattern 5 — EventArgs derived class with init properties
public sealed class PriceChangedEventArgs(decimal oldPrice, decimal newPrice) : EventArgs
{
public decimal OldPrice { get; } = oldPrice;
public decimal NewPrice { get; } = newPrice;
}
Immutable, no allocations beyond the args object itself.
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
event | Built-in, IDE-aware, zero deps | Sync only, leak-prone, no backpressure |
Action/Func field | Simpler, no event semantics | No access protection — anyone can null/invoke |
| Weak event | No leaks | Per-raise overhead, needs per-subscriber care for closures |
IObservable<T> | Composition, backpressure, async | Reactive Extensions dep, learning curve |
Channel<T> | Backpressure, async, multi-consumer | More plumbing |
When to use / when to avoid
- Use
eventfor in-process synchronous notification with stable subscription lifetimes (configuration changes, in-app navigation events). - Use weak events in long-lived publishers + many short-lived subscribers (UI frameworks).
- Avoid events for cross-process, async fan-out, or anywhere a subscriber may take "a while" — those want a queue or pub/sub bus.
Interview Q&A
Q1. Difference between a delegate and an event? A. An event is a pair of add/remove accessors that wraps a private delegate field, restricting external mutation to +=/-=. The delegate field is the actual handler list; the event is the public API.
Q2. Why does subscribing leak memory? A. The publisher's multicast delegate roots each subscriber. If the publisher outlives the subscriber and -= is never called, the subscriber can't be collected.
Q3. Why prefer method-group subscriptions over lambdas? A. Method groups produce a stable delegate identity reusable for -=. Lambdas allocate a unique delegate per +=, and you can't easily remove them later.
Q4. What's the canonical thread-safe raise? A. MyEvent?.Invoke(this, args); — the null-conditional captures the field into a temp atomically. Alternative: Volatile.Read on a private backing field.
Q5. How do you continue raising when one handler throws? A. Iterate GetInvocationList() and try/catch each invocation.
Q6. What's a WeakReference<T> and when do you use it? A. A reference that doesn't prevent GC of the target. Used in caches, weak event handlers, and observers where subscriber lifetime is shorter than publisher.
Q7. When is IObservable<T> better than event? A. When you want operators (Where, Throttle, Buffer), composition across sources, or async observers with backpressure. WPF/UI frameworks live happily with events; pipelines like trading or sensor data prefer Rx.
Q8. What does [field: NonSerialized] do on an event? A. Tells the binary serializer to skip the auto-generated backing field. Used on [Serializable] types so events don't try to serialize handler closures from foreign assemblies.
Gotchas / common mistakes
- Subscribing in a constructor without unsubscribing in
Dispose(). - Using lambdas you can't
-=later. - Async
voidevent handlers — exceptions crash the process. - Race between raise and unsubscribe (handle by taking a snapshot:
var h = MyEvent; h?.Invoke(...)). - Marking event delegates
[ThreadStatic]— almost never what you want. - Forgetting that
event EventHandlerdefaults tonullin C#; use?annotation under NRT. - Long-running synchronous handlers blocking the publisher.