State (Stan)

State UML

Opis

State (Stan) należy do grupy wzorców behawioralnych. Stan umożliwia zmianę zachowania obiektu, wówczas gdy zmienia się jego wewnętrzny stan. Inaczej mówiąc, pomaga zaenkapsulować różnorodność zachowań w zależności od stanu.

Problem i rozwiązanie

Obiekt może być w różnym stanie. To normalne. Co jednak, gdy w każdym z tych stanów może lub powinien zachowywać się inaczej. Ten problem często rozwiązuje się poprzez wprowadzenie pola status. Popularnym rozwiązaniem jest użycie maszyny stanów. Niestety, jeżeli istnieje wiele stanów oraz możliwości przejść między nimi to pojawia się skomplikowane warunkowanie, które może osłabić czytelność i utrzymywalność.

Obsługę skończonej liczby stanów i przejść między nimi w sposób bardziej obiektowy gwarantuje wzorzec projektowy Stan. Poszczególne etapy są reprezentowane przez osobne klasy, a dzięki temu każde, specyficzne dla danego stanu, zachowanie może się tam znaleźć. Wartość wprowadzenia wzorca projektowego State istnieje szczególnie wtedy, gdy logika będzie się często zmieniała. Dzięki temu, wszystkie nieczytelne warunkowania, niejednokrotnie powielane w kilku metodach klasy, zostaną wyeliminowane.

Plusy i minusy

Jak już wspomniałem, prawie każdy obiekt ma stan. W związku z tym wydawać by się mogło, że zawsze należy go ograć za pomocą wzorca projektowego Stan. Co może prowadzić do jego nadmiarowego użycia. Prawda jest jednak taka, że ten wzorzec sprawdza się tylko w przypadku, gdy liczba stanów jest duża, logika przejść między nimi jest skomplikowana, a w dodatku w każdym z tych stanów obiekt zachowuje się inaczej.

Plusów można doszukać się więcej. Przestrzegane są dwie pierwsze zasady SOLID – pojedyczna odpowiedzialność (single responsibility) i otwarte-zamknięte (open-closed). Eliminacja dziedziczenia, a zamiast tego wykorzystywana kompozycja. Ograniczenie, bądź pozbycie się zbędnych instrukcji warunkowych, co wpływa na czytelność i łatwiejszą zarządzalność. Mechanizm gwarantuje również enkapsulację konkretnych stanów i zapewnia elastyczność.

Przykładowa implementacja w PHP

Na początek, kilka słów o założeniach tego przykładu, a następnie przejdę do bardziej technicznych rzeczy. Ogólnie, poniższy kod reprezentuje zadanie w projekcie mogące przyjąć kilka stanów. Najłatwiej można to sobie wyobrazić jako zadanie na tablicy do zarządzania projektami w Trello, Jira czy analogicznym narzędziu. Proces definiuje więc cały przepływ takiego zadania. Od momentu jego stworzenia do zakończenia.

Mimo, że idea wzorca jest jedna to jak zwykle implementacji może być więcej. Moja propozycja jest odpowiedzią na kilka problemów, które widzę w „klasycznym” podejściu. Stan zwykle jest reprezentowany za pomocą interfejsu albo klasy abstrakcyjnej. W tym przykładzie, sensowniejszym wyborem był interfejs. Jest to StateInterface, który ma dwie metody move() i canEstimate(). Klasy reprezentujące konkretne implementacje opisujące możliwe stany to Open, InProgress, Resolved, Closed i Reopen.

Wszystkie te składowe potrzebne są do opisania stanu obiektu Task. Można byłoby to zrobić za pomocą pola status, ale to skończyłoby się sporą ifologią. Trochę bardziej uniwersalne byłoby użycie maszyny stanów, ale ma ona jeden zasadniczy problem – logika jest wydzielana do konfiguracji. Oba wcześniej wymienione rozwiązania bywają dobrym wyborem. Jednakże w przypadku, gdzie możliwych stanów jest dużo i wpływają one na zachowania obiektu to lepiej postawić na implementację podobną do tej z przykładu.

Klasa Task powinna enkapsulować swój własny wewnętrzny stan. Trafić można na kod, gdzie owa klasa posiada metodę setState() i getState() lub tożsame. Trzeba z tym uważać. Z prostych przyczyn. Metoda ustawiająca stan pozwoli to na wprowadzenie obiektu w niepoprawną fazę albo będzie musiała posiadać złożoną logikę, kiedy można przejść do jakiego stanu. Dokładnie ten problem ma być rozwiązany przez wzorzec State. Trzeba zrobić wszystko, by przejścia między stanami były kontrolowane przez klasę kontekstową. W moim przykładzie też jest metoda changeState(), ale nie bazuje ona na obiektach typu StateInterface, bo trzeba zrobic wszystko, żeby one nie wyciekały na zewnątrz. To klasa zadania jest tutaj głównym aktorem i to na niej operujemy, nie dzieląc się szczegółami implementacyjnymi.

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\State;

