JavaRush /Курси /C# SELF /Приклади подієвого програмування (

Приклади подієвого програмування ( event)

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

1. Вступ

Типовий новачок може запитати: «Де на практиці застосовуються події? Невже вся взаємодія між класами будується на подіях, а не на прямих викликах методів?» Відповідь проста: події й делегати — не магія, що розвʼязує всі проблеми архітектури. Але без них сучасний застосунок швидко перетворюється на «жорстко зчеплений» (tightly coupled), коли зміна одного компонента тягне за собою потребу змінювати багато інших. Використання подій і делегатів допомагає створювати гнучкі, розширювані й зручні у підтримці системи.

  • UI-програмування (WinForms, WPF, Xamarin, MAUI): обробка кліків, наведення, введення тексту та інших дій користувача.
  • Асинхронні операції: завершення завантаження файла, отримання даних із мережі, спрацювання таймерів.
  • Архітектура плагінів і розширюваних систем: дозволяє підʼєднувати нові модулі без жорсткої інтеграції в основний код.
  • Сигнальні та розсилкові системи: сповіщення багатьох зацікавлених компонентів про події, що сталися.
  • Спостереження за змінами стану («спостерігач»): реакція на появу нового повідомлення, зміну даних, оновлення інтерфейсу.

А тепер — до справи!

2. Архітектурний скелет

Уявімо, що ви розробляєте невеличкий консольний застосунок — «Міні-чат» для внутрішнього навчання в компанії (або просто, щоб удосконалити навички). Є сутності: Користувач, Чат і, можливо, Бот-помічник. Коли користувач надсилає повідомлення, чат має сповістити всіх підʼєднаних користувачів і ботів — аби вони вивели повідомлення на екран або, у випадку бота, згенерували автовідповідь. Це класичний сценарій: видавець генерує подію (event), обробники підписані на делегат EventHandler/EventHandler<TEventArgs>, а метод-обробник на кшталт OnMessageReceived реагує на сповіщення.

// Клас-користувач (підписник)
public class User
{
    public string Name { get; }

    public User(string name)
    {
        Name = name;
    }

    // Метод, який буде обробником події
    public void OnMessageReceived(object? sender, MessageEventArgs e)
    {
        Console.WriteLine($"[{Name}] побачив нове повідомлення: {e.MessageText}");
    }
}

// Аргументи події
public class MessageEventArgs : EventArgs
{
    public string MessageText { get; }

    public MessageEventArgs(string text) => MessageText = text;
}

// Клас-чат (видавець події)
public class ChatRoom
{
    public event EventHandler<MessageEventArgs>? MessageReceived;

    public void SendMessage(string text)
    {
        // Генеруємо подію (сповіщаємо всіх підписників)
        MessageReceived?.Invoke(this, new MessageEventArgs(text));
    }
}

Приклад використання:

var chat = new ChatRoom();
var user1 = new User("Антон");
var user2 = new User("Марія");

chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;

chat.SendMessage("Привіт усім! 😊");

У консолі побачимо два рядки — обидва користувачі отримають сповіщення.

3. Динамічна підписка та відписка

У реальному житті користувач може вийти з чату й більше не бажає отримувати повідомлення. Навчімо користувача відписуватися коректно (оператор -=):

// У класі User можна додати метод «Вийти з чату»
public void Unsubscribe(ChatRoom chat)
{
    chat.MessageReceived -= OnMessageReceived;
}

Продовжимо приклад:

var chat = new ChatRoom();
var user1 = new User("Антон");
var user2 = new User("Марія");

chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;

chat.SendMessage("Перша новина");

user2.Unsubscribe(chat); // Марія виходить із чату

chat.SendMessage("Марія більше не побачить це повідомлення");

Динамічна підписка/відписка трапляється всюди: вікна й вкладки закривають, тимчасові сервіси відписуються від глобальних джерел подій. Не забувайте — підписник, що не відписався, перетворюється на зомбі, а застосунок — на витік памʼяті!

4. Обробка повідомлень і генерація відповіді

Тепер додамо бота, який реагує на кожне повідомлення. Нехай бот каже «Привіт!» автоматично, якщо в повідомленні є слово «бот». Заодно поглянемо на множинну підписку й демонстрацію багатоадресних делегатів.

public class Bot
{
    public string Name { get; }

    public Bot(string name) => Name = name;

    public void OnMessageReceived(object? sender, MessageEventArgs e)
    {
        // Бот реагує на ключове слово
        if (e.MessageText.Contains("бот", StringComparison.OrdinalIgnoreCase))
        {
            if (sender is ChatRoom chatRoom)
            {
                Console.WriteLine($"[Бот {Name}]: Вітаю! Чим можу допомогти?");
                // Бот відправляє повідомлення у відповідь
                chatRoom.SendMessage($"Бот {Name} готовий вам допомогти.");
            }
        }
    }
}

