When writing object-oriented code, it’s often much easier to create the objects that store stuff rather than the objects that do stuff. Objects that keep some data and their associated actions are more natural than those that model a process over time. However, we still have to write these workflow objects, since they’ll encapsulate a lot of the business logic, flow logic, and complexity of our apps.
What makes writing a workflow object so tough is that it changes over time. This means that when you initialize an object that manages a workflow, you initialize it in an incomplete state. Some of the products of the workflow haven’t been calculated yet, by necessity. This means that instance variables start out set to nil, and slowly take values over time. Sometimes, those variables are for values that aren’t even products of the workflow! They’re just flags, representing how far into the process we are.
How many times have you written code like this?
- (void)performAPIRequest {
if (self.request) {
return;
}
self.request = [RequestFactory requestWith...
}
Here, self.request
serves to assure that we don’t perform the API request more that once. It’s acting as a cheap, shitty version of state machine. The longer-lived and complex your process is, the worse this becomes. The absolute worst is when this code is the view controller, polluting it with instance variables that have nothing to do with the management of the view.
The solution is simple: upgrade your cheap, shitty state machine to a bona fide, grown-up state machine.
A state machine is a construct that can have several states and defines the transitions between those states that are valid. For example, the Initial
state could transition to Loading
, which could transition to Completed
, Failed
, or Cancelled
. Other transitions would be invalid.
In the literature, state machines are sometimes referred to as “Finite State Machines”, “Finite State Automata”, or, my personal favorite jargon, “Deterministic Finite State Automata”. This nomenclature is scary to beginners, but it’s not a complex tool.
When we define our states, they could be enumerations, but long-time readers of my blog know that I love to use polymorphism instead of enumerations. They give us the benefit of storing the information that each state needs within the state object itself, which I find particularly elegant. A concrete example: if we needed access to the request
(for example to cancel it), we could ask the state for it, and only the Loading
state actually needs to store that request. All the other states could respond to that message and return nil.
The above object might be reimplemented like so:
- (instancetype)init {
self = [super init];
if (!self) return nil;
self.state = [InitialState new];
return self;
}
- (void) performAPIRequest {
if (self.state.isLoadingState) {
return;
}
Request *request = [RequestFactory requestWithSuccessBlock:^{
self.state = [CompletedState new];
} failureBlock:((^)(NSError *error)){
self.state = [[FailureState alloc] initWithError:error];
}];
self.state = [[LoadingState alloc] initWithRequest:request];
[request send];
}
- (void)cancel {
self.state = [CancelledState new];
}
- (void)setState:(State *)newState {
[self transitionFromState:self.state toState: newState];
_state = newState;
}
- (void)transitionFromState:(State *)oldState toState:(State *)newState {
if (newState.isCancelledState && oldState.isLoadingState) {
[oldState.request cancel];
} else if (newState.isCompletedState && oldState.isLoadingState) {
//inform delegate of completion
} else {
[[InvalidTransitionException new] raise];
}
}
While this is a lot more code for such a simple thing, notice how much more it does than the first example. We’ve separated the object’s path through the states from what the object actually does when transitioning to those states. We could add multiple Failure
states for the different ways we can expect this to fail. This explicitness is valuable, since it forces us to think about how our object transforms over time. Note the exception that’s raised on an invalid transition. If there’s ever a transition that isn’t defined, the app will crash and you can easily determine why.
Since you have to define the states yourself, using a state machine is sometimes easier to hand-roll than to rely on a pre-packaged library. Nevertheless, such libraries exist.
The benefits of using state machines are myriad. First, explicitly storing the states and transitions that are valid in your system leaves fewer nooks and crannies for bugs to hide out in. It is said that bugs are just states that you haven’t defined yet. If your object is defined by four booleans, it can be in 16 different states, all of which require testing. Imagine how many states it can have with integers, or strings! A state machine limits the number of possible configurations your object can be in, and defines them formally.
Having all of the transitions in one place will help you see the user’s flow through the system. What happens if this step takes longer than expected? Clearly-defined transitions make it painfully obvious what happens if an event happens out of order. Since every transition flows through one point, it is trivial to log all of the transitions. You can use that information to determine how your object went off the rails.
Well-named states like these make it easier for new developers to quickly understand your system. Instead of asking “What does self.request
mean and why are we checking for its presence?”, your new developer can immediately go to asking higher-level questions. State machines don’t tend to be used until its too late, but good naming is valuable even if you have only two states.
You’ll note that in the only example of architecture that Apple has presented (WWDC 2014 Session 232), they make great use of state machines. You can find the transcipt of that talk here, and the sample code on this page, under “Advanced User Interfaces Using Collection View”.
Heraclitus said that you can never step in the same river twice. State machines take this implication and make it obvious in your objects. Start when your class is young, and take advantage of the explicitness of state machines to help you reason about the complex code in your system. It’s never too early to make the jump from five poorly-managed instance variables to one beautiful state machine.