Object-Oriented Design Patterns in Life Series – gain an intuitive understanding of OO design patterns by linking them with real-life examples.
The State Pattern allows an object to alter its behavior when its internal state changes. The object will appear to change its class.
The State Pattern is a beautiful example of how combining simple classes with clean interfaces can produce great power.
This is a long post. But stay with me. We are going to witness the beauty and power of object-oriented design together.
Build a combination lock
We will learn the State pattern by building a combination lock.
According to WikiHow, opening a combination lock involves three steps:
- Spin the dial three times clockwise to clear any previously entered numbers in the lock.
- Enter your three-number combination: a) turn the dial to the right and stop at your first number, b) turn the dial to the left, going past zero and stop at your second number, c) turn the dial to the right and stop at the last number.
- Pull the lock open.
Check out this one-minute video if you have never used a combination lock before.
To build a combination lock, we need to determine three things:
- All possible states a lock might get into
- All possible events that can happen in each state
- The consequences of each possible event happens in each possible states
Don’t worry if this is confusing. The following example should make it clear.
Let’s start by going through the happy path: opening a lock without any mistakes. (To keep things simple, we will ignore the dialing direction in our example.)
Say the lock’s pin is 789.
We start at the cleared state, dial to 7, 8, and 9, and pull the lock open.
Now the lock is unlocked. We can push it to lock it.
What happens if we enter a wrong number? The lock should get into the EnteredWrongPin
state.
The only way to get out from the EnteredWrongPin
state is by clearing the lock, which sets the lock back to the Cleared
state.
At this point, we have identified all possible states:
- Cleared
- Entered Pin One
- Entered Pin Two
- Entered Pin Three
- Unlocked
- Entered Wrong Pin
… and all possible events:
- Dial to a Number
- Pull to Open
- Push to Lock
- Clear
The last thing is to identify what happens when each possible event happens in each possible states.
These are the complete state transition graph and transition table. Don’t worry if either the graph or the table looks a bit overwhelming, we will exam them closely with code.
Transition graph for a lock with pin 789
Transition table for a lock with pin 789
Code It Out
From the above, it should be clear that we need to define six states and their behavior with respect to each of the four possible events.
We will start with the simplest state: ClearedState
.
class ClearedState attr_reader :lock, :state_name def initialize(lock) @lock = lock @state_name = 'Cleared State' end def dial_to(number) if number == 7 lock.state = lock.entered_pin_one_state else lock.state = lock.entered_wrong_pin_state end end def clear puts "The lock is cleared. Clear does nothing." end def pull_to_open puts 'The lock is still locked' puts " currently in #{state_name}" end def push_to_lock puts 'The lock is already locked' puts " currently in #{state_name}" end end
ClearedState
holds the state name and a lock. It responds to four events: dial_to(number)
, clear
, pull_to_open
, push_to_lock
.
When dial_to(number)
happens, ClearedState
checks to see if the number is 7, which is the first digit of the pin of the lock. (The pin of the lock is 789.) If so, it changes the lock’s state to entered_pin_one_state
. If not, it changes the lock’s state to entered_wrong_pin_state
.
When clear
happens, ClearedState
does nothing but print out a message.
When pull_to_open
happens, ClearedState
says that the lock is still locked.
When push_to_lock
happens, ClearedState
says that the lock is already locked.
Notice that when a lock is in ClearedState
, the only event trigging a state change is dial_to(number)
.
The behavior of ClearedState
matches the transition table.
Next up is EnteredPinOneState
.
The lock will be in this state after we enter the first digit of its pin during ClearedState
. In our case, that means we entered 7 when the lock was inClearedState
.
class EnteredPinOneState attr_reader :lock, :state_name def initialize(lock) @lock = lock @state_name = 'Entered Pin One State' end def dial_to(number) if number == 8 lock.state = lock.entered_pin_two_state else lock.state = lock.entered_wrong_pin_state end end def clear lock.state = lock.cleared_state end def pull_to_open puts 'The lock is still locked' puts " currently in #{state_name}" end def push_to_lock puts 'The lock is already locked' puts " currently in #{state_name}" end end
EnteredPinOneState
also holds the state name and a lock and responds to the same events: dial_to(number)
, clear
, pull_to_open
, and push_to_lock
.
When dial_to(number)
happens, EnteredPinOneState
checks to see if the number is 8, which is the second digit of the lock’s pin. (If you don’t remember, the pin of the lock is 789.) If so, it changes the lock’s state to entered_pin_two_state
. If not, it changes the lock’s state to entered_wrong_pin_state
.
When clear
happens, EnteredPinOneState
changes the lock’s state to cleared_state
.
When pull_to_open
happens, EnteredPinOneState
says that the lock is still locked.
When push_to_lock
happens, EnteredPinOneState
says that the lock is already locked.
When a lock is in EnteredPinOneState
, either dial_to(number)
or clear
can change its state, which matches the transition table.
Time for EnteredPinTwoState
The lock will be in this state after we enter the second digit of its pin during EnteredPinOneState
. In our case, that means we entered 8 when the lock was in EnteredPinOneState
.
Pay special attention when we are walking through the code for EnteredPinTwoState
. There will be a quiz waiting for you in the end. ; )
class EnteredPinTwoState attr_reader :lock, :state_name def initialize(lock) @lock = lock @state_name = 'Entered Pin Two State' end def dial_to(number) if number == 9 lock.state = lock.entered_pin_three_state else lock.state = lock.entered_wrong_pin_state end end def clear lock.state = lock.cleared_state end def pull_to_open puts 'The lock is still locked' puts " currently in #{state_name}" end def push_to_lock puts 'The lock is already locked' puts " currently in #{state_name}" end end
Obviously, EnteredPinTwoState
holds the state name and a lock and responds to the same events: dial_to(number)
, clear
, pull_to_open
, push_to_lock
.
When dial_to(number)
happens, EnteredPinTwoState
checks to see if the number is 9, which is the second digit of the pin of the lock. (Again, the pin of the lock is 789.) If so, it changes the lock’s state to entered_pin_three_state
. If not, it changes the lock’s state to entered_wrong_pin_state
.
When clear
happens, EnteredPinTwoState
changes the lock’s state to cleared_state
.
When pull_to_open
happens, EnteredPinTwoState
says that the lock is still locked.
When push_to_lock
happens, EnteredPinTwoState
says that the lock is already locked.
When a lock is in EnteredPinTwoState
, either dial_to(number)
or clear
can change its state, which matches the lock’s transition table.
Quiz Time!
Oops, sorry. That’s the wrong quiz…
Here is the real one: given the transition table for EnteredPinThreeState, what do you think its code should look like?
Don’t scroll down yet. Think about it for a second.
Here you go:
class EnteredPinThreeState attr_reader :lock, :state_name def initialize(lock) @lock = lock @state_name = 'Entered Pin Three State' end def dial_to(number) lock.state = lock.entered_wrong_pin_state end def clear lock.state = lock.cleared_state end def pull_to_open lock.state = lock.unlocked_state end def push_to_lock puts 'The lock is already locked' puts " currently in #{state_name}" end end
If we pull_to_open
at this state, the lock will finally be set to unlocked_state
. But if we dial_to
any number, the lock will go back to entered_wrong_pin_state
. This is because at this state, the lock has already received the whole pin, 789, correctly, and isn’t expecting any more digits.
Of course, we can also clear
the lock and set it back to the cleared_state
if we want to. And push_to_lock
will tell you the lock is already locked.
The UnlockedState
The UnlockedState
is quite simple. The only things you can do to an unlocked lock are to push and lock it. All other events, dial_to(number)
, clear
, and pull_to_open
, do nothing but tell you the lock is already unlocked.
class UnlockedState attr_reader :lock, :state_name def initialize(lock) @lock = lock @state_name = 'Unlocked State' end def dial_to(number) puts 'The lock is unlocked. Dial does nothing.' end def clear puts 'The lock is unlocked. Clear does nothing.' end def pull_to_open puts 'The lock is unlocked. Pull does nothing.' end def push_to_lock lock.state = lock.cleared_state end end
Similarly, if the lock is in EnteredWrongPinState
, nothing changes its state besides clear
. All other events, dial_to(number)
, pull_to_open
, and push_to_lock
, do nothing but tell you the lock is in the EnteredWrongPinState
.
class EnteredWrongPinState attr_reader :lock, :state_name def initialize(lock) @lock = lock @state_name = 'Entered Wrong Pin State' end def dial_to(number) puts "The lock is in #{state_name}. Dial does nothing." end def clear lock.state = lock.cleared_state end def pull_to_open puts 'The lock is still locked' puts " currently in #{state_name}" end def push_to_lock puts 'The lock is already locked' puts " currently in #{state_name}" end end
Time to code out the Lock
class Lock attr_reader :cleared_state, :entered_pin_one_state, :entered_pin_two_state, :entered_pin_three_state, :unlocked_state, :entered_wrong_pin_state attr_accessor :state def initialize @cleared_state = ClearedState.new(self) @entered_pin_one_state = EnteredPinOneState.new(self) @entered_pin_two_state = EnteredPinTwoState.new(self) @entered_pin_three_state = EnteredPinThreeState.new(self) @unlocked_state = UnlockedState.new(self) @entered_wrong_pin_state = EnteredWrongPinState.new(self) @state = cleared_state end def show_current_state puts "currently in #{state.state_name}" end def dial_to(number) state.dial_to(number) end def clear state.clear end def pull_to_open state.pull_to_open end def push_to_lock state.push_to_lock end end
Although the code is long, it is quite straightforward.
In the initializer, the lock does two things: 1) creates the six possible states it can be in, and 2) sets its initial state to cleared_state
.
Then we have a method, show_current_state
, that prints out the lock’s current state.
Lastly, the lock responds to all four possible events: dial_to(number)
, clear
, pull_to_open
, and push_to_lock
. It actually delegates these events to its current state: all it does is simply ask its current state to respond to the event. If we want to dial_to(7)
, the lock ask its state to dial_to(7)
.
Let’s play around with our lock:
Revisit the definition in light of a Lock
1. The State Pattern allows an object to alter its behavior when its internal state changes.
When a lock’s internal state is changed, its behavior changes.
When it’s in the ClearedState
, calling clear
will print out The lock is cleared.
Clear does nothing. But when it’s in the EnteredPinOneState
, calling clear
will update the lock’s state toClearedState
without printing anything.
The lock behaves differently for the same event when it’s in different states.
2. The object will appear to change its class.
From the perspective of an outside client who uses the lock, since the lock’s behavior changes, it appears as if its class has changed.
Woohoo, now you understand the State Pattern!
The State Pattern’s magiccomes from separation of concerns.
All the classes we created, a Lock
and six states
, are simple.
The Cleared
state knows nothing but how to handle the four possible events, dial_to(number)
, clear
, pull_to_open
, and push_to_lock
, when the lock is in the cleared
state. The Cleared
state does not care about any other state at all.
The Lock
knows nothing but its six possible states and four possible events. It doesn’t know what exactly should happen when any of those four events occurs. It trusts its current state to handle that.
A state class knows how to respond to each possible event.
A Lock has a list of its possible states and a list of its possible events and delegates event handling to its current state.
When we combine simple states with a simple object that knows its states and delegates event handling, something magical happen. We have a “smart” object that knows how to behave according to its current context.
Simple + Simple = Smart
Isn’t that amazing?!
Advantage of the State Pattern
If we rewrite the Lock class without using the State Pattern, the code will be incredibly confusing and hard to follow.
There are six states and four events. That is 6 * 4= 24 different behaviors in total. Trying to capture 24 different behaviors in one class is insane. Changing the behavior in the future will be a huge headache.
The State Pattern allows us to encapsulate behavior within state objects, which:
- increases readability — each state clearly captures the results of each event
- and achieves separation of concerns — when we need to change the behavior of a state, we can update that state leaving other states untouched.
Takeaways:
1. The State Pattern allows an object to alter its behavior when its internal state changes. The object will appear to change its class.
2. Strive for readability and separation of concerns.
3. Simple + Simple = Smart. Simple classes in combination can achieve great power. Try to keep your classes simple.
Don’t forget to push_to_lock
your lock before you leave : )
Subscribe below so you won’t miss the next post!
Enjoyed the article?
My best content on Software Design, Rails, and Career in Dev. Delivered weekly.
Hi Sihui Huang!
Wao!
What a amazing!
Yeah, your post is amazing, I love it.
I am thinking what you have done!
yes you are right combination lock is the secure lock to keep your valuable thing secure.
this is an amazing article really appreciate your hard work