1. Вступ
Працювати з делегатами та подіями в C# приємно й зручно — мова робить багато за вас. Але за цією ширмою ховається чимало підводних каменів: невидимі витоки пам’яті, дивні баґи від подвійної підписки, розсинхрон обробників і навіть раптові винятки під час розсилки сповіщень. Делегати й події мають потужні можливості, але вимагають уважної роботи з життєвим циклом об’єктів, розуміння потоків і знання того, як саме працюють виклики та відписки. Якщо ваші події працюють «майже завжди», але інколи не спрацьовують або спричиняють дивні помилки, — ви точно не одні! Розберімося, де найчастіше помиляються навіть досвідчені програмісти і як цього уникнути.
Таблиця основних помилок
| Помилка | Наслідок | Як уникнути |
|---|---|---|
| Подвійна підписка | Обробник викликається кілька разів | Стежити за підпискою, знімати перед додаванням |
| Відсутність відписки (витік пам’яті) | Підписники «висять» у пам’яті, стають «зомбі» | Завжди відписуватися, використовувати IDisposable |
| Виняток в обробнику | Решту обробників не буде виконано | try/catch в обробниках або обхід вручну |
| Модифікація підписок під час події | Пропуски, дублювання викликів | Обхід копії списку обробників (GetInvocationList()) |
| Виклик події, коли MyEvent == null | NullReferenceException | Перевіряти на null, використовувати ?.Invoke |
| Сигнатура обробника не збігається | Помилка компіляції | Перевіряти сигнатуру |
| Виклик події ззовні | Помилка компіляції | Виклик тільки через OnEventName |
| Статичні події не там | Змішування підписок | Не робити static без великої потреби |
| Проблеми із замиканнями в лямбдах | Неочікувані значення | Робити копію змінної |
2. Множинна підписка і множинні виклики
Суть помилки
Якщо ви кілька разів підписуєте один і той самий обробник на одну й ту саму подію, кожен виклик += додає ваш метод у чергу делегата. У підсумку обробник виконається стільки разів, скільки разів його додали.
Як це проявляється?
Уявіть, що у вас є кнопка та обробник натискання:
Button btn = new Button();
btn.Click += OnButtonClick; // Підпишемося
btn.Click += OnButtonClick; // А ось і повторна підписка!
Тепер за кожного натискання кнопки метод OnButtonClick викличеться двічі. Якщо всередині обробника ви, скажімо, оновлюєте лічильник або додаєте запис у журнал, побачите подвоєні результати.
Як це виправити?
Зазвичай повторна підписка стається через невдалу структуру коду — наприклад, якщо += винесено в метод, який викликається кілька разів (на різних етапах життєвого циклу форми).
- Стежте, де саме відбувається підписка.
- Не виносьте += на етапи життєвого циклу, які можуть викликатися багаторазово.
- Іноді зручно робити «унікальну» підписку — перед додаванням обробника спершу зняти його:
myEvent -= MyHandler; // Про всяк випадок знімаємо
myEvent += MyHandler; // Потім знову підписуємо
Це безпечно: якщо обробника ще не було, -= нічого не змінить.
3. Зомбі-підписник
Суть помилки
Якщо підписник підписався на подію довгоживучого видавця і не відписався, збирач сміття не зможе його зібрати: видавець усе ще тримає посилання на делегат обробника, а отже — на весь об’єкт підписника. У результаті — витоки пам’яті.
Типовий приклад
public class TemporaryPopup : IDisposable
{
private Window _hostWindow;
public TemporaryPopup(Window window)
{
_hostWindow = window;
_hostWindow.Closed += OnHostClosed;
}
private void OnHostClosed(object sender, EventArgs e)
{
// ...
}
public void Dispose()
{
_hostWindow.Closed -= OnHostClosed; // Не забудьте відписатися!
}
}
Якщо забути про Dispose() або не викликати його, то навіть якщо ви видалите всі посилання на TemporaryPopup, об’єкт не буде знищений — вікно все ще посилається на його обробник.
Як цього уникнути?
- Реалізуйте IDisposable у підписниках, якщо їх життєвий цикл коротший за життєвий цикл видавця.
- Використовуйте інструкцію using або явно викликайте Dispose():
using (var popup = new TemporaryPopup(mainWindow))
{
// ...
} // Тут Dispose викличеться автоматично
У GUI‑застосунках відписуйтеся під час закриття вікна/форми (наприклад, в обробниках закриття або в Dispose() форми).
4. Обробка винятків усередині обробників
Суть помилки
Коли подія викликає десятки обробників, а один із них кидає виняток, решта вже не виконаються.
Демонстрація
public event EventHandler MyEvent;
public void Raise()
{
MyEvent?.Invoke(this, EventArgs.Empty);
}
Якщо в одному з методів, підписаних на MyEvent, станеться виняток, решту не буде викликано — ланцюжок обірветься.
Як працювати з такими помилками?
- В обробниках подій або обробляйте винятки локально (try/catch), або свідомо повторно їх викидайте.
- У складних сценаріях обходьте список підписників вручну і ізолюйте помилки кожного:
var handlers = MyEvent?.GetInvocationList();
foreach (var handler in handlers)
{
try
{
handler.DynamicInvoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
// Логування, аварійне відновлення
}
}
5. Зміна списку підписників під час розсилки подій
Суть помилки
Якщо обробник усередині події відписує себе або інших, це може спотворити порядок викликів: деякі обробники буде пропущено, а деякі викликано повторно.
Як цього уникнути?
- Не змінюйте підписки з обробників.
- Якщо потрібно — обходьте копію списку делегатів:
var handlers = MyEvent?.GetInvocationList();
foreach (EventHandler handler in handlers)
{
handler(this, EventArgs.Empty);
}
6. Плутанина з null-значенням делегата (немає підписників)
Суть помилки
Якщо на подію ніхто не підписаний, її делегат дорівнює null. Виклик без перевірки призведе до NullReferenceException.
Приклад (помилковий)
public event EventHandler MyEvent;
public void Raise()
{
MyEvent(this, EventArgs.Empty); // Якщо підписників немає — виняток!
}
Як правильно?
- Використовуйте безпечний виклик: MyEvent?.Invoke(this, EventArgs.Empty).
- Або застосовуйте класичний, потокобезпечний прийом: скопіюйте делегат у локальну змінну й викликайте її.
7. Змішування делегатів/подій різних сигнатур
Суть помилки
Делегати суворо типізовані. Невідповідність сигнатури методу-обробника і делегата події — помилка компіляції.
Приклад
public event EventHandler<string> TextChanged;
void WrongHandler(object sender, int number) { /* ... */ }
TextChanged += WrongHandler; // Помилка компіляції!
Використовуйте стандартні делегати EventHandler і EventHandler<T>, і стежте за точним збігом сигнатур.
8. Спроба викликати подію поза класом-видавцем
Суть помилки
event — це інкапсульований делегат: зовнішній код не може викликати його напряму (доступні лише додавання/видалення обробників).
Приклад
public class MyPublisher
{
public event EventHandler SomethingHappened;
}
var publisher = new MyPublisher();
publisher.SomethingHappened?.Invoke(publisher, EventArgs.Empty); // Помилка!
Як правильно?
Виклик через захищений/публічний метод видавця — зазвичай OnEventName. Ззовні — лише +=/-=.
9. Помилки з аксесорами add і remove
Суть помилки
Власні аксесори для подій дають змогу контролювати підписку, але легко порушити правильний порядок викликів або потокобезпеку.
Приклад
public event EventHandler MyEvent
{
add { /* ... */ }
remove { /* ... */ }
}
Якщо не впевнені — користуйтеся стандартною реалізацією подій. Під час ручної реалізації майте під рукою документацію та враховуйте синхронізацію.
10. Доступ до подій зі статичних і нестатичних контекстів
Суть помилки
Легко випадково оголосити подію static там, де вона має бути пов’язаною з екземпляром. Тоді всі об’єкти ділитимуть спільну чергу підписників.
Приклад
public static event EventHandler GlobalEvent; // Ой!
// Екземпляри втрачають індивідуальність, підписки зливаються в одну купу
Як уникнути?
Робіть подію статичною лише коли справді потрібен глобальний рівень (наприклад, глобальний журнал). В інших випадках — інкапсуляція на рівні екземпляра.
11. Проблеми із захопленням змінних у лямбда-виразах
Суть помилки
Лямбда-вирази захоплюють змінні за посиланням. У циклах це часто призводить до «останнього значення».
Приклад
for (int i = 0; i < 5; i++)
{
button.Click += (s, e) => Console.WriteLine(i);
}
// Усі обробники виведуть 5
Як правильно?
for (int i = 0; i < 5; i++)
{
int copy = i; // Локальна копія
button.Click += (s, e) => Console.WriteLine(copy);
}
12. Змішування слабких і сильних посилань: розширена тема «Weak Events»
У великих застосунках (наприклад, WPF) використовується механізм «слабких подій», де видавець тримає слабке посилання на підписника, щоб не заважати збирачеві сміття. Слабкі події допомагають проти витоків, але підписника можуть зібрати — і він не отримає подію.
Докладніше: Weak Event Patterns (Microsoft Learn)
13. Відсутність стандарту найменувань і сигнатур подій
Суть помилки
Дотримуйтеся стандартних сигнатур і назв: події — у формі минулого часу (Changed, Closed, Completed), аргументи — похідні від EventArgs.
Приклад «неправильно»:
public delegate void SomethingHappens(int what);
// ...
public event SomethingHappens Something;
Приклад «правильно»:
public event EventHandler<EventArgs> SomethingHappened;
Для власних подій майже завжди використовуйте EventHandler або EventHandler<T>. Колеги і ви самі в майбутньому скажете «дякую».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