Skip to content

Delegates, Events & Weak Events

Key Points

  • A delegate is a typed function pointer; Action/Func are 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 canonical EventHandler<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 WeakReference so subscribers can be GC'd. Use ConditionalWeakTable<TKey,TValue> for table-backed associations.
  • Modern alternatives: IObservable<T>/IAsyncEnumerable<T>, Channel<T>, IMessageBus patterns. 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

Action a = () => Console.WriteLine("A");
a += () => Console.WriteLine("B");
a();   // prints A then B

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

publisher.OrderSubmitted += (s, e) => this.Refresh(); // 'this' captured forever

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

if (OrderSubmitted != null)            // can become null between check and invoke
    OrderSubmitted(this, args);

✅ Correct: null-conditional invoke

OrderSubmitted?.Invoke(this, args);   // atomic — captures the field once

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 event for 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 void event 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 EventHandler defaults to null in C#; use ? annotation under NRT.
  • Long-running synchronous handlers blocking the publisher.

Further reading