Використовуємо:

var chat = new ChatRoom();
var user = new User("Євген");
var bot = new Bot("Помічник");

chat.MessageReceived += user.OnMessageReceived;
chat.MessageReceived += bot.OnMessageReceived;

// Євген пише повідомлення, яке активує бота
chat.SendMessage("Привіт, бот, як справи?");

Консоль виведе (демонструємо порядок виклику — він не гарантується!):

[Євген] побачив нове повідомлення: Привіт, бот, як справи?
[Бот Помічник]: Вітаю! Чим можу допомогти?
[Євген] побачив нове повідомлення: Бот Помічник готовий вам допомогти.
[Бот Помічник]: Вітаю! Чим можу допомогти?
[Євген] побачив нове повідомлення: Бот Помічник готовий вам допомогти.
[Бот Помічник]: Вітаю! Чим можу допомогти?
...

Хто помітив потенційний баг? Правильно, нескінченний цикл: бот реагує на власні повідомлення (він знову бачить слово «бот»). Один зі способів запобігти цьому — додати просту перевірку:

public void OnMessageReceived(object? sender, MessageEventArgs e)
{
    // Бот не реагує на власні повідомлення
    if (e.MessageText.Contains("бот", StringComparison.OrdinalIgnoreCase) &&
        !e.MessageText.Contains(Name))
    {
        if (sender is ChatRoom chatRoom)
        {
            Console.WriteLine($"[Бот {Name}]: Вітаю! Чим можу допомогти?");
            chatRoom.SendMessage($"Бот {Name} готовий вам допомогти.");
        }
    }
}

На практиці такі ситуації — гарний привід подумати про обробку циклічних подій, лямбда-вирази або навіть механізм скасування подальших обробників (див. попередні лекції).

5. Асинхронні операції та зворотні виклики

Дуже часто події використовують для сповіщення про завершення асинхронної операції. Наприклад, завантаження даних з інтернету або тривалий розрахунок: подія Completed повідомляє про кінець роботи, а метод RunLongOperationAsync виконує довгу операцію.

public class LongRunner
{
    // Подія про завершення роботи
    public event EventHandler<EventArgs>? Completed;

    public async Task RunLongOperationAsync()
    {
        Console.WriteLine("Довга операція почалася...");
        await Task.Delay(2000); // Це імітація довгої роботи
        Console.WriteLine("Операцію завершено, сповіщаємо підписників.");
        Completed?.Invoke(this, EventArgs.Empty);
    }
}

Клієнтський код:

var runner = new LongRunner();

// Підписка на подію завершення
runner.Completed += (sender, e) =>
{
    Console.WriteLine("Отримано сповіщення: операцію завершено!");
};

await runner.RunLongOperationAsync();

Це фундамент асинхронної взаємодії — події часто використовують в UI-фреймворках, щоб сигналізувати про завершення завантаження, кінець анімації, клацання користувача тощо.

6. Сигнальні системи

Розгляньмо інше типове завдання: є система сповіщень (наприклад, в інтернет-магазині, коли на товар знижено ціну). Усі зацікавлені «слухачі» дізнаються про це через подію SaleOccurred:

public class SaleNotifier
{
    public event EventHandler<SaleEventArgs>? SaleOccurred;

    public void AnnounceSale(string product, decimal newPrice)
    {
        SaleOccurred?.Invoke(this, new SaleEventArgs(product, newPrice));
    }
}

public class SaleEventArgs : EventArgs
{
    public string Product { get; }
    public decimal NewPrice { get; }
    public SaleEventArgs(string product, decimal price)
    {
        Product = product; NewPrice = price;
    }
}

public class Customer
{
    public string Name { get; }
    public Customer(string name) => Name = name;

    public void OnSale(object? sender, SaleEventArgs e)
    {
        Console.WriteLine($"[{Name}] отримав сповіщення: {e.Product} тепер коштує {e.NewPrice.ToString("F2")} у.о.!");
    }
}

Використовуємо:

var notifier = new SaleNotifier();
var c1 = new Customer("Андрій");
var c2 = new Customer("Ольга");

notifier.SaleOccurred += c1.OnSale;
notifier.SaleOccurred += c2.OnSale;

notifier.AnnounceSale("Чайник", 999.99m);

Якщо один із клієнтів більше не зацікавлений — відписуємо його:

notifier.SaleOccurred -= c2.OnSale;
notifier.AnnounceSale("Міксер", 1999.99m);

Такий шаблон використовують для розсилки сигналів (notification, pub/sub), що дуже важливо в сучасних архітектурах.

7. Реалізація механізму скасування ланцюжка подій

Іноді один із обробників має «зупинити» подальше сповіщення. Зазвичай для цього використовують нащадка EventArgs із прапорцем скасування. У прикладі нижче ми вручну перебираємо GetInvocationList() і припиняємо виконання, коли Cancel = true.

