C# Delegates, Lambdas and Events

Treat methods as values with delegates, write inline functions with lambda expressions, use the built-in Func, Action and Predicate types, capture variables in closures, and notify other code with events.

Lesson 20 of 43Advanced19 min read

Usually you pass data to methods: numbers, strings, objects. Sometimes you want to pass behaviour instead. A sorting method needs to know how to compare two items; a retry helper needs to know which operation to retry; a button needs to know what to do when it's clicked. In C#, a delegate is a type that holds a reference to a method, so behaviour can be stored in variables, passed as arguments and called later.

You've already used this idea with LINQ. In orders.Where(o => o.Total > 100), the part o => o.Total > 100 is a lambda expression, a short inline function, and Where receives it as a delegate. Lambdas are the everyday way to create delegates in modern C#.

.NET provides ready-made delegate types, Func, Action and Predicate, so you rarely need to declare your own. In this lesson you'll use them to write flexible methods, see how lambdas capture variables, and build events, the pattern C# uses to let objects notify others when something happens.

Delegates and lambdas

C#
Func<int, int> square = x => x * x;
Console.WriteLine(square(5));                 // 25

Func<decimal, decimal, decimal> add = (a, b) => a + b;
Action<string> log = message => Console.WriteLine($"[log] {message}");
Predicate<int> isEven = n => n % 2 == 0;

log("Started");
  • Func<T1, ..., TResult> takes inputs and returns a value; the last type parameter is the return type.
  • Action<T1, ...> takes inputs and returns nothing.
  • Predicate<T> takes one input and returns a bool.
  • A lambda's parameters go on the left of => and the body on the right. A body in braces can contain several statements and a return.
  • An existing method can be used too: Func<string, int> parse = int.Parse;.

Passing functions to methods

Methods that accept delegates can be customised by their callers:

C#
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;
}

var adults = Filter(people, p => p.Age >= 18);

That's exactly how LINQ's Where works. Methods can also return functions, such as Func<decimal, decimal> MakeDiscount(decimal percent), which builds a new function from a setting.

Closures

A lambda can use variables from the code around it. It captures the variable itself, not a copy of its value at the time:

C#
int count = 0;
Action increment = () => count++;
increment();
increment();
Console.WriteLine(count);    // 2

Captured variables live as long as the lambda does. This is powerful for counters and callbacks, but be careful: a lambda that captures a variable later sees its current value, which can surprise you inside loops.

Events

An event lets a class announce that something happened, and lets any number of other objects react:

C#
class Downloader
{
    public event EventHandler<int>? ProgressChanged;

    public void Run()
    {
        for (int p = 50; p <= 100; p += 50)
            ProgressChanged?.Invoke(this, p);
    }
}

var d = new Downloader();
d.ProgressChanged += (sender, percent) => Console.WriteLine($"{percent}%");
d.Run();
  • += subscribes a handler and -= unsubscribes it.
  • ?.Invoke raises the event only if someone has subscribed.
  • Only the class that declares an event can raise it; outside code can only subscribe and unsubscribe.

Capturing loop variables

Closures capture variables, not values, and this matters most inside loops. A for loop has one variable i that's shared by every repetition, so lambdas created in the loop all see its final value:

C#
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
    actions.Add(() => Console.Write(i + " "));
foreach (var a in actions) a();   // 3 3 3

A foreach loop is different: since C# 5, it creates a fresh variable for each item, so the same code with foreach (int n in new[] { 0, 1, 2 }) prints 0 1 2.

To fix a for loop, copy the value into a variable declared inside the loop body. Each repetition gets its own copy:

C#
for (int i = 0; i < 3; i++)
{
    int copy = i;
    actions.Add(() => Console.Write(copy + " "));   // 0 1 2
}

You'll hit this when creating buttons, timers or tasks in a loop. If a lambda shouldn't capture anything at all, mark it static, as in static x => x * 2; the compiler then reports an error if it accidentally uses a variable from outside.

C# Advanced Syntax

Delegate and event syntax

