Углублённое изучение

Делегаты и события в C#: полное руководство

Обновлено: · 7 мин чтения

Делегат в C# - это тип, который хранит ссылку на метод и позволяет передавать поведение как данные. На делегатах построены лямбда-выражения, LINQ, обработчики событий в Windows Forms и WPF и подписки в современных библиотеках.

Статья показывает, как объявлять делегаты, чем отличаются Action, Func и Predicate, как работает мультикаст и как правильно оформлять события через event и EventHandler с подпиской и отпиской.

Делегаты и события в C#: полное руководство
Содержание
  1. Что такое делегат
  2. Action, Func и Predicate
  3. Анонимные методы и лямбда-выражения
  4. Мультикастовые делегаты
  5. События и ключевое слово event
  6. EventHandler и стандартный шаблон событий
  7. Подписка и отписка, утечки памяти
  8. Делегат или событие, что выбрать
  9. Частые вопросы

Что такое делегат

Делегат описывает сигнатуру метода: список параметров и тип результата. Переменная такого типа может ссылаться на любой метод с подходящей сигнатурой и вызывать его. Проще всего представить делегат как типизированный указатель на функцию с проверкой на этапе компиляции.

delegate int Operation(int a, int b);

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

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

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

Ключевое слово delegate объявляет новый тип. После присвоения метода переменную вызывают как обычную функцию или через op.Invoke(3, 4). Делегат может ссылаться на статический метод, метод экземпляра, анонимный метод и лямбда-выражение.

Главная польза - гибкость. Метод может принять делегат параметром и вызвать то, что передал вызывающий код: критерий сортировки, способ расчёта, действие после завершения операции. Как передавать функцию в метод, подробнее показано в материале про функцию как параметр.

static int[] Apply(int[] data, Func<int, int> transform)
{
    var result = new int[data.Length];
    for (int i = 0; i < data.Length; i++)
        result[i] = transform(data[i]);
    return result;
}

var squares = Apply(new[] { 1, 2, 3 }, x => x * x);
Console.WriteLine(string.Join(",", squares)); // 1,4,9

Action, Func и Predicate

Объявлять собственный делегат для каждой сигнатуры не нужно. Библиотека .NET содержит готовые обобщённые типы. Action представляет метод, который ничего не возвращает. Func возвращает значение, причём последний аргумент типа - это тип результата. Predicate принимает один параметр и возвращает bool.

ТипСигнатураПример
Actionvoid ()Action hello = () => Console.WriteLine("Привет");
Action<T>void (T)Action<string> log = s => Console.WriteLine(s);
Func<TResult>TResult ()Func<DateTime> now = () => DateTime.Now;
Func<T, TResult>TResult (T)Func<int, int> sq = x => x * x;
Predicate<T>bool (T)Predicate<int> even = x => x % 2 == 0;
Action<string> print = s => Console.WriteLine($"Лог: {s}");
print("запуск");

Func<int, int, int> sum = (a, b) => a + b;
Console.WriteLine(sum(2, 3)); // 5

Predicate<int> isEven = n => n % 2 == 0;
var list = new List<int> { 1, 2, 3, 4, 5, 6 };
Console.WriteLine(list.FindAll(isEven).Count); // 3
list.RemoveAll(isEven);

Action и Func допускают до 16 параметров. Predicate встречается в старых методах коллекций: Find, FindAll, RemoveAll, Exists. Новые API чаще принимают Func<T, bool>, и для практики разницы между ними нет. В LINQ-запросах все условия и проекции - это Func, поэтому тема тесно связана с лямбдами. Об их применении есть статья про захват переменных в замыканиях.

Анонимные методы и лямбда-выражения

Чтобы не писать отдельный именованный метод ради одного вызова, применяют анонимные методы (ключевое слово delegate) и лямбды (оператор =>). Лямбды компактнее и сегодня используются почти везде. Тело лямбды из одного выражения пишется без скобок, из нескольких операторов - в фигурных скобках.

Func<int, int> old = delegate (int x) { return x * 2; };
Func<int, int> lambda = x => x * 2;

Func<int, string> grade = score =>
{
    if (score >= 90) return "отлично";
    if (score >= 70) return "хорошо";
    return "удовлетворительно";
};

