Skip to main content
C# · lesson 17 of 26
C# intermediate 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

  1. Retry helper. Write T Retry<T>(Func<T> action, int attempts) that calls action until 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.

  1. Fix the loop capture. Change the for loop 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.

  1. A temperature event. Write a Thermostat with event EventHandler<double>? TooHot that fires when Set(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.

  1. 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.

Frequently Asked Questions

What is the difference between Action and Func?
Action is a delegate that returns void. Func is a delegate that returns a value — the last type argument is the return type. Func<int, string> takes an int and returns a string.
What is a multicast delegate?
A delegate that holds a reference to more than one method. When you invoke it, all methods in the invocation list are called in order. Events use multicast delegates — multiple handlers can subscribe to one event.
When should I use events instead of a Func or Action delegate?
Use events for the publisher-subscriber pattern where the publisher doesn't know (or care) who's listening. Events prevent subscribers from invoking or replacing the delegate — only the declaring class can raise it. Use a Func/Action field when you want a single replaceable callback.
Why does a lambda see a variable's new value after I change it?
A lambda captures the variable itself, not a copy of its value at the time the lambda was created. The compiler moves captured locals into a hidden object that both the method and the lambda share, so later assignments are visible when the lambda runs. Copy the value into a fresh local first if you want a snapshot.
What happens if an event has no subscribers?
The event's backing delegate is null, so calling Clicked(this, e) directly throws NullReferenceException. That is why events are raised with Clicked?.Invoke(this, e): the null-conditional operator skips the call when nobody is subscribed and also avoids a race if the last handler unsubscribes on another thread.