1. Вступ
Події в C# — це не просто змінні, у які можна записати делегат. Це захищений список обробників, і лише власник події може запустити її виконання; інші можуть тільки додавати (+=) або прибирати (-=) свої реакції.
Наприклад, ось так можна підписатися на подію:
worker.WorkCompleted += Worker_WorkCompleted;
З першого погляду це схоже на звичайне додавання, але насправді працює інакше. Під капотом подія зберігає ланцюжок викликів («invocation list») — набір делегатів, які треба викликати під час спрацювання події. Коли ви пишете +=, до цього списку додається новий обробник.
Розберімося детальніше, що відбувається всередині, як формується цей ланцюжок і які нюанси можуть виникнути під час підписки.
Що таке ланцюжок делегатів?
Делегати в C# — «мультикастові»: їм можна призначити кілька методів, і під час виклику делегата вони виконуються по черзі. Події користуються цим механізмом: їхнє значення — це, по суті, делегат зі списком обробників.
У термінах коду:
public event EventHandler<WorkCompletedEventArgs> WorkCompleted;
Коли хтось підписується:
worker.WorkCompleted += MyHandler;
Під капотом C# робить приблизно так:
- Бере поточний делегат (список обробників).
- Викликає для нього метод Delegate.Combine (об’єднує обробники).
- Записує оновлений ланцюжок назад у змінну події.
Схематично:
| Операція | Внутрішній список обробників події |
|---|---|
| До | null або [Handler1] |
| Після += Handler2 | [Handler1, Handler2] |
| Після ще += Handler3 | [Handler1, Handler2, Handler3] |
Цікавий факт: механізм мультикаст-делегатів — це не «магія», а цілком конкретна реалізація: під капотом делегат містить масив методів, які треба викликати.
2. Як працює підписка: пояснення на пальцях
Розгляньмо все на конкретному прикладі. Припустімо, у нас є видавець (Worker) і підписники (Logger, Notifier):
public class Worker
{
public event EventHandler<WorkCompletedEventArgs> WorkCompleted;
public void DoWork()
{
// ... робота ...
OnWorkCompleted("Завдання завершено!");
}
protected virtual void OnWorkCompleted(string message)
{
WorkCompleted?.Invoke(this, new WorkCompletedEventArgs { Message = message });
}
}
public class Logger
{
public void LogWorkCompleted(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine("Лог: " + e.Message);
}
}
public class Notifier
{
public void ShowNotification(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine("Сповіщення: " + e.Message);
}
}
У Main:
var worker = new Worker();
var logger = new Logger();
var notifier = new Notifier();
worker.WorkCompleted += logger.LogWorkCompleted;
worker.WorkCompleted += notifier.ShowNotification;
// Запуск роботи
worker.DoWork();
Коли викликається OnWorkCompleted, подія спершу викличе logger.LogWorkCompleted, потім notifier.ShowNotification (у тому порядку, у якому ви підписалися).
3. Корисні нюанси
Візуалізація: як подія зберігає обробники
+---------------------+
| Worker |
|---------------------|
| WorkCompleted Event | ---> [ LogWorkCompleted, ShowNotification ]
+---------------------+
Під час підписки кожний новий обробник «чіпляється» до наявного списку. Після виклику події делегат по черзі викликає всі підписані методи.
Множинна підписка одним методом
worker.WorkCompleted += logger.LogWorkCompleted;
worker.WorkCompleted += logger.LogWorkCompleted; // Двічі!
У такому разі обробник буде викликано стільки разів, скільки він підписаний — відповідно, тут двічі поспіль.
Підписка лямбда-виразом
worker.WorkCompleted += (sender, e) => Console.WriteLine("Анонімний обробник: " + e.Message);
Якщо таку лямбду підписати кілька разів — аналогічно, її буде викликано кілька разів під час кожної події. Але не втрачайте пильності: у кожної лямбди свій об’єкт-делегат, просто так відписатися не вийде (детальніше див. у лекції 260 і далі).
Як працює підписка всередині: розбір на низькому рівні
Подія — це особлива властивість із двома методами доступу (add/remove), які викликаються під час використання += і -=. Якщо спростити, то компілятор генерує приблизно такий код:
// Приблизно так (спрощено)
public event EventHandler<WorkCompletedEventArgs> WorkCompleted
{
add { /* код додавання обробника */ }
remove { /* код видалення обробника */ }
}
За замовчуванням використовується стандартна реалізація: делегат комбінується за допомогою Delegate.Combine і видаляється через Delegate.Remove.
Це захищає подію: ззовні не можна викликати її напряму (ніяких worker.WorkCompleted(…);), можна лише підписатися або відписатися.
Механіка підписки: малюємо схему
+----------------------+
| |
v v
+--------------------+ +----------------------+
| LogWorkCompleted | | ShowNotification |
+--------------------+ +----------------------+
^ ^
\______________________/
^
|
WorkCompleted Event
Так званий «Invocation List» — ланцюжок викликів.
Навіщо це важливо: практичне значення
Розуміння механіки підписки — ключ до керування зв’язками між об’єктами. Ви можете будувати складні системи, де компоненти динамічно підписуються та відписуються від подій, не створюючи жорстких залежностей. Це стандарт у UI-фреймворках, ігрових рушіях, серверних застосунках і навіть у сучасних архітектурах мікросервісів (там це вже на рівні черг, але ідея та сама).
На співбесідах часто питають, як працює подієва модель C#, чому події роблять систему гнучкою та як правильно керувати життєвим циклом підписки.
4. Що можна (і не можна) робити ззовні класу
Можна
- Підписатися на подію (+=)
- Відписатися (-=)
Не можна
- Викликати подію напряму
- Присвоїти події делегат напряму (worker.WorkCompleted = ... — помилка!)
Ці обмеження забезпечує ключове слово event. Якби ви оголосили делегат як звичайне поле:
public EventHandler<WorkCompletedEventArgs> WorkCompleted; // не event!
— хто завгодно міг би робити що завгодно, зокрема скидати список обробників, що спричинило б безлад і потенційні помилки. Саме тому майже завжди використовуйте лише event!
Чи можна підписатися одним методом на кілька подій?
Так! Це називають «мультипідписка». Наприклад:
worker.WorkCompleted += logger.LogWorkCompleted;
anotherWorker.WorkCompleted += logger.LogWorkCompleted;
Якщо ви підписали один і той самий метод на події різних об’єктів, обробник спрацює і на ту, і на іншу подію. Усередині обробника можна зрозуміти, хто викликав подію, за параметром sender.
Реальний кейс: динамічні підписки
Уявімо, що маємо застосунок, у якому користувач може запускати кілька задач паралельно. Для кожної нової задачі створюється об’єкт Worker, на подію якого підписується один і той самий обробник — наприклад, метод логера logger.LogWorkCompleted.
var allWorkers = new List<Worker>();
for (int i = 0; i < 10; i++)
{
var w = new Worker();
w.WorkCompleted += logger.LogWorkCompleted;
allWorkers.Add(w);
}
У результаті коли будь-яка з задач завершиться, логер отримає сповіщення та запише, що і коли сталося.
Практичні моменти: як побачити підписників події
У звичайному коді дізнатися, скільки обробників підписано на подію, напряму неможливо (подія інкапсулює делегат). Втім, усередині класу-видавця ви можете працювати з делегатом події напряму, наприклад, подивитися його GetInvocationList():
// Тільки всередині класу-видавця
var handlers = WorkCompleted?.GetInvocationList();
if (handlers != null)
Console.WriteLine($"Підписників: {handlers.Length}");
Це стане в пригоді, якщо треба зробити нестандартну логіку розсилки або відладки (хоча краще так робити лише з навчальною метою!).
5. Типові помилки та особливості
Обробник не додано? Виклику не буде!
Якщо ви не підписалися, обробник не буде викликано. Перед викликом події завжди є перевірка на null (інакше буде NullReferenceException).
Підписка кілька разів
Якщо ви підписалися кілька разів на одну подію тим самим методом — обробник спрацює стільки ж разів. Іноді це пастка: випадковий дублювальний виклик += перетворює одне повідомлення на кілька однакових.
Статичні/нестатичні методи
Можете підписувати як статичні, так і екземплярні методи. Головне — правильна сигнатура.
public static void StaticHandler(object? sender, WorkCompletedEventArgs e) { /* ... */ }
worker.WorkCompleted += StaticHandler;
Лямбди та підписка всередині циклу
Якщо ви, наприклад, у циклі підписуєтеся лямбдами, переконайтеся, що розумієте, що відбувається зі змінними, які лямбда «захоплює». Легко випадково захопити не те значення, яке ви очікували.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