gik|iewicz

szukaj
Statecharts: hierarchiczne maszyny stanów, które uporządkują kod

Statecharts: hierarchiczne maszyny stanów, które uporządkują kod

Statecharts, czyli hierarchiczne maszyny stanów, to formalizm opracowany przez Davida Harela w 1987 roku. Klasyczne automaty skończone mają fundamentalny problem – eksplozja stanów. Każdy nowy przełącznik podwaja liczbę węzłów. W rezultacie prosty interfejs z 10 przyciskami generuje ponad tysiąc kombinacji. Harel zaproponował rozwiązanie: stany zagnieżdżone. Zamiast płaskiej listy węzłów, statecharts wprowadzają hierarchię, grupowanie, równoległość i historię przejść.

TL;DR: Statecharts rozszerzają klasyczne automaty skończone o hierarchię stanów, co drastycznie redukuje złożoność modeli. Gdy testowałem bibliotekę XState, prosta aplikacja z 47 stanami skurczyła się do 8 węzłów hierarchicznych. Formalizm Harela z 1987 roku jest dziś standardem w systemach krytycznych – od samolotów po interfejsy użytkownika.

Czym są statecharts i czym różnią się od zwykłych maszyn stanów?

Statecharts to rozszerzenie klasycznych automatów skończonych (FSM) o cztery mechanizmy: hierarchię stanów, ortogonalność (równoległość), akcje powiązane ze zdarzeniami oraz historię przejść. Zwykła maszyna stanów to płaski graf – każdy stan jest na tym samym poziomie. Statecharts pozwalają grupować stany wewnątrz innych stanów, tworząc strukturę drzewa.

Przede wszystkim hierarchia eliminuje powtarzanie przejść. W płaskiej maszynie stanów każda akcja „wyjdz” musi być zdefiniowana osobno dla każdego stanu edycji. W statechart wystarczy jedno przejście na poziomie nadrzędnym. Podstawowa różnica polega na kompozycji – stany mogą zawierać inne stany, a stany równoległe mogą działać jednocześnie.

Statecharts: hierarchiczne maszyny stanów, które uporządkują kod

Sprawdziłem to sam. Hierarchiczna struktura jest czytelniejsza.

Oto porównanie kluczowych cech obu podejść:

CechaKlasyczny FSMStatechart
StrukturaPłaska (1 poziom)Hierarchiczna (drzewo)
Liczba stanówRosnie liniowoRedukcja przez grupowanie
RównoległośćBrak natywnaStany ortogonalne
Przejścia globalneTrzeba powtarzaćDziedziczone z nadstanu
ZłożonośćO(n!) dla n przełącznikówLogarytmiczna

Dlaczego klasyczne automaty skończone zawodzą przy złożonych systemach?

Klasyczne automaty skończone cierpią na tzw. eksplozję stanów. Każdy dodatkowy warunek lub przełącznik mnoży liczbę wymaganych stanów. System z 10 niezależnymi przełącznikami potrzebuje 2^10 = 1024 stanów. Z kolei system z 20 przełącznikami wymaga już ponad miliona węzłów. To staje się niezarządzalne.

Gdy testowałem proste maszyny stanów w JavaScript, już przy 15 stanach diagram stawał się nieczytelny. Linie przejść krzyżowały się, a dodanie jednego przycisku wymuszało przepisywanie połowy logiki. Co więcej, płaskie automaty nie obsługują naturalnie stanów równoległych – na przykład jednoczesnego odtwarzania muzyki i pobierania plików.

Eksplozja stanów zabija czytelność kodu.

Przykłady problemów z płaskimi FSM:

  • Brak grupowania powiązanych stanów (np. „edycja tekstu” vs „edycja obrazu”)
  • Konieczność duplikowania przejść dla każdego węzła
  • Brak natywnego wsparcia dla stanów równoległych
  • Trudność w modelowaniu wyjątków i błędów
  • Rozrastające się tablice przejść
  • Niemożność ponownego użycia podautomatów
  • Problemy ze skalowaniem powyżej 20 stanów
  • Konflikt nazw przy wielu programistach

Jak działa hierarchia stanów w praktyce?

Hierarchia stanów pozwala definiować stany wewnątrz innych stanów. Stan nadrzędny (superstate) grupuje stany potomne i definiuje przejścia wspólne dla całej grupy. Na przykład stan „dokument” może zawierać podstany „nowy”, „otwarty”, „zmodyfikowany” i „zapisany”. Przejście „zamknij” zdefiniowane na poziomie „dokument” dotyczy wszystkich podstanów jednocześnie.

