Design Pattern: State and Combination Locks

Design Pattern: State and Combination Locks

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:

  1. Spin the dial three times clockwise to clear any previously entered numbers in the lock.
  2. 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.
  3. 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:

  1. All possible states a lock might get into
  2. All possible events that can happen in each state
  3. 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:

  1. Cleared
  2. Entered Pin One
  3. Entered Pin Two
  4. Entered Pin Three
  5. Unlocked
  6. Entered Wrong Pin

… and all possible events:

  1. Dial to a Number
  2. Pull to Open
  3. Push to Lock
  4. 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)clearpull_to_openpush_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)clearpull_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)clearpull_to_openpush_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)clearpull_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)clearpull_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:

  1. increases readability — each state clearly captures the results of each event
  2. 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.

Unsubscribe at anytime. I'll never spam you. Powered by ConvertKit

2 Comments Design Pattern: State and Combination Locks

  1. Daniel Gray

    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

Leave A Comment

Your email address will not be published. Required fields are marked *