1. Практичні сценарії використання подій
Події — не просто красива теорія з підручників. На практиці вони потрібні майже в кожному другому застосунку, а якщо ви працюєте із UI або мережевими сервісами — то в більшості випадків.
Розгляньмо кілька типових сценаріїв з реальної розробки. Погляньмо також, як стандарти та найкращі практики допомагають уникати поширених помилок.
Реагуємо на дії користувача (як у UI)
Мабуть, найчастіший сценарій — коли користувач щось робить (натискає кнопку, обирає елемент у списку), і застосунок має відреагувати. Саме так працюють Windows Forms, WPF, UWP, Avalonia, MAUI та інші UI‑фреймворки.
Міні‑приклад (без UI — імітуємо кнопку):
public class Button
{
public event EventHandler? Click; // стандартний делегат
public void SimulateClick()
{
Click?.Invoke(this, EventArgs.Empty); // "Кнопку" було "натиснуто"
}
}
class Program
{
static void Main()
{
var button = new Button();
button.Click += (sender, e) => Console.WriteLine("Кнопку натиснули!");
button.SimulateClick(); // > Кнопку натиснули!
}
}
У реальному UI‑фреймворку подія Click спрацьовує, коли користувач натискає кнопку миші або торкається екрана. Усе, що підписалося на цю подію, отримає сповіщення — так і працює логіка застосунків.
Сигналізація прогресу, завершення операцій і помилок
Уявіть: завантаження файла, обробка даних, довготривале обчислення. Потрібно інформувати про прогрес або про помилки. Часто організовують подію на «крок прогресу» та окрему подію на «завершено» або «помилка».
Приклад завантажувача файлів:
public class FileDownloader
{
public event EventHandler<int>? ProgressChanged;
public event EventHandler? DownloadCompleted;
public event EventHandler<string>? DownloadFailed;
public void Download()
{
for (int i = 1; i <= 100; i += 10)
{
Thread.Sleep(50); // імітація затримки
ProgressChanged?.Invoke(this, i);
}
DownloadCompleted?.Invoke(this, EventArgs.Empty);
}
}
var downloader = new FileDownloader();
downloader.ProgressChanged += (s, progress) => Console.WriteLine($"Завантаження: {progress}%");
downloader.DownloadCompleted += (s, e) => Console.WriteLine("Завантаження завершено.");
downloader.Download();
Тут одразу кілька подій: можна підписатися відразу на все (або тільки на потрібні). Це схоже на те, як працюють багато стандартних .NET‑класів для потоків, завантаження файлів, роботи з HTTP тощо.
Реакція на життєвий цикл об’єктів (наприклад, «збережено», «видалено»)
Припустімо, у вас є доменна модель. Ви хочете, щоб коли користувач зберіг або видалив об’єкт, деякі процеси відреагували: оновили кеш, журнал, надіслали повідомлення тощо.
public class UserRepository
{
public event EventHandler<UserEventArgs>? UserSaved;
public event EventHandler<UserEventArgs>? UserDeleted;
public void Save(User user)
{
//... зберігаємо користувача
UserSaved?.Invoke(this, new UserEventArgs(user));
}
public void Delete(User user)
{
//... видаляємо користувача
UserDeleted?.Invoke(this, new UserEventArgs(user));
}
}
public class UserEventArgs : EventArgs
{
public User User { get; }
public UserEventArgs(User user) => User = user;
}
Тепер різні модулі можуть підписатися й — без прямих зв’язків! — отримувати сповіщення про всі зміни користувачів. При цьому сам репозиторій не знає, хто під’єднався.
Асинхронні події та багатопоточність
Іноді події генеруються не з основного (UI) потоку — наприклад, із фонових задач, таймерів або асинхронних операцій. У таких випадках важливо пам’ятати, що код обробників виконуватиметься в «чужому» потоці. Якщо обробник намагається оновити елементи інтерфейсу безпосередньо з іншого потоку — це призводить до помилок.
Що таке маршалінг? Маршалінг — це передача виконання коду з одного потоку в інший, зазвичай із фонового потоку назад у UI‑потік, щоб безпечно оновлювати інтерфейс. В UI‑застосунках (WinForms, WPF) для цього використовують механізми на кшталт SynchronizationContext або Dispatcher, які дозволяють «перенаправити» виклик обробника на потрібний потік.
Так не робіть:
// Подію викликають із фонового потоку, а обробник оновлює UI безпосередньо — отримаєте виняток!
Перевіряйте потік виконання: за потреби виконуйте маршалінг в UI‑потік (через SynchronizationContext або Dispatcher). У консольних і серверних застосунках таких обмежень зазвичай немає.
Event Aggregator / Messaging
У великих застосунках часто використовують централізований агрегатор подій (Event Aggregator). Він зменшує зв’язність: підписники та видавці не знають один про одного і обмінюються повідомленнями через центр.
public class EventAggregator
{
public event EventHandler<SomeEventArgs>? SomeEvent;
public void Publish(SomeEventArgs args)
{
SomeEvent?.Invoke(this, args);
}
}
2. Найкращі патерни та практики
Використовуйте атрибут [CallerMemberName] для помилок
Якщо створюєте інфраструктурний код (наприклад, логери, трейсери), логуйте імʼя методу‑джерела події за допомогою атрибута CallerMemberName — це спрощує діагностику.
Намагайтеся робити події потокобезпечними за потреби
Події мультикастові, а виклики з різних потоків потребують обережності: перед викликом скопіюйте делегат у локальну змінну.
var handler = MyEvent;
if (handler != null) handler(this, args);
У C# 6+ використовуйте безпечний виклик: MyEvent?.Invoke(this, args).
Створюйте власні EventArgs як незмінні
Визначайте свої типи EventArgs з властивостями лише для читання — це запобігає випадковій зміні стану події підписниками.
public class WorkCompletedEventArgs : EventArgs
{
public string Message { get; }
public WorkCompletedEventArgs(string message) => Message = message;
}
public чи private event
Робіть події public event, а виклик інкапсулюйте у protected virtual метод OnEventName. Це забезпечує розширюваність (нащадок може перевизначити поведінку), а зовнішні споживачі не зможуть викликати подію безпосередньо.
Не порушуйте послідовність життєвого циклу
Якщо ви ініціюєте довгий ланцюжок обробників, пам’ятайте: хтось може підписатися і спробувати змінити внутрішню логіку. Документуйте, де й коли викликаються події, і чи може подія спрацювати кілька разів.
Множинні події та композиція
Коли один об’єкт слухає кілька джерел, групуйте підписку та відписку в одному місці (наприклад, методи Subscribe/Unsubscribe). За потреби зберігайте приватні поля‑делегати для зручної відписки.
3. Як НЕ варто використовувати події: типові помилки
Помилка № 1: ігнорування стандартного патерну подій.
Якщо кожен клас оголошує свої власні делегати для кожної події, це швидко перетворюється на хаос. Намагайтеся використовувати стандартні делегати EventHandler або EventHandler<T>, де T наслідується від EventArgs. Такий підхід робить код зрозумілішим і звичнішим для .NET‑розробників.
Помилка № 2: некоректний виклик події із зовнішнього коду.
Ніколи не викликайте подію безпосередньо поза класом‑видавцем. Подію ініціюють лише всередині класу, зазвичай через захищений метод OnEventName. Підписники мають право лише підписуватися (+=) і відписуватися (-=).
Погано:
foo.MyEvent(); // помилка! Не можна викликати подію ззовні напряму
Добре:
// Лише всередині foo:
// protected virtual void OnMyEvent()
// {
// MyEvent?.Invoke(this, EventArgs.Empty);
// }
Помилка № 3: виклик події без перевірки на наявність підписників (null).
Якщо спробувати викликати подію, у якої немає підписників, отримаєте NullReferenceException. У C# 6.0+ використовуйте ?.Invoke(...) — подія буде викликана лише за наявності підписників.
Помилка № 4: забути відписатися від події (витік памʼяті).
Якщо об’єкт підписався на подію, але не відписався перед власним знищенням, видавець утримує посилання на підписника. Збирач сміття не зможе звільнити пам’ять, що критично для довготривалих видавців і короткочасних підписників (наприклад, ViewModel, форми). Найкраще рішення — явна відписка або використання слабких посилань (WeakReference) там, де це доречно.
Помилка № 5: викликати подію поза захищеним віртуальним методом OnEvent.
Викликаючи подію безпосередньо, ви позбавляєте нащадків можливості коректно перевизначати поведінку. Правильний патерн — оголошувати protected virtual метод, який викликає подію.
Стандартний приклад:
protected virtual void OnSomething(EventArgs e)
{
Something?.Invoke(this, e);
}
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