Delegates and Events

Master C# delegates, events, and event-driven programming including Action, Func, lambda expressions, and the observer pattern

C#
Delegates
Events
Functional Programming
22 min read

Overview

Delegates and events are the foundation of callbacks, LINQ, asynchronous patterns, and UI event handling in .NET. Understanding them deeply enables effective use of frameworks and creation of extensible APIs.

Core Concepts

Custom Delegates

// Delegate declaration
public delegate int Calculator(int x, int y);
public delegate void Logger(string message, LogLevel level);
public delegate T Factory<T>() where T : new();

// Methods matching delegate signatures
public int Add(int a, int b) => a + b;
public int Multiply(int a, int b) => a * b;

// Using delegates
Calculator calc = Add;
Console.WriteLine(calc(3, 4));  // 7

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

// Multicast delegate (multiple methods)
Calculator multi = Add;
multi += Multiply;  // Add to invocation list

// Note: For non-void delegates, only last return value is kept
int result = multi(3, 4);  // Returns 12 (Multiply's result)

// Iterating multicast delegate
foreach (Calculator d in multi.GetInvocationList())
{
    Console.WriteLine(d(3, 4));
}

Built-in Delegates

// Action - void return
Action greet = () => Console.WriteLine("Hello!");
Action<string> greetPerson = name => Console.WriteLine($"Hello, {name}!");
Action<string, int> greetWithAge = (name, age) =>
    Console.WriteLine($"{name} is {age}");

greet();
greetPerson("Alice");
greetWithAge("Bob", 30);

// Func - returns value
Func<int> getNumber = () => 42;
Func<int, int> square = x => x * x;
Func<int, int, int> add = (a, b) => a + b;
Func<string, int> getLength = s => s.Length;

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

// Predicate - returns bool (equivalent to Func<T, bool>)
Predicate<int> isPositive = x => x > 0;
Predicate<string> isEmpty = string.IsNullOrEmpty;

Console.WriteLine(isPositive(5));   // true
Console.WriteLine(isEmpty(""));     // true

// Using with LINQ
var numbers = new[] { 1, 2, 3, 4, 5 };
var evens = numbers.Where(x => x % 2 == 0);  // Func<int, bool>
var doubled = numbers.Select(x => x * 2);    // Func<int, int>

Lambda Expressions

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

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

// Lambda with explicit types
Func<int, string> convert = (int x) => x.ToString();

// Capturing variables (closure)
int multiplier = 3;
Func<int, int> multiplyBy = x => x * multiplier;
Console.WriteLine(multiplyBy(5));  // 15

multiplier = 10;
Console.WriteLine(multiplyBy(5));  // 50 (captures variable, not value)

// Static lambda (C# 9) - cannot capture
Func<int, int> staticLambda = static x => x * 2;

Events

public class Order
{
    // Event using EventHandler<T>
    public event EventHandler<OrderEventArgs>? StatusChanged;

    private OrderStatus _status;
    public OrderStatus Status
    {
        get => _status;
        set
        {
            if (_status != value)
            {
                var oldStatus = _status;
                _status = value;
                OnStatusChanged(oldStatus, value);
            }
        }
    }

    // Protected virtual method to raise event (standard pattern)
    protected virtual void OnStatusChanged(OrderStatus oldStatus, OrderStatus newStatus)
    {
        StatusChanged?.Invoke(this, new OrderEventArgs(oldStatus, newStatus));
    }
}

public class OrderEventArgs : EventArgs
{
    public OrderStatus OldStatus { get; }
    public OrderStatus NewStatus { get; }

    public OrderEventArgs(OrderStatus oldStatus, OrderStatus newStatus)
    {
        OldStatus = oldStatus;
        NewStatus = newStatus;
    }
}

public enum OrderStatus { Pending, Processing, Shipped, Delivered }

// Usage
var order = new Order();

// Subscribe with lambda
order.StatusChanged += (sender, e) =>
{
    Console.WriteLine($"Order changed from {e.OldStatus} to {e.NewStatus}");
};

// Subscribe with method
order.StatusChanged += HandleStatusChanged;

order.Status = OrderStatus.Processing;
order.Status = OrderStatus.Shipped;

void HandleStatusChanged(object? sender, OrderEventArgs e)
{
    Console.WriteLine($"[Handler] Status is now: {e.NewStatus}");
}

Custom Event Accessors

public class SafeEventPublisher
{
    // Thread-safe event with custom accessors
    private EventHandler? _myEvent;
    private readonly object _lock = new();

    public event EventHandler MyEvent
    {
        add
        {
            lock (_lock)
            {
                _myEvent += value;
            }
        }
        remove
        {
            lock (_lock)
            {
                _myEvent -= value;
            }
        }
    }

    protected virtual void OnMyEvent()
    {
        EventHandler? handler;
        lock (_lock)
        {
            handler = _myEvent;
        }
        handler?.Invoke(this, EventArgs.Empty);
    }
}

Delegates as Method Parameters

public class DataProcessor
{
    // Method accepting delegate parameter
    public IEnumerable<T> Filter<T>(IEnumerable<T> items, Func<T, bool> predicate)
    {
        foreach (var item in items)
        {
            if (predicate(item))
                yield return item;
        }
    }

    public IEnumerable<TResult> Transform<T, TResult>(
        IEnumerable<T> items,
        Func<T, TResult> selector)
    {
        foreach (var item in items)
        {
            yield return selector(item);
        }
    }

    public void ForEach<T>(IEnumerable<T> items, Action<T> action)
    {
        foreach (var item in items)
        {
            action(item);
        }
    }
}

// Usage
var processor = new DataProcessor();
var numbers = new[] { 1, 2, 3, 4, 5 };

