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.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