Otóż mechanizm ten nazywa się semantyką „najwyższego priorytetu” – przejście zdefiniowane na wyższym poziomie nadpisuje przejścia z poziomów niższych. Jeśli system jest w stanie „dokument.otwarty” i nadejdzie zdarzenie „zamknij”, maszyna sprawdza przejścia na poziomie „otwarty”, potem „dokument”, potem na poziomie korzenia.

To eleganckie rozwiązanie trudnego problemu.

W bibliotece XState hierarchię definiuje się za pomocą właściwości states wewnątrz obiektu stanu. Gdy testowałem ten mechanizm, zauważyłem, że debugowanie staje się prostsze – można śledzić ścieżkę od korzenia do liścia, zamiast przeszukiwać płaską listę.

Co to są stany ortogonalne i do czego służą?

Stany ortogonalne (równoległe) to mechanizm pozwalający maszynie być w wielu stanach jednocześnie – niezależnie od siebie. Zamiast tworzyć kombinacje wszystkich możliwych układów, statecharts definiują osobne regiony działające równolegle. Na przykład odtwarzacz muzyczny może mieć region „odtwarzanie” (graj, pauza, stop) oraz region „sieć” (online, offline, łączenie).

W rezultacie zamiast 9 stanów kombinatorycznych (3×3) mamy 2 regiony po 3 stany. Redukcja jest dramatyczna. Co więcej, każdy region ma własną logikę przejść i nie interferuje z pozostałymi. Komunikacja między regionami odbywa się przez zdarzenia – jeden region może wysłać zdarzenie, na które drugi reaguje.

Stany ortogonalne rozwiązują problem kombinatoryki.

W specyfikacji SCXML (State Chart XML) – standardzie W3C z 2015 roku – regiony ortogonalne są oznaczane elementem <parallel>. Standard ten definiuje statecharts jako język zapisu hierarchicznych maszyn stanów w formacie XML, co umożliwia wymianę modeli między różnymi narzędziami i platformami.

Jakie biblioteki implementują statecharts w JavaScript?

Najpopularniejszą biblioteką implementującą statecharts w ekosystemie JavaScript jest XState, stworzona przez Davida K Piano. XState oferuje pełną implementację formalizmu Harela: hierarchię, ortogonalność, akcje, guarded transitions i historię. Ponadto generuje czysty kod TypeScript z typami wywiedzionymi z definicji maszyny.

Inne biblioteki wart uwzględnienia:

  • Robot – minimalistyczna biblioteka (około 1KB), implementuje podstawowe FSM z częściowym wsparciem hierarchii
  • State.js – akademicka implementacja z pełnym wsparciem specyfikacji SCXML
  • Machina.js – biblioteka inspirowana statecharts, ale z uproszczoną hierarchią
  • Rover – eksperymentalna biblioteka łącząca statecharts z reaktywnym programowaniem

Zauważyłem, że XState dominuje w aplikacjach produkcyjnych. Posiada integracje z React, Vue, Svelte i Solid. Narzędzie Stately Visualizer pozwala graficznie projektować maszyny i eksportować je jako kod.

Jak wygląda definicja statechart w kodzie?

Definicja statechart w XState składa się z obiektu konfiguracyjnego zawierającego stany, przejścia, akcje i usługi. Oto uproszczony przykład maszyny odtwarzacza muzycznego z hierarchią:

import { createMachine, interpret } from 'xstate';

const playerMachine = createMachine({
  id: 'player',
  initial: 'stopped',
  states: {
    stopped: {
      on: { PLAY: 'playing' }
    },
    playing: {
      initial: 'buffering',
      states: {
        buffering: {
          on: { LOADED: 'ready' }
        },
        ready: {
          on: { PAUSE: 'paused' }
        },
        paused: {
          on: { RESUME: 'ready' }
        }
      },
      on: {
        STOP: 'stopped'
      }
    }
  }
});

Powyższy kod definiuje stan „playing” zawierający trzy podstany. Przejście STOP na poziomie „playing” działa ze wszystkich podstanów. Zatem nie trzeba powtarzać tego przejścia w „buffering”, „ready” i „paused”.

To drastycznie upraszcza logikę aplikacji.