interface StateInterface
{
    /**
     * @throws InvalidStateException
     */
    public function move(State $state): self;

    public function canEstimate(): bool;
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\State;

final class Open implements StateInterface
{
    public function move(State $state): StateInterface
    {
        return match ($state) {
            State::IN_PROGRESS => new InProgress(),
            default => throw new InvalidStateException()
        };
    }

    public function canEstimate(): bool
    {
        return true;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\State;

final class InProgress implements StateInterface
{
    public function move(State $state): StateInterface
    {
        return match ($state) {
            State::RESOLVED => new Resolved(),
            State::OPEN => new Open(),
            default => throw new InvalidStateException()
        };
    }

    public function canEstimate(): bool
    {
        return false;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\State;

final class Resolved implements StateInterface
{
    public function move(State $state): StateInterface
    {
        return match ($state) {
            State::CLOSED => new Closed(),
            State::IN_PROGRESS => new InProgress(),
            default => throw new InvalidStateException()
        };
    }

    public function canEstimate(): bool
    {
        return false;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\State;

final class Closed implements StateInterface
{
    public function move(State $state): StateInterface
    {
        return match ($state) {
            State::REOPENED => new Reopened(),
            State::RESOLVED => new Resolved(),
            default => throw new InvalidStateException()
        };
    }

    public function canEstimate(): bool
    {
        return false;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\State;

final class Reopened implements StateInterface
{
    public function move(State $state): StateInterface
    {
        return match ($state) {
            State::IN_PROGRESS => new InProgress(),
            State::CLOSED => new Closed(),
            default => throw new InvalidStateException()
        };
    }

    public function canEstimate(): bool
    {
        return true;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\State;

use Exception;

final class InvalidStateException extends Exception
{
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\State;

enum State: string
{
    case OPEN = 'open';
    case IN_PROGRESS = 'inProgress';
    case RESOLVED = 'resolved';
    case CLOSED = 'closed';
    case REOPENED = 'reopened';
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\State;

final class Task
{
    private int $estimatePoints = 0;

    private StateInterface $state;

    public function __construct() {
        $this->state = new Open();
    }

    /**
     * @throws InvalidStateException
     */
    public function changeState(State $state): void
    {
        $this->state = $this->state->move($state);
    }

    /**
     * @throws InvalidStateException
     */
    public function estimate(int $points): void
    {
        if (! $this->state->canEstimate()) {
            throw new InvalidStateException();
        }

        $this->estimatePoints = $points;
    }

    public function isOpen(): bool
    {
        return $this->state instanceof Open;
    }

    public function isInProgress(): bool
    {
        return $this->state instanceof InProgress;
    }

    public function isResolved(): bool
    {
        return $this->state instanceof Resolved;
    }

    public function isClosed(): bool
    {
        return $this->state instanceof Closed;
    }

    public function isReopened(): bool
    {
        return $this->state instanceof Reopened;
    }

    public function getEstimatePoints(): int
    {
        return $this->estimatePoints;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\State\Test;

use DesignPatterns\Behavioral\State\InvalidStateException;
use DesignPatterns\Behavioral\State\State;
use DesignPatterns\Behavioral\State\Task;
use PHPUnit\Framework\TestCase;

final class TaskTest extends TestCase
{
    public function testCanChangeOpenTaskToInProgress(): void
    {
        $task = new Task();

        $task->changeState(State::IN_PROGRESS);

        self::assertTrue($task->isInProgress());
    }

    public function testCannotChangeOpenTaskToOpen(): void
    {
        $task = new Task();

        self::expectException(InvalidStateException::class);

        $task->changeState(State::OPEN);
    }

    public function testCannotChangeOpenTaskToResolved(): void
    {
        $task = new Task();

        self::expectException(InvalidStateException::class);

        $task->changeState(State::RESOLVED);
    }

    public function testCannotChangeOpenTaskToReopened(): void
    {
        $task = new Task();

        self::expectException(InvalidStateException::class);

        $task->changeState(State::REOPENED);
    }

    public function testCannotChangeOpenTaskToClosed(): void
    {
        $task = new Task();

        self::expectException(InvalidStateException::class);

        $task->changeState(State::CLOSED);
    }

    public function testCanChangeInProgressTaskToOpen(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);

        $task->changeState(State::OPEN);

        self::assertTrue($task->isOpen());
    }

    public function testCanChangeInProgressTaskToResolved(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);

        $task->changeState(State::RESOLVED);

        self::assertTrue($task->isResolved());
    }

    public function testCannotChangeInProgressTaskToInProgress(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::IN_PROGRESS);
    }

    public function testCannotChangeInProgressTaskToReopened(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::REOPENED);
    }

    public function testCannotChangeInProgressTaskToClosed(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::CLOSED);
    }

    public function testCanChangeResolvedTaskToInProgress(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);

        $task->changeState(State::IN_PROGRESS);

        self::assertTrue($task->isInProgress());
    }

    public function testCanChangeResolvedTaskToClosed(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);

        $task->changeState(State::CLOSED);

        self::assertTrue($task->isClosed());
    }

    public function testCannotChangeResolvedTaskToResolved(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::RESOLVED);
    }

    public function testCannotChangeResolvedTaskToOpen(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::OPEN);
    }

    public function testCannotChangeResolvedTaskToReopened(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::REOPENED);
    }

    public function testCanChangeClosedTaskToReopened(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);

        $task->changeState(State::REOPENED);

        self::assertTrue($task->isReopened());
    }

    public function testCanChangeClosedTaskToResolved(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);

        $task->changeState(State::RESOLVED);

        self::assertTrue($task->isResolved());
    }

    public function testCannotChangeClosedTaskToClosed(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::CLOSED);
    }

    public function testCannotChangeClosedTaskToOpen(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::OPEN);
    }