Console.WriteLine(grade(85)); // хорошо

Лямбда может захватывать переменные из окружающего метода. Захваченная переменная живёт, пока жив делегат, и меняется вместе с оригиналом. В C# 9 появились static-лямбды, которые запрещают захват и не создают лишних объектов: static x => x * 2.

Мультикастовые делегаты

Один делегат может хранить сразу несколько методов. Такой список вызова называют мультикастом. Методы добавляют оператором += (или Delegate.Combine) и убирают оператором -=. При вызове они исполняются по очереди в порядке добавления.

Action notify = () => Console.WriteLine("Письмо отправлено");
notify += () => Console.WriteLine("SMS отправлено");
notify += SaveToLog;

notify();

static void SaveToLog() => Console.WriteLine("Запись в журнал");

// Вывод:
// Письмо отправлено
// SMS отправлено
// Запись в журнал

У мультикаста есть особенности. Если делегат возвращает значение, вы получите результат только последнего метода из списка. Чтобы собрать все результаты, переберите список вызова через GetInvocationList(). Если один из методов бросит исключение, остальные не выполнятся, поэтому при необходимости оборачивайте вызовы в try/catch по одному.

Func<int> f = () => 1;
f += () => 2;
f += () => 3;
Console.WriteLine(f()); // 3, результат последнего

foreach (Func<int> part in f.GetInvocationList())
    Console.WriteLine(part()); // 1, 2, 3

Делегаты неизменяемы: операции += и -= создают новый объект и присваивают его переменной. Отписка лямбды через -= работает только если передан тот же экземпляр. Две одинаковые по тексту лямбды - это разные объекты, и отписаться новой записью не получится. Сохраняйте такой обработчик в переменную.

Вызов делегата, который равен null, приводит к NullReferenceException. Безопасная запись - оператор ?.: notify?.Invoke().

События и ключевое слово event

Событие - это способ класса сообщить внешнему коду, что что-то произошло, не зная, кто слушает. Технически событие - обёртка над делегатом с ограничением доступа. Снаружи класса можно только подписаться (+=) и отписаться (-=). Вызвать событие и затереть список подписчиков присваиванием может только сам класс-владелец. Это главное отличие event от открытого поля-делегата.

class Button
{
    public event Action Clicked;

    public void Click()
    {
        Console.WriteLine("Кнопка нажата");
        Clicked?.Invoke();
    }
}

var button = new Button();
button.Clicked += () => Console.WriteLine("Обработчик 1");
button.Clicked += () => Console.WriteLine("Обработчик 2");
button.Click();

// button.Clicked();        // ошибка: вызвать событие снаружи нельзя
// button.Clicked = null;   // ошибка: присваивать нельзя

Паттерн "издатель и подписчик" лежит в основе интерфейсов: кнопки, таймеры, сетевые клиенты, файловые наблюдатели. Издатель (publisher) объявляет событие и вызывает его. Подписчики (subscribers) добавляют обработчики и не знают друг о друге. Связь между классами остаётся слабой: издателю не нужны ссылки на конкретные типы подписчиков.

EventHandler и стандартный шаблон событий

В .NET принят единый шаблон: событие имеет тип EventHandler или EventHandler<TEventArgs>. Обработчик принимает два параметра: object sender (источник) и аргументы события. Класс аргументов наследуют от EventArgs, а в современных версиях допустим и любой тип. Вызов обычно выносят в защищённый виртуальный метод OnИмя, чтобы наследники могли переопределить поведение.

class TemperatureEventArgs : EventArgs
{
    public double Value { get; }
    public TemperatureEventArgs(double value) => Value = value;
}

class Sensor
{
    public event EventHandler<TemperatureEventArgs>? TemperatureChanged;

    private double _temp;
    public double Temperature
    {
        get => _temp;
        set
        {
            if (_temp == value) return;
            _temp = value;
            OnTemperatureChanged(new TemperatureEventArgs(value));
        }
    }

    protected virtual void OnTemperatureChanged(TemperatureEventArgs e)
        => TemperatureChanged?.Invoke(this, e);
}

var sensor = new Sensor();
sensor.TemperatureChanged += (sender, e) => Console.WriteLine($"Сейчас {e.Value} градусов");
sensor.Temperature = 21.5;
sensor.Temperature = 22.0;