Func<int, int, int> multiply = (a, b) => a * b;
Action greet = () => Console.WriteLine("Hi");
Action<string, int> show = (name, age) => { Console.WriteLine(name); Console.WriteLine(age); };
Predicate<string> isEmpty = string.IsNullOrEmpty;
Func<int, bool> big = n => n > 100;
delegate decimal PriceRule(decimal price);          // a custom delegate type
public event EventHandler<OrderEventArgs>? OrderPlaced;
OrderPlaced?.Invoke(this, new OrderEventArgs(order));
service.OrderPlaced += OnOrderPlaced;

C# Advanced Summary

  • A delegate holds a reference to a method so behaviour can be passed and stored.
  • Lambdas (x => x * 2) are the usual way to create delegates.
  • Func returns a value, Action returns nothing, Predicate returns bool.
  • Lambdas capture surrounding variables, not copies of their values.
  • Events let objects notify subscribers; += subscribes and -= unsubscribes.

C# Advanced Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Pricing rules as functions

C#
var rules = new List<(string Name, Func<decimal, decimal> Apply)>
{
    ("10% off", p => p * 0.9m),
    ("£5 off", p => Math.Max(0, p - 5)),
    ("Round down", p => Math.Floor(p))
};

decimal price = 49.99m;
foreach (var (name, apply) in rules)
{
    price = apply(price);
    Console.WriteLine($"{name,-11}{price,8:F2}");
}

Func<decimal, decimal> vat = MakeTax(0.2m);
Console.WriteLine($"With VAT   {vat(price),8:F2}");

Func<decimal, decimal> MakeTax(decimal rate) => p => p * (1 + rate);
Output
10% off       44.99
£5 off        39.99
Round down    39.00
With VAT      46.80
Line-by-line explanation
  1. Line 1Each rule pairs a name with a function from price to price.
  2. Line 349.99 times 0.9 is 44.991, shown as 44.99.
  3. Line 11Calling a delegate looks just like calling a method.
  4. Line 15MakeTax returns a new function that remembers the rate.
  5. Line 18The returned lambda captures rate, a closure.
Example 2

Func, Predicate and closures

C#
var temps = new List<int> { 14, 22, 9, 27, 18 };

Predicate<int> isWarm = t => t >= 20;
Console.WriteLine(string.Join(", ", temps.FindAll(isWarm)));

int threshold = 15;
Func<int, bool> aboveThreshold = t => t > threshold;
Console.WriteLine(temps.Count(aboveThreshold));
threshold = 25;
Console.WriteLine(temps.Count(aboveThreshold));

int calls = 0;
Func<int, int> tracked = n =>
{
    calls++;
    return n * 2;
};
var doubled = temps.Select(tracked).ToList();
Console.WriteLine($"{string.Join(" ", doubled)} after {calls} calls");
Output
22, 27
3
1
28 44 18 54 36 after 5 calls
Line-by-line explanation
  1. Lines 3 to 4List<T>.FindAll takes a Predicate.
  2. Line 7The lambda captures threshold itself, not its value.
  3. Lines 9 to 10Changing threshold changes the lambda's result: only 27 is above 25.
  4. Lines 13 to 17A statement lambda in braces can run several statements and return a value.
  5. Line 18ToList runs the query, calling the lambda once per item.
Example 3

An event for order updates

C#
var shop = new Shop();
shop.OrderPlaced += (sender, e) => Console.WriteLine($"Email: thanks for order #{e.OrderId}");
shop.OrderPlaced += (sender, e) => Console.WriteLine($"Stock: reserve items for #{e.OrderId}");

void Audit(object? sender, OrderEventArgs e) => Console.WriteLine($"Audit: #{e.OrderId} at £{e.Total}");
shop.OrderPlaced += Audit;

shop.Place(501, 42.50m);
shop.OrderPlaced -= Audit;
shop.Place(502, 9.99m);

class OrderEventArgs(int orderId, decimal total) : EventArgs
{
    public int OrderId { get; } = orderId;
    public decimal Total { get; } = total;
}

class Shop
{
    public event EventHandler<OrderEventArgs>? OrderPlaced;

