Yesterday you filtered, sorted, and transformed collections with LINQ โ elegant one-liners that felt like SQL had a baby with C#. But here's a question that should've been nagging you: how does .Where() know what to keep? How does .OrderBy() decide the sort order? You passed it... a chunk of code? A mini-method? What was that x => x.Price > 50 thing? Today you find out. That arrow syntax is a lambda expression, and it's built on top of something called a delegate. Time to meet the backstage crew. ๐ญ
1. What Is a Delegate?
Think of a delegate as a TV remote control. A normal remote is hardwired to one TV. But imagine a programmable remote โ one you can point at your TV today, your sound system tomorrow, and your neighbor's drone on weekends. You don't care what device it controls. You just care that when you press "play," something plays.
A delegate is exactly that: a variable that holds a reference to a method. Instead of calling a method directly by name, you store it in a variable and call it later โ or pass it to someone else to call.
Why would you want this? Because sometimes you don't know which method to call at compile time. You want to say, "Here's a slot. Plug in whatever method you want, as long as it matches this shape."
2. Declaring and Using Delegates โ The Old-School Way
Before C# got fancy, you had to declare your own delegate types. It looks a bit ceremonial:
// Declare a delegate type โ it defines the method signature
delegate int MathOperation(int a, int b);
// Some methods that match the signature
int Add(int a, int b) => a + b;
int Multiply(int a, int b) => a * b;
// Create a delegate instance pointing to Add
MathOperation operation = Add;
Console.WriteLine(operation(3, 4)); // 7
// Swap it to Multiply โ same variable, different method
operation = Multiply;
Console.WriteLine(operation(3, 4)); // 12
The delegate int MathOperation(int a, int b) line says: "A MathOperation is any method that takes two int parameters and returns an int." That's it. Any method matching that signature can be assigned to it.
Notice you assign Add without parentheses โ operation = Add, not operation = Add(). You're handing over the method itself, not calling it. It's like giving someone the TV remote, not pressing the button for them.
3. Built-in Delegates: Func<> and Action<>
Declaring your own delegate types works, but it gets tedious. "Do I really need to create a new type every time I want to pass a method around?"
No. C# has two built-in delegate families that cover 99% of use cases:
Func<>โ for methods that return something. The last type parameter is always the return type.Action<>โ for methods that return nothing (void).
// Func<input types..., return type>
Func<int, int, int> add = (a, b) => a + b;
Console.WriteLine(add(5, 3)); // 8
// Func with one input, one output
Func<string, int> getLength = s => s.Length;
Console.WriteLine(getLength("Hello")); // 5
// Func with no input, one output
Func<DateTime> now = () => DateTime.Now;
Console.WriteLine(now());
// Action<input types...> โ returns void
Action<string> shout = message => Console.WriteLine(message.ToUpper());
shout("hello delegates"); // HELLO DELEGATES
// Action with no parameters
Action lineBreak = () => Console.WriteLine("---");
lineBreak(); // ---
Quick cheat sheet:
Func<int, string>โ takes anint, returns astringFunc<string, string, bool>โ takes twostrings, returns aboolAction<int>โ takes anint, returns nothingActionโ takes nothing, returns nothingFunc<T>โ takes nothing, returns aT
From now on, use Func<> and Action<> instead of declaring custom delegate types โ unless you need a descriptive name for readability.
4. Lambda Expressions โ The Arrow That Changed Everything
You've already been using lambdas without maybe knowing the name. That => arrow you saw in LINQ? That's a lambda expression โ an anonymous method you define right there, inline, no name needed.
// A named method
bool IsLongEmail(string email) => email.Length > 20;
// The same logic as a lambda stored in a Func
Func<string, bool> isLongEmail = email => email.Length > 20;
// Using it directly with LINQ โ no variable needed
List<string> emails = ["alice@example.com", "bob@superlongemailprovider.com", "c@d.e"];
var longEmails = emails.Where(email => email.Length > 20).ToList();
// ["bob@superlongemailprovider.com"]
Lambda syntax rules:
- One parameter, no type needed:
x => x * 2 - Multiple parameters need parentheses:
(a, b) => a + b - No parameters need empty parentheses:
() => Console.WriteLine("Hi") - Multiple statements need braces and an explicit
return:
Func<int, int, int> safeDivide = (a, b) =>
{
if (b == 0)
{
Console.WriteLine("Can't divide by zero!");
return 0;
}
return a / b;
};
Think of => as saying "goes to" or "maps to." So x => x * 2 reads as "x goes to x times 2."
5. Lambda Evolution โ A Brief History of Getting Lazier
C# didn't always have this clean syntax. Here's how the same filtering logic looked across versions โ a timeline of programmers refusing to type more than absolutely necessary:
// C# 1.0 โ Named method + explicit delegate instantiation
delegate bool NumberFilter(int n);
bool IsEven(int n) { return n % 2 == 0; }
NumberFilter filter1 = new NumberFilter(IsEven);
// C# 2.0 โ Anonymous method (delegate keyword)
NumberFilter filter2 = delegate(int n) { return n % 2 == 0; };
// C# 3.0 โ Lambda expression with explicit types
NumberFilter filter3 = (int n) => n % 2 == 0;
// C# 3.0 โ Lambda with type inference
NumberFilter filter4 = n => n % 2 == 0;
// Modern C# โ Skip the custom delegate, use Func<>
Func<int, bool> filter5 = n => n % 2 == 0;
Five versions of the same thing. Each one shorter. The delegate keyword approach from C# 2.0 still compiles, but writing it today is like sending a fax โ technically functional, socially concerning.
6. Closures โ When Lambdas Remember Too Much
Here's where things get spicy. A closure happens when a lambda expression captures a variable from its surrounding scope:
int threshold = 100;
Func<int, bool> isAboveThreshold = n => n > threshold;
Console.WriteLine(isAboveThreshold(150)); // True
Console.WriteLine(isAboveThreshold(50)); // False
// Change the outer variable...
threshold = 200;
Console.WriteLine(isAboveThreshold(150)); // False โ it reads the CURRENT value!
The lambda doesn't snapshot threshold at the moment it's created. It captures a reference to it. If the outer variable changes later, the lambda sees the new value. This is a feature, not a bug โ but it trips people up regularly.
The classic trap โ closures in a for loop:
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i));
}
// You'd expect 0, 1, 2? Nope.
foreach (var action in actions) action();
// Prints: 3, 3, 3 โ all three closures share the same 'i'
All three lambdas captured the same variable i, and by the time the loop ends, i is 3. Every lambda reads 3.
The fix: create a local copy inside the loop body:
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
int local = i; // Fresh copy each iteration
actions.Add(() => Console.WriteLine(local));
}
foreach (var action in actions) action();
// Prints: 0, 1, 2 โ
Good news: foreach loops in modern C# (5.0+) don't have this problem โ each iteration gets its own loop variable automatically.
7. Delegates in Action โ Callbacks, Strategy Pattern, and Your Old Friend LINQ
So why do delegates actually matter in real code? Three patterns show up everywhere.
Callbacks โ "Call me when you're done."
You hand a method to another method and say, "Run this when you've finished your work." It's how asynchronous-feeling code worked before async/await existed:
void DownloadFile(string url, Action<string> onComplete)
{
Console.WriteLine($"Downloading {url}...");
Thread.Sleep(1000); // Simulate slow download
onComplete($"Finished downloading {url}");
}
DownloadFile("https://example.com/data.csv",
message => Console.WriteLine(message));
// "Downloading https://example.com/data.csv..."
// "Finished downloading https://example.com/data.csv"
The caller decides what happens on completion. The downloader doesn't need to know or care.
Strategy Pattern โ Swap behavior without rewriting code.
List<string> playlist = ["Bohemian Rhapsody", "Stairway to Heaven",
"Hotel California", "Imagine"];
// Sort by name length
playlist.Sort((a, b) => a.Length.CompareTo(b.Length));
// ["Imagine", "Hotel California", "Bohemian Rhapsody", "Stairway to Heaven"]
// Sort alphabetically โ same method, different strategy
playlist.Sort((a, b) => string.Compare(a, b, StringComparison.Ordinal));
// ["Bohemian Rhapsody", "Hotel California", "Imagine", "Stairway to Heaven"]
You didn't write two sort methods. You wrote zero sort methods. You passed the strategy โ the comparison logic โ as a delegate. The sorting algorithm stays the same; only the comparison changes.
LINQ โ now you see what's underneath.
List<int> prices = [12, 45, 67, 23, 89, 34, 56];
// Where() takes a Func<int, bool>
var expensive = prices.Where(p => p > 40);
// Select() takes a Func<int, string>
var labels = prices.Select(p => $"${p}.00");
// OrderBy() takes a Func<int, int>
var sorted = prices.OrderBy(p => p);
Every LINQ method you used on Day 15 was accepting a Func<> delegate as its parameter. The lambdas you wrote were the implementations of those delegates. .Where(p => p > 40) is just .Where(someFuncThatReturnsBool). Now you know what's actually going on under the hood.
8. Multicast Delegates โ One Remote, Multiple TVs
Remember the TV remote analogy? What if one button press could turn on the TV, start the sound system, and dim the lights? That's a multicast delegate โ a delegate that holds references to multiple methods.
Action<string> notify = message => Console.WriteLine($"Email: {message}");
notify += message => Console.WriteLine($"SMS: {message}");
notify += message => Console.WriteLine($"Push: {message}");
notify("Your order has shipped!");
// Email: Your order has shipped!
// SMS: Your order has shipped!
// Push: Your order has shipped!
Use += to add methods and -= to remove them. When you invoke the delegate, every subscribed method fires in order.
One thing to watch: if a multicast delegate returns a value (Func<>), you only get the return value from the last method in the chain. Everything else is silently discarded. For this reason, multicast delegates are almost always Action<> โ they do things, they don't return things.
This pattern โ "subscribe to get notified when something happens" โ is the foundation of events, which you'll meet tomorrow.
9. Your Homework: Build a Mini Filtering Engine
Build a console app that manages a music library. Each song has a Title, Artist, and DurationInSeconds. Use Func<> delegates to make filtering and sorting fully configurable:
record Song(string Title, string Artist, int DurationInSeconds);
List<Song> library =
[
new("Bohemian Rhapsody", "Queen", 354),
new("Imagine", "John Lennon", 187),
new("Hotel California", "Eagles", 391),
new("Yesterday", "The Beatles", 125),
new("Stairway to Heaven", "Led Zeppelin", 482),
new("Billie Jean", "Michael Jackson", 294)
];
// 1. Write a method: void PrintSongs(List<Song> songs, Func<Song, bool> filter)
// that prints only the songs matching the filter.
// 2. Write a method: void PrintSorted(List<Song> songs, Func<Song, object> keySelector)
// that prints songs sorted by whatever key the caller chooses.
// 3. Call them with different lambdas:
// - Songs longer than 5 minutes (300 seconds)
// - Songs by artists whose name starts with a vowel
// - Songs sorted by title length
// - Songs sorted by duration (shortest first)
// 4. BONUS: Create an Action<Song> notification pipeline using multicast delegates
// that logs to console AND adds to a List<string> โ test with one song.
Summary of Day 16
- A delegate is a type that holds a reference to a method โ a variable for functions.
Func<>is for methods that return a value;Action<>is for methods that returnvoid. Prefer these over custom delegate types.- Lambda expressions (
=>) are anonymous inline methods โ shorthand for creating delegate instances on the spot. - Closures capture outer variables by reference, not by value โ watch out for loop variables in
forloops. - Delegates enable callbacks ("do this when you're done"), the strategy pattern ("sort using this comparison"), and are the machinery behind every LINQ method.
- Multicast delegates (
+=) chain multiple methods onto a single delegate โ the building block for tomorrow's topic: events.
Tomorrow: we'll talk about Events โ your objects will learn to shout "something happened!" and other objects will listen and react. It's like a newspaper subscription system, except nobody loses your paper in the rain. ๐
See you on Day 17!