Zaawansowane techniki obiektowe
Rok akademicki: 2026/27
Instrukcja laboratoryjna – Część 3: Wzorce Czynnościowe
(Behawioralne), Architektura Zdarzeniowa, Paradygmat Funkcyjny i
Komunikacja Rozproszona
Prowadzący: Tomasz Goluch
Wersja: 1.3
1. Wprowadzenie do Wzorców Czynnościowych (Behawioralnych)
1.1. Cel i Istota Wzorców Czynnościowych
Celem trzeciej części laboratorium jest poznanie, zrozumienie i praktyczna implementacja wzorców czynnościowych (behawioralnych, operacyjnych – ang. Behavioral Patterns) w języku C# (standard C# 12 / .NET 8+). Wzorce czynnościowe opisują sposób, w jaki klasy lub obiekty oddziałują między sobą, komunikują się i dzielą odpowiedzialności, a także precyzują algorytmy oraz przepływ sterowania w trakcie wykonywania programu (runtime).
Podczas gdy wzorce kreacyjne (Część 1) koncentrowały się na procesie powoływania instancji, a wzorce strukturalne (Część 2) na kompozycji klas i obiektów w większe struktury, wzorce czynnościowe odpowiadają na pytanie: „Jak rozdzielić odpowiedzialność algorytmiczną i komunikacyjną pomiędzy obiektami, aby zachować luźne powiązania, wysoką testowalność i brak wycieków pamięci?”.
1.2. Logiczna Kolejność Wzorców Czynnościowych
Wzorce behawioralne stanowią najliczniejszą grupę w klasyfikacji GoF oraz nowoczesnej inżynierii oprogramowania. Aby ułatwić ich przyswojenie oraz dostrzeżenie naturalnej ewolucji między nimi, zostały one podzielone na cztery spójne grupy tematyczne:
Grupa I – Hermetyzacja Algorytmów, Żądań i Przepływu Sterowania:
- Łańcuch Zobowiązań (2.1): Punkt wyjścia pokazujący
przekazywanie żądania wzdłuż potoku obiektów obsługujących
(
TaskHandler). - Metoda Szablonowa vs Paradygmat Funkcyjny (2.2):
Wynika bezpośrednio ze struktury
TaskHandlerz punktu 2.1. Pokazuje definiowanie szkieletu algorytmu w klasie bazowej oraz jego nowoczesną ewolucję w stronę funkcji wyższego rzędu (Func<T>,Action). - Strategia (2.3): Rozwija ideę wymienności
algorytmów – zamiast dziedziczenia (jak w Metodzie Szablonowej)
hermetyzuje całe algorytmy w wymiennych obiektach (np.
IComparer<T>). - Polecenie / Command i MVVM (2.4): Hermetyzuje
żądanie jako obiekt (
ICommand), łącząc świat obiektowy z reaktywnym UI (WPF / MVVMRelayCommand) oraz domykając porównanie z Metodą Szablonową i Strategią.
- Łańcuch Zobowiązań (2.1): Punkt wyjścia pokazujący
przekazywanie żądania wzdłuż potoku obiektów obsługujących
(
Grupa II – Zarządzanie Stanem, Historią i Cyklem Życia Zasobów:
- Stan / State (3.1): Hermetyzuje zachowania zależne
od wewnętrznego stanu obiektu w osobnych klasach, eliminując rozbudowane
instrukcje warunkowe
if/switch. - Pamiątka / Memento (3.2): Umożliwia bezpieczne zapisywanie i odtwarzanie migawek stanu obiektu bez naruszania jego hermetyzacji (fundament operacji Undo/Redo we wzorcu Polecenie).
- RAII i
IDisposable(3.3): Wiąże cykl życia zasobów niezarządzanych (pamięć, pliki, gniazda sieciowe) z czasem życia obiektu i blokiemusing.
- Stan / State (3.1): Hermetyzuje zachowania zależne
od wewnętrznego stanu obiektu w osobnych klasach, eliminując rozbudowane
instrukcje warunkowe
Grupa III – Architektura Zdarzeniowa, Reaktywność i Systemy Rozproszone:
- Obserwator / Observer i
eventw C# (4.1): Definiuje relację \(1:N\) powiadamiania o zmianie stanu oraz ukazuje niebezpieczeństwo wycieków pamięci (Lapsed Listener Problem) przy klasycznych zdarzeniach C#. - Weak Event Pattern (4.2): Bezpośrednia naprawa
problemu wycieków pamięci Obserwatora z punktu 4.1 przy użyciu słabych
referencji (
WeakReferenceiWeakEventManager). - Mediator / Sekretarka i CQRS (4.3): Zmienia topologię komunikacji z siatki bezpośrednich powiązań \(N:M\) na układ gwiaździsty z centralnym pośrednikiem (biblioteka MediatR).
- Event Aggregator, Wydawca-Abonent (Pub/Sub) i Saga
(4.4): Skaluje wzorce komunikacyjne od architektury wtyczkowej
(
EventAggregator) aż po asynchroniczne mikroserwisy, brokery wiadomości (MassTransit) i rozproszone transakcje kompensacyjne (Saga).
- Obserwator / Observer i
Grupa IV – Trawersowanie Struktur, Podwójne Wysłanie i Gramatyki:
- Iterator / Kursor (5.1): Ujednolica sekwencyjny
dostęp do struktur liniowych i nieliniowych (drzewa BFS/DFS, grafy)
poprzez
IEnumerable<T>orazyield return. - Odwiedzający / Wizytator a Metody Rozszerzające (5.2): Pozwala dodawać nowe operacje do hierarchii obiektów bez modyfikacji ich klas, rozwiązując problem podwójnego wysłania (Double Dispatch), którego nie potrafią obsłużyć statyczne metody rozszerzające.
- Interpreter (5.3): Reprezentuje gramatykę języka w postaci drzewa wyrażeń obiektowych (symboli terminalnych i nieterminalnych).
- Iterator / Kursor (5.1): Ujednolica sekwencyjny
dostęp do struktur liniowych i nieliniowych (drzewa BFS/DFS, grafy)
poprzez
┌──────────────────────────────────────────────┐
│ WZORCE CZYNNOŚCIOWE / BEHAWIORALNE │
└──────────────────────┬───────────────────────┘
│
┌─────────────────────────────────────┴─────────────────────────────────────┐
▼ ▼
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ GRUPA I: ALGORYTMY, ŻĄDANIA I POTOKI │ │ GRUPA II: STAN, HISTORIA I ZASOBY │
├─────────────────────────────────────────┤ ├─────────────────────────────────────────┤
│ 2.1. Łańcuch Zobowiązań (Potok ogniw) │ │ 3.1. Stan (Maszyna stanów zamiast if) │
│ 2.2. Metoda Szablonowa (Klasowa vs Func)│ │ 3.2. Pamiątka (Migawki stanu / Undo) │
│ 2.3. Strategia (Wymienne algorytmy) │ │ 3.3. RAII / IDisposable (Czas życia) │
│ 2.4. Polecenie (ICommand i MVVM) │ └─────────────────────────────────────────┘
└─────────────────────────────────────────┘ │
│ ▼
▼ ┌─────────────────────────────────────────┐
┌─────────────────────────────────────────┐ │ GRUPA IV: STRUKTURY, DISPATCH I DRZEWA │
│ GRUPA III: ZDARZENIA I ROZPROSZENIE │ ├─────────────────────────────────────────┤
├─────────────────────────────────────────┤ │ 5.1. Iterator (IEnumerable, BFS/DFS) │
│ 4.1. Obserwator i pułapka wycieków event│ │ 5.2. Wizytator (Double Dispatch vs Ext.)│
│ 4.2. Weak Event (WeakReference i GC) │ │ 5.3. Interpreter (Gramatyki i drzewa) │
│ 4.3. Mediator (Sekretarka, MediatR/CQRS)│ └─────────────────────────────────────────┘
│ 4.4. Event Aggregator, Pub/Sub i Saga │
└─────────────────────────────────────────┘
2. Hermetyzacja Algorytmów, Żądań i Przepływu Sterowania
2.1. Łańcuch Zobowiązań / Odpowiedzialności (Chain of Responsibility – Zakres Obiektowy)
Wzorzec Łańcuch Zobowiązań pozwala na przekazywanie
żądań kolejno wzdłuż łańcucha potencjalnych obiektów je obsługujących.
Każde ogniwo w łańcuchu decyduje, czy jest w stanie obsłużyć dane
żądanie, a jeśli nie – przekazuje je dalej do swojego następnika
(_successor).
Elementy wzorca:
- Handler (Obsługujący –
TaskHandler): Interfejs lub abstrakcyjna klasa bazowa definiująca metodę obsługi żądania oraz metodę ustawiania kolejnego ogniwa w łańcuchu (SetNext). - ConcreteHandler (Konkretny Obsługujący –
LowPriorityHandler,HighPriorityHandler): Implementuje właściwą obsługę dla swojego zakresu odpowiedzialności. Jeśli nie potrafi obsłużyć żądania, deleguje je do następnika.
Kod
klasy bazowej TaskHandler oraz klas konkretnych ogniw:
public abstract class TaskHandler
{
protected TaskHandler _successor;
public TaskHandler SetNext(TaskHandler successor)
{
_successor = successor;
return successor;
}
// Główna metoda sterująca przepływem (wykorzystuje wzorzec Template Method)
public void Handle(int taskId, string taskType)
{
if (CanHandle(taskType))
{
Execute(taskId, taskType);
}
else if (_successor != null)
{
_successor.Handle(taskId, taskType);
}
else
{
// Tutaj lądują WSZYSTKIE nieobsłużone zadania na końcu łańcucha
Console.WriteLine($"[ALERT] Task {taskId} ({taskType}) przeszedł przez cały łańcuch i NIE został obsłużony!");
}
}
// Metody abstrakcyjne, które muszą zaimplementować klasy konkretne
protected abstract bool CanHandle(string taskType);
protected abstract void Execute(int taskId, string taskType);
}
// 1. ConcreteHandlerA (Obsługa zadań niskiego priorytetu)
public class LowPriorityHandler : TaskHandler
{
protected override bool CanHandle(string taskType) => taskType == "Low";
protected override void Execute(int taskId, string taskType)
{
Console.WriteLine($"Task {taskId} (Low) obsłużony przez LowPriorityHandler.");
}
}
// 2. ConcreteHandlerB (Obsługa zadań wysokiego priorytetu)
public class HighPriorityHandler : TaskHandler
{
protected override bool CanHandle(string taskType) => taskType == "High";
protected override void Execute(int taskId, string taskType)
{
Console.WriteLine($"Task {taskId} (High) obsłużony przez HighPriorityHandler.");
}
}
Użycie w kodzie:
var low = new LowPriorityHandler();
var high = new HighPriorityHandler();
low.SetNext(high); // Tworzenie łańcucha: Low -> High
low.Handle(101, "Low");
low.Handle(102, "High");
low.Handle(103, "Medium"); // Nieobsłużone przez żadne ogniwo
Przykładowe wyjście:
Task 101 (Low) obsłużony przez LowPriorityHandler.
Task 102 (High) obsłużony przez HighPriorityHandler.
[ALERT] Task 103 (Medium) przeszedł przez cały łańcuch i NIE został obsłużony!
💡 Zastosowanie w ekosystemie .NET:
Na wzorcu Łańcucha Zobowiązań opiera się cały potok przetwarzania żądań HTTP (Middleware Pipeline) w ASP.NET Core (app.UseAuthentication() -> app.UseAuthorization() -> app.UseRouting()), potoki wiadomościDelegatingHandlerwHttpClientoraz zachowania potokoweIPipelineBehavior<TRequest, TResponse>w bibliotece MediatR.
2.2. Metoda Szablonowa (Template Method – Zakres Klasowy) vs Paradygmat Funkcyjny
Wzorzec Metody Szablonowej definiuje szkielet algorytmu w metodzie klasy bazowej, pozostawiając szczegóły implementacji poszczególnych kroków dla podklas. Pozwala to klasom pochodnym na nadpisywanie wybranych etapów algorytmu bez możliwości zmiany jego ogólnej struktury oraz kolejności wykonywania operacji.
A. Podejście tradycyjne (obiektowe / klasowe oparte na dziedziczeniu)
W podejściu klasycznym klasa bazowa definiuje nadrzędną metodę
sterującą przepływem (niewirtualną lub oznaczoną jako
sealed), która wywołuje w ustalonej sekwencji metody
abstrakcyjne bądź chronione (tzw. operacje pierwotne).
Doskonałym przykładem zastosowania tej konstrukcji jest metoda
Handle w klasie bazowej TaskHandler z punktu
2.1:
public abstract class TaskHandler
{
protected TaskHandler _successor;
public TaskHandler SetNext(TaskHandler successor)
{
_successor = successor;
return successor;
}
// Główna metoda sterująca przepływem (wzorzec Template Method)
public void Handle(int taskId, string taskType)
{
if (CanHandle(taskType))
{
Execute(taskId, taskType);
}
else if (_successor != null)
{
_successor.Handle(taskId, taskType);
}
else
{
Console.WriteLine($"[ALERT] Task {taskId} ({taskType}) nie został obsłużony!");
}
}
// Kroki zmiennego algorytmu delegowane do podklas
protected abstract bool CanHandle(string taskType);
protected abstract void Execute(int taskId, string taskType);
}
Nadrzędny schemat postępowania z żądaniem (Handle) jest
stały i zdefiniowany odgórnie. Konkretne klasy pochodne (np.
LowPriorityHandler) implementują jedynie specyficzne dla
siebie warunki brzegowe (CanHandle) oraz samą logikę
wykonania operacji (Execute).
B. Podejście funkcyjne (Higher-Order Functions)
W podejściu funkcyjnym, zamiast klasycznego dziedziczenia i budowania
głębokich hierarchii klas, wykorzystuje się funkcje wyższego
rzędu (Higher-Order Functions). „Szkielet” algorytmu
staje się wówczas metodą, która przyjmuje inne funkcje
(Func<T>, Action) jako parametry
wejściowe.
Przykład implementacji procesu obsługi zamówienia:
public void ProcessOrder(Func<bool> validate, Action save)
{
// Stały krok algorytmu
Console.WriteLine("Inicjalizacja potoku przetwarzania...");
// Zmienny krok wstrzyknięty jako funkcja walidująca
if (validate())
{
// Zmienny krok wstrzyknięty jako akcja zapisu
save();
Console.WriteLine("Proces zamówienia zakończony sukcesem.");
}
else
{
Console.WriteLine("Przerwano potok: błąd walidacji.");
}
}
Struktura metody ProcessOrder jest niezmienna, jednak
reguły weryfikacji oraz sposób zapisu danych są wstrzykiwane dynamicznie
w postaci wyrażeń lambda w miejscu wywołania:
ProcessOrder(
validate: () => order.TotalAmount > 0 && order.Items.Any(),
save: () => database.Save(order)
);
Podejście to charakteryzuje się znacznie większą elastycznością, eliminuje konieczność tworzenia jednorazowych podklas i radykalnie ułatwia testowanie jednostkowe.
2.3. Strategia (Strategy – Zakres Obiektowy)
Wzorzec Strategia pozwala na zdefiniowanie rodziny algorytmów, hermetyzację każdego z nich w osobnej klasie implementującej wspólny interfejs i uczynienie ich wzajemnie wymiennymi. Strategia pozwala na dynamiczną podmianę algorytmu w trakcie działania programu (runtime), oddzielając klienta od szczegółów technicznych implementacji algorytmu.
Przykładowa
implementacja oraz natywny odpowiednik IComparer<T> w
.NET:
Klasycznym przykładem w bibliotece standardowej .NET jest interfejs
IComparer<T> przekazywany do metody
List<T>.Sort(). Możemy w locie zmienić sposób
sortowania kolekcji (np. najpierw po nazwisku, a chwilę później po wieku
lub wynagrodzeniu), wstrzykując inną strategię porównywania:
public record Employee(string Name, int Age, decimal Salary);
// 1. Interfejs Strategii (w .NET wbudowany jako IComparer<T>)
public class SortByAgeStrategy : IComparer<Employee>
{
public int Compare(Employee? x, Employee? y) => x!.Age.CompareTo(y!.Age);
}
public class SortBySalaryDescendingStrategy : IComparer<Employee>
{
public int Compare(Employee? x, Employee? y) => y!.Salary.CompareTo(x!.Salary);
}
// Użycie w kodzie:
var employees = new List<Employee>
{
new("Jan Kowalski", 42, 12000m),
new("Anna Nowak", 29, 15500m)
};
// Wymiana strategii w czasie wykonywania (runtime):
employees.Sort(new SortByAgeStrategy());
employees.Sort(new SortBySalaryDescendingStrategy());
Alternatywa w nowoczesnym .NET: Microsoft DI, Autofac i biblioteka Polly
W kontenerach DI wzorzec Strategii realizuje się poprzez rejestrację
wielu implementacji pod jednym interfejsem i wybieranie właściwej
strategii za pomocą klucza (IIndex<TKey, TStrategy> w
Autofac lub [FromKeyedServices] w .NET 8), natomiast w
obszarze odporności na błędy (Resilience) standardem
produkcyjnym jest biblioteka Polly:
// Wstrzyknięcie wszystkich dostępnych strategii do selektora (Strategy Resolver):
public class PaymentProcessor(IEnumerable<IPaymentStrategy> strategies)
{
public void ExecutePayment(string providerName, decimal amount)
{
var strategy = strategies.First(s => s.CanHandle(providerName));
strategy.Pay(amount);
}
}
2.4. Polecenie (Command – Zakres Obiektowy) oraz Wzorzec Architektoniczny MVVM
Wzorzec Polecenie hermetyzuje żądanie jako obiekt, umożliwiając parametryzowanie klientów różnymi żądaniami, tworzenie kolejek operacji, harmonogramowanie zadań lub logowanie historii operacji (wsparcie dla Undo / Redo). Obiekt polecenia spaja samą operację z jej odbiorcą (Receiver), a moment jej wywołania jest delegowany do obiektu wywołującego (Invoker).
A. Klasyczna implementacja GoF (Pilot i Urządzenie):
- Command (Polecenie –
ICommand): Interfejs z metodąExecute()(oraz opcjonalnieUndo()). - ConcreteCommand (
TurnOnCommand,TurnOffCommand): ImplementujeICommand, przechowuje referencję do odbiorcy (IDevice) i wywołuje na nim docelową akcję. - Receiver (Odbiorca –
IDevice,Television): Obiekt domenowy, który wie, jak wykonać właściwą operację sprzętową lub biznesową. - Invoker (Wywołujący –
RemoteControl): Obiekt, który przechowuje i uruchamia przypisane Polecenie, nie znając szczegółów odbiorcy.
// 1. Command (Interfejs Polecenia)
public interface ICommand
{
void Execute();
}
// 2. ConcreteCommandA (Polecenie Włącz)
public class TurnOnCommand : ICommand
{
private readonly IDevice _tv;
public TurnOnCommand(IDevice tv) => _tv = tv;
public void Execute() => _tv.TurnOn();
}
// 2. ConcreteCommandB (Polecenie Wyłącz)
public class TurnOffCommand : ICommand
{
private readonly IDevice _tv;
public TurnOffCommand(IDevice tv) => _tv = tv;
public void Execute() => _tv.TurnOff();
}
// 3. Receiver (Odbiorca)
public interface IDevice
{
void TurnOn();
void TurnOff();
}
public class Television : IDevice
{
public void TurnOn() => Console.WriteLine("Telewizor włączony.");
public void TurnOff() => Console.WriteLine("Telewizor wyłączony.");
}
// 4. Invoker (Pilot)
public class RemoteControl
{
private ICommand? _command;
public void SetCommand(ICommand command) => _command = command;
public void PressButton() => _command?.Execute();
}
Użycie w kodzie:
var tv = new Television();
var turnOn = new TurnOnCommand(tv);
var turnOff = new TurnOffCommand(tv);
var remote = new RemoteControl();
remote.SetCommand(turnOn);
remote.PressButton();
remote.SetCommand(turnOff);
remote.PressButton();
Przykładowe wyjście:
Telewizor włączony.
Telewizor wyłączony.
B. Ewolucja wzorca Polecenie we frameworkach UI: WPF / .NET MAUI / Avalonia i wzorzec MVVM
Wzorce projektowe ewoluują wraz z frameworkami. W środowisku WPF (Windows Presentation Foundation) oraz nowoczesnych wieloplatformowych frameworkach pokrewnych (Avalonia UI, .NET MAUI) wzorzec Polecenie stanowi fundament wzorca architektonicznego MVVM (Model-View-ViewModel):
using System.Windows.Input;
// W MVVM używamy interfejsu ICommand z przestrzeni System.Windows.Input
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => _canExecute == null || _canExecute();
public void Execute(object? parameter) => _execute();
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
Widok (XAML) wiąże się deklaratywnie
(Binding) bezpośrednio z właściwością typu
ICommand w ViewModelu. Dzięki temu osiągamy
trzy fundamentalne korzyści architektoniczne:
- Izolacja logiki biznesowej: W klasycznym podejściu
Code-Behind (np.
BadIncrementButton_Click) sprawdzanie limitu biznesowego i modyfikacja zmiennej odbywają się bezpośrednio obok odwołań do kontrolek UI (BadCountTextBlock.Text). Nie da się wówczas przetestować reguły „nie przekraczaj wartości 5” bez utworzenia ciężkiego obiektu oknaMainWindow. - Hermetyzacja stanu UI: W przypadku tradycyjnych
zdarzeń (
events) programista musi w kodzie ręcznie odwoływać się do właściwości wizualnejBadIncrementButton.IsEnabled = false. Gdy korzystamy z kontraktuICommand, framework UI sam odpytuje metodęCanExecute()i automatycznie zarządza włączaniem oraz wyłączaniem kontrolki na ekranie. - Czyste środowisko testowe: Klasa
MainViewModeljest prostą klasą POCO (Plain Old C# Object), całkowicie odseparowaną od cyklu życia obiektów interfejsu graficznego. Testy jednostkowe wykonują się w czasie mikrosekund na dowolnym systemie (również w kontenerach Linux Docker w potoku CI/CD) bez konieczności uruchamiania wirtualnego wątku STA (Single-Threaded Apartment) wymaganego przez kontrolki WPF.
C. Analogie i powiązania: Metoda Szablonowa vs Polecenie (Command)
Wzorzec Metody Szablonowej (2.2) wykazuje bezpośrednie pokrewieństwo koncepcyjne ze wzorcem Polecenie (2.4). Główne punkty wspólne oraz różnice obejmują:
- Zakres i poziom działania (Obiekt vs Metoda):
Wzorzec Polecenie hermetyzuje całe żądanie wraz z jego stanem i odbiorcą
jako osobny obiekt implementujący wspólny interfejs
(
ICommand). Metoda Szablonowa działa bardziej punktowo – operuje wewnątrz pojedynczej metody, zarządzając sekwencją jej kroków. - Separacja logiki stałej od zmiennej (DIP): Oba
rozwiązania dążą do odizolowania stałego mechanizmu wywoławczego od
dynamicznie wymiennej logiki wykonawczej, wspierając zasadę
Dependency Inversion Principle (DIP). W Poleceniu obiekt
wywołujący (
Invoker) nie zna szczegółów komendy; w Metodzie Szablonowej szkielet algorytmu nie zna szczegółów implementacji poszczególnych etapów. - Ewolucja w stronę paradygmatu funkcyjnego:
- Wzorzec Polecenie w nowoczesnych frameworkach
wykorzystuje klasę
RelayCommand, do której właściwa akcja biznesowa jest przekazywana przez konstruktor w postaci delegatuAction. Zapobiega to eksplozji małych klas dla każdego przycisku w aplikacji. - Metoda Szablonowa podlega identycznej transformacji
– zamiast tworzenia kolejnych podklas nadpisujących metody chronione
protected abstract, zmienne kroki procesu przekazuje się bezpośrednio jako parametryFunc<T>lubAction.
- Wzorzec Polecenie w nowoczesnych frameworkach
wykorzystuje klasę
3. Zarządzanie Stanem, Historią i Cyklem Życia Zasobów
3.1. Stan (State – Zakres Obiektowy)
Wzorzec Stan pozwala obiektowi na dynamiczną zmianę
swojego zachowania w zależności od jego wewnętrznego stanu. Obiekt
kontekstu deleguje operacje do aktualnie przypisanego obiektu stanu i
może wymieniać ten obiekt na inny w trakcie działania programu, co z
zewnątrz sprawia wrażenie, jakby obiekt zmienił swoją klasę. Wzorzec ten
pozwala wyeliminować rozbudowane, trudne w utrzymaniu bloki warunkowe
(if/else lub switch) sterujące
zachowaniem obiektu.
Przykład implementacji (Cykl życia dokumentu / zamówienia):
// Interfejs Stanu
public interface IOrderState
{
void Pay(OrderContext context);
void Ship(OrderContext context);
}
// Kontekst przechowujący aktualny stan
public class OrderContext
{
public IOrderState CurrentState { get; set; } = new NewOrderState();
public void Pay() => CurrentState.Pay(this);
public void Ship() => CurrentState.Ship(this);
}
// Konkretny Stan 1: Nowe zamówienie
public class NewOrderState : IOrderState
{
public void Pay(OrderContext context)
{
Console.WriteLine("Płatność zaakceptowana. Przejście do stanu: Opłacone.");
context.CurrentState = new PaidOrderState();
}
public void Ship(OrderContext context)
=> Console.WriteLine("[BŁĄD] Nie można wysłać nieopłaconego zamówienia!");
}
// Konkretny Stan 2: Opłacone zamówienie
public class PaidOrderState : IOrderState
{
public void Pay(OrderContext context)
=> Console.WriteLine("[INFO] Zamówienie zostało już wcześniej opłacone.");
public void Ship(OrderContext context)
{
Console.WriteLine("Paczka przekazana kurierowi. Przejście do stanu: Wysłane.");
context.CurrentState = new ShippedOrderState();
}
}
// Konkretny Stan 3: Wysłane zamówienie
public class ShippedOrderState : IOrderState
{
public void Pay(OrderContext context) => Console.WriteLine("[BŁĄD] Zamówienie już zamknięte.");
public void Ship(OrderContext context) => Console.WriteLine("[BŁĄD] Paczka jest już w drodze.");
}
💡 Biblioteka
Statelessw .NET:
W systemach produkcyjnych .NET do tworzenia hierarchicznych maszyn stanów powszechnie wykorzystuje się bibliotekę Stateless (stworzoną przez twórcę Autofac, Nicholasa Blumhardta), pozwalającą definiować stany i wyzwalacze (Triggers) za pomocą płynnego API (Fluent API).
3.2. Pamiątka (Memento – Zakres Obiektowy)
Wzorzec Pamiątka (ang. Memento) pozwala na zapisanie i odtworzenie poprzedniego stanu wewnętrznego obiektu (Originator – Obiekt Pierwotny) bez naruszania jego hermetyzacji (tj. bez ujawniania prywatnych pól na zewnątrz klasy).
Elementy wzorca:
- Originator (Obiekt Pierwotny): Posiada wewnętrzny
stan oraz metody tworzenia pamiątki (
Save()) i przywracania stanu z pamiątki (Restore(IMemento)). - Memento (Pamiątka): Niemutowalny obiekt przechowujący migawkę stanu obiektu pierwotnego.
- Caretaker (Opiekun): Przechowuje historię pamiątek
(np. na stosie
Stack<IMemento>), ale nie ma prawa modyfikować ani zaglądać do wnętrza stanu pamiątki.
// Niemutowalna Pamiątka (w C# 12 idealnie reprezentowana przez record)
public record EditorMemento(string TextContent, int CursorPosition, DateTime Timestamp);
// Originator (Obiekt Pierwotny)
public class TextEditor
{
public string Content { get; set; } = string.Empty;
public int Cursor { get; set; } = 0;
public EditorMemento SaveSnapshot() => new(Content, Cursor, DateTime.UtcNow);
public void RestoreSnapshot(EditorMemento memento)
{
Content = memento.TextContent;
Cursor = memento.CursorPosition;
}
}
// Caretaker (Opiekun zarządzający historią Undo)
public class HistoryCaretaker
{
private readonly Stack<EditorMemento> _history = new();
public void Backup(TextEditor editor) => _history.Push(editor.SaveSnapshot());
public void Undo(TextEditor editor)
{
if (_history.TryPop(out var memento))
{
editor.RestoreSnapshot(memento);
}
}
}
3.3.
Zabór Zasobu Jest Inicjalizacją (RAII – Zakres Obiektowy) i Wzorzec
IDisposable
RAII (ang. Resource Acquisition Is Initialization –
Zabór Zasobu Jest Inicjalizacją) to fundamentalna technika
programistyczna (wywodząca się z C++ i zaadaptowana w .NET poprzez
interfejsy IDisposable oraz
IAsyncDisposable), która polega na
powiązaniu cyklu życia zasobów zewnętrznych (pamięć natywna, uchwyty
plików, połączenia z bazą danych, gniazda sieciowe, blokady
synchronizacyjne) z czasem życia obiektu.
- Zasób jest nabywany podczas inicjalizacji (w konstruktorze obiektu).
- Zasób jest deterministycznie zwalniany w destruktorze / metodzie
Dispose()(w .NET automatycznie wywoływanej przy wyjściu z blokuusing). - Konstrukcja
using(kompilowana do blokutry ... finally) gwarantuje, że zasób zostanie bezpiecznie zwolniony nawet w przypadku wystąpienia wyjątku wewnątrz sekcji krytycznej.
public sealed class TelemetryFileSession : IDisposable
{
private readonly FileStream _fileStream;
private bool _disposed;
public TelemetryFileSession(string filePath)
{
// RAII: Nabycie zasobu w konstruktorze
_fileStream = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None);
}
public void WriteData(ReadOnlySpan<byte> data)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_fileStream.Write(data);
}
public void Dispose()
{
if (!_disposed)
{
// Deterministyczne zwolnienie uchwytu plikowego systemu operacyjnego
_fileStream.Dispose();
_disposed = true;
}
}
}
// Użycie gwarantujące zwolnienie zasobu nawet w razie wyjątku:
using var session = new TelemetryFileSession("flight_log.bin");
session.WriteData(new byte[] { 0x01, 0xFF });
💡 Automatyczne zarządzanie
IDisposablew kontenerach IoC/DI:
ZarównoAutofac(ILifetimeScope), jak iMicrosoft.Extensions.DependencyInjection(IServiceScope) śledzą wszystkie utworzone przez siebie instancje implementująceIDisposablelubIAsyncDisposable. W momencie zamknięcia zasięgu (using var scope = container.BeginLifetimeScope()) kontener automatycznie wywołujeDispose()na wszystkich powołanych w tym zasięgu obiektach w odwrotnej kolejności ich utworzenia.
4. Architektura Zdarzeniowa, Reaktywność i Systemy Rozproszone
4.1. Obserwator (Observer – Zakres Obiektowy) i Ryzyko Wycieków Pamięci w C#
Wzorzec Obserwator definiuje relację jeden-do-wielu (\(1:N\)) pomiędzy obiektami, w której, gdy stan obiektu Obserwowanego (Subject) ulega zmianie, wszystkie zależne od niego obiekty Obserwujące (Observer) są automatycznie powiadamiane i aktualizowane.
A.
Klasyczna implementacja obiektowa GoF (ISubject oraz
IObserver):
public interface ISubject
{
// Dołącza obserwatora do tematu.
void Attach(IObserver observer);
// Odłącza obserwatora od tematu.
void Detach(IObserver observer);
// Powiadamia wszystkich obserwatorów o zdarzeniu.
void Notify();
}
public interface IObserver
{
// Odbiera aktualizację stanu od tematu
void Update(ISubject subject);
}
// Temat (Subject) posiada istotny stan i powiadamia obserwatorów, gdy stan ulega zmianie.
public class Subject : ISubject
{
public int State { get; set; } = 0;
// Lista subskrybentów
private List<IObserver> _observers = new List<IObserver>();
public void Attach(IObserver observer)
{
Console.WriteLine("Subject: Attached an observer.");
this._observers.Add(observer);
}
public void Detach(IObserver observer)
{
this._observers.Remove(observer);
Console.WriteLine("Subject: Detached an observer.");
}
// Wyzwala aktualizację u każdego subskrybenta.
public void Notify()
{
Console.WriteLine("Subject: Notifying observers...");
foreach (var observer in _observers)
{
observer.Update(this);
}
}
// Logika biznesowa wyzwalająca powiadomienie po zmianie stanu
public void SomeBusinessLogic()
{
Console.WriteLine("\nSubject: I'm doing something important.");
this.State = new Random().Next(0, 10);
Thread.Sleep(15);
Console.WriteLine("Subject: My state has just changed to: " + this.State);
this.Notify();
}
}
public class ConcreteObserverA : IObserver
{
public void Update(ISubject subject)
{
if ((subject as Subject)!.State < 3)
{
Console.WriteLine("ConcreteObserverA: Reacted to the event.");
}
}
}
public class ConcreteObserverB : IObserver
{
public void Update(ISubject subject)
{
var state = (subject as Subject)!.State;
if (state == 0 || state >= 2)
{
Console.WriteLine("ConcreteObserverB: Reacted to the event.");
}
}
}
Użycie w kodzie:
var subject = new Subject();
var observerA = new ConcreteObserverA();
subject.Attach(observerA);
var observerB = new ConcreteObserverB();
subject.Attach(observerB);
subject.SomeBusinessLogic();
subject.SomeBusinessLogic();
subject.Detach(observerB);
subject.SomeBusinessLogic();
Przykładowe wyjście:
Subject: Attached an observer.
Subject: Attached an observer.
Subject: I'm doing something important.
Subject: My state has just changed to: 1
Subject: Notifying observers...
ConcreteObserverA: Reacted to the event.
Subject: I'm doing something important.
Subject: My state has just changed to: 4
Subject: Notifying observers...
ConcreteObserverB: Reacted to the event.
Subject: Detached an observer.
Subject: I'm doing something important.
Subject: My state has just changed to: 9
Subject: Notifying observers...
B.
Natywna realizacja w języku C# przy użyciu delegatów i słowa kluczowego
event
W języku C# wzorzec Obserwatora jest wbudowany bezpośrednio w
składnię języka poprzez delegaty
EventHandler<TEventArgs> oraz słowo kluczowe
event:
public class GoingToVacationEventArgs : EventArgs
{
public string Address { get; set; } = string.Empty;
public string DefaultEmailResponse { get; set; } = string.Empty;
public DateTime WhenItBegan { get; set; }
public DateTime WhenItEnd { get; set; }
}
public class Person
{
public event EventHandler<GoingToVacationEventArgs>? GotoVacationEvent;
public void GoingToSopot()
{
GotoVacationEvent?.Invoke(this, new GoingToVacationEventArgs
{
Address = "Orzechowa 21",
DefaultEmailResponse = "Jestem na urlopie",
WhenItBegan = DateTime.Now,
WhenItEnd = DateTime.Now.AddDays(10),
});
}
}
// Użycie w kodzie:
Person p = new Person();
p.GotoVacationEvent += (object? sender, GoingToVacationEventArgs eventArgs) =>
{
Console.WriteLine("Poszedł na urlop:");
Console.WriteLine(eventArgs.DefaultEmailResponse);
Console.WriteLine($"Od Kiedy: {eventArgs.WhenItBegan}");
Console.WriteLine($"Do Kiedy: {eventArgs.WhenItEnd}");
};
p.GoingToSopot();
C.
Pułapka wycieków pamięci przy użyciu event (Lapsed
Listener Problem)
Używanie słowa kluczowego event w C# niesie ze sobą
poważne ryzyko wycieków pamięci (ang. memory
leaks):
- Przyczyna: Gdy obiekt Obserwujący subskrybuje
zdarzenie obiektu Obserwowanego
(
subject.NotifyMe += observer.OnNotify), wewnątrz listy delegatów obiektusubjectzapisywana jest silna referencja (strong reference) wskazująca na instancjęobserver(Delegate.Target). - Skutek: Nawet jeśli zmienna
observerwyjdzie poza zasięg w kodzie klienckim i nie jest już nigdzie używana, Garbage Collector (GC) nie usunie jej z pamięci RAM, ponieważ długożyjącysubject(np. Singleton) wciąż „trzyma” ją na swojej prywatnej liście subskrybentów! - Rozwiązanie: Zawsze należy jawnie odpiąć zdarzenie
operatorem
-=w metodzieDispose()obserwatora lub zastosować wzorzec Weak Event Pattern (opisany w punkcie 4.2).
❌ KLASYCZNY EVENT (Silna referencja blokuje Garbage Collector):
[ Globalny Subject (Singleton) ] ───(Silna referencja w liście event)───► [ Krótkotrwały Observer ]
(ZOMBIE W PAMIĘCI RAM!
GC nie może go usunąć)
✅ WEAK EVENT PATTERN (Słaba referencja pozwala GC zwolnić pamięć):
[ Globalny Subject ] ──► [ WeakEventManager ] ───(WeakReference<T>) - - ► [ Krótkotrwały Observer ]
(GC bezpiecznie usuwa obiekt,
gdy klient kończy pracę)
4.2. Wzorzec Słabego Zdarzenia (Weak Event Pattern – Zakres Obiektowy)
Weak Event to „naprawiony” Obserwator rozwiązujący
problem wycieków pamięci spowodowanych przez „wiszące” subskrypcje
zdarzeń. W platformie .NET wykorzystuje on klasę
WeakReference (lub generyczną
WeakReference<T>). Pozwala ona na
trzymanie wskaźnika do obiektu w taki sposób, aby nie blokował on pracy
Garbage Collectora. Jeśli jedynym odniesieniem do obiektu w całym
procesie jest „słaba referencja”, GC może go bezpiecznie usunąć przy
najbliższym cyklu odśmiecania pamięci.
// Zamiast bezpośredniego: source.SomeEvent += observer.OnEvent;
WeakEventManager<SourceClass, EventArgs>.AddHandler(source, "SomeEvent", observer.OnEvent);
Mechanizm działania pośrednika (WeakEventManager):
Zamiast bezpośredniego, silnego powiązania, wprowadzany jest pośrednik (Manager), który zarządza listą słabych referencji do subskrybentów:
- Subskrypcja: Obserwator rejestruje się w managerze
(opakowany w
WeakReference), a nie bezpośrednio na liście delegatów Źródła. - Powiadomienie: Gdy wystąpi zdarzenie, manager przegląda swoją wewnętrzną listę słabych referencji.
- Weryfikacja i auto-czyszczenie: Jeśli obiekt pod
słabą referencją wciąż żyje w pamięci
(
TryGetTarget(out var target)zwracatrue), manager wywołuje jego metodę obsługi. Jeśli obiekt został już usunięty przez Garbage Collector (TryGetTargetzwracafalse), manager automatycznie usuwa „martwy” rekord z listy subskrybentów.
🛠️ Dla dociekliwych: Własna, wieloplatformowa implementacja WeakEventManager w czystym .NET 8+ (Kliknij, aby rozwinąć)
Wbudowana klasa
WeakEventManager<TEventSource, TEventArgs> wchodzi w
skład biblioteki WindowsBase (część WPF). W aplikacjach
serwerowych, konsolowych oraz wieloplatformowych (.NET 8 na systemach
Linux / macOS) własny bezpieczny brokera słabych zdarzeń buduje się
bezpośrednio w oparciu o
WeakReference<Action<TEventArgs>> lub parę
(WeakReference<object> Target, MethodInfo Method):
using System.Reflection;
public class WeakEventBroadcaster<TEventArgs> where TEventArgs : EventArgs
{
private readonly List<(WeakReference<object>? Target, MethodInfo Method)> _handlers = new();
public void Subscribe(EventHandler<TEventArgs> handler)
{
// Jeśli metoda jest instancyjna, zapisujemy słabą referencję do obiektu docelowego (Target)
WeakReference<object>? weakTarget = handler.Target != null
? new WeakReference<object>(handler.Target)
: null;
_handlers.Add((weakTarget, handler.Method));
}
public void Publish(object sender, TEventArgs args)
{
for (int i = _handlers.Count - 1; i >= 0; i--)
{
var (weakTarget, method) = _handlers[i];
if (weakTarget == null)
{
// Metoda statyczna – wywołujemy bezpośrednio
method.Invoke(null, new object[] { sender, args });
}
else if (weakTarget.TryGetTarget(out object? targetInstance))
{
// Obiekt obserwatora nadal żyje w RAM – dostarczamy powiadomienie
method.Invoke(targetInstance, new object[] { sender, args });
}
else
{
// Obiekt obserwatora został usunięty przez GC! Czyścimy martwą subskrypcję:
_handlers.RemoveAt(i);
}
}
}
public int ActiveSubscribersCount => _handlers.Count;
}
Kiedy stosować wzorzec Weak Event?
Wzorzec ten (mimo minimalnego narzutu wydajnościowego wynikającego z
dodatkowej warstwy abstrakcji i weryfikacji WeakReference)
jest kluczowy w sytuacjach, gdy:
- Obiekt Źródło żyje znacznie dłużej niż Obserwator (np. globalny serwis telemetryczny typu Singleton i krótkotrwałe widoki UI lub zadania robocze).
- Relacja między obiektami jest złożona, a ręczne wymuszenie wywołania
Dispose()i-=u wszystkich klientów jest trudne do zagwarantowania. - Projektujesz architekturę wtyczek (Plugins), gdzie nie masz pełnej kontroli nad tym, jak zewnętrzni deweloperzy będą podpinać się pod Twoje zdarzenia.
4.3. Mediator / Sekretarka (Mediator – Zakres Obiektowy) i Architektura CQRS
Wzorzec Mediator (nazywany też Sekretarką) ogranicza bezpośrednie interakcje między obiektami, zmuszając je do współpracy wyłącznie za pośrednictwem centralnego obiektu (Mediatora). Zmniejsza to drastycznie liczbę powiązań między komponentami (Kolaborantami), zamieniając chaotyczną siatkę zależności „każdy z każdym” na uporządkowaną topologię gwiazdy.
Praktyczne
zastosowanie w nowoczesnych mikroserwisach i .NET: Biblioteka
MediatR oraz CQS/CQRS
W nowoczesnych aplikacjach .NET wzorzec Mediatora jest powszechnie realizowany za pomocą biblioteki MediatR w połączeniu z architekturą CQS / CQRS (Command Query Responsibility Segregation):
- Separacja komend od zapytań: Mediator pozwala oddzielić komendy zmieniające stan systemu (Commands) od zapytań tylko do odczytu (Queries).
- Eliminacja Constructor Over-injection:
Zamiast wstrzykiwać 10 różnych serwisów domenowych do jednego kontrolera
HTTP, wstrzykujemy wyłącznie interfejs
IMediatori wysyłamy obiekt żądania:await _mediator.Send(new GetUserQuery(id));. - Całkowite rozluźnienie powiązań: Nadawca żądania
nie wie, jaka klasa je obsłuży – Mediator wykorzystuje kontener DI, aby
odnaleźć odpowiedni
IRequestHandler<TRequest, TResponse>w czasie wykonywania programu.
// 1. Definicja zapytania (Query) i odpowiedzi:
public record GetShipStatusQuery(string Callsign) : IRequest<string>;
// 2. Handler obsługujący zapytanie (odizolowany od kontrolera):
public class GetShipStatusHandler : IRequestHandler<GetShipStatusQuery, string>
{
public Task<string> Handle(GetShipStatusQuery request, CancellationToken ct)
=> Task.FromResult($"Okręt {request.Callsign}: Osłony 100%, Reaktor stabilny.");
}
// 3. Użycie po stronie klienta (np. w kontrolerze lub serwisie):
string status = await mediator.Send(new GetShipStatusQuery("ORBITER-01"));
4.4. Od Event Aggregatora do Systemów Rozproszonych: Wydawca-Abonent (Pub/Sub) i Saga
Gdy komunikacja między komponentami wykracza poza pojedynczy moduł lub pojedynczy proces, klasyczny Obserwator i Mediator ewoluują w trzy specjalistyczne wzorce architektoniczne:
Event Aggregator (Zakres Obiektowy – komunikacja wewnątrzprocesowa):
- Działa jak centralna centrala telefoniczna (In-Memory Message Bus) dla komponentów, które w ogóle o sobie nie wiedzą.
- Przykład: W aplikacji z architekturą wtyczkową, wtyczka „A”
publikuje komunikat
PluginLoadedEvent, a wtyczka „B” subskrybuje ten typ komunikatu i go odbiera, mimo że oba moduły nie mają do siebie żadnych referencji projektowych.
Wydawca-Abonent / Publish-Subscribe (Zakres Obiektowy – systemy rozproszone):
- Rozszerzenie wzorca Obserwatora i Event Aggregatora na systemy rozproszone, z wykorzystaniem zewnętrznego pośrednika – Brokera Wiadomości (Message Broker).
- W ekosystemie .NET standardem jest biblioteka MassTransit lub Wolverine, która realizuje ten wzorzec poprzez kolejki i wymienniki (RabbitMQ, Azure Service Bus, Apache Kafka), dostarczając asynchronicznie wiadomości do wielu niezależnych mikroserwisów jednocześnie.
Saga (Zakres Obiektowy – koordynacja transakcji rozproszonych):
- Wzorzec zarządzania stanem w wieloetapowych transakcjach rozproszonych, w których nie można użyć klasycznej transakcji bazodanowej ACID.
- Saga to „długożyjący proces” (deterministyczna maszyna stanów),
który koordynuje sekwencję komunikatów pomiędzy usługami (np.
OrderCreated\(\rightarrow\)PaymentCharged\(\rightarrow\)InventoryReserved\(\rightarrow\)PackageShipped). - Akcje kompensacyjne (Compensation): Jeśli
na którymkolwiek etapie wystąpi błąd (np. brak towaru w magazynie po
pobraniu płatności), Saga automatycznie uruchamia kroki cofające
zatwierdzone wcześniej operacje (np. wysyła komendę
RefundPaymentCommand).
Przepływ pomyślny (Happy Path):
[1. Utwórz Zamówienie] ──► [2. Pobierz Płatność] ──► [3. Zarezerwuj Towar] ──► [4. Wyślij Paczkę]
│
Awaria na etapie 3 (Brak towaru w magazynie!) ▼ (Błąd!)
[Anuluj Zamówienie] ◄── [Zwróć Płatność (Refund)] ◄──────────┘
(Kompensacja Sagi 2) (Kompensacja Sagi 1)
5. Trawersowanie Struktur, Podwójne Wysłanie i Gramatyki
5.1. Iterator / Kursor (Iterator – Zakres Obiektowy)
Wzorzec Iterator (zwany też kursorem) dostarcza ujednolicony sposób na sekwencyjny dostęp do elementów obiektu zbiorczego bez ujawniania jego wewnętrznej reprezentacji (tablicy, listy wiązanej, drzewa czy grafu).
Obiektem zbiorczym nie musi być prosta płaska lista. Iterator doskonale sprawdza się przy strukturach nieliniowych i hierarchicznych:
- Drzewa (BFS / DFS): Możemy zaimplementować dwa
różne iteratory dla tego samego drzewa – jeden przechodzący strukturę
wszerz (Breadth-First Search z użyciem kolejki
Queue<T>) oraz drugi przechodzący w głąb (Depth-First Search z użyciem stosuStack<T>), całkowicie ukrywając złożoność algorytmiczną przed klientem. - Grafy: Iterator może wewnętrznie zarządzać zbiorem
odwiedzonych wierzchołków (
HashSet<T>), aby automatycznie unikać zapętleń (cykli) podczas iteracji. - Złożone obiekty domenowe: Np. obiekt
Firm, który bezszwowo iteruje po swoich działach (Department), a te po pracownikach (Employee).
W języku C# wzorzec ten jest wbudowany natywnie poprzez interfejsy
IEnumerable<T> oraz
IEnumerator<T> (oraz ich
asynchroniczny odpowiednik
IAsyncEnumerable<T> z C# 8+), a
kompilator automatycznie generuje maszynę stanów iteratora po użyciu
słów kluczowych yield return:
public class TreeNode<T>
{
public T Value { get; }
public List<TreeNode<T>> Children { get; } = new();
public TreeNode(T value) => Value = value;
// Iterator BFS (Przeszukiwanie wszerz - warstwami) wygenerowany przez yield return:
public IEnumerable<T> TraverseBreadthFirst()
{
var queue = new Queue<TreeNode<T>>();
queue.Enqueue(this);
while (queue.Count > 0)
{
var current = queue.Dequeue();
yield return current.Value;
foreach (var child in current.Children)
queue.Enqueue(child);
}
}
// Iterator DFS (Przeszukiwanie w głąb)
public IEnumerable<T> TraverseDepthFirst()
{
var stack = new Stack<TreeNode<T>>();
stack.Push(this);
while (stack.Count > 0)
{
var current = stack.Pop();
yield return current.Value;
// Dodajemy w odwrotnej kolejności, aby zachować porządek od lewej do prawej
for (int i = current.Children.Count - 1; i >= 0; i--)
stack.Push(current.Children[i]);
}
}
}
5.2. Odwiedzający / Wizytator (Visitor – Zakres Obiektowy) a Metody Rozszerzające
Wzorzec Odwiedzający (ang. Visitor) pozwala na zdefiniowanie nowej operacji na elementach złożonej struktury obiektów (np. drzewa Kompozytu) bez konieczności modyfikowania klas tych elementów (zgodnie z zasadą Open/Closed). Umożliwia dodanie nowego zachowania, które zależy od dokładnego typu konkretnej klasy odwiedzanego elementu.
Dlaczego metody rozszerzające (extension methods) nie zastępują Wizytatora? Problem Double Dispatch
Choć metody rozszerzające w C# pozwalają „dokleić” nową funkcję do istniejącej klasy lub interfejsu, nie zastępują one w pełni wzorca Wizytatora:
- Metody rozszerzające są wiązane statycznie (Single /
Static Dispatch): Kompilator wybiera przeciążenie metody
rozszerzającej na podstawie zadeklarowanego typu zmiennej (np.
interfejsu
IUnitComponent) w czasie kompilacji, a nie rzeczywistego typu obiektu w pamięci w czasie wykonania! - Wizytator obsługuje mechanizm Podwójnego Wysłania
(Double Dispatch): Potrafi polimorficznie wykonać
zupełnie inną logikę dla różnych typów w hierarchii (np. inaczej dla
liścia
Soldier, a inaczej dla kompozytuCommanderComposite) bez używania instrukcji rzutowania i sprawdzania typówif (unit is Soldier).
// 1. Interfejs Wizytatora posiadający dedykowaną metodę dla każdego konkretnego typu w hierarchii:
public interface IUnitVisitor
{
void VisitSoldier(Soldier soldier);
void VisitCommander(CommanderComposite commander);
}
// 2. Rozszerzenie interfejsu komponentu o metodę Accept(IUnitVisitor):
public interface IUnitComponent
{
void Accept(IUnitVisitor visitor);
}
public class Soldier : IUnitComponent
{
public int Health { get; set; } = 100;
// Pierwszy Dispatch: polimorficzne wywołanie Accept() na obiekcie IUnitComponent.
// Drugi Dispatch: przekazanie silnie typowanego 'this' (jako Soldier!) do visitora:
public void Accept(IUnitVisitor visitor) => visitor.VisitSoldier(this);
}
public class CommanderComposite : IUnitComponent
{
public string Rank { get; set; } = "Pułkownik";
private readonly List<IUnitComponent> _children = new();
public void AddChild(IUnitComponent unit) => _children.Add(unit);
public void Accept(IUnitVisitor visitor)
{
visitor.VisitCommander(this);
foreach (var child in _children)
{
child.Accept(visitor);
}
}
}
// 3. Konkretny Wizytator (np. eksporter raportu gotowości bojowej):
public class ReadinessAuditVisitor : IUnitVisitor
{
public void VisitSoldier(Soldier soldier)
=> Console.WriteLine($"[AUDYT ŻOŁNIERZA] Zdrowie: {soldier.Health}%");
public void VisitCommander(CommanderComposite commander)
=> Console.WriteLine($"[AUDYT DOWÓDCY] Stopień: {commander.Rank}");
}
5.3. Interpreter (Zakres Klasowy)
Wzorzec Interpreter definiuje, w jaki sposób można
zinterpretować formalny język (gramatykę) przy użyciu obiektów. Stanowi
to reprezentację reguł gramatycznych w postaci hierarchii klas (Drzewa
Składni Abstrakcyjnej – AST), a zdania w tym języku są ewaluowane
poprzez wywołanie metody Interpret na instancjach tych
klas.
Elementy wzorca (Proste Wyrażenia Logiczne):
- AbstractExpression (
IExpression): Wspólny interfejs dla wszystkich wyrażeń w drzewie składniowym. - TerminalExpression
(
VariableExpression): Implementuje operację dla symboli terminalnych (końcowych liści w gramatyce, takich jak stałe lub zmienne). - NonTerminalExpression (
OrExpression,AndExpression): Implementuje operację dla reguł złożonych (operatorów łączących podwyrażenia). - Context (
Context): Przechowuje stan globalny interpretera (np. słownik wartości zmiennych).
// 1. AbstractExpression
public interface IExpression
{
bool Interpret(Context context);
}
// 2. TerminalExpression (Zmienna)
public class VariableExpression : IExpression
{
private readonly string _name;
public VariableExpression(string name) => _name = name;
public bool Interpret(Context context) => context.GetValue(_name);
}
// 3. NonTerminalExpression (Operator Logiczny OR)
public class OrExpression : IExpression
{
private readonly IExpression _exp1;
private readonly IExpression _exp2;
public OrExpression(IExpression exp1, IExpression exp2)
{
_exp1 = exp1;
_exp2 = exp2;
}
public bool Interpret(Context context) => _exp1.Interpret(context) || _exp2.Interpret(context);
}
// 4. Context (Kontekst - zawiera mapę zmiennych i ich wartości)
public class Context
{
private readonly Dictionary<string, bool> _variables = new Dictionary<string, bool>();
public bool GetValue(string name) => _variables.ContainsKey(name) ? _variables[name] : false;
public void SetValue(string name, bool value) => _variables[name] = value;
}
Użycie w kodzie
(Ewaluacja wyrażenia A OR B):
var context = new Context();
context.SetValue("A", true);
context.SetValue("B", false);
var A = new VariableExpression("A");
var B = new VariableExpression("B");
var statement = new OrExpression(A, B);
bool result = statement.Interpret(context);
Console.WriteLine($"Wynik wyrażenia 'A OR B': {result}"); // True
Przykładowe wyjście:
Wynik wyrażenia 'A OR B': True
💡 Zastosowanie w ekosystemie .NET:
Wzorzec Interpretera (w połączeniu z WizytatoremExpressionVisitor) stanowi serce przestrzeni nazwSystem.Linq.Expressions. Drzewa wyrażeń (Expression<Func<T, bool>>) są interpretowane przez dostawców takich jak Entity Framework Core i tłumaczone w locie z kodu C# na natywne zapytania języka SQL.
6. Podsumowanie Wzorców Czynnościowych w Ekosystemie .NET i Kontenerach IoC/DI
| Wzorzec Czynnościowy | Zakres | Odpowiednik w .NET / Bibliotekach Produkcyjnych | Rola w Architekturze i Kontenerach IoC/DI |
|---|---|---|---|
| Łańcuch Zobowiązań | Obiektowy | ASP.NET Core Middleware,
IPipelineBehavior (MediatR). |
Sekwencyjne potoki walidacji, logowania i obsługi błędów. |
| Metoda Szablonowa | Klasowy | Szkielety klas abstrakcyjnych lub delegaty
Func/Action. |
Separacja niezmiennego szkieletu algorytmu od kroków zmiennych. |
| Strategia | Obiektowy | IComparer<T>, polityki
odpornościowe Polly,
IIndex<K,V>. |
Wymienne algorytmy wstrzykiwane w runtime zgodnie z DIP i OCP. |
| Polecenie (Command) | Obiektowy | ICommand /
RelayCommand (MVVM), IRequest
(MediatR). |
Hermetyzacja intencji, kolejkowanie zadań, izolacja UI od logiki. |
| Stan (State) | Obiektowy | Biblioteka Stateless, obiektowe maszyny stanów. | Eliminacja instrukcji warunkowych
switch/if zależnych od stanu. |
| Pamiątka (Memento) | Obiektowy | Niemutowalne rekordy migawek, mechanizmy Undo/Redo. | Bezpieczny zapis i odtwarzanie stanu wewnętrznego obiektu. |
RAII /
IDisposable |
Obiektowy | IDisposable,
IAsyncDisposable, bloki using,
ILifetimeScope. |
Deterministyczne zwalnianie zasobów niezarządzanych. |
| Obserwator & WeakEvent | Obiektowy | event EventHandler<T>,
IObservable<T> (Rx.NET),
WeakReference. |
Powiadomienia \(1:N\) bez ryzyka wycieków pamięci (Lapsed Listener). |
| Mediator | Obiektowy | Biblioteka MediatR (architektura CQS / CQRS). | Redukcja zależności z siatki \(N:M\) do układu gwiaździstego \(1:N\). |
| Pub/Sub & Saga | Obiektowy | MassTransit, Wolverine (RabbitMQ / Azure Service Bus). | Komunikacja między mikroserwisami i transakcje kompensacyjne. |
| Iterator | Obiektowy | IEnumerable<T>,
IAsyncEnumerable<T>, yield return. |
Polimorficzne przechodzenie drzew (BFS/DFS), grafów i kolekcji. |
| Wizytator (Visitor) | Obiektowy | ExpressionVisitor (LINQ),
podwójne wysłanie (Double Dispatch). |
Dodawanie operacji na hierarchiach klas bez modyfikacji ich kodu. |
| Interpreter | Klasowy | System.Linq.Expressions,
silniki reguł biznesowych. |
Ewaluacja gramatyk formalnych i drzew składniowych AST. |
7. Zadania Laboratoryjne
Zadania laboratoryjne do Części 3 zostaną udostępnione w osobnym
dokumencie:
📄
Zadanie Laboratoryjne do Części 3: Architektura Behawioralna Systemu Dowodzenia Flotą Orbitalną (zad_lab_3_wzorce_behaw.md)
8. Literatura i Źródła
- Refactoring Guru: Wzorce projektowe w C# – Wzorce Czynnościowe (Behawioralne) – https://refactoring.guru/pl/design-patterns/behavioral-patterns
- Infotraining: Wzorce projektowe w C-Sharp – https://infotraining.bitbucket.io/cs-dp/
- Maciej Szczepaniak: Wzorce projektowe – czym są i dlaczego warto je znać? – https://devszczepaniak.pl/wzorce-projektowe/
- Dokumentacja Microsoft Learn: Weak Event Patterns in .NET – https://learn.microsoft.com/en-us/dotnet/desktop/wpf/events/weak-event-patterns
- Jimmy Bogard: MediatR – Simple, unambitious mediator implementation in .NET – https://github.com/jbogard/MediatR
- MassTransit Documentation: Sagas, State Machines and Distributed Publish/Subscribe – https://masstransit.io/documentation/patterns/saga