Skip to content
Happy Programming Guide
Start learning
C#

C# Events and Delegates

What a delegate is, how events build on them, Action and Func, and the two mistakes that cause memory leaks and null reference exceptions.

Hands typing on a laptop keyboard

A delegate is a variable that holds a method. An event is a delegate with restrictions that make it safe to expose publicly. Together they are how one part of a C# program tells another that something happened, without the two knowing about each other. This guide covers both, plus the memory leak that events cause if you never unsubscribe.

Delegates#

CSHARP
public delegate int Operation(int a, int b);

static int Add(int a, int b) => a + b;
static int Multiply(int a, int b) => a * b;

Operation op = Add;
Console.WriteLine(op(3, 4));      // 7

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

The declaration defines a shape — two integers in, one integer out. Any method matching that shape can be assigned. This is what makes it possible to pass behaviour around as data.

Delegates are multicast: several methods can be attached, and all run in turn.

CSHARP
Action<string> log = Console.WriteLine;
log += message => File.AppendAllText("app.log", message + "\n");

log("Both of these run");

Action, Func and Predicate#

You rarely need to declare your own delegate type now. Three built-in ones cover almost everything:

CSHARP
Action greet = () => Console.WriteLine("hi");        // no parameters, no return
Action<string> log = m => Console.WriteLine(m);       // one parameter, no return
Action<string, int> two = (m, n) => { };             // up to 16 parameters

Func<int> random = () => 4;                          // returns int
Func<int, int> square = n => n * n;                  // int in, int out
Func<int, int, int> add = (a, b) => a + b;           // last type is the return

Predicate<int> isEven = n => n % 2 == 0;             // same as Func<int, bool>

In Func, the last type parameter is the return type. That trips people up the first time.

These are what LINQ is built from:

CSHARP
numbers.Where(n => n > 10);       // takes a Func<int, bool>
numbers.Select(n => n * 2);       // takes a Func<int, int>
list.ForEach(Console.WriteLine);  // takes an Action<T>

Events#

CSHARP
public class Downloader
{
    public event EventHandler<ProgressEventArgs>? Progress;
    public event EventHandler? Completed;

    public void Run()
    {
        for (int i = 0; i <= 100; i += 10)
        {
            Progress?.Invoke(this, new ProgressEventArgs(i));
        }
        Completed?.Invoke(this, EventArgs.Empty);
    }
}

public class ProgressEventArgs : EventArgs
{
    public int Percent { get; }
    public ProgressEventArgs(int percent) => Percent = percent;
}
CSHARP
var downloader = new Downloader();

downloader.Progress += (sender, e) => Console.WriteLine($"{e.Percent}%");
downloader.Completed += (sender, e) => Console.WriteLine("Done");

downloader.Run();

The convention is a sender and an arguments object derived from EventArgs. Following it means your events work with tooling and look familiar to other C# developers.

What the event keyword adds#

A public delegate field would let outside code do two things it should not:

CSHARP
// If Progress were a plain field:
downloader.Progress = null;              // wipe out everyone else's handlers
downloader.Progress.Invoke(this, args);  // raise it from outside

The event keyword makes both illegal. From outside the class, only += and -= are allowed. Raising it stays private to the declaring class. That is the whole difference, and it is worth having.

The null-conditional call#

CSHARP
Progress?.Invoke(this, args);

An event with no subscribers is null, and calling it throws. The question mark handles that — and it also handles a subtler race, where the last subscriber unsubscribes between your null check and your call. The null-conditional operator reads the delegate once, so this cannot happen.

CSHARP
// The old, thread-safe long form
var handler = Progress;
if (handler != null) handler(this, args);

The memory leak#

This is the most important thing on the page. Subscribing to an event creates a reference from the publisher to the subscriber:

CSHARP
public class Window
{
    private readonly Downloader _downloader;

    public Window(Downloader downloader)
    {
        _downloader = downloader;
        _downloader.Progress += OnProgress;      // downloader now holds a reference to this
    }

    private void OnProgress(object? sender, ProgressEventArgs e) { }
}

Close the window and it cannot be collected, because the long-lived downloader still points at it. Open and close it a thousand times and you have a thousand windows in memory, all still receiving events.

CSHARP
public class Window : IDisposable
{
    public void Dispose()
    {
        _downloader.Progress -= OnProgress;
    }
}
CSHARP
EventHandler<ProgressEventArgs> handler = (s, e) => Console.WriteLine(e.Percent);

downloader.Progress += handler;
downloader.Progress -= handler;      // works, because we kept the reference

Handling exceptions in handlers#

CSHARP
protected virtual void OnProgress(ProgressEventArgs e)
{
    foreach (var handler in
             Progress?.GetInvocationList().Cast<EventHandler<ProgressEventArgs>>()
             ?? Enumerable.Empty<EventHandler<ProgressEventArgs>>())
    {
        try
        {
            handler(this, e);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "A progress handler failed");
        }
    }
}

By default, if the second of five handlers throws, the remaining three never run. Iterating the invocation list yourself isolates them. Worth doing when subscribers are outside your control; unnecessary within a small application.

Async handlers#

CSHARP
// Fire and forget - exceptions vanish
downloader.Completed += async (s, e) => await SaveAsync();

An async void handler cannot be awaited, so the publisher continues immediately and any exception is lost. It is unavoidable with classic events, so keep such handlers short and wrap their bodies in try/catch. For genuinely asynchronous notification, consider a channel or a message queue rather than an event.

Questions people ask#

What is the difference between a delegate and an event?

An event is a delegate with access restrictions. Outside code can subscribe and unsubscribe but cannot replace the list or raise it.

Should I use EventHandler or my own delegate type?

EventHandler and its generic form, for consistency with the rest of .NET. A custom type is only worth it when the sender-and-args shape genuinely does not fit.

Are events thread-safe?

Subscribing and unsubscribing are, because the compiler generates locked add and remove methods. Raising is not — use the null-conditional invoke, and be aware handlers may run on whichever thread raised the event.

What is IObservable used for?

It is the reactive alternative, with proper support for composing, filtering and unsubscribing via a disposable. Worth reaching for when you have streams of events rather than occasional notifications.

Where to go next#

Async and await in C#, properlyRead next

Keep reading

C#

C# Design Patterns

The design patterns that earn their place in C#: strategy, factory, repository, decorator, observer and options — with the language features that…

4 min read

C#

Distinct() Not Working on Objects in C#

Distinct removes duplicates by equality, and for classes equality means the same instance. Three fixes: DistinctBy, a record, or IEquatable, and which…

3 min read

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *