JavaRush /Курси /C# SELF /Підписники та безпечний виклик подій

Підписники та безпечний виклик подій

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

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.

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