State

Wzorce behawioralne

ukończona

1. Definicja

State to wzorzec projektowy behawioralny, który pozwala obiektowi zmieniać swoje zachowanie w momencie, gdy zmienia się jego stan wewnętrzny. Z zewnątrz wygląda to tak, jakby obiekt zmienił swoją klasę.

Allow an object to alter its behavior when its internal state changes. The object will appear to change its class. — Gang of Four, Design Patterns

Najprościej rzecz ujmując: zamiast pytać obiekt "w jakim jesteś stanie i co w związku z tym powinienem zrobić", pytamy go po prostu "zrób to", a on sam wie, jak się zachować - bo jego bieżący stan jest reprezentowany przez osobny obiekt, który wie, co wolno, a czego nie.

Dlaczego to ważne? Bo bez tego wzorca zachowanie zależne od stanu zwykle ląduje w jednej metodzie jako długi ciąg instrukcji if-else lub switch. Taki kod rośnie z każdym nowym stanem, staje się coraz trudniejszy do zrozumienia i coraz łatwiej w nim o błąd - bo dodanie jednego nowego stanu wymaga przejrzenia i poprawienia dziesiątek miejsc w kodzie.


2. Przykład z życia: zobaczmy jak to zrozumieć?

Wyobraźmy sobie zamówienie w sklepie internetowym - dokładnie takie, jakim zajmowaliśmy się wcześniej przy okazji SRP.

Zamówienie przechodzi przez kilka etapów:

  • Nowe - klient dopiero co złożył zamówienie,
  • Opłacone - płatność została zaksięgowana,
  • Wysłane - paczka trafiła do kuriera,
  • Dostarczone - klient odebrał przesyłkę,
  • Anulowane - zamówienie zostało przerwane.

Kluczowa obserwacja: to, co wolno zrobić z zamówieniem, zależy od tego, w jakim jest ono aktualnie stanie.

Nowe zamówienie można opłacić albo anulować - ale nie można go "wysłać", bo przecież nikt jeszcze za nie nie zapłacił. Zamówienie dostarczone nie powinno dać się już anulować - paczka jest w rękach klienta, nie ma odwrotu. Zamówienie wysłane też nie powinno dać się anulować w tak prosty sposób, bo kurier jest już w drodze.

Ćwiczenie myślowe: wyobraźmy sobie, że jesteśmy pracownikiem infolinii i klient dzwoni z prośbą "proszę anulować moje zamówienie". Zanim cokolwiek zrobimy, musimy sprawdzić, na jakim etapie jest zamówienie - bo nasza odpowiedź i dostępne działania są całkowicie zależne od tego stanu. Dla zamówienia "Nowe" powiemy "oczywiście, anuluję". Dla "Dostarczone" powiemy "niestety, paczka już do Pana dotarła, proszę skorzystać ze zwrotu". To dokładnie to samo żądanie - cancel() - ale zupełnie inne zachowanie w zależności od stanu. Właśnie to zjawisko modelujemy wzorcem State.


3. Przykład - naruszenie State

Wyobraźmy sobie, że implementujemy klasę Order w najbardziej intuicyjny, ale niebezpieczny sposób - trzymając stan jako pole enum i sprawdzając je w każdej metodzie.

enum OrderStatus {
    NOWE, OPLACONE, WYSLANE, DOSTARCZONE, ANULOWANE
}

class Order {

    private OrderStatus status = OrderStatus.NOWE;

    public void pay() {
        if (status == OrderStatus.NOWE) {
            status = OrderStatus.OPLACONE;
            System.out.println("Zamówienie opłacone.");
        } else if (status == OrderStatus.OPLACONE) {
            System.out.println("Zamówienie już zostało opłacone.");
        } else if (status == OrderStatus.WYSLANE || status == OrderStatus.DOSTARCZONE) {
            throw new IllegalStateException("Nie można opłacić zamówienia na tym etapie.");
        } else if (status == OrderStatus.ANULOWANE) {
            throw new IllegalStateException("Zamówienie zostało anulowane.");
        }
    }

