1. Введение
Представьте, что у вас есть объект, который выполняет какие-то действия — например, кнопка или наш Worker. При этом существует множество других объектов, которые должны реагировать на эти действия. Если в классе Worker жёстко прописывать всех возможных «слушателей», то поддержка такого кода превратится в настоящий кошмар: любые изменения в списке подписчиков потребуют правок внутри самого Worker.
Это нарушает принцип открытости-закрытости (OCP) и считается плохой архитектурной практикой.
Паттерн Observer: общая идея
Паттерн "Наблюдатель" (Observer) решает эту проблему. Он позволяет объекту-издателю оповещать любое количество заинтересованных объектов-слушателей о произошедших изменениях, не зная ничего о том, кто эти слушатели и что они делают. Издатель просто "бросает клич", а желающие реагируют, как хотят.
Аналогия: подписка на рассылку новостей. Редакция или канал (издатель) отправляет новое письмо, а все подписчики (наблюдатели) получают его. Редакция не знает, кто все эти люди, да ей это и не нужно.
Интересный факт: "Наблюдатель" настолько популярен, что официально входит в "банду четырех" паттернов проектирования (GoF).
Observer в C#: воплощение через события и делегаты
В C# паттерн "Наблюдатель" реализован "из коробки" через механизмы событий и делегатов. Событие — это "точка расширения", на которую могут подписываться разные обработчики. Вместо ручного ведения списка подписчиков всё делает языковой механизм событий. Ниже посмотрим на «ручную» реализацию, а затем на реализацию через события.
2. Классическая реализация Observer без использования событий
Рассмотрим, как бы это могло выглядеть, если бы событий в языке не было:
// Интерфейс наблюдателя
public interface IObserver
{
void Update(string message);
}
// Издатель
public class Worker
{
private List<IObserver> observers = new List<IObserver>();
public void Subscribe(IObserver observer)
{
observers.Add(observer);
}
public void Unsubscribe(IObserver observer)
{
observers.Remove(observer);
}
public void DoWork()
{
Console.WriteLine("Worker работает...");
NotifyObservers("Работа завершена!");
}
private void NotifyObservers(string message)
{
foreach (var observer in observers)
{
observer.Update(message);
}
}
}
// Конкретный наблюдатель
public class WorkListener : IObserver
{
public void Update(string message)
{
Console.WriteLine($"
WorkListener получил сообщение: {message}");
}
}
Инициализация:
var worker = new Worker();
var listener = new WorkListener();
worker.Subscribe(listener);
worker.DoWork();
На заметку: Здесь список подписчиков (List<IObserver> observers) ведется вручную, и подписка/отписка — явные методы Subscribe/Unsubscribe.
3. События и делегаты — "высокоуровневая" реализация Observer
Мы можем реализовать то же самое проще и изящнее с помощью событий. Это и есть Observer в стиле C#:
public class Worker
{
public event EventHandler<WorkCompletedEventArgs>? WorkCompleted;
public void DoWork()
{
Console.WriteLine("Worker работает...");
OnWorkCompleted("Работа завершена!");
}
protected virtual void OnWorkCompleted(string message)
{
WorkCompleted?.Invoke(this, new WorkCompletedEventArgs { Message = message });
}
}
public class WorkCompletedEventArgs : EventArgs
{
public string Message { get; set; }
}
public class WorkListener
{
public void OnWorkCompleted(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine($"WorkListener получил сообщение: {e.Message}");
}
}
// Подписка:
var worker = new Worker();
var listener = new WorkListener();
worker.WorkCompleted += listener.OnWorkCompleted;
worker.DoWork();
Плюсы такого подхода:
- Нет необходимости вручную поддерживать список подписчиков.
- Доступны все возможности событий: множественная подписка, отписка, лямбды.
- Гарантирована безопасность: только издатель может вызвать событие.
- Слабая связанность: издатель не знает ничего о слушателях.
4. Как "наблюдатель" вписывается в наше приложение
Давайте интегрируем паттерн Observer в наше консольное приложение. Пусть Worker имеет любое количество обработчиков, которые будут реагировать на завершение работы по-разному: кто-то пишет в консоль, кто-то считает количество выполненных работ, кто-то отправляет письмо "Шеф! Всё сделано!".
Расширяем код примерами
// Второй слушатель-счетчик
public class WorkCounter
{
public int Count { get; private set; }
public void OnWorkCompleted(object? sender, WorkCompletedEventArgs e)
{
Count++;
Console.WriteLine($"Работа учтена. Всего: {Count} выполнено.");
}
}
// Создаем объекты
var worker = new Worker();
var listener = new WorkListener();
var counter = new WorkCounter();
// Обе подписки
worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted += counter.OnWorkCompleted;
// Имитация нескольких работ
worker.DoWork();
worker.DoWork();
// Output:
// Worker работает...
// WorkListener получил сообщение: Работа завершена!
// Работа учтена. Всего: 1 выполнено.
// Worker работает...
// WorkListener получил сообщение: Работа завершена!
// Работа учтена. Всего: 2 выполнено.
Таким образом, вы добавляете "наблюдателей" по мере необходимости, не меняя ни одной строчки в коде Worker. Класс Worker остается неизменным, а поведение всей системы расширяется через подписчиков.
5. Полезные нюансы
Реальный пример: Observer в интерфейсах и GUI
Observer-паттерн — основа всех GUI-фреймворков. В Windows Forms или WPF нажатие кнопки вызывает событие Click. Вы пишете обработчики (наблюдатели), которые будут реагировать на это событие — и ни вашему классу Button, ни библиотеке .NET ничего о ваших подписчиках знать не нужно.
// В WPF или WinForms (примерно)
myButton.Click += (s, e) => MessageBox.Show("Пользователь нажал на кнопку!");
Observer в реальных проектах
- Пользовательский интерфейс (реакция на клики, изменения, таймеры и т.п.).
- Системы уведомлений и событий.
- Плагины для расширяемых систем (ядро генерирует события, расширения подписываются).
- Распределённые системы и игровые движки (слабосвязанные реакционные цепочки).
Короче говоря, если вам нужно сделать расширяемую систему, где одни части могут реагировать на изменения других — Observer must have!
7. Особенности, типичные ошибки и их предотвращение
Потенциальные трудности при использовании Observer
Утечки памяти. Если подписчик подписался на событие, но не отписался (особенно в долгоживущих объектах), то сборщик мусора не сможет освободить этот объект, потому что издатель всё ещё хранит на него ссылку через делегат события. Это критично, если подписчик больше не нужен, а издатель продолжает жить.
Множественная подписка. Если один и тот же обработчик подписан дважды, он вызовется два раза — получите дублирование действий и неожиданные эффекты.
Исключения в обработчиках. Если один из обработчиков бросит исключение, выполнение следующих подписчиков может прерваться. Продумайте устойчивость обработчиков и, при необходимости, вызывать их вручную в try-catch, чтобы остальные подписчики сработали даже при сбое одного.
Распространенная схема утечек
flowchart LR
Publisher["Издатель
(Worker)"] -- событие --> ObserverA["Слушатель A (Жив!)"]
Publisher -- событие --> ObserverB["Слушатель B (Утечка: забыли отписаться)"]
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