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