    public void ship() {
        if (status == OrderStatus.OPLACONE) {
            status = OrderStatus.WYSLANE;
            System.out.println("Zamówienie wysłane.");
        } else if (status == OrderStatus.NOWE) {
            throw new IllegalStateException("Nie można wysłać nieopłaconego zamówienia.");
        } else {
            throw new IllegalStateException("Nie można wysłać zamówienia na tym etapie.");
        }
    }

    public void deliver() {
        if (status == OrderStatus.WYSLANE) {
            status = OrderStatus.DOSTARCZONE;
            System.out.println("Zamówienie dostarczone.");
        } else {
            throw new IllegalStateException("Nie można dostarczyć zamówienia na tym etapie.");
        }
    }

    public void cancel() {
        if (status == OrderStatus.NOWE || status == OrderStatus.OPLACONE) {
            status = OrderStatus.ANULOWANE;
            System.out.println("Zamówienie anulowane.");
        } else {
            throw new IllegalStateException("Nie można anulować zamówienia na tym etapie.");
        }
    }
}

Co jest nie tak?

Każda metoda musi znać wszystkie możliwe stany, mimo że logicznie dotyczy tylko przejścia z jednego stanu do drugiego. Widać to szczególnie w pay(), gdzie musimy obsłużyć aż pięć przypadków, choć realnie sensowne jest tylko jedno przejście.

Konkretne problemy:

  • Rozrost kodu wraz z liczbą stanów. Dodanie nowego stanu, np. "Zwrócone", wymaga przejrzenia i poprawienia każdej metody klasy Order - a mamy ich tu tylko cztery. W realnym systemie takich metod bywa kilkanaście.
  • Łamanie zasady Open/Closed. Nie da się dodać nowego stanu bez modyfikacji istniejącego, przetestowanego kodu.
  • Trudne testowanie. Żeby przetestować zachowanie w stanie "Wysłane", trzeba przebrnąć przez cały if-else w każdej metodzie, mimo że interesuje nas tylko jedna gałąź.
  • Duplikacja logiki. Warunek "czy stan pozwala na anulowanie" pojawia się osobno w cancel(), a podobne sprawdzenia rozproszone są po całej klasie.
  • Ryzyko błędu. Łatwo zapomnieć o obsłużeniu jakiegoś stanu w jednej z metod - kompilator tego nie wychwyci.

4. Przykład - zgodny z State

Zamiast trzymać stan jako enum sprawdzany wszędzie, wydzielmy każdy stan jako osobny obiekt, który wie, co w tym stanie wolno zrobić.

Najpierw definiujemy wspólny interfejs stanu:

interface OrderState {
    void pay(Order order);
    void ship(Order order);
    void deliver(Order order);
    void cancel(Order order);
}

Teraz każdy stan implementuje tylko te przejścia, które faktycznie ma sens obsłużyć:

class NoweState implements OrderState {

    @Override
    public void pay(Order order) {
        System.out.println("Zamówienie opłacone.");
        order.setState(new OplaconeState());
    }

    @Override
    public void ship(Order order) {
        throw new IllegalStateException("Nie można wysłać nieopłaconego zamówienia.");
    }

    @Override
    public void deliver(Order order) {
        throw new IllegalStateException("Nie można dostarczyć nieopłaconego zamówienia.");
    }

    @Override
    public void cancel(Order order) {
        System.out.println("Zamówienie anulowane.");
        order.setState(new AnulowaneState());
    }
}

class OplaconeState implements OrderState {

    @Override
    public void pay(Order order) {
        System.out.println("Zamówienie już zostało opłacone.");
    }

    @Override
    public void ship(Order order) {
        System.out.println("Zamówienie wysłane.");
        order.setState(new WyslaneState());
    }

