🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

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)

ActionTransaction — 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 opcjonalnie Undo()).
  • ConcreteCommand — wiąże konkretną akcję z konkretnym Receiver, implementuje Execute() wywołując jego metody.
  • Client — tworzy ConcreteCommand i konfiguruje go odpowiednim Receiver.
  • 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.

👨‍💻
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 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.

Dodaj komentarz

czytanie to początek

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ę →