Підрозділ 6.3
Події
Пояснює події в C#: оголошення через event, підписку обробників, додавання і видалення обробників, керування add/remove та передавання даних події.
6.3. Події
Події сигналізують системі про те, що сталася певна дія. Якщо делегат — це лише посилання на метод, то подія — це спеціальна конструкція, яка обгортає делегат і накладає обмеження на доступ до нього. Зовнішній код може лише підписатися на подію (+=) або відписатися від неї (-=), але не може викликати подію безпосередньо або замінити весь список обробників через звичайне присвоєння. Це важливе архітектурне рішення: клас контролює, коли і чому виникає подія, а зовнішній код лише реагує на неї.
Навіщо потрібні події
Розглянемо клас Patient, який описує пацієнта медичної системи із страховим рахунком:
class Patient
{
public int Balance { get; private set; }
public string Name { get; }
public Patient(string name, int balance) { Name = name; Balance = balance; }
public void AddFunds(int amount) => Balance += amount;
public void Spend(int amount)
{
if (Balance >= amount) Balance -= amount;
}
}Якщо ми захочемо повідомляти про кожне списання страхових коштів, найпростіший варіант — додати Console.WriteLine прямо в метод Spend:
public void Spend(int amount)
{
if (Balance >= amount)
{
Balance -= amount;
Console.WriteLine($"Списано {amount} грн. зі страхового рахунку пацієнта {Name}.");
}
}Але цей підхід має серйозні обмеження. На момент написання класу ми можемо точно не знати, яку саме дію потрібно виконати після списання. У консольному застосунку — це виведення рядка. У графічному WPF-додатку — спливне вікно. У веб-API — запис у лог або відправка HTTP-запиту. Якщо клас Patient планується як бібліотека для різних проектів — жорстко вшитий Console.WriteLine не підходить: інші розробники захочуть реагувати на списання по-своєму, і ми навіть не знаємо заздалегідь як саме. Саме для вирішення цієї проблеми існують події.
Визначення та виклик події
Подія оголошується в класі за допомогою ключового слова event, після якого вказується тип делегата, що представляє подію:
delegate void PatientHandler(string message);
event PatientHandler Notify;Спочатку визначається делегат PatientHandler, що приймає один параметр string. Потім за допомогою event визначається подія Notify, представлена цим делегатом. Назва події може бути довільною, але вона завжди прив'язана до конкретного делегата.
Оголошену подію можна викликати всередині класу як метод:
Notify("Сталося списання");Оскільки Notify представляє делегат PatientHandler, що приймає рядок, — при виклику передається рядок. Якщо жоден обробник не зареєстрований, подія дорівнює null, тому при виклику краще завжди перевіряти:
Notify?.Invoke("Сталося списання");Метод Invoke із оператором умовного null ?. не виконає виклик, якщо подія null, — виняток не виникне.
Поєднаємо все разом — визначимо подію і виклики у повному класі:
Поки жоден обробник не зареєстрований, виклики Notify?.Invoke(...) нічого не роблять — подія null.
Додавання обробника події
Обробник події — це метод, який виконується під час виклику події. Його список параметрів і тип повернення мають відповідати делегату події. Обробник додається через +=:
Метод DisplayMessage відповідає делегату PatientHandler — приймає string, нічого не повертає. При виклику Notify?.Invoke(...) тепер буде виконуватися цей метод. Клас Patient нічого не знає про Console.WriteLine — він лише надсилає повідомлення через подію. Зовнішній код вирішує, що з ним робити.

Додавання та видалення обробників
Для однієї події можна зареєструвати кілька обробників — і в будь-який момент видалити будь-який із них через -=:
Як обробники можна використовувати не лише іменовані методи, а й анонімні методи та лямбда-вирази:
Управління обробниками (аксесори add/remove)
За допомогою спеціальних аксесорів add і remove можна керувати логікою підписки та відписки. Це корисно тоді, коли потрібно, наприклад, записати у лог хто підписався, або обмежити кількість обробників:
Визначення події тепер розбивається на дві частини. Спочатку оголошується приватна змінна делегата _notify, якою клас викликає обробники зсередини. Потім визначаються аксесори: add виконується при операції +=, а remove — при -=. Усередині аксесора обробник, що додається або видаляється, доступний через ключове слово value. Зовнішній код працює з Notify (публічна подія), але реальний список обробників зберігається у _notify.

Передача даних події
Нерідко обробнику події потрібно отримати детальну інформацію про те, що саме сталося — не просто рядок, а структурований об'єкт з кількома полями. Для цього визначається спеціальний клас аргументів події. Додамо клас PatientEventArgs:
Делегат PatientHandler тепер приймає два параметри: перший — об'єкт Patient, що є джерелом події (відправник), другий — PatientEventArgs із деталями операції. Перший параметр this передає посилання на сам об'єкт Patient, тому обробник може звернутися до будь-якого стану пацієнта — наприклад, прочитати поточний баланс. Другий параметр містить повідомлення і суму операції.
Такий патерн — (sender, eventArgs) — є стандартним у .NET і використовується у бібліотечних подіях: Button.Click, Timer.Elapsed, HttpClient — усі вони слідують цій же конвенції. У реальних проектах PatientEventArgs зазвичай успадковують від System.EventArgs, а делегат замінюють вбудованим EventHandler<T>, що буде розглянуто пізніше.
Антипатерн: витік пам'яті через незвільнені підписки
Підписка на подію через += створює посилання від видавця (publisher) до підписника (subscriber). Якщо видавець — довготривалий об'єкт (наприклад, статичний клас або синглтон), а підписник — об'єкт із коротким циклом життя, сміттєзбирач (GC) не зможе видалити підписника, доки він залишається в списку обробників події.
Це — витік пам'яті через події: об'єкти, які мали б зібратися GC, залишаються живими через приховане посилання з події.
Правила безпечної роботи з подіями:
| Сценарій | Рекомендація |
|---|---|
| Підписник живе стільки ж, скільки видавець | Відписка необов'язкова, але бажана для ясності |
| Підписник має коротший цикл життя | Реалізуйте IDisposable, відписуйтесь у Dispose() |
| Підписка через лямбду | Збережіть лямбду у поле, щоб можна було передати в -= |
| Відписатися від усіх одразу | У класі-видавці зробіть метод ClearSubscriptions() |
Правило просте: кожному += має відповідати -= у відповідному місці (Dispose, Close, деструктор або явна очистка).