    @Override
    public void deliver(Order order) {
        throw new IllegalStateException("Nie można dostarczyć niewysłanego zamówienia.");
    }

    @Override
    public void cancel(Order order) {
        System.out.println("Zamówienie anulowane, zwrot płatności zostanie zainicjowany.");
        order.setState(new AnulowaneState());
    }
}

class WyslaneState implements OrderState {

    @Override
    public void pay(Order order) {
        throw new IllegalStateException("Zamówienie zostało już opłacone i wysłane.");
    }

    @Override
    public void ship(Order order) {
        System.out.println("Zamówienie zostało już wysłane.");
    }

    @Override
    public void deliver(Order order) {
        System.out.println("Zamówienie dostarczone.");
        order.setState(new DostarczoneState());
    }

    @Override
    public void cancel(Order order) {
        throw new IllegalStateException("Nie można anulować - paczka jest już w drodze.");
    }
}

class DostarczoneState implements OrderState {

    @Override
    public void pay(Order order) {
        throw new IllegalStateException("Zamówienie zostało już zrealizowane.");
    }

    @Override
    public void ship(Order order) {
        throw new IllegalStateException("Zamówienie zostało już dostarczone.");
    }

    @Override
    public void deliver(Order order) {
        System.out.println("Zamówienie zostało już dostarczone.");
    }

    @Override
    public void cancel(Order order) {
        throw new IllegalStateException("Nie można anulować dostarczonego zamówienia. Skorzystaj ze zwrotu.");
    }
}

class AnulowaneState implements OrderState {

    @Override
    public void pay(Order order) {
        throw new IllegalStateException("Zamówienie zostało anulowane.");
    }

    @Override
    public void ship(Order order) {
        throw new IllegalStateException("Zamówienie zostało anulowane.");
    }

    @Override
    public void deliver(Order order) {
        throw new IllegalStateException("Zamówienie zostało anulowane.");
    }

    @Override
    public void cancel(Order order) {
        System.out.println("Zamówienie już zostało anulowane.");
    }
}

Na koniec klasa Order staje się bardzo prosta - jedynie deleguje żądania do aktualnego stanu:

class Order {

    private OrderState state = new NoweState();

    public void setState(OrderState state) {
        this.state = state;
    }

    public void pay() {
        state.pay(this);
    }

    public void ship() {
        state.ship(this);
    }

    public void deliver() {
        state.deliver(this);
    }

    public void cancel() {
        state.cancel(this);
    }
}

Co się zmieniło i dlaczego?

  • Klasa Order nie wie już nic o tym, co wolno w danym stanie - całą tę wiedzę przeniósł do siebie każdy konkretny stan. Order jedynie przekazuje żądanie dalej.
  • Każdy stan implementuje interfejs OrderState, ale obsługuje tylko sensowne dla siebie przejścia - reszta metod po prostu sygnalizuje błąd lub informuje o braku zmiany.
  • Dodanie nowego stanu, np. "Zwrócone", oznacza dodanie jednej nowej klasy implementującej OrderState - nie trzeba dotykać istniejącego kodu innych stanów. To właśnie realizacja zasady Open/Closed.
  • Testowanie stało się prostsze - żeby przetestować zachowanie w stanie "Wysłane", testujemy wyłącznie klasę WyslaneState, bez przebijania się przez logikę pozostałych stanów.
  • Przejścia między stanami są jawne i widoczne w jednym miejscu - w metodzie, która wywołuje order.setState(...).

5. Diagram struktury