Такой шаблон понятен любому разработчику .NET и совместим с инструментами проектирования форм. Поле sender пригодится, когда один обработчик подписан на много объектов: по нему можно узнать источник. Для событий без данных используют EventHandler и EventArgs.Empty. Обработчики событий, вызванные из потока, отличного от потока интерфейса, не могут напрямую менять элементы управления. Эту проблему, например, решают в статье об остановке BackgroundWorker.

Подписка и отписка, утечки памяти

Подписка создаёт ссылку от издателя к подписчику: делегат события хранит ссылку на объект-обработчик. Если издатель живёт долго (статический класс, синглтон, главное окно), а подписчики создаются и удаляются, то без отписки они не будут освобождены сборщиком мусора. Это классическая утечка памяти в приложениях на C#.

class Logger : IDisposable
{
    private readonly Sensor _sensor;

    public Logger(Sensor sensor)
    {
        _sensor = sensor;
        _sensor.TemperatureChanged += OnChanged;
    }

    private void OnChanged(object? sender, TemperatureEventArgs e)
        => Console.WriteLine($"Лог: {e.Value}");

    public void Dispose()
    {
        _sensor.TemperatureChanged -= OnChanged; // обязательная отписка
    }
}

Правило: что подписали в конструкторе или методе Start, то отписываем в Dispose или Stop. Если подписка нужна на всё время жизни приложения, отписку можно опустить, но в остальных случаях она нужна. Для редких случаев с временной подпиской лямбдой сохраняйте обработчик в локальной переменной, чтобы потом снять его через -=.

EventHandler<TemperatureEventArgs>? handler = null;
handler = (s, e) =>
{
    Console.WriteLine("Одноразовый обработчик");
    sensor.TemperatureChanged -= handler; // отписываемся после первого вызова
};
sensor.TemperatureChanged += handler;

Делегат или событие, что выбрать

Если метод должен получить поведение от вызывающего кода и использовать его прямо сейчас (сортировка, фильтрация, колбэк по завершении), передавайте делегат параметром. Если класс должен уведомлять неизвестное число слушателей о происходящем с ним, объявляйте событие. Публичные поля-делегаты без event лучше не использовать: любой внешний код сможет вызвать их или удалить чужие подписки.

  • Колбэк в метод - параметр типа Action или Func.
  • Уведомление о состоянии - event с EventHandler.
  • Условие отбора - Func<T, bool> или Predicate<T>.
  • Асинхронные уведомления с потоком данных - лучше обратить внимание на IObservable или каналы, чем на цепочки событий.

Если обработчики события выполняют долгую работу, они блокируют издателя: вызов события синхронен, и Invoke вернёт управление только после всех обработчиков. Тяжёлую работу переносите в асинхронные методы или отдельные задачи, а в обработчике запускайте её без ожидания.

Частые вопросы

Чем событие отличается от делегата?

Событие - это делегат с ограничениями: снаружи класса можно только подписаться и отписаться. Вызвать событие и очистить список подписчиков может лишь владелец. Обычный публичный делегат такой защиты не имеет.

Что лучше использовать, Action или собственный delegate?

В большинстве случаев Action и Func. Собственный делегат оправдан, когда нужно осмысленное имя типа в публичном API, параметры ref или out, либо сигнатура с более чем 16 аргументами.

Как вызвать событие безопасно?

Через оператор условного доступа: Changed?.Invoke(this, args). Он проверяет, что подписчики есть, и копирует ссылку на делегат, поэтому безопасен при отписке из другого потока.

Как отписаться от события, подписанного лямбдой?

Сохраните лямбду в переменную до подписки и используйте ту же переменную в -=. Новая лямбда с тем же текстом - другой объект, и отписка не сработает.

Что происходит, если обработчик бросает исключение?

Исключение прерывает вызов, и обработчики, стоящие после него в списке, не выполняются. Исключение попадает в код издателя, поэтому либо перехватывайте его в обработчике, либо обходите список через GetInvocationList.

Нужно ли отписываться от событий всегда?

Нужно, когда издатель живёт дольше подписчика. Тогда ссылка в событии удерживает подписчика в памяти. Если оба объекта живут вместе и освобождаются одновременно, отписка не обязательна.