    public function testCannotChangeClosedTaskToInProgress(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::IN_PROGRESS);
    }

    public function testCanChangeReopenedTaskToInProgress(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);
        $task->changeState(State::REOPENED);

        $task->changeState(State::IN_PROGRESS);

        self::assertTrue($task->isInProgress());
    }

    public function testCanChangeReopenedTaskToClosed(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);
        $task->changeState(State::REOPENED);

        $task->changeState(State::CLOSED);

        self::assertTrue($task->isClosed());
    }

    public function testCannotChangeReopenedTaskToReopened(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);
        $task->changeState(State::REOPENED);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::REOPENED);
    }

    public function testCannotChangeReopenedTaskToResolved(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);
        $task->changeState(State::REOPENED);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::RESOLVED);
    }

    public function testCannotChangeReopenedTaskToOpen(): void
    {
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);
        $task->changeState(State::REOPENED);

        self::expectException(InvalidStateException::class);

        $task->changeState(State::OPEN);
    }

    public function testCanEstimateOpenTask(): void
    {
        $points = 5;
        $task = new Task();

        $task->estimate($points);

        self::assertSame($points, $task->getEstimatePoints());
    }

    public function testCanEstimateReopenedTask(): void
    {
        $points = 5;
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);
        $task->changeState(State::REOPENED);

        $task->estimate($points);

        self::assertSame($points, $task->getEstimatePoints());
    }

    public function testCannotEstimateInProgressTask(): void
    {
        $points = 5;
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);

        self::expectException(InvalidStateException::class);

        $task->estimate($points);
    }

    public function testCannotEstimateResolvedTask(): void
    {
        $points = 5;
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);

        self::expectException(InvalidStateException::class);

        $task->estimate($points);
    }

    public function testCannotEstimateClosedTask(): void
    {
        $points = 5;
        $task = new Task();
        $task->changeState(State::IN_PROGRESS);
        $task->changeState(State::RESOLVED);
        $task->changeState(State::CLOSED);

        self::expectException(InvalidStateException::class);

        $task->estimate($points);
    }
}

State – podsumowanie

State to świetne rozwiązanie popularnego problemu w sposób w pełni obiektowy. Mała uwaga. Rzecz jasna nie ma sensu go wprowadzać dla klas, która ma dwa statusy: aktywny i nieaktywny, a w dodatku oba te stany nie wpływają na zachowanie obiektu. Sprawdzi się w bardziej skomplikowanych przypadkach.

Kiedy użyć maszyny stanów, a kiedy wzorca projektowego Stan? Niełatwo odpowiedzieć na to pytanie. Jeżeli są to obiekty dziedzinowe to poszedłbym raczej we wzorzec projektowy, zamiast wyciągać logikę do plików konfiguracyjnych. State Machine może być sensownym rozwiązaniem, kiedy istnieje tylko logika przechodzenia pomiędzy stanami, ale nie ma różnicy w zachowaniach. Stan bardzo dobrze kontroluje spójność i konkretne odpowiedzialności.

Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan dobrego humoru i podcastów.