┌──────────────────────┐        deleguje do        ┌────────────────────┐
│       Order          │ ────────────────────────► │   OrderState       │
│----------------------│                           │   «interface»      │
│ - state: OrderState  │                           │--------------------│
│----------------------│                           │ + pay(Order)       │
│ + pay()              │                           │ + ship(Order)      │
│ + ship()             │                           │ + deliver(Order)   │
│ + deliver()          │                           │ + cancel(Order)    │
│ + cancel()           │                           └────────────────────┘
│ + setState(state)    │                                    ▲
└──────────────────────┘                                    │
                                                            │ implementują
                        ┌────────────────┬───────────────┬──┴─────────────┬──────────────────┐
                        │                │               │                │                  │
                 ┌─────────────┐ ┌────────────────┐ ┌─────────────┐ ┌──────────────────┐ ┌───────────────┐
                 │ NoweState   │ │ OplaconeState  │ │ WyslaneState│ │ DostarczoneState │ │ AnulowaneState│
                 └─────────────┘ └────────────────┘ └─────────────┘ └──────────────────┘ └───────────────┘

Przejścia między stanami:

  Nowe ──pay()──► Opłacone ──ship()──► Wysłane ──deliver()──► Dostarczone
   │                  │
   └──cancel()────────┴──cancel()──► Anulowane

6. Dlaczego State jest ważne?

Eliminuje rozrastające się if-else i switch. Zamiast jednej metody, która musi znać wszystkie stany, mamy zestaw małych, jednoznacznych klas, z których każda odpowiada za jeden stan.

Ułatwia dodawanie nowych stanów. Nowy stan to nowa klasa, a nie modyfikacja istniejącego, przetestowanego kodu - to bezpośrednia realizacja zasady Open/Closed z SOLID.

Ułatwia testowanie. Każdy stan można testować w izolacji, bez konieczności przechodzenia przez logikę pozostałych stanów.

Porządkuje pracę zespołu. Jeśli różne osoby pracują nad logiką różnych etapów zamówienia, każda może edytować "swoją" klasę stanu bez ryzyka konfliktu w tym samym pliku.

Zaniedbanie tej zasady w dużych projektach zwykle kończy się tzw. "boskim obiektem" (God Object) z metodami liczącymi setki linii pełnych zagnieżdżonych warunków - kod, którego nikt już nie chce dotykać, bo każda zmiana grozi zepsuciem innej gałęzi logiki.

Kiedy NIE stosować? Jeśli obiekt ma tylko dwa-trzy stany o bardzo prostym zachowaniu (np. proste włączony/wyłączony), wzorzec State bywa nadmiarowy - prosty if lub enum z kilkoma metodami pomocniczymi wystarczy, a tworzenie osobnych klas dla każdego stanu tylko zwiększa liczbę plików bez realnej korzyści.


7. Kiedy stosować?

  • Systemy zarządzania zamówieniami i workflow - stany zamówienia, wniosku urlopowego, zgłoszenia serwisowego, procesu zatwierdzania dokumentu.
  • Automaty i maszyny stanów - odtwarzacze multimedialne (Odtwarzanie/Pauza/Zatrzymanie), połączenia sieciowe (TCP: LISTEN, ESTABLISHED, CLOSED), bramki płatnicze.
  • Interfejsy użytkownika - stan przycisku (aktywny/nieaktywny/w trakcie ładowania), stan formularza (edycja/zapisywanie/błąd).
  • Frameworki i biblioteki: Spring oferuje dedykowany projekt Spring State Machine, który wprost implementuje ten wzorzec do modelowania złożonych procesów biznesowych z jawnie zdefiniowanymi stanami i przejściami. Podobną ideę znajdziemy w silnikach workflow takich jak Activiti czy Camunda, gdzie każdy krok procesu biznesowego to de facto osobny stan.
  • Wzorzec State jest szczególnie wartościowy tam, gdzie liczba stanów rośnie z czasem i gdzie zespół chce uniknąć sytuacji, w której jedna metoda staje się "centrum dowodzenia" całą logiką biznesową aplikacji.

"Nie pytaj obiektu, w jakim jest stanie - niech stan sam wie, co robić." Innymi słowy: każdy stan to osobna klasa, zero switchy w kodzie.