cswork / libraryOpen practice workspace

Articles

C# Delegates and Events Interview Questions: Invocation and Subscription Lifetimes

Practice C# delegates and events interview questions with invocation traces, exception policies, subscription ownership, disposal, and async handler pitfalls.

C# delegates and events interview questions become easier when you trace three contracts: who can invoke the callback, what counts as completed delivery, and who owns the subscription. A correct += statement can still leave a closed view alive, and a successful event raise can still leave asynchronous work unfinished.

Official language boundary: an event has a delegate type, but callers outside its declaring type normally receive subscription access rather than permission to invoke or replace its backing delegate. Microsoft's event reference describes this access boundary. The exercises below use ordinary field-like events.

What is the difference between a delegate and an event?

A delegate instance represents callable methods compatible with a specific delegate type. An instance-method entry also identifies its target object. The C# delegate specification defines these mechanics.

Use Action<T> when a callback returns no value and Func<T, TResult> when it returns a result. A custom delegate can express a domain-specific signature. Choosing a name does not establish an error policy or make the callback asynchronous.

Consider public Action? Changed; versus public event Action? Changed;. With the public delegate field, an external caller can assign a replacement, invoke it, or clear it. The event declaration narrows that public interface to adding and removing handlers. The publisher decides when to raise its notification.

That distinction matters in code review: a subscriber should not accidentally erase another subscriber by assigning its own callback. For custom add and remove accessors, inspect their implementation; the field-like storage assumptions used in this article are not a universal promise about every event.

In what order do multicast handlers execute?

Official invocation behavior: ordinary multicast delegate invocation follows the invocation list synchronously. An uncaught exception interrupts that invocation, leaving later entries uncalled. See the MulticastDelegate reference.

Here is an original trace exercise. It assumes nullable reference types are enabled. The snippets were checked against documentation and reviewed as source; they were not compiled or executed locally because this workspace has no .NET compiler.

using System;
using System.Collections.Generic;

var trace = new List<string>();
Action? handlers = null;
handlers += () => trace.Add("A");
handlers += () => {
    trace.Add("B");
    throw new InvalidOperationException("B failed");
};
handlers += () => trace.Add("C");

try { handlers?.Invoke(); }
catch (InvalidOperationException) { trace.Add("caught"); }

The expected trace is A, B, caught. C is skipped. The outer catch observes the failure after invocation has stopped; it does not resume delivery at C. State this before suggesting a blanket exception handler.

Now change the requirement: every independent observer should get a chance to run, and failures should be reported afterward. That is a different delivery contract, requiring an explicit loop rather than relying on ordinary multicast invocation.

How would you continue delivery after one handler fails?

The GetInvocationList API returns the delegate entries in invocation order. This original extension captures a list, invokes each entry separately, and aggregates failures:

var errors = new List<Exception>();
if (handlers is not null) {
    foreach (Action handler in handlers.GetInvocationList()) {
        try { handler(); }
        catch (Exception error) { errors.Add(error); }
    }
}
if (errors.Count != 0) throw new AggregateException(errors);

Applied instead of the previous invocation, its expected handler trace is A, B, C, followed by an aggregate failure. Catching broad exceptions here is a deliberate observer-delivery policy, not a recommendation to swallow failures throughout the application.

Original exercise policyHandler attemptsCaller outcome
Ordinary invocation, B throwsA, then B; C skippedB's exception propagates
Per-entry continue and aggregateA, B, then CAggregate failure after attempts
Retry the whole ordinary invocationA and B run againEarlier side effects can repeat

Ask whether A already wrote a file or changed state before B failed. Retrying the whole notification can duplicate A's effect. If the event announces an already committed change, a delivery error also does not automatically roll that change back. Separate observer failure from business-operation failure.

What does a multicast delegate return?

Official return rule: if invocation completes normally, a non-void multicast delegate returns the final entry's result. It does not return a collection of every result. The delegate specification also explains shared reference parameters.

In an original exercise, combine two Func<int> entries returning 7 and 12. Both are invoked, but the returned value is 12. That is not an average, a vote, or evidence that the first entry never ran.

If several observers must approve a request, design a result-collection policy: evaluate every response, stop on a rejection, or combine responses under an explicit rule. A multicast function with an accidental “last subscriber wins” contract is a poor substitute for that design.

Why can a subscription keep a closed view alive?

Official lifetime guidance: the publisher's event delegate can retain the target of a subscribed instance method. A reachable long-lived publisher can therefore keep that subscriber reachable. Microsoft's subscription guide recommends unsubscribing when the subscriber's lifetime ends.

