1. Wprowadzenie
Wyobraź sobie, że masz obiekt, który wykonuje jakieś akcje — na przykład przycisk albo nasz Worker. Istnieje przy tym wiele innych obiektów, które powinny reagować na te akcje. Jeśli w klasie Worker na twardo wpiszesz wszystkich możliwych „słuchaczy”, utrzymanie takiego kodu stanie się koszmarem: każda zmiana w liście subskrybentów będzie wymagać edycji samego Worker.
To łamie zasadę otwarte-zamknięte (OCP) i jest uznawane za kiepską praktykę architektoniczną.
Wzorzec Obserwator: ogólna idea
Wzorzec "Obserwator" (Observer) rozwiązuje ten problem. Pozwala obiektowi-wydawcy powiadamiać dowolną liczbę zainteresowanych obiektów-słuchaczy o zajściach, nie wiedząc nic o tym, kim są ci słuchacze i co robią. Wydawca po prostu „wywołuje alarm”, a chętni reagują według własnego widzimisię.
Analogia: subskrypcja newslettera. Redakcja lub kanał (wydawca) wysyła nową wiadomość, a wszyscy subskrybenci (obserwatorzy) ją otrzymują. Redakcja nie musi wiedzieć, kim są ci ludzie.
Ciekawostka: "Obserwator" jest tak popularny, że oficjalnie wchodzi w skład „bandy czterech” (GoF).
Observer w C#: wcielenie przez zdarzenia i delegaty
W C# wzorzec "Obserwator" jest zaimplementowany „z pudełka” przez mechanizmy zdarzeń i delegatów. Zdarzenie — to punkt rozszerzalności, do którego można się podpinać różnymi handlerami. Zamiast ręcznego trzymania listy subskrybentów, robi to mechanizm języka. Poniżej najpierw zobaczymy „ręczną” implementację, a potem wersję przez zdarzenia.
2. Klasyczna implementacja Observer bez użycia zdarzeń
Sprawdźmy, jak to mogłoby wyglądać, gdyby w języku nie było zdarzeń:
// Interfejs obserwatora
public interface IObserver
{
void Update(string message);
}
// Wydawca
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 pracuje...");
NotifyObservers("Praca zakończona!");
}
private void NotifyObservers(string message)
{
foreach (var observer in observers)
{
observer.Update(message);
}
}
}
// Konkretny obserwator
public class WorkListener : IObserver
{
public void Update(string message)
{
Console.WriteLine($"WorkListener otrzymał wiadomość: {message}");
}
}
Inicjalizacja:
var worker = new Worker();
var listener = new WorkListener();
worker.Subscribe(listener);
worker.DoWork();
Warto zauważyć: Tutaj lista subskrybentów (List<IObserver> observers) jest prowadzona ręcznie, a subskrypcja/odsubskrypcja — jawne metody Subscribe/Unsubscribe.
3. Zdarzenia i delegaty — "wysokopoziomowa" implementacja Observer
Możemy to samo zrobić prościej i ładniej za pomocą zdarzeń. To właśnie Observer w stylu C#:
public class Worker
{
public event EventHandler<WorkCompletedEventArgs>? WorkCompleted;
public void DoWork()
{
Console.WriteLine("Worker pracuje...");
OnWorkCompleted("Praca zakończona!");
}
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 otrzymał wiadomość: {e.Message}");
}
}
// Subskrypcja:
var worker = new Worker();
var listener = new WorkListener();
worker.WorkCompleted += listener.OnWorkCompleted;
worker.DoWork();
Plusy takiego podejścia:
- Nie trzeba ręcznie utrzymywać listy subskrybentów.
- Dostępne są wszystkie możliwości zdarzeń: wielokrotna subskrypcja, odsubskrypcja, lambdy.
- Gwarantowane bezpieczeństwo: tylko wydawca może wywołać zdarzenie.
- Luźne powiązanie: wydawca nic nie wie o słuchaczach.
4. Jak "obserwator" wpisuje się w naszą aplikację
Zintegrujmy wzorzec Observer w naszej aplikacji konsolowej. Niech Worker ma dowolną liczbę handlerów, które zareagują na zakończenie pracy na różne sposoby: ktoś wypisze do konsoli, ktoś policzy liczbę wykonanych zadań, ktoś wyśle wiadomość "Szef! Wszystko zrobione!".
Rozszerzamy kod przykładami
// Drugi słuchacz - licznik
public class WorkCounter
{
public int Count { get; private set; }
public void OnWorkCompleted(object? sender, WorkCompletedEventArgs e)
{
Count++;
Console.WriteLine($"Praca zaliczona. Razem: {Count} wykonanych.");
}
}
// Tworzymy obiekty
var worker = new Worker();
var listener = new WorkListener();
var counter = new WorkCounter();
// Obie subskrypcje
worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted += counter.OnWorkCompleted;
// Symulacja kilku prac
worker.DoWork();
worker.DoWork();
// Wyjście:
// Worker pracuje...
// WorkListener otrzymał wiadomość: Praca zakończona!
// Praca zaliczona. Razem: 1 wykonanych.
// Worker pracuje...
// WorkListener otrzymał wiadomość: Praca zakończona!
// Praca zaliczona. Razem: 2 wykonanych.
W ten sposób dodajesz "obserwatorów" w miarę potrzeby, nie zmieniając ani jednej linii w kodzie Worker. Klasa Worker pozostaje niezmieniona, a zachowanie systemu rozszerza się przez subskrybentów.
5. Przydatne niuanse
Praktyczny przykład: Observer w interfejsach i GUI
Wzorzec Observer to podstawa wszystkich frameworków GUI. W Windows Forms czy WPF kliknięcie przycisku wywołuje zdarzenie Click. Piszesz handlery (obserwatorów), które reagują na to zdarzenie — i ani twoja klasa Button, ani biblioteka .NET nie muszą nic wiedzieć o twoich subskrybentach.
// W WPF lub WinForms (przykład)
myButton.Click += (s, e) => MessageBox.Show("Użytkownik kliknął przycisk!");
Observer w realnych projektach
- Interfejs użytkownika (reakcja na kliknięcia, zmiany, timery itp.).
- Systemy powiadomień i eventów.
- Pluginy do systemów rozszerzalnych (rdzeń generuje zdarzenia, rozszerzenia się subskrybują).
- Systemy rozproszone i silniki gier (słabo powiązane łańcuchy reakcyjne).
Krótko mówiąc, jeśli chcesz zrobić rozszerzalny system, w którym jedne części mogą reagować na zmiany innych — Observer must have!
7. Cecha, typowe błędy i ich zapobieganie
Potencjalne trudności przy użyciu Obserwatora
Wycieki pamięci. Jeśli subskrybent zapisał się na zdarzenie, ale się nie wypisał (szczególnie w obiektach długożyjących), to garbage collector nie będzie w stanie go zwolnić, bo wydawca dalej trzyma referencję przez delegat zdarzenia. To krytyczne, jeśli subskrybent nie jest już potrzebny, a wydawca żyje dalej.
Wielokrotna subskrypcja. Jeśli ten sam handler subskrybuje się dwa razy, zostanie wywołany dwa razy — dostaniesz duplikaty akcji i niespodziewane efekty.
Wyjątki w handlerach. Jeśli jeden z handlerów rzuci wyjątek, wykonanie kolejnych subskrybentów może zostać przerwane. Przemyśl odporność handlerów i w razie potrzeby wywołuj je ręcznie w try-catch, żeby pozostali subskrybenci wykonali się mimo awarii jednego.
Typowa schematyczna przyczyna wycieków
flowchart LR
Publisher["Wydawca
(Worker)"] -- zdarzenie --> ObserverA["Słuchacz A (Żyje!)"]
Publisher -- zdarzenie --> ObserverB["Słuchacz B (Wyciekanie: zapomnieli odsubskrybować)"]
GO TO FULL VERSION