JavaRush /Курси /C# SELF /Типові помилки з делегатами та подіями

Типові помилки з делегатами та подіями

C# SELF
Рівень 54 , Лекція 0
Відкрита

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>. Колеги і ви самі в майбутньому скажете «дякую».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