Comprehensive implementation of finite state machines using std::variant in modern C++
Maszyny stanów skończonych (FSM) stanowią fundamentalny paradygmat w inżynierii oprogramowania do modelowania zachowań w systemach, począwszy od sterowników wbudowanych po sztuczną inteligencję w grach. Wprowadzenie std::variant w C++17 zrewolucjonizowało implementację FSM poprzez umożliwienie typowo-bezpiecznej reprezentacji stanów, semantyki wartościowej i rozwiązań bezalokacyjnych (heap-free), które przewyższają tradycyjne podejścia obiektowe. W poniższym artykule omawiamy podstawy techniczne, wzorce implementacyjne oraz praktyczne zastosowania FSM z użyciem std::variant poprzez liczne przykłady kodu i analizę wydajności.
Theoretical foundations of finite state machines
Computational model and classifications
Maszyny stanów skończonych są abstrakcjami matematycznymi opisanymi przez 5-tkę $(Q, \Sigma, \delta, q_0, F)$, gdzie:
- $Q$ reprezentuje skończony zbiór stanów,
- $\Sigma$ oznacza alfabet wejściowy (zdarzenia),
- $\delta : Q \times \Sigma \rightarrow Q$ to funkcja przejść,
- $q_0 \in Q$ to stan początkowy,
- $F \subseteq Q$ definiuje stany akceptujące.
W implementacjach sprzętowych i programowych dominują dwa główne modele:
- Maszyny Moore’a – Wyjście zależy wyłącznie od bieżącego stanu; formalnie: $\lambda : Q \rightarrow \Gamma$, gdzie $\Gamma$ to alfabet wyjściowy;
- Maszyny Mealy’ego – Wyjście zależy zarówno od bieżącego stanu, jak i wejścia: $\lambda : Q \times \Sigma \rightarrow \Gamma$.
Podejście z std::variant elegancko obsługuje oba modele dzięki składaniu typów na etapie kompilacji, eliminując narzut polimorfizmu czasu wykonania przy zachowaniu matematycznej ścisłości.
Limitations of traditional implementations
Tradycyjne implementacje FSM w C++ zwykle wykorzystują:
- Enum/switch – Stają się nieczytelne wraz ze wzrostem liczby stanów/zdarzeń;
- Wirtualną dziedziczenie – Wymaga alokacji na stercie i utrudnia semantykę wartościową;
- Wzorzec stanu – Wprowadza pośrednictwo przez wywołania wirtualne.
Pomiar czasu działania pokazuje, że FSM bazujące na wywołaniach wirtualnych są o 15–20% wolniejsze niż podejścia z std::variant w systemach o wysokiej częstotliwości działania. Ponadto tradycyjne projekty nie pozwalają efektywnie wykorzystać semantyki przenoszenia (move semantics), co wprowadza zbędne kopiowanie podczas przejść między stanami.
std::variant technical fundamentals
Type-safe state representation
Najważniejszą innowację stanowi kodowanie stanów jako różne typy wewnątrz wariantu:
namespace states { struct Idle { /* no data */ }; struct Processing { std::chrono::milliseconds elapsed; JobID current_job; }; struct Terminated { ErrorCode last_error; }; } using State = std::variant<states::Idle, states::Processing, states::Terminated>;
Taka konstrukcja przechowuje dane specyficzne dla stanu bezpośrednio w typach stanów, czyniąc nielegalne kombinacje stanów niereprezentowalnymi. Przykładowo, stan Processing wymusza obecność danych o czasie i zadaniu, zaś Idle nie zawiera zbędnych pól.
Value semantics and transition mechanics
Przejścia stanów sprowadzają się do przypisania wartości:
State current = states::Idle{}; // Zdarzenie powoduje przejście current = states::Processing{0ms, job123};
Przypisanie to:
- Wywołuje destruktor poprzedniego obiektu stanu;
- Tworzy nowy stan „na miejscu”;
- Zachowuje bezpieczeństwo wyjątków dzięki gwarancjom przypisania wariantu.
Event handling with std::visit
Wzorzec przeciążenia funkcji umożliwia elegancką dyspozycję zdarzeń:
template<class... Ts> struct overload : Ts... { using Ts::operator()...; }; template<class... Ts> overload(Ts...) -> overload<Ts...>; std::visit(overload{ [](states::Idle& s, const StartEvent& e) -> State { return states::Processing{0ms, e.job}; }, [](states::Processing& s, const CancelEvent& e) -> State { return states::Terminated{ErrorCode::Cancelled}; }, // ... inne obsługi zdarzeń }, current_state, event);
Każda lambda obsługuje konkretne przejście typowane, a kompilator wymusza pełne pokrycie przypadków dzięki regułom odwiedzania wariantu.
Implementation patterns
Basic FSM structure
Kompletna maszyna wymaga trzech głównych komponentów:
// 1. Definicje stanów namespace states { struct Off { }; struct On { uint32_t brightness = 100; }; } // 2. Definicje zdarzeń namespace events { struct Toggle { }; struct AdjustBrightness { int32_t delta; }; } // 3. Logika FSM class LightFSM { using State = std::variant<states::Off, states::On>; State current_state = states::Off{}; public: template<typename Event> void dispatch(const Event& e) { std::visit([this, &e](auto& state) { current_state = transition(state, e); }, current_state); } private: State transition(states::Off&, const events::Toggle&) { return states::On{}; } State transition(states::On&, const events::Toggle&) { return states::Off{}; } State transition(states::On& s, const events::AdjustBrightness& e) { return states::On{std::clamp(s.brightness + e.delta, 0, 100)}; } };
Prezentowany przykład pokazuje:
- Stany i zdarzenia jako proste struktury (z parametrami lub bez);
- Obsługę przejść realizowaną jako przeciążone metody;
- Niezmienne przejścia – każda zmiana zwraca nową instancję stanu.
Nested state machines
Złożone systemy dekomponuje się na zagnieżdżone FSM:
struct MotorController { struct Stopped { }; struct Running { FSM<states::Accelerating, states::Cruising> phase; }; // ... inne stany void onEmergencyStop() { if(std::holds_alternative<Running>(current_state)) { // Dostęp do zagnieżdżonego FSM auto& running = std::get<Running>(current_state); running.phase.dispatch(events::Stop{}); } current_state = states::Stopped{}; } };
Warianty naturalnie obsługują kompozycję przez zagnieżdżanie stanów.
Guard conditions and transition actions
Realistyczne przejścia wymuszają walidację i efekty uboczne:
State transition(states::Active& s, const events::Shutdown& e) { if(s.unsaved_work > 0) { triggerAutosave(); return states::Saving{ s.unsaved_work }; } return states::Terminated{}; }
Warunki zaporowe pojawiają się jako prewarunki, zaś akcje są wykonywane przed zmianą stanu.
Performance and optimization
Memory layout characteristics
Wariantowa konstrukcja posiada optymalne cechy pamięciowe:
| Podejście | Rozmiar stanu | Alokacja | Koszt dostępu |
|---|---|---|---|
| Bazowy wirtualny | 1 wskaźnik | Sterta (każdy stan) | Wywołanie pośrednie + miss w cache |
| std::variant | maxstatesize | Stos/statycznie | Sprawdzenie indeksu + dostęp bezpośredni |
| Union | maxstatesize | Stos | Ręczne śledzenie typu |
Pomiary empiryczne wykazują:
- Brak alokacji na stercie podczas przejść między stanami,
- 2–5 razy szybsze przejścia w porównaniu do wywołań wirtualnych,
- Przyjazny dla cache’a dostęp dzięki spójnej lokalizacji danych.
Compile-time efficiency
Realizacje silnie oparte na szablonach wydłużają czas kompilacji:
- Ograniczanie eksplozji stanów – stosowanie wymazywania typów dla rzadkich stanów,
- Pamiętanie przejść – cache’owanie obiektów odwiedzających,
- Hermetyzacja modułów – izolacja FSM w osobnych modułach.
Comparative analysis
Versus traditional state pattern
Rozważ kontroler stanu automatu sprzedającego:
// Tradycyjne podejście OOP class VendingState { public: virtual void insertCoin(int) = 0; virtual void selectItem(string) = 0; // ...inne zdarzenia protected: VendingContext& context; }; class IdleState : public VendingState { void insertCoin(int amount) override { context.balance += amount; context.setState(new EnteredAmountState()); } // ... }; // Podejście std::variant namespace states { struct Idle { void insertCoin(int amount, VendingContext& ctx) { ctx.balance += amount; return states::AmountEntered{ctx.balance}; } }; }
Najważniejsze różnice:
- Brak alokacji na stercie – Stany przechowywane wewnątrz instancji wariantu;
- Silna enkapsulacja – Dane specyficzne dla stanu są izolowane;
- Lepsze debugowanie – Jawny typ stanu w debugerze.
Limitations and workarounds
Wybrane wyzwania:
- Współdzielenie danych między stanami – Rozwiązane przez referencje do obiektu kontekstu;
- Historia przejść – Realizowana przez migawki stanu;
- Rozproszone FSM – Wymagają proxy serializacyjnego.
Real-world implementations
Game character AI
Trójstanowy kontroler gracza ilustruje praktyczne użycie:
struct PlayerState { struct Normal { Vec3 position; Quaternion rotation; }; struct Jumping { Vec3 launch_velocity; float air_time = 0.0f; }; struct Dead { float respawn_timer; }; }; class CharacterFSM { std::variant<Normal, Jumping, Dead> state; PhysicsBody& body; public: void update(float dt) { std::visit([dt, this](auto& s) { if constexpr(requires { s.apply_physics; }) { body.applyForce(s.calculate_gravity()); } // Logika typowa dla stanu }, state); } void onCollision(const CollisionEvent& e) { if(e.impact > DEATH_THRESHOLD) { state = Dead{RESPAWN_DELAY}; playDeathAnimation(); } } };
Przykład pokazuje:
- Specyficzną fizykę dla stanu dzięki
if constexpr; - Bezpośredni dostęp do danych bez pośrednictwa wskaźników;
- Płynną integrację z kontekstem.
Industrial control system
Dla systemów czasu rzeczywistego deterministyczność działania jest kluczowa:
template<typename Context> class ControlFSM { std::variant<states::Init, states::Running, states::Fault> state; Context& ctx; // Transition table na etapie kompilacji auto get_transition(states::Init&, const events::Ready&) { ctx.initializeSensors(); return states::Running{}; } public: void process(const auto& event) noexcept { static_assert(noexcept(transition(event)), "Transitions must be noexcept"); state = std::visit([&](auto& s) -> State { return transition(s, event); }, state); } };
Najważniejsze cechy czasu rzeczywistego:
- Gwarancje noexcept dla przewidywalności czasowej,
- Walidacja przejść na etapie kompilacji,
- Brak dynamicznych alokacji.
Advanced techniques
State machine metaprogramming
Automatyczne generowanie FSM przez szablony umożliwia wielokrotne użycie wzorców:
template<typename... States> class FSM { std::tuple<States...> state_objects; std::variant<States*...> current; public: template<typename State> void transition_to() { current = &std::get<State>(state_objects); } template<typename Event> void dispatch(const Event& e) { std::visit([&e](auto* s) { s->handle(e); }, current); } }; // Użycie FSM<Idle, Active, Error> fsm; fsm.transition_to<Active>();
Taki wzorzec:
- Centralizuje przechowywanie stanów w krotce,
- Minimalizuje narzut tworzenia stanów,
- Umożliwia wielokrotne użycie stanów pomiędzy instancjami.
Transition visualization
Introspekcja typów umożliwia tworzenie diagramów stanów w czasie działania:
std::string state_name = std::visit(overload{ [](const states::Idle&) { return "Idle"; }, [](const states::Active&) { return "Active"; }, // ... }, current_state); std::cout << "Obecny stan: " << state_name;
Połączona z logowaniem zdarzeń, technika ta pozwala na monitorowanie FSM w aplikacji.
Conclusion and recommendations
FSM w oparciu o std::variant stanowią przełom w projektowaniu maszyn stanów skończonych w nowoczesnym C++, oferując znaczące korzyści w zakresie bezpieczeństwa typów, wydajności podczas działania i czytelności kodu w porównaniu do tradycyjnych rozwiązań. Przedstawione tu wzorce i przykłady pozwalają inżynierom wdrażać niezawodne systemy zarządzania stanem w dziedzinach od programowania gier po automatykę przemysłową.
Best practice recommendations
- Preferuj semantykę wartościową – projektuj stany jako typy trywialne, przenośne;
- Wykorzystuj constexpr – walidacja stanów podczas kompilacji;
- Izoluj efekty uboczne – obiekt kontekstu dla operacji nieczystych;
- Implementuj historię – opcjonalne migawki stanu do debugowania;
- Profiluj przejścia – monitoruj kluczowe ścieżki FSM.
Ewolucja C++ stale zwiększa możliwości FSM, a propozycje pattern matchingu w C++23 zapowiadają jeszcze zwięźlejszą składnię. W przyszłości warto badać podejścia hybrydowe łączące std::variant z modelami równoległego działania dla rozproszonych systemów stanowych.
// Kompleksowy framework FSM, łączący opisane techniki template<typename Context, typename... States> class AdvancedFSM { Context ctx; std::variant<States...> current_state; std::vector<StateSnapshot> history; public: template<typename Event> void dispatch(const Event& e) { auto new_state = std::visit([&](auto& s) { if constexpr(requires { s.handle(e, ctx); }) { return s.handle(e, ctx); } else { return s; // Brak przejścia } }, current_state); if(!std::holds_same_type(current_state, new_state)) { history.push_back({typeid(current_state).name(), typeid(new_state).name(), std::chrono::system_clock::now()}); current_state = std::move(new_state); } } };
