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, o ile 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, iż zawsze należy go ograć dzięki wzorca projektowego Stan. Co może prowadzić do jego nadmiarowego użycia. Prawda jest jednak taka, iż 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, jirze czy analogicznym narzędziu. Proces definiuje więc cały przepływ takiego zadania. Od momentu jego stworzenia do zakończenia.
Mimo, iż 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 dzięki interfejsu albo klasy abstrakcyjnej. W tym przykładzie, sensowniejszym wyborem był interfejs. Jest to StateInterface, który ma trzy metody move(), undo() 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ć dzięki 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. W moim odczuciu to błąd, dlatego w tym przykładzie ich nie ma. 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ą.
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\State;
interface StateInterface
{
public function move(): self;
/**
* @throws InvalidStateException
*/
public function undo(): self;
public function canEstimate(): bool;
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\State;
final class Open implements StateInterface
{
public function move(): StateInterface
{
return new InProgress();
}
public function undo(): StateInterface
{
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(): StateInterface
{
return new Resolved();
}
public function undo(): StateInterface
{
return new Open();
}
public function canEstimate(): bool
{
return false;
}
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\State;
final class Resolved implements StateInterface
{
public function move(): StateInterface
{
return new Closed();
}
public function undo(): StateInterface
{
return new InProgress();
}
public function canEstimate(): bool
{
return false;
}
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\State;
final class Closed implements StateInterface
{
public function move(): StateInterface
{
return new Reopened();
}
public function undo(): StateInterface
{
return new Resolved();
}
public function canEstimate(): bool
{
return false;
}
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Behavioral\State;
final class Reopened implements StateInterface
{
public function move(): StateInterface
{
return new InProgress();
}
public function undo(): StateInterface
{
return new Closed();
}
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;
final class Task
{
private int $estimatePoints = 0;
private StateInterface $state;
public function __construct() {
$this->state = new Open();
}
public function move(): void
{
$this->state = $this->state->move();
}
/**
* @throws InvalidStateException
*/
public function undo(): void
{
$this->state = $this->state->undo();
}
/**
* @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\Task;
use PHPUnit\Framework\TestCase;
final class TaskTest extends TestCase
{
public function testIsOpenTaskBecomesInProgressAfterMove(): void
{
$task = new Task();
$task->move();
self::assertTrue($task->isInProgress());
}
public function testCannotUndoOpenTask(): void
{
$task = new Task();
self::expectException(InvalidStateException::class);
$task->undo();
}
public function testIsInProgressTaskBecomesResolvedAfterMove(): void
{
$task = new Task();
$task->move();
$task->move();
self::assertTrue($task->isResolved());
}
public function testIsInProgressTaskBecomesOpenAfterUndo(): void
{
$task = new Task();
$task->move();
$task->undo();
self::assertTrue($task->isOpen());
}
public function testIsResolvedTaskBecomesClosedAfterMove(): void
{
$task = new Task();
$task->move();
$task->move();
$task->move();
self::assertTrue($task->isClosed());
}
public function testIsResolvedTaskBecomesInProgressAfterUndo(): void
{
$task = new Task();
$task->move();
$task->move();
$task->undo();
self::assertTrue($task->isInProgress());
}
public function testIsClosedTaskBecomesReopenedAfterMove(): void
{
$task = new Task();
$task->move();
$task->move();
$task->move();
$task->move();
self::assertTrue($task->isReopened());
}
public function testIsClosedTaskBecomesResolvedAfterUndo(): void
{
$task = new Task();
$task->move();
$task->move();
$task->move();
$task->undo();
self::assertTrue($task->isResolved());
}
public function testIsReopenedTaskBecomesInProgressAfterMove(): void
{
$task = new Task();
$task->move();
$task->move();
$task->move();
$task->move();
$task->move();
self::assertTrue($task->isInProgress());
}
public function testIsReopenedTaskBecomesClosedAfterUndo(): void
{
$task = new Task();
$task->move();
$task->move();
$task->move();
$task->move();
$task->undo();
self::assertTrue($task->isClosed());
}
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->move();
$task->move();
$task->move();
$task->move();
$task->estimate($points);
self::assertSame($points, $task->getEstimatePoints());
}
public function testCannotEstimateInProgressTask(): void
{
$points = 5;
$task = new Task();
$task->move();
self::expectException(InvalidStateException::class);
$task->estimate($points);
}
public function testCannotEstimateResolvedTask(): void
{
$points = 5;
$task = new Task();
$task->move();
$task->move();
self::expectException(InvalidStateException::class);
$task->estimate($points);
}
public function testCannotEstimateClosedTask(): void
{
$points = 5;
$task = new Task();
$task->move();
$task->move();
$task->move();
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. o ile są to obiekty dziedzinowe to poszedłbym raczej we wzorzec projektowy, zamiast wyciągać logikę do plików konfiguracyjnych. State machine może być sesnownym 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 odpowiedzialnosci
Wpis State (Stan) pojawił się pierwszy raz pod Koddlo.