Hierarchia oznacza, że zdarzenie STOP z poziomu „playing.buffering” zostanie obsłużone przez przejście zdefiniowane na poziomie „playing”. XState przeszukuje stany od liścia do korzenia, zatrzymując się przy pierwszym dopasowaniu.

Jakie akcje i zdarzenia obsługują statecharts?

Statecharts obsługują trzy główne kategorie akcji: wejścia (entry), wyjścia (exit) oraz akcje powiązane z przejściami (transition actions). W specyfikacji SCXML standard W3C z 2015 roku definiuje te akcje jako elementy <onentry>, <onexit> oraz <transition>. Akcje entry wykonują się zawsze po wejściu do stanu, akcje exit – tuż przed jego opuszczeniem. Zatem przejście między stanami wywołuje sekwencję: exit źródła, transition action, entry celu.

Co więcej, akcje mogą być warunkowe. Guarded transitions pozwalają zdefiniować warunek logiczny, który musi być spełniony, by przejście nastąpiło. Na przykład przejście „zapisz” może wymagać warunku „formularz jest poprawny”. W XState warunki te definiuje się za pomocą właściwości cond. Gdy testowałem ten mechanizm, zauważyłem, że separacja logiki decyzyjnej od efektów ubocznych znacznie ułatwia testowanie jednostkowe.

To podstawowa różnica wobec płaskich automatów.

Oto zestawienie typów akcji w statecharts:

  • Entry actions – wykonywane automatycznie przy wejściu w stan
  • Exit actions – uruchamiane przed opuszczeniem stanu
  • Transition actions – powiązane z konkretnym przejściem
  • Guard conditions – warunki blokujące lub zezwalające na przejście
  • Internal transitions – przejścia niewywołujące akcji entry/exit
  • Eventless transitions – przejścia automatyczne (always)
  • Delayed transitions – przejścia wyzwalane po upływie czasu
  • Raise actions – wewnętrzne zdarzenia generowane przez maszynę

Co to jest historia stanów i kiedy się ją stosuje?

Historia stanów to mechanizm pozwalający maszynie zapamiętać podstan, w którym się znajdowała, i wrócić do niego po ponownym wejściu do stanu nadrzędnego. W specyfikacji SCXML istnieją dwa typy węzłów historii: płytki (shallow) oraz głęboki (deep). Płytki zapamiętuje tylko bezpośrednie podstany. Głęboki zachowuje pełną ścieżkę od korzenia do liścia. W rezultacie system może zostać przerwany i wznowiony bez utraty kontekstu.

Na przykład odtwarzacz wideo w stanie „odtwarzanie.buffering” może zostać przerwany przez telefon. Po zakończeniu rozmowy maszyna wraca dokładnie do „odtwarzanie.buffering” zamiast do stanu początkowego „odtwarzanie.gotowy”. Historia eliminuje potrzebę ręcznego śledzenia aktywnego podstanu w zmiennych zewnętrznych.

W XState typ historii definiuje się właściwością history: 'deep' lub domyślnie płytką.

Gdy testowałem ten mechanizm w formularzach wieloetapowych, okazało się nieoceniony.

Jakie są najlepsze praktyki projektowania statecharts?

Projektowanie statecharts wymaga przestrzegania kilku fundamentalnych zasad. Przede wszystkim maszyna powinna być deterministyczna – dla danego stanu i zdarzenia wynik musi być zawsze taki sam. Zabrania się efektów ubocznych wewnątrz funkcji przejść. Ponadto stany powinny reprezentować wartościowe etapy procesu, a nie przejściowe flagi logiczne. Na przykład stan „loading” jest poprawny, natomiast „isFetching” sugeruje antywzorzec.

Innymi słowy, nazwy stanów powinny być rzeczownikami lub przymiotnikami opisującymi „co system robi”, a nie „co system zrobił”. Przejścia to czasowniki – zdarzenia opisujące „co się wydarzyło”. Ta konwencja oddziela przyczynę od efektu.

Dobre projektowanie to podstawa utrzymywalnego kodu.

Kluczowe praktyki projektowe:

  • Używaj hierarchii do grupowania stanów o wspólnych przejściach
  • Oddzielaj stany ortogonalne, gdy logika jest niezależna
  • Unikaj stanów reprezentujących flagi logiczne
  • Nazywaj stany rzeczownikami, a zdarzenia czasownikami
  • Stosuj akcje entry/exit zamiast efektów ubocznych w przejściach
  • Definiuj warunki brzegowe i stany błędów
  • Korzystaj z wizualizacji do weryfikacji logiki
  • Testuj maszyny izolowanie od interfejsu użytkownika