public class CancelEventArgs : EventArgs
{
    public bool Cancel { get; set; }
}

public class EventSource
{
    public event EventHandler<CancelEventArgs>? SomethingHappened;

    public void DoSomething()
    {
        var args = new CancelEventArgs();
        // Класичний цикл для перебору підписників (якщо потрібен контроль над порядком)
        var handlers = SomethingHappened?.GetInvocationList();
        if (handlers != null)
        {
            foreach (var handler in handlers)
            {
                ((EventHandler<CancelEventArgs>)handler)(this, args);
                if (args.Cancel)
                {
                    Console.WriteLine("Ланцюжок повідомлень перервано.");
                    break;
                }
            }
        }
    }
}

Використовуємо:

var source = new EventSource();
source.SomethingHappened += (s, e) =>
{
    Console.WriteLine("Перший обробник");
};
source.SomethingHappened += (s, e) =>
{
    Console.WriteLine("Другий обробник скасовує подію.");
    e.Cancel = true;
};
source.SomethingHappened += (s, e) =>
{
    Console.WriteLine("Цей обробник не буде викликано.");
};

source.DoSomething();

Результат:

Перший обробник
Другий обробник скасовує подію.
Ланцюжок повідомлень перервано.

Такий механізм стане у пригоді для валідації, під час обробки подій перед закриттям вікна, перевірки дозволів та в інших завданнях, де важливо мати змогу сказати: «Стоп! Далі не обробляємо.»

8. Потокобезпечність і керування підписниками

У багатопотокових застосунках, де підписники можуть додаватися й видалятися одночасно з генерацією події, важливо використовувати потокобезпечні шаблони (див. офіційна документація). Для контролю можна реалізувати подію вручну з аксесорами add і remove і захищати доступ через lock:

public class CustomEvent
{
    private EventHandler? _handlers;

    public event EventHandler SomethingHappened
    {
        add
        {
            lock (this) // Потокобезпечно
            {
                _handlers += value;
            }
        }
        remove
        {
            lock (this)
            {
                _handlers -= value;
            }
        }
    }

    protected void RaiseEvent()
    {
        // Використовуємо копію
        EventHandler? handler;
        lock (this)
        {
            handler = _handlers;
        }
        handler?.Invoke(this, EventArgs.Empty);
    }
}

Цей підхід трапляється рідко (зазвичай стандартного механізму достатньо), але іноді потрібен для високонавантажених систем.

9. Події в UI — у WinForms/WPF/MAUI

WinForms:

private void button1_Click(object sender, EventArgs e)
{
    MessageBox.Show("Кнопку натиснуто!");
}

За цим методом ховається механізм підписки:

button1.Click += button1_Click;

WPF/MAUI: події типу PropertyChanged (сповіщення про зміну властивостей моделі):

public class MyViewModel : INotifyPropertyChanged
{
    private string _value;
    public string Value
    {
        get => _value;
        set
        {
            if (_value != value)
            {
                _value = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value));
            }
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;
}

Фреймворки активно використовують події для побудови реактивних інтерфейсів (MVVM).

10. Типові помилки й граблі під час роботи з подіями

Помилка №1: витоки памʼяті.
Це найчастіша проблема. Якщо короткоживучий обʼєкт (наприклад, вікно або тимчасовий сервіс) підписується на подію довгоживучого обʼєкта (наприклад, глобального кешу чи статичного класу) і не відписується під час свого знищення, він назавжди залишиться в памʼяті. Завжди реалізовуйте відписку в методі Dispose або в іншому доречному місці життєвого циклу обʼєкта.

Помилка №2: створення нескінченних циклів подій.
Підписник може відреагувати на подію, викликавши дію, яка знову генерує ту саму подію. Це призводить до нескінченної рекурсії та падіння застосунку з StackOverflowException. Завжди перевіряйте умови, щоб обробник не реагував на події, які він же і спровокував.

Помилка №3: залежність від порядку виклику підписників.
Ніколи не покладайтеся на те, що обробники подій будуть викликані в тому ж порядку, у якому їх було підписано. Специфікація C# і реалізація CLR цього не гарантують. Якщо порядок важливий, викликайте підписників вручну через GetInvocationList() у потрібній послідовності.

Помилка №4: небезпечна робота з подіями у багатопотоковому середовищі.
Якщо підписники додаються або видаляються з одного потоку, а подію викликають з іншого, може виникнути стан гонки. Класичний захист — копіювати делегат у локальну змінну перед викликом:

var handler = MyEvent;
handler?.Invoke(this, EventArgs.Empty);

Або використовувати lock під час ручної реалізації подій через add/remove.

1
Опитування
Типові помилки з делегатами, рівень 54, лекція 4
Недоступний
Типові помилки з делегатами
Кращі практики подієвого програмування
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