C# · lesson 17 of 26
Delegates, Events, and Lambdas in C#
C# delegates by example: Func, Action and Predicate, lambdas and closures, multicast invocation, events and EventHandler<T>, and delegates as strategies.
By SkillByExample · Updated
Delegates
A delegate is a type-safe reference to a method: a variable you can call. Any method with a matching signature can be assigned to it.
MathOperation op = Ops.Add;
Console.WriteLine(op(3, 4));
op = Ops.Multiply;
Console.WriteLine(op(3, 4));
Console.WriteLine(op.Method.Name);
MathOperation combined = Ops.Add;
combined += Ops.Multiply;
Console.WriteLine(combined(3, 4));
Console.WriteLine(combined.GetInvocationList().Length);
delegate int MathOperation(int a, int b);
static class Ops
{
public static int Add(int a, int b) { Console.WriteLine(" Add ran"); return a + b; }
public static int Multiply(int a, int b) { Console.WriteLine(" Multiply ran"); return a * b; }
}
$ dotnet run
Add ran
7
Multiply ran
12
Multiply
Add ran
Multiply ran
12
2
op switched methods at run time and op.Method.Name reports which one it currently points to. The combined delegate called both methods, in the order they were added, but combined(3, 4) returned 12: with a multicast delegate, only the last method’s return value survives. That is why multicast is used with void delegates in practice. The delegate declaration is a type, so like Ops it goes after the top-level statements.
Func, Action and Predicate
You rarely need to declare delegate types: Action<...> returns nothing, Func<..., TResult> returns its last type argument, and Predicate<T> returns bool.
Action greet = () => Console.WriteLine("Hello!");
Action<string, int> greetAge = (name, age) => Console.WriteLine($"{name} is {age}");
greet();
greetAge("Bob", 30);
Func<int, int> square = x => x * x;
Func<int, int, int> add = (a, b) => a + b;
Func<string, int> strlen = s => s.Length;
Console.WriteLine($"{square(5)} {add(3, 4)} {strlen("delegate")}");
Predicate<int> isEven = n => n % 2 == 0;
var nums = new List<int> { 1, 2, 3, 4, 5, 6 };
Console.WriteLine(string.Join(",", nums.FindAll(isEven)));
var twice = (int x) => x * 2;
Console.WriteLine($"{twice.GetType().Name} {twice(21)}");
$ dotnet run
Hello!
Bob is 30
25 7 8
2,4,6
Func`2 42
Func<int, int, int> reads as “takes two ints, returns an int” — the last type argument is always the return type. List<T>.FindAll predates LINQ and takes a Predicate<T>. Since C# 10, var can infer a delegate type from a lambda whose parameter types are written out: (int x) => x * 2 becomes a Func<int, int> — printed with its generic arity as Func`2. It cannot do this for an overloaded method group such as var parse = int.Parse;, which fails with CS8917 because the compiler has no way to choose an overload.
Lambdas and Closures
A lambda that uses a local variable captures the variable, not its value at the moment the lambda was created.
int multiplier = 3;
Func<int, int> scale = x => x * multiplier;
Console.WriteLine(scale(7));
multiplier = 10;
Console.WriteLine(scale(7));
Func<int> counter = MakeCounter();
counter();
counter();
Console.WriteLine(counter());
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
actions.Add(() => Console.Write($"for:{i} "));
foreach (var v in new[] { 0, 1, 2 })
actions.Add(() => Console.Write($"foreach:{v} "));
actions.ForEach(a => a());
Console.WriteLine();
static Func<int> MakeCounter()
{
int count = 0;
return () => ++count;
}
$ dotnet run
21
70
3
for:3 for:3 for:3 foreach:0 foreach:1 foreach:2
scale(7) returned 70 the second time because the lambda reads multiplier when it runs. MakeCounter returns a lambda that keeps count alive after the method has returned — that is a closure, and the variable lives on the heap. The loop line is the classic trap: a for loop has one i shared by every lambda, so all three print its final value, 3. A foreach creates a fresh v per iteration, so each lambda gets its own. Add static to a lambda (static x => x * 2) when you want the compiler to forbid capturing anything.
Passing Functions to Methods
A method that takes a delegate lets callers plug in behaviour — this is how LINQ’s Where and Select work.
var numbers = Enumerable.Range(1, 10).ToList();
Console.WriteLine(string.Join(",", Filter(numbers, n => n % 3 == 0)));
Console.WriteLine(string.Join(",", Transform(numbers.Take(3).ToList(), n => $"#{n}")));
Func<int, int> inc = x => x + 1;
Func<int, int> dbl = x => x * 2;
Console.WriteLine(Compose(inc, dbl)(5));
Console.WriteLine(Compose(dbl, inc)(5));
static List<T> Filter<T>(List<T> items, Func<T, bool> keep)
{
var result = new List<T>();
foreach (var item in items)
if (keep(item)) result.Add(item);
return result;
}
static List<TOut> Transform<TIn, TOut>(List<TIn> items, Func<TIn, TOut> f) =>
items.Select(f).ToList();
static Func<T, T> Compose<T>(Func<T, T> first, Func<T, T> then) => x => then(first(x));
$ dotnet run
3,6,9
#1,#2,#3
12
11
Compose takes two functions and returns a new one. Order matters: increment-then-double gives 12, double-then-increment gives 11.
Events
An event is a multicast delegate that outsiders may only subscribe to (+=) or unsubscribe from (-=). Only the declaring class can raise it.
var button = new Button();
button.Clicked += (sender, e) => Console.WriteLine("Button clicked!");
button.Clicked += LogClick;
button.SimulateClick();
button.Clicked -= LogClick;
button.SimulateClick();
var silent = new Button();
silent.SimulateClick();
Console.WriteLine("no subscribers, no crash");
static void LogClick(object? sender, EventArgs e) =>
Console.WriteLine($"Logged: click on {sender?.GetType().Name}");
public class Button
{
public event EventHandler? Clicked;
protected virtual void OnClicked() => Clicked?.Invoke(this, EventArgs.Empty);
public void SimulateClick() => OnClicked();
}
$ dotnet run
Button clicked!
Logged: click on Button
Button clicked!
no subscribers, no crash
After -= LogClick only the lambda remains. The silent button has no subscribers, so Clicked is null; ?.Invoke skips the call instead of throwing. From outside the class, button.Clicked(...) or button.Clicked = null would not compile (CS0070) — that restriction is the whole difference between an event and a public delegate field.
Custom Event Data
To pass data with an event, use EventHandler<TEventArgs> with your own EventArgs subclass.
var service = new OrderService();
service.OrderPlaced += (_, e) => Console.WriteLine($"Email: order {e.OrderId} for {e.Amount:0.00}");
service.OrderPlaced += (_, e) => { if (e.Amount > 100) Console.WriteLine($"Fraud check on {e.OrderId}"); };
service.PlaceOrder(1, 25m);
service.PlaceOrder(2, 250m);
public class OrderEventArgs(int orderId, decimal amount) : EventArgs
{
public int OrderId { get; } = orderId;
public decimal Amount { get; } = amount;
}
public class OrderService
{
public event EventHandler<OrderEventArgs>? OrderPlaced;
public void PlaceOrder(int id, decimal amount)
{
Console.WriteLine($"Placing order {id}");
OrderPlaced?.Invoke(this, new OrderEventArgs(id, amount));
}
}
$ dotnet run
Placing order 1
Email: order 1 for 25.00
Placing order 2
Email: order 2 for 250.00
Fraud check on 2
OrderService does not know an email sender or a fraud checker exists; each subscribed itself. Handlers run synchronously, in subscription order, before PlaceOrder returns — an event is a method call, not a message queue.
Delegates as Strategies
Instead of a class hierarchy, inject the varying behaviour as Funcs.
var shout = new DataProcessor(transform: s => s.ToUpper(), filter: s => s.Length > 3);
var reverse = new DataProcessor(transform: s => new string(s.Reverse().ToArray()), filter: _ => true);
string[] input = { "hi", "hello", "world", "c#" };
Console.WriteLine(string.Join(", ", shout.Process(input)));
Console.WriteLine(string.Join(", ", reverse.Process(input)));
public class DataProcessor(Func<string, string> transform, Func<string, bool> filter)
{
public IEnumerable<string> Process(IEnumerable<string> input) =>
input.Where(filter).Select(transform);
}
$ dotnet run
HELLO, WORLD
ih, olleh, dlrow, #c
Two processors, zero subclasses. When a “strategy” is a single method, a Func is lighter than an interface with one implementation per behaviour.
Practice
- Retry helper. Write
T Retry<T>(Func<T> action, int attempts)that callsactionuntil it succeeds or attempts run out. Test it with a lambda that fails twice.
Solution
int calls = 0;
string result = Retry(() =>
{
calls++;
if (calls < 3) throw new InvalidOperationException($"fail {calls}");
return "ok";
}, attempts: 5);
Console.WriteLine($"{result} after {calls} calls");
static T Retry<T>(Func<T> action, int attempts)
{
for (int i = 1; ; i++)
{
try { return action(); }
catch (Exception ex) when (i < attempts) { Console.WriteLine($"retry: {ex.Message}"); }
}
}
$ dotnet run
retry: fail 1
retry: fail 2
ok after 3 calls
The exception filter when (i < attempts) lets the last failure propagate instead of swallowing it.
- Fix the loop capture. Change the
forloop in the closures example so each lambda prints its own index.
Solution
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
int copy = i;
actions.Add(() => Console.Write($"{copy} "));
}
actions.ForEach(a => a());
Console.WriteLine();
$ dotnet run
0 1 2
copy is declared inside the loop body, so each iteration gets a new variable.
- A temperature event. Write a
Thermostatwithevent EventHandler<double>? TooHotthat fires whenSet(double)receives a value above 30.
Solution
var t = new Thermostat();
t.TooHot += (_, temp) => Console.WriteLine($"Alert: {temp}°C");
foreach (var temp in new[] { 22.0, 31.5, 29.0, 35.0 })
t.Set(temp);
public class Thermostat
{
public event EventHandler<double>? TooHot;
public void Set(double temp)
{
if (temp > 30) TooHot?.Invoke(this, temp);
}
}
$ dotnet run
Alert: 31.5°C
Alert: 35°C
Since .NET Framework 4.5, EventHandler<T> accepts any T, not just EventArgs subclasses.
- Pipeline. Build a
Func<string, string>by composing a list of steps: trim, lower-case, replace spaces with dashes. Apply it to" Hello Big World ".
Solution
var steps = new List<Func<string, string>>
{
s => s.Trim(),
s => s.ToLowerInvariant(),
s => s.Replace(' ', '-'),
};
Func<string, string> pipeline = steps.Aggregate((f, g) => s => g(f(s)));
Console.WriteLine($"[{pipeline(" Hello Big World ")}]");
$ dotnet run
[hello-big-world]
Aggregate folds the list into one function, applying the steps left to right.