DEV Community

Discussion on: A Functional-Style State Machine in C++

Collapse
 
davidsackstein profile image
david-sackstein • Edited

Hi Tamir,

This is an interesting post, and masterfully presented.

I think you might agree with this high level comparison of your approach with that of the traditional State pattern:

In the traditional State pattern, each state is represented by a polymorphic type (state is data) and events are represented as (virtual) methods of that type (events are methods).
You represent state as a function, and events are represented as data.
In a way, this inverses the traditional approach because states are methods, events are data.

So which is more desirable?
Traditional: (state, event) = (data, method) or
Function Style: (state, event) = (method, data)

I think that the Traditional approach is more desirable for two main reasons.
Maybe you can comment on these:

Reason 1:

In the traditional approach, the compiler enforces the requirement that every state must handle every event (a no-op is also a handler)
But in your approach, you write the dispatch code in each state function as a switch.
It is therefore easy to omit a case and in doing so to fail to handle an event in a particular state.
In general, switches have this weakness, and many design patterns prefer to convert switches on values to dispatching on a virtual type for this reason.

Reason 2:

You have not entirely addressed the handling of context.
The context object that you pass to each state is context information that pertains to all the states. But often, each state has, in addition, its own internal state (example below). This state-specific-state must necessarily be passed around to each of the state functions, so that if ever we return to the state that created it this information can read, used and updated by the state that it belongs to.

This not only requires adding another argument to the signature of the state function, it also requires that all states use the same type to store their state information. This would be awkward to implement.

So here is an example.
Consider an application that manages one document.
The document can be open or closed (two states).
The document has three functions (open, close, delete)
Some functions are illegal in some of the states.
Using the traditional approach, each state is in instance of an abstract type with at least three methods (open, close, delete). Each may implement these 3 methods differently.
It is natural that all states hold a reference to the same document, because in fact there is only one document. This is state wide context and I believe is what you refer to with your IContext interface.
But in addition, each state may have its own specific data that needs to be persisted even when the state is not current.
Continuing this example, imagine that you want to enforce a limit on the number of times the document can be opened.
Using the traditional approach, this would probably be implemented as a counter stored in the closed state class which is the only state that actually opens the document in its implementation of open.
Closed would increment that counter in closed::open() and fail the open call if that counter ever reached its limit. This would work even though the application moved out of the closed state to the open state and then back to the closed state (as long as the states themselves are persisted - a reasonable and common approach).

Similarly, you might have other such state-specific-data in the other states. Again, using the traditional approach, this data are simply fields of each individual state.
Those fields do not need to be passed out of one state into another. No other state needs to know the shape of that data and states do not need to shape their specific data in the same way as other states do. Therefore, each state can change its representation of its state without effecting the types or implementations of other states.

Back to your approach, you would need to add some state neutral container of state-specific-context to the signature of the state function and pass it to from state to state. Furthermore, this object would need to change every time state specific information changes.
This is a brittle design and rather awkward to implement.

My conclusion:

I agree that the concept of a function returning an object of its own type is interesting but I think that it is less desirable as an implementation of a state-machine than that of the traditional State pattern.

What do you think?

Collapse
 
tmr232 profile image
Tamir Bahar • Edited

Hi David,

I would like to address your analysis, as well as add some of my insights.

Reason 1: Switch Statements & Compile-Time Guarantees

In the past, switch statements provided very weak compile time guarantees. It used to be impossible to ensure case exhaustion (as enum types were ints)
and you could always get accidental fallthroughs.

With moden C++ and recent compilers, this is changing. Enum classes allow compilers to check for case exhaustion (-Wswitch). Then can also ensure that all cases are handled explicitly (-Wswitch-enum). Additionally, C++17's [[fallthrough]] helps ensure that there are no accidental fallthroughs (-Wimplicit-fallthrough). See here.

In my opinion, those are fairly good guarantees. Additionally, the switch statement design makes the code easier to hold in your head and reason about. You can clearly see all the state transitions at the same time.

Reason 2: Context

The State pattern allows for the different states to hold their own contexts internally. It is not, however, a critical part of the design. You can either hold the context in each individual state, or hold a state-machine wide context and pass it around. This can be done either by the States holding a reference to the context as a private member, or by using stateless states and passing the context on each call.
In my design, the state-functions are stateless and the context is passed into them with every call. This decouples the context from the state functions, and allows for far easier testing. To test a state function, I simply pass it a context representing a given situation, and an event to handle. No need for any further set-up. Were the States holding internal context, it would have had to be set up before every test, and therefore would have had to be exposed in one way or another, complicating the design.

Conclusion

Given the switch-related compiler warnings, it seems to me that both options are reasonably sound. With that in mind, I think that the choice between the functional-style state machine and the State pattern is mainly a stylistic one. I much prefer this solution as it has less textual-overhead. That said, the State pattern is well known and well used - so it very well might solve some issues not-yet addressed here.