Skip to content
C++
Idiom
since C++17
Intermediate

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++17

A 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.

ApproachExhaustivePer-state dataDispatch costMinimum standard
enum class + switch-Wswitch-enumNo β€” all in parentNear-zeroC++11
std::variant + visitCompile-timeYes~virtual callC++17
Function pointer tableManualSharedNear-zeroC++98
CoroutineBy structureAutomatic (frame)LowC++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:

cpp
// 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:

cpp
// C++17
State transition(State current, Event e);  // pure: in β†’ out, no side effects

A 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.

cpp
// 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.

cpp
// 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:

cpp
// 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:

cpp
// 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.

cpp
// 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.

cpp
// 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.

cpp
// 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.

cpp
// 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.

cpp
// 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.

cpp
// 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.

cpp
// 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.

cpp
// 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