Command Pattern w C# – implementacja z obsługą Undo
Kolejny wzorzec z serii opartej na Anatomii wzorca — 8 elementach opisu GoF. Tym razem Command — wzorzec, bez którego trudno wyobrazić sobie funkcję “Cofnij” w jakimkolwiek edytorze.
1. Intent (Zamierzenie)
Hermetyzuje żądanie jako obiekt, dzięki czemu możesz parametryzować klientów różnymi żądaniami, kolejkować je lub logować, a także obsługiwać operacje odwracalne.
Krótko: zamiast wywoływać metodę bezpośrednio, pakujesz “wywołanie tej metody, z tymi argumentami” w osobny obiekt. Ten obiekt możesz przechować, przekazać dalej, wykonać później albo cofnąć.
2. Also Known As (Inne nazwy)
Action, Transaction — w kontekście systemów transakcyjnych Command bywa nazywany właśnie tak, bo “polecenie” i “transakcja do wykonania lub cofnięcia” to ten sam pomysł.
3. Motivation (Motywacja)
GoF opisuje przycisk w pasku narzędzi — ten sam przycisk “Zapisz” musi działać identycznie, niezależnie od tego, czy kliknięto go myszką, wybrano ze skrótu klawiszowego, czy z menu. Przycisk nie powinien wiedzieć, jak zapisać dokument — powinien tylko wiedzieć, że ma wykonać przypisane mu polecenie.
Command rozdziela obiekt, który wywołuje operację (przycisk, skrót klawiszowy) od obiektu, który wie, jak ją wykonać (edytor dokumentu). Dodatkowa korzyść: skoro polecenie jest teraz obiektem, a nie bezpośrednim wywołaniem metody, można je przechować na stosie i cofnąć.
4. Applicability (Kiedy stosować)
Stosuj Command gdy chcesz:
- parametryzować obiekty akcją do wykonania (przycisk, który wykonuje różne polecenia w zależności od konfiguracji),
- kolejkować, logować albo wykonywać żądania w innym czasie niż w momencie ich utworzenia,
- wspierać cofanie operacji (Undo/Redo),
- budować złożone operacje z prostszych, jak transakcję złożoną z wielu kroków.
5. Structure (Struktura)
┌───────────────┐ ┌──────────────────┐
│ Invoker │ ──────> │ <<interface>> │
│───────────────│ │ Command │
│ +SetCommand() │ │──────────────────│
│ +ExecuteCmd() │ │ +Execute() │
└───────────────┘ │ +Undo() │
└──────────────────┘
△
┌───────────────┴───────────────┐
┌──────────────────┐ ┌──────────────────┐
│ ConcreteCommand │ │ Receiver │
│──────────────────│ ─ ─ zna ─>│──────────────────│
│ -receiver │ │ +Action() │
│ +Execute() │ └──────────────────┘
│ +Undo() │
└──────────────────┘
Invoker wywołuje Execute() na obiekcie Command, nie wiedząc nic o tym, co konkretnie się stanie. ConcreteCommand trzyma referencję do Receiver (obiektu, który faktycznie wie, jak wykonać operację) i deleguje do niego pracę.
6. Participants (Uczestnicy)
- Command — wspólny interfejs z metodą
Execute()(i opcjonalnieUndo()). - ConcreteCommand — wiąże konkretną akcję z konkretnym
Receiver, implementujeExecute()wywołując jego metody. - Client — tworzy
ConcreteCommandi konfiguruje go odpowiednimReceiver. - Invoker — przechowuje polecenie i w odpowiednim momencie wywołuje
Execute(), nie znając jego szczegółów. - Receiver — wie, jak faktycznie wykonać operację (np. edytor dokumentu, silnik bazy danych).
7. Collaborations (Współpraca)
Client tworzy ConcreteCommand i ustawia jego Receiver. Invoker przechowuje ConcreteCommand. Invoker wywołuje Execute() na poleceniu. ConcreteCommand wywołuje operacje na swoim Receiver, żeby zrealizować żądanie.
Invoker i Receiver nigdy się bezpośrednio nie znają — komunikują się wyłącznie przez obiekt Command.
8. Consequences (Konsekwencje)
Plus: rozdziela obiekt wywołujący operację od obiektu, który wie, jak ją wykonać. Plus: polecenia to obiekty pierwszej klasy — można je składać, kolejkować, logować, cofać. Plus: łatwo dodać nowe polecenie bez zmiany istniejących klas. Minus: dla każdej akcji powstaje osobna, mała klasa — przy wielu akcjach liczba klas rośnie szybko.
Command w C# — od diagramu do kodu
Scenariusz
Prosty edytor tekstu: wstawianie tekstu, pogrubienie zaznaczenia, z obsługą Undo. Bez wzorca, Edytor musiałby sam pamiętać historię operacji i wiedzieć, jak cofnąć każdy typ akcji — logika edytora i logika cofania wymieszane w jednej klasie.
Command, Receiver, ConcreteCommand
public interface ICommand
{
void Execute();
void Undo();
}
// Receiver — wie, JAK wykonać operację
public class Dokument
{
private readonly StringBuilder _tresc = new();
public string Tresc => _tresc.ToString();
public void WstawTekst(string tekst, int pozycja) => _tresc.Insert(pozycja, tekst);
public void UsunTekst(int pozycja, int dlugosc) => _tresc.Remove(pozycja, dlugosc);
}
// ConcreteCommand — wiąże konkretną akcję z Receiver
public class WstawTekstCommand : ICommand
{
private readonly Dokument _dokument;
private readonly string _tekst;
private readonly int _pozycja;
public WstawTekstCommand(Dokument dokument, string tekst, int pozycja)
{
_dokument = dokument;
_tekst = tekst;
_pozycja = pozycja;
}
public void Execute() => _dokument.WstawTekst(_tekst, _pozycja);
public void Undo() => _dokument.UsunTekst(_pozycja, _tekst.Length);
}
Invoker ze stosem Undo
public class EdytorInvoker
{
private readonly Stack<ICommand> _historia = new();
public void Wykonaj(ICommand command)
{
command.Execute();
_historia.Push(command); // polecenie samo wie, jak się cofnąć
}
public void Cofnij()
{
if (_historia.Count == 0) return;
_historia.Pop().Undo();
}
}
var dokument = new Dokument();
var invoker = new EdytorInvoker();
invoker.Wykonaj(new WstawTekstCommand(dokument, "Witaj, ", 0));
invoker.Wykonaj(new WstawTekstCommand(dokument, "świecie!", 7));
Console.WriteLine(dokument.Tresc); // "Witaj, świecie!"
invoker.Cofnij();
Console.WriteLine(dokument.Tresc); // "Witaj, "
EdytorInvoker nie wie nic o wstawianiu tekstu — zna tylko interfejs ICommand. Dodanie nowej operacji (np. PogrubTekstCommand) nie wymaga zmiany ani w EdytorInvoker, ani w Dokument.
Delegaty jako lekki Command
Dla prostych, bezstanowych akcji (bez potrzeby Undo) C# pozwala pominąć osobne klasy i użyć wprost Action:
var polecenia = new List<Action>
{
() => Console.WriteLine("Zapisz dokument"),
() => Console.WriteLine("Wydrukuj dokument"),
};
foreach (var polecenie in polecenia) polecenie.Invoke();
To wciąż Command Pattern — Action to hermetyzowane wywołanie. Tracisz tylko możliwość dopisania do niego stanu potrzebnego do Undo(); gdy tego potrzebujesz, wracasz do pełnej klasy ConcreteCommand.
Pułapka: Undo bez zapisanego stanu
public class UsunTekstCommand : ICommand
{
private readonly Dokument _dokument;
private readonly int _pozycja, _dlugosc;
public void Execute() => _dokument.UsunTekst(_pozycja, _dlugosc);
public void Undo() => throw new NotSupportedException(); // ❌ tekst już usunięty, nie ma go skąd przywrócić
}
Żeby cofnąć usunięcie, ConcreteCommand musi zapamiętać usunięty fragment przed wykonaniem operacji — Undo nie odgadnie danych, których już nie ma:
public class UsunTekstCommand : ICommand
{
private readonly Dokument _dokument;
private readonly int _pozycja, _dlugosc;
private string? _usunietyFragment; // zapamiętany stan do przywrócenia
public void Execute()
{
_usunietyFragment = _dokument.Tresc.Substring(_pozycja, _dlugosc);
_dokument.UsunTekst(_pozycja, _dlugosc);
}
public void Undo() => _dokument.WstawTekst(_usunietyFragment!, _pozycja); // ✅
}
Macro Command — polecenie złożone z poleceń
Skoro ICommand jest zwykłym obiektem, można zbudować polecenie, które wykonuje listę innych poleceń jako jedną, atomową operację (np. “Znajdź i zamień wszystko” = wiele pojedynczych zamian cofanych razem):
public class MacroCommand : ICommand
{
private readonly List<ICommand> _polecenia;
public MacroCommand(List<ICommand> polecenia) => _polecenia = polecenia;
public void Execute() => _polecenia.ForEach(p => p.Execute());
public void Undo() => _polecenia.AsEnumerable().Reverse().ToList().ForEach(p => p.Undo()); // odwrotna kolejność!
}
Cofanie w odwrotnej kolejności niż wykonanie to nie stylistyczny wybór — jeśli polecenie B zależało od efektu polecenia A, cofnięcie musi zdjąć B przed A, inaczej Undo zostawi dokument w niespójnym stanie.
Podsumowanie
Command zamienia wywołanie metody w pełnoprawny obiekt — dzięki temu można je przechować, przekazać, zakolejkować i cofnąć. Invoker (przycisk, skrót klawiszowy) nigdy nie zna Receivera (logiki, która faktycznie wykonuje pracę) — łączy ich wyłącznie wspólny interfejs ICommand. Klucz do działającego Undo to zapamiętanie stanu potrzebnego do cofnięcia przed wykonaniem operacji, nie próba odgadnięcia go po fakcie.
🚀 Co dalej?
Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
Zamień wiedzę w umiejętności
Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.
Pobieram roadmapę →