Deep Dive Into Delegates
Reviewed & published by Brayan K
By the end of this lesson you'll be able to chain several methods onto a single delegate, manage that invocation list with += and -=, compose Func pipelines, and avoid the two traps that bite everyone: multicast return values and closures over captured loop variables.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Build a multicast delegate and inspect its invocation list
- Add and remove targets with += / -= (Delegate.Combine and Remove)
- Understand why a multicast Func only returns the LAST result
- Compose Func<> delegates into reusable pipelines
- Explain closures — how a lambda captures variables, not values
- Tell delegates, lambdas, and method groups apart
💡 Real-World Analogy
A multicast delegate is a group chat. When you post one message, it fans out to everyone currently in the group — that ordered list of members is the invocation list. You can add a member (+=) or remove one (-=) at any time, and the next message goes to whoever's in the group right then. The twist: if you ask the group "what's 2+2?" and three people reply, you only hear the last answer — which is exactly why a multicast Func hands back only the final return value. And a closure is a member who keeps a live link to a shared whiteboard: if you update the whiteboard later, they read the new value, not the old one.
📊 Delegate Toolkit Quick Reference
| Tool | What it does | Example |
|---|---|---|
| += | Add a method to the chain | log += FileLog; |
| -= | Remove a method from the chain | log -= FileLog; |
| GetInvocationList() | Inspect every method in order | log.GetInvocationList() |
| Delegate.Combine | What += calls under the hood | Delegate.Combine(a, b) |
| Delegate.Remove | What -= calls under the hood | Delegate.Remove(a, b) |
| Func<int,int> | A delegate returning a value | x => x * 2 |
+= and -= are just friendly syntax for Delegate.Combine and Delegate.Remove — they build a brand-new combined delegate rather than mutating the old one.
1. Multicast Delegates & the Invocation List
In C#, every delegate is a multicast delegate — it can hold a whole list of methods, not just one. That ordered list is called the invocation list. You add a method with += and remove one with -=; when you call the delegate, every method in the list runs in turn, in the order they were added. You can peek at the list any time with GetInvocationList(). Read this worked example, run it, then you'll build a chain yourself.
using System;
// A delegate type for any "do something with a string" method.
delegate void Logger(string message);
class Program
{
static void ConsoleLog(string msg) => Console.WriteLine($"[CONSOLE] {msg}");
static void FileLog(string msg) => Console.WriteLine($"[FILE] {msg}"); // simulated
static void AlertLog(string msg) => Console.WriteLine($"[ALERT] {msg}");
static void Main()
{
// Every delegate in C# is MULTICAST: it can hold many methods at once.
Logger logger = ConsoleLog; // the chain now has 1 method
logger += FileLog; // += ADDS a second method
logger += AlertLog; // += ADDS a third
Console.WriteLine("=== All 3 run, in the order added ===");
logger("Application started");
// [CONSOLE] Application started
// [FILE] Application started
// [ALERT] Application started
Console.WriteLine("\n=== Remove AlertLog with -= ===");
logger -= AlertLog; // -= REMOVES one method
logger("User logged in");
// [CONSOLE] User logged in
// [FILE] User logged in
// The "invocation list" is the ordered list of methods inside.
Console.WriteLine($"\nMethods still in the chain: {logger.GetInvocationList().Length}"); // 2
foreach (var d in logger.GetInvocationList())
Console.WriteLine($" -> {d.Method.Name}"); // ConsoleLog, FileLog
}
}
// ✅ Expected output:
// === All 3 run, in the order added ===
// [CONSOLE] Application started
// [FILE] Application started
// [ALERT] Application started
//
// === Remove AlertLog with -= ===
// [CONSOLE] User logged in
// [FILE] User logged in
//
// Methods still in the chain: 2
// -> ConsoleLog
// -> FileLogYour turn. Build a multicast delegate from scratch: point it at one method, += a second, invoke it, then -= the second back off and invoke again. Fill in the three ___ blanks using the hints, then run it.
using System;
class Program
{
static void SayHi(string name) => Console.WriteLine($"Hi {name}!");
static void SayBye(string name) => Console.WriteLine($"Bye {name}!");
static void Main()
{
// 🎯 YOUR TURN — build a multicast delegate. Replace each ___.
// 1) Start the chain pointing at SayHi (note: NO parentheses).
Action<string> greeters = ___; // 👉 SayHi
// 2) ADD SayBye to the same delegate with the += operator.
greeters ___ SayBye; // 👉 +=
Console.WriteLine("Both run:");
greeters("Sam"); // runs SayHi AND SayBye
// 3) REMOVE SayBye again with the -= operator.
greeters ___ SayBye; // 👉 -=
Console.WriteLine("Only SayHi now:");
greeters("Sam"); // runs Sayhi only
// ✅ Expected output:
// Both run:
// Hi Sam!
// Bye Sam!
// Only SayHi now:
// Hi Sam!
}
}2. Multicast Return Values — the Last-One-Wins Trap
Here's the gotcha that surprises everyone. A Func<T> can be multicast just like an Action — but when you invoke it, all the methods run, yet you only get back the return value of the last one. Every earlier result is silently discarded. That's why multicast delegates are almost always built from Action (void) methods. If you genuinely need every result, walk the invocation list yourself.
using System;
class Program
{
static void Main()
{
// A Func<T> CAN be multicast too — but watch the return value.
Func<int> roll = () => 1;
roll += () => 2;
roll += () => 3;
// All three run, but you only get the LAST one's result back.
int result = roll();
Console.WriteLine($"roll() returned: {result}"); // 3 (1 and 2 are thrown away!)
// If you actually want every result, walk the invocation list yourself.
Console.WriteLine("\nAll results via GetInvocationList():");
foreach (Func<int> f in roll.GetInvocationList())
Console.WriteLine($" {f()}"); // 1, then 2, then 3
}
}
// ✅ Expected output:
// roll() returned: 3
//
// All results via GetInvocationList():
// 1
// 2
// 33. Composing Func Pipelines
Multicast chaining runs methods side-by-side and throws away their results — but often you want the opposite: feed the output of one function into the next. That's composition. With Func<int,int> delegates you build a small pipeline by wrapping one call inside another: x => second(first(x)). Each step transforms the value and passes it along. Try it yourself below.
Fill in the two ___ blanks to chain doubleIt into addThree, then invoke the composed pipeline.
using System;
class Program
{
static void Main()
{
// 🎯 YOUR TURN — COMPOSE two Func<int,int> into a pipeline.
// Composition means: feed the output of one into the next.
Func<int, int> doubleIt = x => x * 2; // takes an int, returns an int
Func<int, int> addThree = x => x + 3;
// 1) Build "compose": run doubleIt FIRST, then addThree on its result.
Func<int, int> compose = x => addThree(___); // 👉 doubleIt(x)
// 2) Invoke the pipeline with 5.
int result = ___; // 👉 compose(5)
Console.WriteLine($"double then +3 of 5 = {result}");
// ✅ Expected output:
// double then +3 of 5 = 13
}
}4. Closures — Capturing Variables, Not Values
When a lambda uses a variable from the surrounding code, it captures it — and this is the subtle part: it captures the variable itself, not a copy of its current value. So if that variable changes later, the lambda sees the new value when it runs. This is brilliant for building configurable functions, and a notorious foot-gun inside loops. Read the worked example and watch both behaviours.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// A lambda CAPTURES the variables it uses from the surrounding scope.
// It captures the VARIABLE, not a snapshot of its value.
int factor = 10;
Func<int, int> scale = n => n * factor; // 'factor' is captured
Console.WriteLine(scale(5)); // 50
factor = 100; // change the captured variable...
Console.WriteLine(scale(5)); // 500 (the lambda sees the new value!)
// The classic trap: capturing a LOOP variable.
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
int copy = i; // capture a FRESH copy each iteration
actions.Add(() => Console.WriteLine(copy));
}
foreach (var a in actions) a(); // 0, 1, 2 (thanks to 'copy')
// Without 'copy' (capturing 'i' directly in old C#) you'd get 3, 3, 3.
}
}
// ✅ Expected output:
// 50
// 500
// 0
// 1
// 2🔎 Deep Dive: delegate vs lambda vs method group
These three words get muddled constantly. A delegate is the type (and the object) that holds one or more methods — it's the container. A lambda (x => x * 2) is one concise way to create a method inline that fills that container. A method group is simply the name of an existing method, with no parentheses — like Console.WriteLine — which C# can also convert into a delegate.
Func<int,int> a = x => x * 2; // lambda -> creates a delegate
Func<string> b = DateTime.Now.ToString; // method group -> converts to a delegate
Action c = Console.WriteLine; // method group conversion (no parentheses!)
// All three are delegate INSTANCES once assigned. Add parentheses to INVOKE:
a(5); // 10
c(); // prints a blank lineRule of thumb: write a lambda for throwaway inline logic, pass a method group when a suitable named method already exists, and remember both end up as the same thing — a delegate instance.
Pro Tips
- 💡 Keep multicast delegates to Action: because only the last Func result survives, use void methods when you chain — and walk GetInvocationList() if you truly need every result.
- 💡 Use a named method (or stored lambda) if you'll -= later: d -= someName; works, but d -= x => ...; removes nothing — the new lambda is a different instance.
- 💡 Capture a fresh copy in loops: declare int copy = i; inside a for loop before capturing, so each lambda gets its own value.
- 💡 Cache before invoking for thread safety: var handler = OnChanged; handler?.Invoke(); avoids a race where another thread clears the delegate mid-call.
- 💡 += builds a new delegate: delegates are immutable — combining returns a fresh instance, so the original variable is reassigned, not mutated in place.
Common Errors (and the fix)
- "My multicast Func only returns one value": that's by design — an invoked multicast Func<T> hands back only the last method's result; the rest are discarded. Use Action for chains, or loop over GetInvocationList() and call each yourself to collect every result.
- "My -= didn't remove anything": removing a handler that was never added is a silent no-op. With lambdas this bites hard — d += x => f(x); then d -= x => f(x); removes nothing because each lambda is a brand-new object. Store the lambda in a variable and pass that same reference to both.
- "System.NullReferenceException" when invoking: an empty (unassigned) delegate is null, and calling d(...) throws. Guard with the null-conditional operator: d?.Invoke(...); simply does nothing when the list is empty.
- "All my loop lambdas print the same number": they captured the same loop variable, not its value at the time. Fix it by copying inside the loop: int copy = i; and capture copy — each iteration then closes over its own variable.
- "CS0123: No overload for 'Method' matches delegate 'D'": the method group you tried to add doesn't match the delegate's signature. Check the parameter types and return type line up exactly before +=-ing it onto the chain.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Add a method | log += FileLog; | Calls Delegate.Combine |
| Remove a method | log -= FileLog; | No-op if not present |
| Count the chain | log.GetInvocationList().Length | Number of methods |
| Invoke safely | log?.Invoke("hi"); | Skips if null |
| Compose Funcs | x => g(f(x)) | Pipe one into the next |
| Multicast Func result | int r = roll(); | Last method's value only |
| Capture safely in a loop | int copy = i; | Fresh variable each pass |
Frequently Asked Questions
Q: If a multicast Func throws away earlier results, how do I get them all?
Loop over GetInvocationList() and invoke each entry yourself: foreach (Func<int> f in roll.GetInvocationList()) results.Add(f());. That way you collect every method's return value instead of just the last.
Q: What's actually happening when I write d += handler?
The compiler turns it into d = (T)Delegate.Combine(d, handler);. Delegates are immutable, so this creates a new combined delegate and reassigns d — the old delegate object is untouched. -= works the same way via Delegate.Remove.
Q: Why do all my lambdas in a loop print the same value?
They all captured the same loop variable, and by the time they run the loop has finished. Copy the value into a new variable inside the loop body (int copy = i;) and capture that — each iteration then closes over its own variable.
Q: Is a lambda the same as a delegate?
No — a lambda is a concise way to write a method inline; a delegate is the type/object that holds a method. C# converts the lambda into a delegate instance to match the target type. A method group (a method name with no parentheses) converts the same way.
Mini-Challenge: a Handler Pipeline
No blanks this time — just a brief and an outline. Build one Action<string> delegate, chain three handlers onto it with +=, and invoke it once so all three run in turn. This is the pattern behind logging pipelines, middleware, and event broadcasting in real apps. Run it and check your output against the expected lines.
using System;
class Program
{
static void Main()
{
// 🎯 MINI-CHALLENGE: a text-processing pipeline
// 1. Make an Action<string> called pipeline.
// 2. CHAIN three handlers onto it with += , each printing one step:
// - "Step 1: received '{text}'"
// - "Step 2: length is {text.Length}"
// - "Step 3: upper is {text.ToUpper()}"
// (you can use lambdas: pipeline += t => Console.WriteLine(...);)
// 3. Invoke the pipeline once with the word "hello".
// All three handlers run in turn from a SINGLE call.
//
// ✅ Expected output:
// Step 1: received 'hello'
// Step 2: length is 5
// Step 3: upper is HELLO
// your code here
}
}🎉 Lesson Complete
- ✅ Every C# delegate is multicast — it holds an ordered invocation list of methods
- ✅ += adds (Delegate.Combine) and -= removes (Delegate.Remove); removing a non-added handler is a no-op
- ✅ A multicast Func<T> runs every method but returns only the last result
- ✅ Compose Func delegates into pipelines by nesting calls: x => g(f(x))
- ✅ Lambdas capture variables, not values — copy loop variables to avoid the classic trap
- ✅ A delegate is the container; a lambda and a method group are two ways to fill it
Practice quiz
What does it mean that every delegate in C# is 'multicast'?
- It can only hold one method
- It runs methods on multiple threads
- It can hold an ordered list of methods (the invocation list), all run when invoked
- It broadcasts over a network
Answer: It can hold an ordered list of methods (the invocation list), all run when invoked. Every C# delegate is multicast: it can hold a whole invocation list of methods, run in order when the delegate is invoked.
What do += and -= do to a multicast delegate?
- += adds a method to the chain; -= removes one
- += replaces all methods; -= clears it
- They only work on numeric delegates
- Nothing — they are compile errors on delegates
Answer: += adds a method to the chain; -= removes one. += adds a method to the invocation list and -= removes one; under the hood they call Delegate.Combine and Delegate.Remove.
When you invoke a multicast Func<T> with several methods, which return value do you get?
- An array of all results
- The first method's result
- The sum of all results
- Only the last method's result; earlier ones are discarded
Answer: Only the last method's result; earlier ones are discarded. All methods run, but a multicast Func<T> returns only the last method's result — the rest are silently thrown away.
How can you collect every result from a multicast Func<int>?
- Call it twice
- Loop over GetInvocationList() and invoke each delegate yourself
- Use the .Results property
- It is impossible
Answer: Loop over GetInvocationList() and invoke each delegate yourself. Walk the invocation list with GetInvocationList() and call each Func yourself to gather every return value.
What do the operators += and -= actually call under the hood?
- Delegate.Combine and Delegate.Remove
- Delegate.Add and Delegate.Subtract
- List.Add and List.Remove
- Delegate.Append and Delegate.Pop
Answer: Delegate.Combine and Delegate.Remove. += compiles to Delegate.Combine and -= to Delegate.Remove; both produce a brand-new combined delegate rather than mutating the old one.
What does composing two Func<int,int> like x => addThree(doubleIt(x)) do?
- Runs them side by side and discards results
- Adds their return values
- Feeds the output of doubleIt into addThree, forming a pipeline
- Runs only addThree
Answer: Feeds the output of doubleIt into addThree, forming a pipeline. Composition feeds one function's output into the next: doubleIt runs first, then addThree on its result.
What does a lambda capture when it uses a variable from the surrounding scope?
- A snapshot copy of the value at that moment
- The variable itself, so it sees later changes to that variable
- Nothing — lambdas can't use outer variables
- Only constants
Answer: The variable itself, so it sees later changes to that variable. A closure captures the variable, not a snapshot of its value, so if the variable changes later the lambda sees the new value.
Why might all lambdas created in a loop print the same value?
- Because lambdas are always identical
- Because the loop runs only once
- Because Console.WriteLine caches output
- They captured the same loop variable; copy it into a fresh variable (int copy = i;) inside the loop to fix it
Answer: They captured the same loop variable; copy it into a fresh variable (int copy = i;) inside the loop to fix it. They all close over the same loop variable. Declaring a fresh 'int copy = i;' inside the loop gives each lambda its own captured value.
Why does 'd -= x => f(x);' often remove nothing?
- Because -= is broken for lambdas
- Each lambda literal is a different object, so it doesn't match the one you added; store it in a variable and pass that reference
- Because lambdas can't be removed at all
- Because f must be static
Answer: Each lambda literal is a different object, so it doesn't match the one you added; store it in a variable and pass that reference. Removing a handler that was never added is a silent no-op. A new lambda is a distinct instance, so store the lambda and pass the same reference to both += and -=.
How should you safely invoke a delegate that might be null (have no methods)?
- Wrap it in a try/catch only
- Call d.Invoke() and ignore the crash
- Use the null-conditional operator: d?.Invoke(...)
- Assign it to null first
Answer: Use the null-conditional operator: d?.Invoke(...). An empty/unassigned delegate is null and calling it throws. d?.Invoke(...) simply does nothing when the invocation list is empty.
Continue this course
- Previous: Advanced LINQ Queries & Optimization
- Next: Events: Advanced Patterns & Custom EventArgs — Custom EventArgs, weak event patterns, and event-driven architecture
- Quick reference: C# cheat sheet