Trace the original scenario: an application service survives all windows; each window subscribes its own OnChanged; closing a window removes it from the visible interface but never removes the handler. Later notifications still reach old window objects.

Draw the relevant reference path: rooted service → event invocation list → delegate target → window. The fact that the window is hidden says nothing about that path. Conversely, an unreachable publisher/subscriber cycle alone is not proof of a leak; the root and lifetime mismatch matter.

An event is not inherently a memory leak. The bug is a subscription whose lifetime exceeds its intended owner. Diagnose the retaining path before adding weak references, forcing collections, or blaming the garbage collector.

How do you make subscription ownership explicit?

This original minimal repair gives one subscriber responsibility for one subscription. It is intentionally single-threaded; disposal, notification, and construction are serialized by the owner.

using System;

public sealed class Publisher {
    public event EventHandler? Changed;
    public void Raise() => Changed?.Invoke(this, EventArgs.Empty);
}

public sealed class Listener : IDisposable {
    private readonly Publisher publisher;
    private bool disposed;
    public int Notifications { get; private set; }

    public Listener(Publisher publisher) {
        this.publisher = publisher;
        publisher.Changed += OnChanged;
    }
    private void OnChanged(object? sender, EventArgs args) {
        Notifications++;
    }
    public void Dispose() {
        if (disposed) return;
        publisher.Changed -= OnChanged;
        disposed = true;
    }
}

An expected sequence is: construct listener, raise once, dispose twice, raise again. The counter remains one. The second disposal does nothing, and the later raise has no entry for this listener. Verify that sequence in your target .NET test project; this article's source review is not runtime proof.

Tie disposal to an actual owner: a view's close path, a component's teardown, or a service scope. A finalizer is a poor substitute for removing a reference that itself prevents collection. If several registrations are possible, the owner must account for each one rather than assume one removal clears them all.

Why do duplicate subscriptions and rewritten lambdas surprise people?

For an ordinary invocation list, adding the same handler twice creates two entries. Removing it once removes the last matching occurrence, leaving one. The Delegate.Remove reference defines removal of the last matching list occurrence.

Use the original ledger += H, += H, raise, -= H, raise. Expect two calls followed by one. This can explain duplicate UI updates after a component subscribes on every activation without balancing deactivation.

A newly written lambda expression is not a reliable way to identify an earlier registration. Store the delegate you subscribe, such as EventHandler handler = (s, e) => Refresh();, then use that saved variable with both += and -=. The subscription guide explicitly discusses retaining anonymous handlers when later removal is required.

For a named instance method, the relevant match includes the target instance. Removing otherView.OnChanged does not remove thisView.OnChanged merely because the method name is the same. Check the registered identity and registration count together.

Does ?.Invoke make the entire event thread-safe?

Official null-check behavior: Changed?.Invoke(...) evaluates the delegate once and avoids invoking a null delegate after a separate check. It behaves like using a captured local reference. Microsoft's member-access reference explains this narrow guarantee.

It does not synchronize the handlers' state or promise that a removed handler cannot run from a previously captured list. In an original timeline, thread A captures H, thread B unsubscribes H, and thread A invokes its captured H. Removing the current subscription did not cancel A's earlier snapshot.

If teardown requires “no callback after close returns,” define stronger coordination: serialize delivery and closing on the same owner, or track active invocations and wait for them to drain. Consider callbacks that close themselves or re-enter the publisher before designing a lock. Holding an arbitrary application lock while invoking unknown handlers can create another problem.

The minimal Listener example makes no concurrent-disposal guarantee. Adding a boolean flag without deciding synchronization and ordering would not establish one either.

Can a Task-returning multicast delegate await every handler?

Official async distinction: async void cannot be awaited through its call, and exceptions are not observed like faults on an awaited Task. Ordinary event handlers often use a void signature; see Microsoft's async return-types guide.

Changing the delegate to Func<Task> does not automatically collect tasks. Under the final-result rule, ordinary multicast invocation returns only the final handler's Task. Earlier handlers may have returned unfinished tasks too. Awaiting that last result is not proof that all work completed.

For an original sequential contract, enumerate the captured entries and await each one. Decide whether a failure stops delivery or is collected before continuing. For a parallel contract, explicitly collect tasks and await their completion together, also accounting for handlers that throw before returning a task.

Choose ordering, cancellation, and failure behavior before implementing either approach. If durable delivery, replay, or cross-process acknowledgment is required, those are additional system contracts; a language event alone does not supply them.

Sources and Further Reading