1. Вступ
Коли ми пишемо
worker.WorkCompleted += listener.OnWorkCompleted;
ми насправді додаємо вказівник на метод у «список виклику» (multicast delegate) події. Цей «список» усередині події — просто послідовність методів, які буде викликано під час спрацьовування події. У C# подію реалізовано поверх делегата, що підтримує багато підписників.
Уявіть розсилку електронною поштою: у вас є список підписників (електронні адреси). Коли ви надсилаєте розсилку (викликаєте подію), усі підписники отримують лист. Якщо хтось відпишеться, його вилучають зі списку, і він більше листів не отримує.
Як додати або прибрати підписника
Підписка (+=) і відписка (-=) працюють із делегатом усередині події. Ось приклад із лямбдою, яку можна і підписати на подію, і відписати від неї:
EventHandler<WorkCompletedEventArgs> handler = (sender, e) =>
{
Console.WriteLine($"[Лямбда] Роботу завершено: {e.Message}");
};
worker.WorkCompleted += handler; // Підписуємося
worker.WorkCompleted -= handler; // Відписуємося
У випадку звичайних методів відписка виглядає так само:
worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted -= listener.OnWorkCompleted;
Зверніть увагу: якщо ви підписали один і той самий метод кілька разів, він буде викликаний стільки ж разів, і відписуватися доведеться стільки ж разів, викликаючи -=.
2. Навіщо керувати підписками вручну?
Чому важливо керувати підписками?
У реальних застосунках, особливо довготривалих (наприклад, настільних або серверних), неправильне керування підписками може призвести до витоків памʼяті. Якщо обʼєкт-підписник уже нікому не потрібен, але все ще «висить» у списку підписників події, його не видалить збирач сміття, оскільки на нього й далі є посилання з делегата події.
Ілюстрація
| Дія | Підсумок для підписника |
|---|---|
| += (підписалися) | Додано до списку |
| -= (відписалися) | Вилучено зі списку |
| Обʼєкт підписника видалено | Якщо НЕ відписаний! — не буде звільнений, бо все ще є посилання в події |
| Обʼєкт підписника видалено | Якщо ВІДПИСАНИЙ — видалиться нормально |
Як дізнатися, хто підписаний на подію?
Події інкапсулюють список підписників, тому ззовні класу-видавця ви не можете отримати цей список напряму — можна лише додати (+=) або вилучити (-=) обробники.
Втім, усередині класу, де оголошено подію на базі делегата (наприклад, EventHandler), можна отримати поточний список підписників за допомогою методу GetInvocationList():
// Усередині класу-видавця
if (WorkCompleted != null)
{
foreach (Delegate subscriber in WorkCompleted.GetInvocationList())
{
Console.WriteLine($"Обробник: {subscriber.Method.Name}, Обʼєкт: {subscriber.Target}");
}
}
Такий прийом рідко потрібен у повсякденній розробці, але може стати в пригоді для налагодження або реалізації методів масової відписки.
3. Безпечний виклик подій: «міни» та як їх обійти
Що може піти не так під час виклику події?
Здається, все просто: ви викликаєте
WorkCompleted?.Invoke(this, args);
і все чудово працює… Більшу частину часу. Але є нюанси. Ось вони:
1. Багатопотокова небезпека
У багатопотоковому застосунку можлива ситуація, коли між перевіркою події на null і викликом обробників інший потік змінив підписки. Наприклад:
1) Потік A перевіряє: WorkCompleted != null.
2) У цей самий час потік B відписується від події (-=), і список обробників стає порожнім.
3) Потік A намагається викликати WorkCompleted.Invoke(...) — виникає NullReferenceException, бо обробників уже немає.
Це класична гонка даних під час роботи з подіями.
2. Неочікувані винятки в обробниках
Якщо один із підписників кидає виняток під час обробки події, виклик інших обробників переривається. Тобто подія «ламається» на першому ж винятку, і решта підписників не отримує повідомлення. Щоб цього уникнути, рекомендується обгорнути виклик кожного обробника в try-catch, якщо важливо, щоб усі отримали сигнал.
3. Небажаний витік контексту
Обробник події часто є екземплярним методом, який захоплює посилання на обʼєкт-підписник (this). Якщо підписник забув відписатися від події видавця, посилання на нього зберігається в списку делегатів видавця. У результаті збирач сміття не зможе звільнити цей обʼєкт — виникає витік памʼяті.
Як безпечно викликати подію?
1) Копіювати делегат у локальну змінну
Виклик через локальну змінну гарантує, що під час виклику список підписників не зміниться:
// Старий добрий спосіб
var handler = WorkCompleted;
if (handler != null)
{
handler(this, args);
}
Або більш сучасно — з оператором «?.» (null-conditional):
WorkCompleted?.Invoke(this, args);
У більшості випадків цього достатньо, адже компілятор C# «розуміє» цю конструкцію і робить внутрішнє копіювання посилання (див. офіційна документація).
2) Захист від винятків обробників
Якщо критично, щоб усі обробники події були викликані (навіть якщо один упав), використовуйте перебирання вручну:
var handler = WorkCompleted;
if (handler != null)
{
foreach (EventHandler<WorkCompletedEventArgs> subscriber in handler.GetInvocationList())
{
try
{
subscriber(this, args);
}
catch (Exception ex)
{
// Фіксуємо помилку, але не даємо події «звалитися» повністю
Console.WriteLine($"Помилка в обробнику: {ex.Message}");
}
}
}
Такий варіант рідко потрібен для звичайних UI-сценаріїв, але його часто застосовують у бібліотеках, системах журналювання та складних системах.
3) Запобігаємо витокам памʼяті
Якщо підписник «живе» менше, ніж видавець (наприклад, вікно підписалося на подію застосунку), він зобовʼязаний відписатися:
worker.WorkCompleted -= listener.OnWorkCompleted;
Інакше збирач сміття не зможе звільнити listener, навіть якщо на нього більше немає «явних» посилань.
4. Практичний приклад: менеджер масових підписок і відписок
Розширимо наш навчальний застосунок. Уявімо, що в нас є кілька слухачів, яких потрібно динамічно підписувати і знімати з підписки в процесі роботи програми.
public class WorkListener
{
private readonly string _name;
public WorkListener(string name)
{
_name = name;
}
public void OnWorkCompleted(object sender, WorkCompletedEventArgs e)
{
Console.WriteLine($"Слухач {_name}: {e.Message}");
}
}
В основній програмі:
var worker = new Worker();
var listeners = new List<WorkListener>
{
new WorkListener("Іван"),
new WorkListener("Марія"),
new WorkListener("Денис")
};
// Підписуємо всіх слухачів
foreach (var listener in listeners)
worker.WorkCompleted += listener.OnWorkCompleted;
// Викликаємо подію
worker.DoWork();
// Масова відписка
foreach (var listener in listeners)
worker.WorkCompleted -= listener.OnWorkCompleted;
// Перевіряємо, що ніхто більше не реагує
worker.DoWork();
У консолі після першого запуску зʼявиться 3 повідомлення, після другого — жодного.
5. Поради щодо безпечної роботи з подіями
- Вчасно відписуйтеся, якщо життєвий цикл підписника коротший за життєвий цикл видавця.
- Якщо реалізуєте патерн «довгоживучий видавець — тимчасовий підписник», завжди робіть відписку, наприклад, у Dispose(), під час закриття вікна або іншого явного завершення життя обʼєкта.
- Для одноразових подій можна використати анонімний обробник-лямбду і відразу всередині нього відписатися:
EventHandler<WorkCompletedEventArgs> handler = null;
handler = (s, e) =>
{
Console.WriteLine("Подію оброблено один раз!");
worker.WorkCompleted -= handler;
};
worker.WorkCompleted += handler;
- Не зберігайте посилання на підписників чи обробники для перевірки «хто підписаний» — це не потрібно у звичайній бізнес-логіці. Робіть це лише для налагодження.
6. Поширені помилки та як їх уникнути
Помилка №1: забули відписатися від події — витік памʼяті.
Якщо підписник не відписався, особливо у великих застосунках із багатьма подіями та підписниками, обʼєкти можуть залишатися в памʼяті довше, ніж потрібно. Така помилка часто довго не проявляється явно, але призводить до зростання споживання памʼяті та погіршення продуктивності.
Помилка №2: виклик події без перевірки на null.
Якщо у події немає підписників і спробувати викликати її напряму, виникне NullReferenceException. У нових версіях C# виручає оператор безпечного виклику ?., але якщо ви працюєте зі старим кодом або перебираєте обробники вручну, не забувайте перевіряти подію на null.
Помилка №3: виняток в одному з обробників перериває виклик решти.
Якщо один із обробників кидає виняток, наступні обробники не будуть викликані. Якщо важливо, щоб усі підписники отримали сповіщення, перебирайте обробники в циклі й обгорніть виклик кожного в блок try-catch.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