As an application developer for Mojo Lingo, I've had the opportunity to work on many telephony applications in various stages: from an idealistic idea to complete mess of code. One of the most useful patterns I find myself using over and over again is the state machine.
What Are State Machines?
State machines, or more specifically finite-state machines in computer science terms, encapsulate the idea that an object:
- Can be in one of a number of states at any given time
- Can transition between states by a trigger
- Has limitations on which transitions are allowable among states
A simple example of a state machine would be a traffic light. At any given time a traffic light can be in one state: red, yellow, or green. It can also only transition to certain states:
- red -> green
- green -> yellow
- yellow -> red
A standard traffic light cannot go from green to red, bypassing yellow, or yellow back down to green.
The behaviour of the object is enforced by the state and its transitions between states.
Why Should You Use Them?
State Machines allow us to describe the behaviour of an object in a simpler and finite way. Transitions, and their triggers or events, determine allowable states of any given instance.
There are several excellent gems for adding state machines to ruby projects, if you're looking for a good one to start with, try the state_machine gem.
Imagine we are modelling a phone call for our telephony application. A call is either answered or not, so we have a boolean answered
in our model.
class PhoneCall
attr_accessible :answered
def initialize
self.answered = false
end
end
Now our application grows, we want to report if the call failed or just wasn't answered. So we add a boolean failed
and maybe a failure_reason
:
class PhoneCall
attr_accessible :answered, :failed, :failure_reason
def initialize
self.answered = false
self.failed = false
end
end
This is kinda ugly: a call be both answered
and failed
at the same time.
Ok, so we implement methods to enforce these rules:
class PhoneCall
attr_reader :answered, :failed, :failure_reason
def initialize
self.answered = false
self.failed = false
end
def answer
self.answered = true
end
def fail(reason)
self.failed = true
self.failure_reason = reason
end
end
That's better, but a successfully answered
call exposes the method failure_reason
, which doesn't make sense in the context of an answered call. Without some meta-programming there's not much we can do, so we carry on.
Now we also want to track if the call is dialing or in progress, and the length of the call. So we start adding more methods, add more checks to each method for the various booleans, implement some custom validators to ensure a call cannot be failed
and in_progress
, etc.
Our model is starting to get complex, and as we add more states and attributes, it gets exponetially more complex.
Replacing with State Machines
It's obvious to us that a phone call can only be in one state at a time:
dialing
in_progress
completed
failed
And that only certain states can go to other states:
dialing
->in_progress
orfailed
in_progress
->completed
In addition, only certain things make sense at certain states:
failure_reason
only makes sense if the call failed.duration
only makes sense for completed calls.
All of this behaviour can be easily described using a state machine instead of manually managing multiple booleans and flags:
# Using the state_machine gem.
class PhoneCall
state_machine :state, :initial => :dialing do
event :answered do
transition :dialing => :in_progress
end
event :failure do
transition :dialing => :failed
end
event :hangup do
transition :in_progress => :completed
end
state :completed do
def duration
@end_time - @start_time
end
end
state :failed do
attr_accessible :failure_reason
end
before_transition :dialing => :in_progress, :do => :rec_start_time
after_transition :in_progress => :completed, :do => :rec_end_time
end
def answered?
in_progress? || completed?
end
private
def rec_start_time
@start_time = Time.now
end
def rec_end_time
@end_time = Time.now
end
end
While the class may have more lines, it is much more obvious what is happening. The state machine enforces a lot of the business logic we were trying to do manually with booleans, flags, and validators. By introducing the concept of states and events, the class's API is much more semantic. Extending functionality or adding more states is as simple as defining a new state or event. Testing your object's behaviour becomes simpler; be sure to unit test that your transition paths are enforced and any events are correctly invoked.
When Should You Use State Machines?
I have a few general things I consider for when to use state machines:
- More than one boolean field in your class.
- You have a
status
(or similar) attribute. - You allow the deletion of a instance (don't delete it, make a
deleted
state). - Your object has a defined process it follows.
- If you're thinking about adding one.
In addition, state machines are usually quite easy to retrofit into a class that can benefit them. Don't be afraid to refactor one in!
Conclusion
State machines are a great tool at your disposal for simplifying the behaviour of classes. They might seem like a lot of work at first, but as your system and objects grow, they allow you to reduce complexity dramatically. You can concentrate on functionality of the object, rather than enforcing the rules of it's behaviour.
More Info
- state_machine, a gem for Ruby.
- aasm, another gem for Ruby.
- Finite State Machines on Wikipedia.
The post State Machines in Your Applications appeared first on Mojo Lingo.