2
\$\begingroup\$

I decided to try a hand at writing a simple state machine. The StateMachine class accepts any number of classes as template arguments, along with a base class. The classes act as states (a class so it can have permanent data), which have enter, exit and tick functions. The tick function can return nullptr to stay on the current state or another class object to transition to another state. exit and enter are called appropriately.

The StateMachine class contains a StateContainer class that houses the actual state objects as an array of std::unique_ptr<Base>. When StateMachine is constructed, the constructor on StateContainer instantiates an array of pointers to the classes provided. These pointers are only deleted when the StateContainer class is destroyed. StateContainer provides a getState<T> function, which is intended to be used inside each state's tick. It looks up the given class T inside the state array and returns it, allowing one to simply return getState<NewState>(); inside tick.

One thing I don't much like about this is how the state classes are given the StateContainer. You have to forward declare all states, define the StateContainer type, and then use that in the state classes. It feels like a code smell but I don't know any other way around it. I tried a variation using std::vector<std::any> but that meant I had to use std::any_cast(T*) and I don't fancy that in a real time system.

An earlier version of this (written in C#) had a hard-coded StateContainer where every state was just a public property. I didn't quite like that limitation, hence the iteration of StateContainer here, but it gets around the above problems.

template<class StateBase, class... StateTypes>
struct StateContainer
{
private:
    constexpr static int state_count = sizeof...(StateTypes);
    std::array<std::unique_ptr<StateBase>, state_count> _states;

    template<int n, class Match, class HeadState, class... TailStates>
    StateBase* getStateHelper()
    {
        if constexpr (std::same_as<Match, HeadState>)
        {
            return _states[n].get();
        }
        else if constexpr (sizeof...(TailStates) > 0)
        {
            auto x = getStateHelper<n + 1, Match, TailStates...>();
            return x;
        }
        else
        {
            return nullptr;
        }
    }

public:
    StateContainer()
    {
        int i = 0;
        ((_states[i++] = std::make_unique<StateTypes>(*this)), ...);
    }

    template<class T>
    T* getState()
    {
        auto x = this->getStateHelper<0, T, StateTypes...>();
        return static_cast<T*>(x);
    }
};

template<class T, class StateContainer>
concept is_state_constructible = std::constructible_from<T, StateContainer&>;

template<class StateBase, class... StateTypes>
    requires (std::derived_from<StateTypes, StateBase> && ...) && (is_state_constructible<StateTypes, StateContainer<StateBase, StateTypes...>> && ...)
class StateMachine
{
public:
    using StateContainerType = StateContainer<StateBase, StateTypes...>;
private:
    StateContainerType _states;
    StateBase* _current;

public:
    StateMachine()
        : _current(nullptr)
    {

    }

    void setState(StateBase* state)
    {
        if (_current != nullptr)
        {
            _current->exit();
        }
        _current = state;
        if (state != nullptr)
        {
            state->enter();
        }
    }

    void tick()
    {
        if (_current != nullptr)
        {
            if (auto newState = _current->tick(); newState != nullptr)
            {
                setState(newState);
            }
        }
    }

    StateContainerType& states()
    {
        return _states;
    }
};

class StateBase;
struct s_one;
struct s_two;
struct s_three;

using StateContainerT = StateContainer<StateBase, s_one, s_two, s_three>;

class StateBase
{
private:
    StateContainerT* _states;
protected:
    template<class T>
    T* getState()
    {
        return _states->getState<T>();
    }
public:
    StateBase(StateContainerT& states) : _states(&states) { }
    virtual ~StateBase() { }
    virtual void enter() = 0;
    virtual StateBase* tick() = 0;
    virtual void exit() = 0;
};

struct s_one final : public StateBase
{
public:
    s_one(StateContainerT& states) : StateBase(states) { }
    int x = 1;
    void enter() override { }
    StateBase* tick() override { return nullptr; };
    void exit() override { }
};

struct s_two final : public StateBase
{
public:
    s_two(StateContainerT& states) : StateBase(states) { }
    int y = 2;
    void enter() override { }
    StateBase* tick() override { return getState<s_one>(); };
    void exit() override { }
};

struct s_three final : public StateBase
{
public:
    s_three(StateContainerT& states) : StateBase(states) { }
    int z = 3;
    void enter() override { }
    StateBase* tick() override { return getState<s_two>(); };

    void exit() override { }
};


int main()
{
    StateMachine<StateBase, s_one, s_two, s_three> sm;

    auto s = sm.states().getState<s_three>();
    sm.setState(s);

    sm.tick();
    sm.tick();
    sm.tick();

    return 0;
}
\$\endgroup\$
5
  • \$\begingroup\$ any_cast is an efficient cast unlike dynamic_cast. It doesn't rely on polymorphism but it is also weaker, as you need to know exact type. \$\endgroup\$
    – ALX23z
    Nov 24, 2022 at 19:45
  • \$\begingroup\$ @ALX23z I tried to look up how any_cast works, and if I remember right, it winds up doing a string compare of the typeid name. That was kind of an oof, especially for a real time system where dozens of objects might be calling it. Admittedly, the primary benefit is it gets around some of the stupid forward declaration problems. \$\endgroup\$ Nov 25, 2022 at 3:56
  • \$\begingroup\$ it checkes if their type_info matches. It's not specified how it actually does that. I believe it is possible to implement it efficiently so the string comparisons are unnecessary most of not all of the time, but no idea if they do anything of the sort. \$\endgroup\$
    – ALX23z
    Nov 25, 2022 at 6:57
  • \$\begingroup\$ I tried to find out how to do that. msvc, at least, uses a string comparison. I also tried to use some template trickery, creating a class that returns a unique number per class by abusing static ints and a class type as a template argument. In the end I kinda didn't trust it and it would mean that I would have to implement my own any. \$\endgroup\$ Nov 28, 2022 at 11:33
  • \$\begingroup\$ in this case, unless you have way too many states, just use a std::tuple. \$\endgroup\$
    – ALX23z
    Nov 28, 2022 at 12:13

1 Answer 1

1
\$\begingroup\$

I am confused about the design. You have state container that stores exactly a single instance of each state type via unique pointer to their base type. Just why? Why not simply store a tuple of all state types? Here you have some unnecessary mixture of static and dynamic techniques and you should just use either static or dynamic.

Dynamic state container is composed at runtime with arbitrary number of states sharing the same base - not sure if that's what you want.

Static state container is just a tuple of state types. You won't need polymorphism or state base to work with that. Just skills to write the more convoluted template code.

The other issue is that your states define pretty much everything, and state machine is rather redundant class that does almost nothing. From the implementation of state machines that I've seen, they usually define states to be some trivial classes that store data while state machine defines the machinery as to how navigate between the states. Basically, it has "transitions" between states that have optional condition function that determine if it is time to transition to another state and callable that transfers to another state and perhaps does some side effects.

You can check various state machine implementations like https://www.boost.org/doc/libs/1_80_0/?view=category_state or https://github.com/endurodave/StateMachine.

\$\endgroup\$
1
  • \$\begingroup\$ It's worth mentioning this controls AI in an ARPG. It's gone through minor changes since this posting but it's still roughly the same. The reason for holding a "state list" is so each AI agent has its own data: being able to hold the current target as an agent goes from "Attacking" to "Moving" and back to "Attacking". There is a data instance that holds behavioral attributes specific to each agent. I debated putting "target" and "current ability" on it, which they would be able to modify at will. That would leave the states without any data but what they need while they're currently active. \$\endgroup\$ Nov 28, 2022 at 3:26

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.