Skip to main content
C# intermediate Lesson 16 of 25

Delegates, Events, and Lambdas in C#

Action, Func, Predicate, lambda expressions, multicast delegates, and events in C#.

Delegates

A delegate is a type-safe function pointer — a variable that holds a reference to a method.

// Declare a delegate type
delegate int MathOperation(int a, int b);

// Methods that match the signature
int Add(int a, int b) => a + b;
int Multiply(int a, int b) => a * b;

// Assign and invoke
MathOperation op = Add;
Console.WriteLine(op(3, 4));   // 7

op = Multiply;
Console.WriteLine(op(3, 4));   // 12

// Multicast delegate — combine methods with +
MathOperation combined = Add;
combined += Multiply;
combined(3, 4);  // calls Add then Multiply (return value is from last)

Built-in Delegate Types

Instead of declaring custom delegate types, use the built-in ones:

// Action — no return value
Action greet = () => Console.WriteLine("Hello!");
Action<string> greetName = name => Console.WriteLine($"Hello, {name}!");
Action<string, int> greetAge = (name, age) => Console.WriteLine($"{name} is {age}");

greet();
greetName("Alice");
greetAge("Bob", 30);

// Func — returns a value (last type arg = return type)
Func<int>              getZero   = () => 0;
Func<int, int>         square    = x => x * x;
Func<int, int, int>    add       = (a, b) => a + b;
Func<string, int>      strlen    = s => s.Length;
Func<string, bool>     isLong    = s => s.Length > 10;

Console.WriteLine(square(5));      // 25
Console.WriteLine(add(3, 4));      // 7

// Predicate<T> — shorthand for Func<T, bool>
Predicate<int> isEven = n => n % 2 == 0;
Console.WriteLine(isEven(4));      // True

Lambda Expressions

Lambdas are inline anonymous functions:

// Expression lambda (single expression)
Func<int, int> square = x => x * x;

// Statement lambda (multiple statements)
Func<int, int> factorial = n =>
{
    if (n <= 1) return 1;
    int result = 1;
    for (int i = 2; i <= n; i++) result *= i;
    return result;
};

// Lambda with no parameters
Action sayHi = () => Console.WriteLine("Hi!");

// Lambda capturing outer variables (closure)
int multiplier = 3;
Func<int, int> triple = x => x * multiplier;  // captures 'multiplier'
Console.WriteLine(triple(7));  // 21

multiplier = 10;
Console.WriteLine(triple(7));  // 70 — captures the VARIABLE, not the value

Static lambdas (C# 9)

Mark a lambda static to prevent it from capturing outer variables accidentally:

Func<int, int> doubler = static x => x * 2;
// static x => x * multiplier  ← compile error: can't capture 'multiplier'

Passing Delegates to Methods

Delegates make methods flexible and reusable:

// Higher-order function — takes a delegate
static List<T> Filter<T>(List<T> items, Func<T, bool> predicate)
{
    var result = new List<T>();
    foreach (var item in items)
        if (predicate(item)) result.Add(item);
    return result;
}

static List<TOut> Transform<TIn, TOut>(List<TIn> items, Func<TIn, TOut> selector)
    => items.Select(selector).ToList();

var numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

var evens    = Filter(numbers, n => n % 2 == 0);
var squared  = Transform(numbers, n => n * n);
var strings  = Transform(numbers, n => $"Item {n}");

Multicast Delegates

Action<string> logger = msg => Console.WriteLine($"[LOG] {msg}");
Action<string> audit  = msg => Console.WriteLine($"[AUDIT] {msg}");

// Combine into a multicast delegate
Action<string> combined = logger + audit;
combined("User logged in");
// [LOG] User logged in
// [AUDIT] User logged in

// Remove a handler
combined -= audit;
combined("User logged out");
// [LOG] User logged out

// Inspect invocation list
foreach (Action<string> handler in combined.GetInvocationList().Cast<Action<string>>())
    Console.WriteLine(handler.Method.Name);

Events

Events are multicast delegates with restricted access — only the declaring class can invoke them:

public class Button
{
    // Declare an event using EventHandler or a custom delegate
    public event EventHandler? Clicked;
    public event EventHandler<string>? HoverChanged;

    // The class raises its own events
    protected virtual void OnClicked()
        => Clicked?.Invoke(this, EventArgs.Empty);

    protected virtual void OnHoverChanged(string state)
        => HoverChanged?.Invoke(this, state);

    public void SimulateClick() => OnClicked();
    public void SimulateHover(string state) => OnHoverChanged(state);
}

var button = new Button();

// Subscribe (+=)
button.Clicked += (sender, args) => Console.WriteLine("Button clicked!");
button.Clicked += (sender, args) => Console.WriteLine("Also handled!");

// Subscribe with a named method
void LogClick(object? sender, EventArgs e)
    => Console.WriteLine($"Logged: click on {sender?.GetType().Name}");

button.Clicked += LogClick;

button.SimulateClick();
// Button clicked!
// Also handled!
// Logged: click on Button

// Unsubscribe (-=)
button.Clicked -= LogClick;

Custom EventArgs

public class OrderEventArgs : EventArgs
{
    public int OrderId { get; }
    public decimal Amount { get; }
    public OrderEventArgs(int orderId, decimal amount)
        => (OrderId, Amount) = (orderId, amount);
}

public class OrderService
{
    public event EventHandler<OrderEventArgs>? OrderPlaced;
    public event EventHandler<OrderEventArgs>? OrderCancelled;

    protected virtual void OnOrderPlaced(int id, decimal amount)
        => OrderPlaced?.Invoke(this, new OrderEventArgs(id, amount));

    public async Task PlaceOrderAsync(Order order)
    {
        // ... process order ...
        OnOrderPlaced(order.Id, order.Total);
    }
}

var service = new OrderService();
service.OrderPlaced += (_, e) =>
    Console.WriteLine($"Order {e.OrderId} placed: ${e.Amount}");

Func as a Strategy Pattern

public class DataProcessor
{
    private readonly Func<string, string> _transform;
    private readonly Func<string, bool>   _filter;

    public DataProcessor(
        Func<string, string> transform,
        Func<string, bool>   filter)
    {
        _transform = transform;
        _filter    = filter;
    }

    public IEnumerable<string> Process(IEnumerable<string> input)
        => input.Where(_filter).Select(_transform);
}

// Build different processors by injecting different lambdas
var upperCaseFilter = new DataProcessor(
    transform: s => s.ToUpper(),
    filter:    s => s.Length > 3);

var results = upperCaseFilter.Process(new[] { "hi", "hello", "world", "c#" });
// HELLO, WORLD

Delegate Caching for Performance

In hot paths, avoid creating new delegates on every call:

public class OrderValidator
{
    // Cache the delegate — don't allocate a new lambda on every call
    private static readonly Func<Order, bool> IsValidOrder =
        order => order.Lines.Count > 0 && order.Total > 0;

    public bool Validate(Order order) => IsValidOrder(order);
}

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.