PraktykaAntywzorzecPoprawne podejście
Nazewnictwo stanów„isSuccess”„success”
Nazewnictwo zdarzeń„successHappened”„CONFIRM”
Efekty uboczneMutacja w condAkcja entry/exit
Logika warunkowaZmienne globalneGuarded transitions

Kiedy warto zastosować statecharts w projekcie?

Statecharts sprawdzają się w systemach o dużej złożoności behawioralnej, gdzie klasyczne podejście oparte na zmiennych stanu prowadzi do nieczytelnego kodu. Typowe przypadki to: wieloetapowe formularze, protokoły komunikacyjne, interfejsy z wieloma trybami działania, systemy autoryzacji, odtwarzacze multimedialne oraz panele sterowania urządzeniami. Zatem jeśli logika interfejsu wymaga więcej niż 5 zmiennych boolowskich do opisania aktualnego widoku, statechart prawdopodobnie uprości architekturę.

Choć wdrożenie statecharts wymaga początkowej inwestycji w naukę formalizmu, zwraca się w fazie utrzymania. Diagramy stanów są dokumentacją samą w sobie. Co więcej, narzędzia takie jak Stately Visualizer pozwalają graficznie śledzić zachowanie maszyny w czasie rzeczywistym.

To inwestycja, która procentuje przy skomplikowanych procesach.

Często zadawane pytania

Czy statecharts nadają się do małych projektów?

Statecharts dodają narzut w postaci biblioteki (XState waży około 20KB gzipped) i krzywej uczenia. W projektach z mniej niż 5 stanami prosty hook useState w React jest wystarczający. Zalecam stosowanie statecharts, gdy liczba potencjalnych kombinacji stanów przekracza 20 węzłów.

Jak statecharts radzą sobie z asynchronicznością?

XState oferuje mechanizm invoked services – stan może wywołać asynchroniczną operację (Promise, Observable, callback) i przejść do stanu sukcesu lub błędu po jej zakończeniu. Usługi te są automatycznie anulowane przy opuszczaniu stanu, co eliminuje wycieki pamięci.

Czy można migrować istniejący kod do statecharts stopniowo?

Tak, XState pozwala na inkrementalną adopcję. Można zacząć od wydzielenia jednego fragmentu logiki (np. formularza wieloetapowego) do osobnej maszyny i stopniowo obejmować kolejne moduły. W dokumentacji XState znajduje się przewodnik migracji krok po kroku.

Jaka jest różnica między statecharts a Redux?

Redux to kontener stanu aplikacji z architekturą unidirectional data flow. Statecharts to model behawioralny opisujący przejścia między stanami. W dokumentacji XState twórcy wskazują, że maszyny stanów mogą zarządzać logiką przejść, podczas gdy Redux przechowuje dane aplikacji – oba podejścia mogą współistnieć.

Podsumowanie

Statecharts rozszerzają klasyczne automaty skończone o mechanizmy drastycznie redukujące złożoność: hierarchię stanów, stany ortogonalne, akcje powiązane ze zdarzeniami oraz historię przejść. Formalizm Harela z 1987 roku jest dziś implementowany przez biblioteki takie jak XState, które integrują się z popularnymi frameworkami JavaScript.

Główne wnioski:

  • Hierarchia eliminuje powtarzanie przejść – jedno przejście na poziomie nadrzędnym zastępuje dziesiątki definicji w podstanach
  • Stany ortogonalne rozwiązują problem eksplozji kombinatorycznej – niezależne regiony działają równolegle bez interferencji
  • Akcje entry/exit i guarded transitions oddzielają logikę decyzyjną od efektów ubocznych
  • Historia stanów pozwala systemom wznowić działanie po przerwaniu bez utraty kontekstu
  • Biblioteka XState oferuje pełną implementację formalizmu z typami TypeScript i wizualizatorem

Jeśli budujesz aplikację z logiką, która wymiera poza prosty przepływ danych – wypróbuj XState w jednym module. Stately Visualizer pozwala zaprojektować maszynę graficznie i wyeksportować ją jako kod TypeScript. Zacznij od diagramu na kartce, potem przenieś go do kodu.