var evens = processor.Filter(numbers, n => n % 2 == 0);
var squared = processor.Transform(numbers, n => n * n);
processor.ForEach(numbers, n => Console.WriteLine(n));

When to Use vs. When to Avoid

| Scenario | Use It? | Why | |----------|---------|-----| | Callback from library | ✅ Delegate/Action/Func | Standard pattern for customization | | Notify multiple subscribers | ✅ Event | Publisher-subscriber decoupling | | LINQ operations | ✅ Lambda expressions | Concise, readable predicates and transforms | | UI button clicks | ✅ Event | Standard pattern, framework support | | Single callback | ⚠️ Consider interface | Interface may be more explicit for complex contracts | | High-frequency hot path | ⚠️ Avoid allocating lambdas | Use static lambda or cached delegate | | Cross-process communication | ❌ Not delegates | Use messaging, SignalR, or IPC instead |

Common Patterns

Safe Event Raising

Always use null-conditional for thread safety.

public class DataService
{
    public event EventHandler<DataEventArgs>? DataReceived;

    protected virtual void OnDataReceived(string data)
    {
        // Thread-safe: captures delegate reference before null check
        DataReceived?.Invoke(this, new DataEventArgs(data));
    }
}

Subscription Management

Return IDisposable for easy cleanup.

public class EventBus
{
    private readonly List<Action<string>> _handlers = new();

    public IDisposable Subscribe(Action<string> handler)
    {
        _handlers.Add(handler);
        return new Subscription(() => _handlers.Remove(handler));
    }

    private class Subscription(Action unsubscribe) : IDisposable
    {
        public void Dispose() => unsubscribe();
    }
}

// Usage with automatic cleanup
using var sub = eventBus.Subscribe(msg => Console.WriteLine(msg));

Common Mistakes

Mistake: Lambda Closure in Loop

// ❌ Bad - all handlers print the same value (10)
for (int i = 0; i < 10; i++)
{
    button.Click += (s, e) => Console.WriteLine(i);  // Captures variable, not value
}

// ✅ Good - capture a copy of the value
for (int i = 0; i < 10; i++)
{
    int captured = i;  // Local copy
    button.Click += (s, e) => Console.WriteLine(captured);
}

Why: Lambdas capture variables by reference. When the loop ends, i is 10, so all handlers see 10.

Mistake: Not Unsubscribing from Events

// ❌ Bad - memory leak
public class Component
{
    public Component(DataService service)
    {
        service.DataReceived += OnDataReceived;  // Never unsubscribed
    }
}

// ✅ Good - implement IDisposable
public class Component : IDisposable
{
    private readonly DataService _service;

    public Component(DataService service)
    {
        _service = service;
        _service.DataReceived += OnDataReceived;
    }

    public void Dispose()
    {
        _service.DataReceived -= OnDataReceived;
    }
}

Why: Event subscriptions create strong references. If the publisher lives longer than the subscriber, the subscriber can't be garbage collected.

Practical Example

Scenario: Creating a simple event-driven file watcher.

public class FileMonitor : IDisposable
{
    public event EventHandler<FileChangedEventArgs>? FileChanged;

    private readonly FileSystemWatcher _watcher;

    public FileMonitor(string path)
    {
        _watcher = new FileSystemWatcher(path)
        {
            NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName
        };

        _watcher.Changed += (s, e) =>
            FileChanged?.Invoke(this, new FileChangedEventArgs(e.FullPath, ChangeType.Modified));
        _watcher.Created += (s, e) =>
            FileChanged?.Invoke(this, new FileChangedEventArgs(e.FullPath, ChangeType.Created));

        _watcher.EnableRaisingEvents = true;
    }

    public void Dispose() => _watcher.Dispose();
}

public record FileChangedEventArgs(string Path, ChangeType Type);
public enum ChangeType { Created, Modified, Deleted }

Related Topics

  • Async/Await: Async events and Task-based patterns
  • LINQ: Built entirely on Func delegates for predicates and projections
  • IObservable: Reactive Extensions for advanced event streams

Interview Tips

Tip 1: Know the difference between delegate and event: delegates can be invoked by any code with access; events can only be raised by the declaring class.

Tip 2: Understand closures: lambdas capture variables, not values. This is a common source of bugs in loops.

Tip 3: Events should always be raised using the null-conditional operator (?.Invoke) for thread safety.

Common Interview Questions

  1. What is a delegate?

    • A type-safe function pointer that holds references to methods with a specific signature. Can be invoked like a method. Supports multicast (multiple methods).
  2. What's the difference between delegate and event?

    • Delegate: Can be invoked by anyone, can be assigned (=). Event: Can only be raised by declaring class, can only add/remove (+=/-=). Events are encapsulated delegates that prevent external invocation.
  3. What are Action and Func?

    • Built-in generic delegates. Action: void return, 0-16 parameters. Func: has return type (last type parameter), 0-16 input parameters. Eliminate need for custom delegate types.
  4. What is a closure in C#?

    • When a lambda captures variables from its enclosing scope. The variable is captured by reference, so changes affect the lambda. Can cause unexpected behavior if captured variable changes.
  5. How do you unsubscribe from an event?

    • Use -= operator: button.Click -= HandleClick;. Important for preventing memory leaks. Anonymous lambdas can't be unsubscribed (no reference to original delegate).
  6. Why use null-conditional operator with events?

    • Thread safety: between null check and invocation, another thread could unsubscribe, causing NullReferenceException. Event?.Invoke() is atomic.
  7. What is a multicast delegate?

    • Delegate holding references to multiple methods. When invoked, all methods are called in order. Built on delegate's GetInvocationList(). All delegates in C# are multicast capable.