State Machine Patterns
"C++ state machine implementations: enum+switch, std::variant+std::visit (C++17), function tables, and coroutine-based sequential machines (C++20)."
State Machinesince C++17A finite state machine encodes behavior as a fixed set of discrete states and legal transitions between them; in modern C++, std::variant (C++17) makes this type-safe with per-state data and compile-time exhaustiveness, replacing fragile inheritance hierarchies and error-prone enum+flag combinations.
Overview
Every implementation trades off four properties: exhaustiveness (does the compiler catch unhandled states?), per-state data (can each state carry its own fields?), dispatch cost, and readability against a state diagram.
| Approach | Exhaustive | Per-state data | Dispatch cost | Minimum standard |
|---|---|---|---|---|
enum class + switch | -Wswitch-enum | No β all in parent | Near-zero | C++11 |
std::variant + visit | Compile-time | Yes | ~virtual call | C++17 |
| Function pointer table | Manual | Shared | Near-zero | C++98 |
| Coroutine | By structure | Automatic (frame) | Low | C++20 |
Use enum + switch when every state shares the same data shape. Reach for std::variant the moment states differ structurally β it eliminates the entire "which fields are valid in this state?" class of bug. Function tables win for character-by-character parsers that process megabytes. Coroutines suit sequential protocol handlers where the state is the program counter.
Syntax
The overload Helper (C++17)
The variadic overload aggregate enables inline multi-arm visitors without named structs:
// C++17: inheriting from multiple lambda types
template<typename... Fs>
struct overload : Fs... {
using Fs::operator()...; // C++17: pack expansion in using-declaration
};
// C++17: class template argument deduction guide
template<typename... Fs>
overload(Fs...) -> overload<Fs...>;Transition as a Pure Function
Separating transitions from the owning object makes machines testable in isolation:
// C++17
State transition(State current, Event e); // pure: in β out, no side effectsA free transition function can be unit-tested by constructing state values directly. Side effects (I/O, timers) belong in the caller after the new state is computed.
Examples
Enum + Switch (C++11)
Scoped enums (enum class, C++11) prevent implicit integer conversion and name collisions. With -Wswitch-enum, GCC and Clang error on unhandled enumerators even when a default: label is present β the stricter and more useful setting.
// C++11
enum class TrafficLight { Red, Yellow, Green };
struct Light {
TrafficLight state = TrafficLight::Red;
void tick() {
switch (state) {
case TrafficLight::Red: state = TrafficLight::Green; break;
case TrafficLight::Green: state = TrafficLight::Yellow; break;
case TrafficLight::Yellow: state = TrafficLight::Red; break;
}
}
bool can_go() const noexcept { return state == TrafficLight::Green; } // noexcept: C++11
};std::variant State Machine (C++17)
Each state is a distinct type. std::get_if probes the active alternative without throwing; std::visit dispatches exhaustively β adding a new state type and forgetting to update a visitor is a compile error.
// C++17
#include <variant>
#include <chrono>
#include <string>
struct Idle {};
struct Connecting { std::string host; int port; };
struct Connected {
int fd;
std::chrono::steady_clock::time_point connected_at; // per-state data
};
struct Failed { std::string reason; int retry_count = 0; };
using ConnState = std::variant<Idle, Connecting, Connected, Failed>;
class Connection {
ConnState state_ = Idle{};
public:
void connect(std::string host, int port) {
// Only transition from Idle or Failed
if (std::holds_alternative<Idle>(state_) || // C++17
std::holds_alternative<Failed>(state_)) {
state_ = Connecting{std::move(host), port};
do_connect();
}
}
void on_connected(int fd) {
if (auto* c = std::get_if<Connecting>(&state_)) { // C++17: non-throwing probe
(void)c; // c->host / c->port available here if needed
state_ = Connected{fd, std::chrono::steady_clock::now()};
}
}
void on_error(std::string reason) {
int retries = 0;
if (auto* f = std::get_if<Failed>(&state_))
retries = f->retry_count + 1;
state_ = Failed{std::move(reason), retries};
}
// std::visit forces all four arms β missing one is a compile error
std::string status() const {
return std::visit(overload{
[](const Idle&) { return std::string{"idle"}; },
[](const Connecting& c){ return "connecting:" + c.host; },
[](const Connected& c) { return "fd=" + std::to_string(c.fd); },
[](const Failed& f) { return "failed:" + f.reason; },
}, state_);
}
private:
void do_connect() { /* async I/O setup */ }
};Transition Table Style (C++17)
For machines with many events, pure transition functions scale cleanly and keep the owning class thin:
// C++17
struct ConnectCmd { std::string host; int port; };
struct DisconnectCmd {};
ConnState apply(ConnState s, ConnectCmd cmd) {
return std::visit(overload{
[&](Idle&) -> ConnState { return Connecting{std::move(cmd.host), cmd.port}; },
[&](Connecting& c) -> ConnState { return std::move(c); }, // in-flight, no-op
[&](Connected& c) -> ConnState { return std::move(c); }, // no-op
[&](Failed&) -> ConnState { return Connecting{std::move(cmd.host), cmd.port}; },
}, s);
}
ConnState apply(ConnState s, DisconnectCmd) {
return std::visit(overload{
[](Connected&) -> ConnState { return Idle{}; },
[](auto& other) -> ConnState { return std::move(other); }, // all other: no-op
}, s);
}The auto& catch-all still participates in variant exhaustiveness β a new state type added later does not silently break this arm; it simply routes through the default. This is safer than switch's default:, which also silences -Wswitch for genuinely unhandled cases.
Function Pointer Table (C++11)
For lexers or parsers that process high-volume byte streams, a jump table eliminates branch-prediction pressure that a switch chain cannot:
// C++11
enum class LexState { Start, InInt, InIdent, Done };
struct Lexer {
using Handler = LexState(*)(Lexer&, char);
LexState state = LexState::Start;
std::string token;
static LexState from_start(Lexer& l, char c) {
// Cast to unsigned char before isdigit/isalpha β signed char is UB
if (std::isdigit(static_cast<unsigned char>(c))) { l.token += c; return LexState::InInt; }
if (std::isalpha(static_cast<unsigned char>(c))) { l.token += c; return LexState::InIdent; }
return LexState::Start;
}
static LexState from_int(Lexer& l, char c) {
if (std::isdigit(static_cast<unsigned char>(c))) { l.token += c; return LexState::InInt; }
return LexState::Done;
}
static LexState from_ident(Lexer& l, char c) {
if (std::isalnum(static_cast<unsigned char>(c))) { l.token += c; return LexState::InIdent; }
return LexState::Done;
}
static constexpr Handler table[] = { // constexpr array: C++11
from_start, from_int, from_ident,
[](Lexer&, char) { return LexState::Done; }
};
void feed(char c) {
state = table[static_cast<std::size_t>(state)](*this, c);
}
};Coroutine State Machine (C++20)
Coroutines encode sequential state machines as straight-line code β the coroutine frame is the state, and co_await marks each state boundary. Adding a protocol step means inserting a co_await pair; no enum update, no transition table entry.
// C++20
#include <coroutine>
struct Task {
struct promise_type {
Task get_return_object() {
return {std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; } // noexcept required by standard
void return_void() noexcept {}
void unhandled_exception() noexcept { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
bool resume() { if (!handle.done()) handle.resume(); return !handle.done(); }
~Task() { if (handle) handle.destroy(); }
Task(const Task&) = delete;
Task& operator=(const Task&) = delete;
Task(Task&& o) noexcept : handle(std::exchange(o.handle, {})) {}
};
// Each co_await is a state transition; control flow is the state diagram
Task smtp_session(Transport& t) {
auto banner = co_await t.recv_line();
if (!banner.starts_with("220")) co_return; // starts_with: C++20
co_await t.send("EHLO client.local\r\n");
if (!(co_await t.recv_line()).starts_with("250")) co_return;
co_await t.send("MAIL FROM:<a@b.com>\r\n");
if (!(co_await t.recv_line()).starts_with("250")) co_return;
co_await t.send("DATA\r\n");
co_await t.recv_line(); // "354 start input"
for (auto& line : message_lines)
co_await t.send(line + "\r\n");
co_await t.send(".\r\n");
co_await t.recv_line(); // "250 OK"
}Best Practices
Match structure to need. Two or three states with identical data shapes: enum + switch is correct and readable. Reserve std::variant for machines where states genuinely differ structurally.
Keep transitions pure. A free State apply(State, Event) function with no side effects is trivially testable. Emit side effects β I/O, callbacks β in the caller after computing the new state, not inside the transition.
Put guard logic inside visitor arms, not before std::visit. Calling std::holds_alternative then visiting means two lookups and no exhaustiveness guarantee. A missing arm in std::visit is a compile error; a missing holds_alternative check is a silent bug.
Enable -Wswitch-enum for enum machines. -Wswitch is silenced by a default: label. -Wswitch-enum warns even when default: is present, which is the only setting that guarantees coverage on new enumerators.
Common Pitfalls
auto& catch-all hiding new states. An [](auto&) { ... } arm in std::visit silences the compiler on unhandled alternatives added later. Document it explicitly, or prefer exhaustive arms for safety-critical machines.
std::isdigit on plain char. Passing a negative char value to <cctype> functions is undefined behavior. Always cast to unsigned char first.
Missing noexcept on final_suspend (C++20). The standard requires final_suspend() to return noexcept. Omitting it compiles on some implementations but is undefined behavior when the coroutine frame is destroyed.
Coroutine handle aliasing. std::coroutine_handle is a non-owning raw pointer. If two Task objects wrap the same handle and both call destroy(), that is a double-free. Delete the copy constructor and copy-assignment operator, and move-only.
switch on an unscoped enum. Unscoped enumerators implicitly convert to int, so an out-of-range integer cast to the enum type reaches switch without any trap. Prefer enum class (C++11) and validate at system boundaries.
Variant-based state machine (C++17)
std::variant turns impossible states into compile errors. When each state carries different data, wrapping them all in a single struct forces every member to be "optionally valid," which is the source of entire categories of runtime bugs. A variant eliminates that by making only the active state's data accessible.
// C++17
#include <variant>
#include <string>
// Each state owns exactly the data it needs β no optional fields
struct Locked { int failed_attempts = 0; };
struct Unlocked { std::string user_id; };
struct Alarming { std::string trigger_reason; };
using DoorState = std::variant<Locked, Unlocked, Alarming>;
// Transition: pin correct β unlock; too many failures β alarm
DoorState on_pin(DoorState s, bool correct) {
return std::visit(overload{
[&](Locked& l) -> DoorState {
if (correct)
return Unlocked{"user"};
if (++l.failed_attempts >= 3)
return Alarming{"too many attempts"};
return l; // stay locked, increment counter
},
[](Unlocked& u) -> DoorState { return std::move(u); }, // already open
[](Alarming& a) -> DoorState { return std::move(a); }, // alarm overrides
}, s);
}Adding a fourth state type (e.g., MaintenanceMode) and omitting it from any visitor is a hard compile error β unlike adding an enum value, which silently falls through to default.
State machine with entry/exit actions
Entry and exit hooks enforce invariants at state boundaries without scattering them across every transition. Rather than calling setup/teardown code at each call site, encode them in the transition driver so they run exactly once per boundary crossing.
// C++17
#include <variant>
#include <iostream>
struct Idle {};
struct Running { int job_id; };
struct Paused { int job_id; int progress; };
using WorkerState = std::variant<Idle, Running, Paused>;
// Called once whenever a state is entered
void on_enter(const WorkerState& s) {
std::visit(overload{
[](const Idle&) { std::cout << "[enter] idle\n"; },
[](const Running& r){ std::cout << "[enter] running job " << r.job_id << '\n'; },
[](const Paused& p) { std::cout << "[enter] paused at " << p.progress << "%\n"; },
}, s);
}
// Called once whenever a state is exited
void on_exit(const WorkerState& s) {
std::visit(overload{
[](const Idle&) { std::cout << "[exit] idle\n"; },
[](const Running& r){ std::cout << "[exit] running job " << r.job_id << '\n'; },
[](const Paused&) { std::cout << "[exit] paused\n"; },
}, s);
}
class Worker {
WorkerState state_ = Idle{};
public:
template<typename Event>
void send(Event e) {
WorkerState next = apply(state_, e);
// Only fire hooks when the active type actually changes
if (state_.index() != next.index()) {
on_exit(state_);
on_enter(next);
}
state_ = std::move(next);
}
};Comparing state_.index() before and after detects type changes without dynamic allocation. For machines where re-entering the same state must also fire hooks, remove the index() guard.
Hierarchical state machines
Hierarchical (nested) state machines reduce duplication when groups of states share common transitions. A parent state handles events that apply to all children, while child states only override what differs β this is the UML statechart model.
// C++17: lightweight two-level hierarchy via nested variants
struct Stopped {};
struct Playing { double position_sec; };
struct Buffering { double position_sec; int percent; };
// "Active" is a parent pseudo-state grouping Playing and Buffering
using ActiveState = std::variant<Playing, Buffering>;
struct Active {
ActiveState sub;
int volume = 50; // shared data for all active sub-states
};
using PlayerState = std::variant<Stopped, Active>;
// A "stop" event is handled at the top level regardless of sub-state
PlayerState on_stop(PlayerState s) {
return std::visit(overload{
[](Stopped& s) -> PlayerState { return std::move(s); },
[](Active&) -> PlayerState { return Stopped{}; },
}, s);
}
// A "buffer_progress" event only applies inside Active
PlayerState on_buffer(PlayerState s, int pct) {
return std::visit(overload{
[](Stopped& s) -> PlayerState { return std::move(s); },
[&](Active& a) -> PlayerState {
// Forward to sub-state machine
a.sub = std::visit(overload{
[&](Playing& p) -> ActiveState { return Buffering{p.position_sec, pct}; },
[&](Buffering& b) -> ActiveState { b.percent = pct; return b; },
}, a.sub);
return std::move(a);
},
}, s);
}Nesting variants two levels deep keeps compile times reasonable. For deeper hierarchies, consider a dedicated HSM library (e.g., Boost.SML or TinyFSM) rather than hand-rolling additional variant layers.
State machine and concurrency
A state machine shared across threads requires a protection strategy; the simplest correct approach is to serialise all transitions through a single mutex. Because a transition reads the current state, computes the next, and writes it back, any unsynchronised concurrent call risks a torn read-modify-write.
// C++11
#include <variant>
#include <mutex>
#include <string>
struct Idle {};
struct Active { std::string task; };
struct Done { int result; };
using JobState = std::variant<Idle, Active, Done>;
class SafeJob {
JobState state_ = Idle{};
std::mutex mu_; // C++11: std::mutex
public:
void start(std::string task) {
std::lock_guard lock{mu_}; // C++17: CTAD for lock_guard
if (std::holds_alternative<Idle>(state_))
state_ = Active{std::move(task)};
}
void finish(int result) {
std::lock_guard lock{mu_};
if (std::holds_alternative<Active>(state_))
state_ = Done{result};
}
// Return a snapshot β caller sees a consistent copy
JobState snapshot() {
std::lock_guard lock{mu_};
return state_;
}
};For lock-free machines, std::atomic<int> can guard a pure-enum state as long as each transition is a single compare-and-swap (compare_exchange_strong). Do not attempt lock-free updates on std::variant β it is not atomically copyable.
// C++11: lock-free enum-only machine
#include <atomic>
enum class Phase : int { Init, Run, Done };
class AtomicFSM {
std::atomic<int> phase_{static_cast<int>(Phase::Init)};
public:
bool try_start() {
int expected = static_cast<int>(Phase::Init);
return phase_.compare_exchange_strong( // C++11: CAS
expected, static_cast<int>(Phase::Run));
}
bool try_finish() {
int expected = static_cast<int>(Phase::Run);
return phase_.compare_exchange_strong(
expected, static_cast<int>(Phase::Done));
}
};Transition table pattern
A flat, data-driven transition table separates policy (what transitions are legal) from mechanism (how to execute them), making the full state diagram auditable at a glance. Every legal {from, event} β to triple appears in one place; adding a state or event only requires a new row.
// C++17
#include <array>
#include <optional>
#include <functional>
enum class S { Off, Standby, On };
enum class E { PowerOn, PowerOff, Suspend, Wake };
struct Row {
S from;
E event;
S to;
std::function<void()> action; // may be nullptr for no-op
};
// The table is the specification β mirrors a UML state diagram
constexpr std::array table{
Row{S::Off, E::PowerOn, S::Standby, nullptr},
Row{S::Standby, E::Wake, S::On, nullptr},
Row{S::On, E::Suspend, S::Standby, nullptr},
Row{S::On, E::PowerOff, S::Off, nullptr},
Row{S::Standby, E::PowerOff, S::Off, nullptr},
};
class Device {
S state_ = S::Off;
public:
bool send(E event) {
for (const auto& row : table) {
if (row.from == state_ && row.event == event) {
if (row.action) row.action();
state_ = row.to;
return true; // transition fired
}
}
return false; // illegal transition β caller decides how to handle
}
S state() const noexcept { return state_; }
};std::function adds heap overhead; for tight loops replace it with a plain function pointer or a small enum naming a fixed set of actions. Mark the table constexpr only when all actions can be nullptr or compile-time-evaluable β std::function is not constexpr.
Testing state machines
State machines are among the most testable C++ code because a pure transition function is a total function from (State, Event) to State β no mocking required. Test each transition in isolation, then test invariants that should hold across all reachable states.
// C++17 + any test framework (shown with raw assertions for portability)
#include <cassert>
#include <variant>
// --- States and events (reuse ConnState from earlier) ---
struct Idle {};
struct Connecting { std::string host; };
struct Connected { int fd; };
struct Failed { std::string reason; };
using State = std::variant<Idle, Connecting, Connected, Failed>;
struct ConnectCmd { std::string host; };
struct SuccessCmd { int fd; };
struct FailureCmd { std::string reason; };
State apply(State s, ConnectCmd cmd) {
if (std::holds_alternative<Idle>(s) || std::holds_alternative<Failed>(s))
return Connecting{std::move(cmd.host)};
return s;
}
State apply(State s, SuccessCmd cmd) {
if (std::holds_alternative<Connecting>(s))
return Connected{cmd.fd};
return s;
}
State apply(State s, FailureCmd cmd) {
return Failed{std::move(cmd.reason)};
}
// --- Unit tests: one assertion per transition row ---
void test_connect_from_idle() {
State s = Idle{};
s = apply(s, ConnectCmd{"db.local"});
assert(std::holds_alternative<Connecting>(s));
assert(std::get<Connecting>(s).host == "db.local");
}
void test_connect_from_failed_retries() {
State s = Failed{"timeout"};
s = apply(s, ConnectCmd{"db.local"});
assert(std::holds_alternative<Connecting>(s));
}
void test_connect_from_connected_noop() {
State s = Connected{42};
State after = apply(s, ConnectCmd{"db.local"});
// Must not disturb an already-connected state
assert(std::holds_alternative<Connected>(after));
assert(std::get<Connected>(after).fd == 42);
}
void test_success_requires_connecting() {
State s = Idle{};
State after = apply(s, SuccessCmd{7});
assert(std::holds_alternative<Idle>(after)); // guard: no-op from Idle
}
int main() {
test_connect_from_idle();
test_connect_from_failed_retries();
test_connect_from_connected_noop();
test_success_requires_connecting();
}Property-based testing catches transition-sequence bugs that unit tests miss. Feed a random sequence of events to the machine and assert invariants (e.g., "the machine never leaves Failed without a ConnectCmd") rather than testing specific paths. Libraries such as RapidCheck provide shrinking so a minimal failing sequence is reported.
See Also
std::variantβ the discriminated union underlying the type-safe pattern- Coroutines β C++20 coroutine machinery:
co_await,co_yield, promise types - CRTP β compile-time polymorphism for hierarchical state machines without vtable overhead
- Policy-Based Design β compile-time behavior injection, a complement to runtime state machines