Делегат в 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 не поддерживает сравнение == произвольных вызываемых объектов.



