A delegate variable doesn't have to hold just one method — it can hold a whole list of them, and call every one.
In the last lesson you defined a delegate type and pointed a single variable at a single method. That's already useful — but real applications often need more: several independent pieces of code reacting to the same thing.
Think about saving a document. You might want to: write the file to disk, update the "last saved" timestamp in the UI, and log the save for auditing. Three unrelated actions, all triggered by the same moment. Do you really want Save() to know about the UI and the logger and the disk, all mixed together?
C# delegates have a feature built in for exactly this: a single delegate variable can reference more than one method at once, and invoking it runs all of them in order. This is called a multicast delegate, and it's the mechanism that makes C# events possible.
A custom delegate is simply a delegate type you declare yourself, with a signature that matches your specific need — you saw this already with MathOperation. A multicast delegate is a delegate variable holding not one method, but a chain of them, all with the same signature. Invoking it calls each one, in the order they were added.
Every delegate type you declare in C# actually derives from System.MulticastDelegate, which itself derives from System.Delegate. This means every delegate you create can hold multiple methods in its invocation list — a single method is just the special case of an invocation list with one entry.
You build up that list with the += operator (add a method) and shrink it with -= (remove a method):
public delegate void Notify(string message);
Notify onSave = LogSave;
onSave += UpdateUi;
onSave += AuditSave;
onSave("Document saved."); // calls LogSave, then UpdateUi, then AuditSave
Without multicast delegates, "notify several unrelated pieces of code that something happened" means either:
Save() now has to know about logging, UI, and auditing directly).List<SomeDelegate> and looping over it manually to call each one.Multicast delegates give you that "list of subscribers" behavior for free. += and -= are just operator overloads that add to or remove from the invocation list — no manual list management required. This is exactly the plumbing that C#'s event keyword (covered in lesson 105) builds on: an event is a multicast delegate with extra access restrictions.
Multicast delegates let one action trigger many independent reactions, without any of those reactions knowing about each other, and without the triggering code knowing what they do. This is the seed of publish/subscribe — a pattern you'll use constantly once you reach events.
LogSaveUpdateUiAuditSavepublic delegate void Notify(string message);
Notify onSave = null;
onSave += LogSave;
onSave += UpdateUi;
+= on a null delegate works fine — it initializes the invocation list+= appends the method to the end of the listonSave("Document saved.");
// LogSave runs, then UpdateUi runs
onSave -= UpdateUi;
onSave("Document saved."); // only LogSave runs now
using System;
public delegate void Notify(string message);
class Program
{
static void LogSave(string message) => Console.WriteLine($"[LOG] {message}");
static void UpdateUi(string message) => Console.WriteLine($"[UI] Showing: {message}");
static void AuditSave(string message) => Console.WriteLine($"[AUDIT] Recorded: {message}");
static void Main()
{
Notify onSave = LogSave;
onSave += UpdateUi;
onSave += AuditSave;
onSave("Document saved.");
// [LOG] Document saved.
// [UI] Showing: Document saved.
// [AUDIT] Recorded: Document saved.
onSave -= UpdateUi; // stop updating the UI
Console.WriteLine("--- after removing UpdateUi ---");
onSave("Document saved again.");
// [LOG] Document saved again.
// [AUDIT] Recorded: Document saved again.
}
}
Code → Meaning → Result: Each += appends a method to onSave's invocation list. A single call, onSave(...), fans out to every subscriber currently on the list, in the order they were added. Removing a subscriber with -= takes it out of future invocations without touching the others.
Consider a background job runner that needs to report progress to multiple listeners — a console logger, a progress bar, and a metrics collector — without knowing any of them exist.
using System;
public delegate void ProgressHandler(int percentComplete);
public class FileImportJob
{
public ProgressHandler? OnProgress; // multicast delegate field
public void Run()
{
for (int percent = 0; percent <= 100; percent += 25)
{
// simulate work...
OnProgress?.Invoke(percent); // notify every subscriber, safely
}
}
}
class Program
{
static void Main()
{
var job = new FileImportJob();
job.OnProgress += p => Console.WriteLine($"Console: {p}% done");
job.OnProgress += p => Console.Title = $"Importing... {p}%";
job.OnProgress += RecordMetric;
job.Run();
}
static void RecordMetric(int percent)
{
if (percent == 100)
Console.WriteLine("Metrics: import completed.");
}
}
Notice OnProgress?.Invoke(percent) — the null-conditional operator guards against the field still being null if nobody has subscribed yet, avoiding the classic NullReferenceException from the previous lesson's "Common Mistakes." This pattern is everywhere in real event-driven code.
A single-method delegate is like a letter addressed to one person. A multicast delegate is a mailing list: you += to subscribe an address, -= to unsubscribe, and sending one message (invoking the delegate) delivers a copy to every address currently on the list — in signup order.
The sender doesn't maintain the list of subscribers by hand, and doesn't know or care who's on it. That's exactly the decoupling multicast delegates provide.
Delegate instance in .NET is immutable — += doesn't mutate the existing object.onSave += UpdateUi creates a brand-new multicast delegate instance whose invocation list is the old list plus the new method, and assigns it back to onSave.+= and -= only work as an assignment (x += y, shorthand for x = x + y) — you're always creating and reassigning, never editing in place.onSave.GetInvocationList() returns a Delegate[] — one entry per subscribed method, in call order.void return type, invoking it directly only gives you the return value of the last subscriber in the list — the others' return values are discarded (you'd have to use GetInvocationList() to inspect them all).It looks like mutation, but as explained above, each += produces a new delegate object under the hood. This matters if you ever capture a delegate reference in another variable before adding a subscriber — that older reference still points at the shorter, original invocation list.
Because invocation is a simple sequential loop, a throwing subscriber stops the chain — subscribers registered after it never run. If you need every subscriber to run regardless of failures, you must call GetInvocationList() and invoke each one yourself inside a try/catch.
-= removes the last matching entry from the invocation list based on target + method — if the method you're trying to remove isn't in the list, -= is a silent no-op, not an error.
Assuming result = multicastFunc(x) reflects all subscribers when only the last one's return value actually comes through.
For multicast scenarios, prefer void-returning delegates (or events, covered soon). If you truly need every result, call GetInvocationList() and collect each one explicitly.
Adding a subscriber with += and never removing it when the subscribing object is no longer needed keeps that object alive (referenced) — a common source of memory leaks in long-lived publishers (more in lesson 106).
Pair every += with a matching -= once the subscriber no longer needs to listen.
Calling onSave("x") directly on a field that no one has subscribed to yet throws a NullReferenceException.
Use onSave?.Invoke("x") — it's a habit worth building now, before you reach events, where it's essential.
event rather than a bare public multicast field in real application code, but understanding the mechanism first makes events far less mysterious.Func<T> called once is simpler and clearer there.+= appends. -= removes.+= adds a method to the invocation list; -= removes it. Both create a new delegate instance rather than mutating the existing one.onSave?.Invoke(...)) when subscribers might not exist yet.event keyword is built on top of, coming up later in this module.You've seen how a single delegate can hold and invoke multiple methods. Let's check what you've learned.
1. What does the += operator do to a delegate variable?
Correct: B
Why B is correct: Delegate instances are immutable in .NET. += is shorthand for combining delegates with Delegate.Combine and reassigning the result to the variable.
Why A is incorrect: Nothing about the original delegate object is mutated; a new one is produced.
Why C is incorrect: That would discard existing subscribers, which is not what += does — it appends.
Why D is incorrect: += works on any delegate type, regardless of return type, though only the last subscriber's return value is observable through direct invocation.
Reinforcement: Delegate immutability is why += and -= must be assignments, not in-place mutations.
2. A multicast delegate has three subscribers: A, B, and C, added in that order. Subscriber B throws an exception when invoked. What happens?
Correct: B
Why B is correct: Multicast delegate invocation is a simple sequential loop on the calling thread. An unhandled exception in one subscriber stops the loop immediately and propagates out — subsequent subscribers are never reached.
Why A is incorrect: Nothing in the default invocation mechanism swallows exceptions; you'd need to invoke each subscriber yourself with individual try/catch blocks to get that behavior.
Why C is incorrect: Subscribers run sequentially on one thread by default, not in parallel.
Why D is incorrect: There is no automatic retry mechanism built into delegate invocation.
Reinforcement: If you need all subscribers to run regardless of individual failures, iterate GetInvocationList() and wrap each call yourself.
3. Why is onSave?.Invoke("message") generally preferred over onSave("message") for a multicast delegate field?
Correct: B
Why B is correct: If nobody has subscribed, the delegate field is still null. Calling it directly throws; the null-conditional operator short-circuits to doing nothing instead.
Why A is incorrect: The null-conditional operator has no effect on the sequential-vs-parallel execution model.
Why C is incorrect: It has nothing to do with exception handling inside subscribers.
Why D is incorrect: Return value capture behavior (only the last subscriber's result) is unaffected by null-conditional invocation.
Reinforcement: Guarding invocation with ?.Invoke(...) is standard practice for any delegate field that might have zero subscribers.
4. You need a delegate call that must return a value from every one of several subscribers, not just the last one. What should you do?
Correct: B
Why B is correct: GetInvocationList() exposes each subscriber as an individual delegate; invoking them separately lets you capture every return value.
Why A is incorrect: Direct invocation of a multicast delegate only surfaces the return value of the last subscriber called; the rest are discarded.
Why C is incorrect: It's entirely possible — it just requires the explicit invocation-list approach rather than a single direct call.
Why D is incorrect: Making the delegate void means there's no return value at all, which doesn't solve the stated problem.
Reinforcement: Direct invocation is convenient but lossy for non-void multicast delegates — GetInvocationList() is the escape hatch when every result matters.
You now understand multicast delegates — the exact mechanism behind C# events. Next, you'll learn about Action, the built-in delegate type that means you'll rarely need to declare a custom void-returning delegate again.
dotnetmadeeasy.com — Learn C# and .NET, the right way.