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

Делегаты и события в C#: как устроены и чем отличаются Полезное

Делегат в C# — тип, который хранит ссылку на метод (или несколько) с заданной сигнатурой и вызывает их через переменную, как обычную функцию. Событие (event) — член класса поверх делегата с ограничением: снаружи его можно только подписать (+=) или отписать (-=), но не вызвать напрямую. Разбираю оба механизма на рабочем коде и смотрю, что из этого есть в C++, а что там делается вручную.

Мини-словарь: delegate, event, Action/Func, EventHandler

Эти четыре термина в C# путают чаще всего, хотя они на разных уровнях одной идеи:

  • delegate — тип-указатель на метод с конкретной сигнатурой. Базовый строительный блок.
  • event — модификатор поля-делегата, скрывающий прямой вызов (Invoke) и присваивание (=) от внешнего кода, оставляя только +=/-=.
  • Action / Func — готовые обобщенные делегаты из .NET, которые избавляют от объявления своего типа delegate почти во всех случаях.
  • EventHandler / EventHandler — готовая сигнатура делегата для событий: (object? sender, TEventArgs e). Это соглашение, а не отдельный механизм.

Дальше различаю их на одном примере — банковский счет, который уведомляет об изменении баланса.

Delegate: тип-указатель на метод

Свой тип delegate объявляется через ключевое слово delegate с сигнатурой:

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

Переменной такого типа можно присвоить любой метод с той же сигнатурой и переприсваивать ее как обычную переменную:

using System;

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

Operation op = Add;
Console.WriteLine(op(2, 3));

op = Multiply;
Console.WriteLine(op(2, 3));

Вывод:

5
6

На практике свой тип delegate объявляют редко: обычно хватает готовых Action<T1,...> (без возврата) и Func<T1,...,TResult> (с возвратом):

Action<string> log = message => Console.WriteLine($"LOG: {message}");
log("Отладочное сообщение");

Func<int, int, int> add = (a, b) => a + b;
Console.WriteLine(add(10, 5));

Вывод:

LOG: Отладочное сообщение
15

Делегат в C# мультикаст по умолчанию: одной переменной можно назначить несколько методов через +=, они выполнятся по очереди. Это свойство и использует event ниже.

Event: делегат с ограниченным доступом

Событие объявляется через event и тип делегата (обычно EventHandler<TEventArgs>). Разница с обычным полем-делегатом — в правах: владелец класса вызывает событие через ?.Invoke, а внешний код — только подписывается и отписывается.

Полный рабочий пример — счет, который уведомляет подписчиков об изменении баланса:

using System;

public class BalanceChangedEventArgs : EventArgs
{
    public int Amount { get; }
    public int NewBalance { get; }

    public BalanceChangedEventArgs(int amount, int newBalance)
    {
        Amount = amount;
        NewBalance = newBalance;
    }
}

public class Account
{
    public event EventHandler<BalanceChangedEventArgs>? BalanceChanged;

    public int Balance { get; private set; }

    public void Deposit(int amount)
    {
        Balance += amount;
        BalanceChanged?.Invoke(this, new BalanceChangedEventArgs(amount, Balance));
    }
}

class Program
{
    static void Main()
    {
        var account = new Account();
        account.BalanceChanged += (sender, e) =>
            Console.WriteLine($"Баланс изменился на {e.Amount}, стал {e.NewBalance}");

        account.Deposit(100);
        account.Deposit(50);
    }
}

Вывод (в шаблоне консольного проекта .NET с включенным по умолчанию Nullable reference types):

Баланс изменился на 100, стал 100
Баланс изменился на 50, стал 150

EventHandler<BalanceChangedEventArgs> — стандартная сигнатура для событий в .NET (отправитель плюс данные события). ?.Invoke защищает от NullReferenceException, если подписчиков еще нет. private set у Balance не дает менять баланс в обход метода Deposit.

Если убрать ?. и вызвать BalanceChanged.Invoke(...) без подписчиков, Deposit упадет с NullReferenceException — частый баг новичков с событиями. Правильное исправление — именно ?.Invoke, а не отдельная проверка if (BalanceChanged != null): между проверкой и вызовом другой поток может отписать последнего подписчика.

Чем event отличается от обычного delegate-поля

Если объявить public Action<int> OnDeposit; вместо event Action<int> OnDeposit;, внешний код сможет не только подписаться через +=, но и вызвать account.OnDeposit(100) напрямую или стереть подписки чужим кодом через OnDeposit = null. Модификатор event убирает обе возможности — единственная содержательная разница на уровне компилятора.

Термин Что это Мультикаст Вызов извне класса Типичное применение
delegate (свой тип) тип-указатель на метод(ы) заданной сигнатуры да да, если поле публичное нестандартная сигнатура, legacy-код
Action / Func готовые обобщенные делегаты из .NET да да, если поле публичное коллбэки, LINQ, короткие лямбды
event (на делегате) обертка над полем-делегатом с ограничением доступа да, через +=/-= нет, только подписка/отписка публичные уведомления класса о состоянии
EventHandler / EventHandler<T> стандартная сигнатура делегата для событий да нет (доступ как у event) общепринятый паттерн .NET для событий