    public void Place(int id, decimal total)
    {
        Console.WriteLine($"Order #{id} placed");
        OrderPlaced?.Invoke(this, new OrderEventArgs(id, total));
    }
}
Output
Order #501 placed
Email: thanks for order #501
Stock: reserve items for #501
Audit: #501 at £42.50
Order #502 placed
Email: thanks for order #502
Stock: reserve items for #502
Line-by-line explanation
  1. Lines 2 to 3Two lambdas subscribe to the same event.
  2. Lines 5 to 6A named method can subscribe too, which makes unsubscribing possible.
  3. Line 9-= removes the audit handler, so it doesn't run for the second order.
  4. Line 12Event data classes inherit from EventArgs by convention.
  5. Line 20The event is declared with the standard EventHandler<T> delegate.
  6. Line 25?.Invoke calls every subscriber in the order they subscribed.

Common C# Advanced Mistakes

Calling an event with no subscribers

Don't
OrderPlaced(this, args);              // NullReferenceException when nobody subscribed
Do
OrderPlaced?.Invoke(this, args);

Unsubscribing a lambda

Don't
shop.OrderPlaced += (s, e) => Log(e);
shop.OrderPlaced -= (s, e) => Log(e); // a different lambda, so nothing is removed
Do
EventHandler<OrderEventArgs> handler = (s, e) => Log(e);
shop.OrderPlaced += handler;
shop.OrderPlaced -= handler;

A long lambda inline

Don't
var late = orders.Where(o => { /* 15 lines of date and status checks */ return true; });
Do
var late = orders.Where(IsLate);
bool IsLate(Order o) => o.DueDate < DateOnly.FromDateTime(DateTime.Today) && !o.Delivered;

C# Advanced in Real Projects

Delegates and events in .NET

ASP.NET Core minimal APIs map routes to lambdas: app.MapGet("/orders/{id}", (int id) => ...). Middleware is a chain of delegates. Desktop frameworks such as WPF and .NET MAUI use events for button clicks and text changes, and Unity scripts subscribe to events for collisions and UI actions. LINQ, List<T>.Sort(comparison) and Task.Run(() => ...) all take delegates.

C# Advanced FAQ

What is the difference between Func and Action in C#?

Func delegates return a value, and the last type argument is the return type: Func<int, string> takes an int and returns a string. Action delegates return nothing: Action<string> takes a string and returns void. Both come in versions with up to 16 parameters.

What is a lambda expression in C#?

A lambda is a short, unnamed function written with =>, such as x => x * 2. The parameters go on the left and the result or a block of statements goes on the right. Lambdas are usually passed to methods like LINQ's Where or stored in Func and Action variables.

What is the difference between a delegate and an event?

A delegate is a type that holds a reference to one or more methods, and anyone holding it can call it or replace it. An event wraps a delegate so that outside code can only subscribe with += and unsubscribe with -=, while only the declaring class can raise it. Events protect subscribers from each other.

Why is my event handler being called twice?

The handler was subscribed twice, often because += runs each time a screen or object is set up. Subscribe once, in one place, and unsubscribe with -= when the subscriber is finished. Unsubscribing only works with the same method or the same stored delegate, not with a new copy of a lambda.

Can a delegate call more than one method?

Yes. Combining delegates with += creates a multicast delegate that calls every method in order. If the delegate returns a value, such as a Func<int>, you only get the result of the last method, so multicast is mainly used with Action and events.

C# Advanced Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the lambda so it doubles its input.

Func<int, int> twice = x  x * 2;
Multiple choiceeasy

Which built-in delegate type returns nothing?

Predict the outputmedium

What does this print?

Func<int, int, int> f = (a, b) => a * b + 1;
Console.WriteLine(f(3, 4));
Predict the outputmedium

What does this print?

int x = 1;
Func<int> get = () => x;
x = 10;
Console.WriteLine(get());
Multiple choicehard

Why can't outside code raise a class's event directly?

C# Advanced Quiz

5 questions to check what you learned.

  1. In Func<string, int>, what is the return type?

    • int
    • string
    • void
    • bool
  2. What does += do with an event?

    • Raises it
    • Subscribes a handler
    • Removes all handlers
    • Adds a number
  3. What does Predicate<T> return?

    • T
    • void
    • int
    • bool
  4. What is a closure?

    • A lambda together with the variables it captures from surrounding code
    • A finally block
    • A sealed class
    • A closed file
  5. Why use ?.Invoke when raising an event?

    • It's faster
    • The event is null when there are no subscribers, and ?. avoids a NullReferenceException
    • It runs handlers asynchronously
    • It's required by the compiler