Как быть с C++

В C++ нет ключевого слова event и нет типа delegate — это встроенные механизмы .NET/C#, а не общие для языков C-семейства. Похожего поведения в C++ добиваются другими средствами — это приближение, а не полный аналог.

Указатель на функцию — самый старый и самый ограниченный вариант, совместимый с C:

#include <iostream>

void onBalanceChanged(int amount, int newBalance) {
    std::cout << "Balance changed by " << amount << ", now " << newBalance << std::endl;
}

int main() {
    void (*handler)(int, int) = onBalanceChanged;
    handler(10, 10);
    return 0;
}

Вывод:

Balance changed by 10, now 10

Указатель на функцию не умеет напрямую хранить лямбду с захватом переменных или метод объекта — нужны дополнительные ухищрения (std::bind, обертка статическим методом). Это его главное ограничение против делегата C#.

std::function (заголовок <functional>, доступен с C++11) снимает это ограничение — хранит любой вызываемый объект нужной сигнатуры: функцию, лямбду с захватом, функтор, результат std::bind:

#include <iostream>
#include <functional>

class Account {
public:
    std::function<void(int, int)> onChange;
    int balance = 0;

    void deposit(int amount) {
        balance += amount;
        if (onChange) {
            onChange(amount, balance);
        }
    }
};

int main() {
    Account account;
    account.onChange = [](int amount, int newBalance) {
        std::cout << "Balance changed by " << amount << ", now " << newBalance << std::endl;
    };

    account.deposit(100);
    account.deposit(50);
    return 0;
}

Вывод (проверено на g++ со стандартом C++17):

Balance changed by 100, now 100
Balance changed by 50, now 150

Граница здесь важна: std::function в примере хранит ровно один обработчик — это не мультикаст, как event в C#. Для нескольких подписчиков нужен свой контейнер, например std::vector<std::function<void(int,int)>>, с вызовом элементов по очереди вручную; штатной отписки по значению нет — std::function не поддерживает сравнение == произвольных вызываемых объектов.

Полноценный аналог event с мультикастом и отпиской в C++ дают отдельные библиотеки — Boost.Signals2 и система signals/slots в Qt, но устроены они по-разному. Boost.Signals2 хранит слоты через boost::function — обертку того же рода, что и std::function. Qt signals/slots работает иначе: сигналы генерирует moc (meta-object compiler) в отдельный рантайм с собственной таблицей соединений (QMetaObject::activate), без std::function внутри — это дает Qt дополнительно потокобезопасную доставку между потоками (queued connections), которой нет ни в std::function, ни в Boost.Signals2 из коробки. Оба варианта — сторонние библиотека и фреймворк, а не часть языка или стандартной библиотеки C++23.

Механизм в C++ Мультикаст из коробки Лямбда с захватом / член-функция Отписка одного обработчика Когда применять
указатель на функцию нет нет нет простой C-совместимый колбэк
std::function нет (один слот) да нет один настраиваемый обработчик
Boost.Signals2 / Qt signals-slots да да да (через возвращаемое соединение) нужен полный аналог C# event

Выводы

  • Delegate — тип-указатель на метод (или несколько методов через мультикаст), event — делегат с ограниченным доступом: подписка и отписка снаружи, вызов только изнутри класса.
  • Action/Func закрывают почти все случаи, где раньше объявляли свой тип delegate; EventHandler/EventHandler<T> — это просто стандартная сигнатура делегата для событий, а не отдельный механизм.
  • Вызывать событие нужно через ?.Invoke, а не через отдельную проверку на null, из-за гонки между проверкой и вызовом.
  • В C++ нет встроенных event и delegate; указатель на функцию и std::function — однослотовые аналоги без мультикаста, полный аналог event дают только сторонние библиотеки вроде Boost.Signals2 или Qt.

Где применяется / связь с практикой

Освойте тему на практике

События и делегаты — базовая техника почти в любом C#-приложении с UI (WPF, WinForms, MAUI используют события для кликов и изменений состояния) и в уведомлениях между слоями кода без жесткой связи компонентов. Разобраться в этом на практике удобнее на структурированном курсе: на курсе по C# разбирают делегаты, события и остальной язык на реальных задачах. Если пока не готовы к полному курсу — можно начать с открытых уроков Otus, среди которых регулярно проходят занятия по C#.

FAQ

Нужно ли явно отписываться от события (-=), если подписчик больше не нужен?
Да, если источник события живет дольше подписчика: ссылка на обработчик держит подписчика живым, и сборщик мусора не удалит его без отписки. Частая причина утечек памяти в долгоживущих UI-объектах.

Можно ли дождаться завершения обработчика события, если он асинхронный (async void)?
Штатно нет: сигнатура события обычно void, и Task для ожидания вызывающий код не получает. Для сценариев, где важно дождаться завершения, событие не подходит — нужен обычный метод, возвращающий Task.

Можно ли сделать мультикаст на std::function в C++ без сторонних библиотек?
Да, вручную: контейнер std::vector<std::function<...>> и вызов элементов по очереди. Минус — без библиотеки нет простого способа отписать конкретный обработчик по значению: std::function не поддерживает сравнение == произвольных вызываемых объектов.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)