This is a small example of the state pattern using the notion of a gumball machine. This is from the book Head First Design Patterns, Chapter 10.
This pattern will enforce a pattern which is ideal for dealing with a mutable state where a number of things can happen and the behavior of our app should change at runtime without crashing.
This pattern has an collection of constants that represents various possible states. Each constant points to an integer value which represents state.
STATES = {
SOLD_OUT: 0,
NO_QUARTER: 1,
HAS_QUARTER: 2,
SOLD: 3
}
state = STATES[:SOLD_OUT]
When the user interacts with our gumball machine they are now taking an
action of inserting a quarter. Let's name it insert_quarter
to reflect
the intent of this action and define some behavior for our new method.
Depending on what our current state is will affect the behavior that gets
returned.
def insert_quarter(state)
if state == STATES[:HAS_QUARTER]
puts "You cannot insert another quarter."
elsif state == STATES[:NO_QUARTER]
state = STATES[:HAS_QUARTER]
puts "You inserted a quarter."
elsif state == STATES[:SOLD_OUT]
puts "You can't insert a quarter the machine is sold out."
elsif state == STATES[:SOLD]
puts "Please wait, we're already getting you a gumball."
end
end
Lets set up a namespace for all this behavior and state. Let's use a GumballMachine class for this inside of ./lib.
class GumballMachine
STATES = {
SOLD_OUT: 0,
NO_QUARTER: 1,
HAS_QUARTER: 2,
SOLD: 3
}
attr_accessor :state, :count
def initialize(count=0)
@count = count
if count > 0
@state = STATES[:NO_QUARTER]
end
end
def insert_quarter
if state == STATES[:HAS_QUARTER]
puts "You cannot insert another quarter."
elsif state == STATES[:NO_QUARTER]
state = STATES[:HAS_QUARTER]
puts "You inserted a quarter."
elsif state == STATES[:SOLD_OUT]
puts "You can't insert a quarter the machine is sold out."
elsif state == STATES[:SOLD]
puts "Please wait, we're already getting you a gumball."
end
end
def eject_quarter
if state == STATES[:HAS_QUARTER]
puts "Quarter returned."
state = STATES[:NO_QUARTER]
elsif state == STATES[:NO_QUARTER]
puts "You haven't inserted a quarter."
elsif state == STATES[:SOLD_OUT]
puts "Sorry, you can't eject because you haven't inserted a quarter yet."
elsif state == STATES[:SOLD]
puts "Please wait, we're already getting you a gumball."
end
end
def turn_crank
if state == STATES[:HAS_QUARTER]
puts "You turned.."
state = STATES[:SOLD]
dispense
elsif state == STATES[:NO_QUARTER]
puts "You turned but there's no quarter."
elsif state == STATES[:SOLD_OUT]
puts "You turned but there are no Gumballs."
elsif state == STATES[:SOLD]
puts "Turning twice doesn't get you more gumballs!"
end
end
def dispense
if state == STATES[:HAS_QUARTER]
puts "Has quarter should never be the current state when dispensing."
elsif state == STATES[:NO_QUARTER]
puts "You need to pay first."
elsif state == STATES[:SOLD_OUT]
puts "Sold out should never be the current state when dispensing."
elsif state == STATES[:SOLD]
count = count - 1
if count == 0
puts "Whoops, out of gumballs"
state = STATES[:SOLD_OUT]
else
state = STATES[:NO_QUARTER]
end
puts "A gumball comes rolling out of the slot."
end
end
def to_string
end
def refill
count = 10
end
end
By now we are seeing a use of logic that is being repeated across our
methods. Why not abstract these different pieces of logic that depend
on our current state to make our code more DRY? While we're at it lets
use a class of State
to act as an interface which will ensure
it's children objects keep their promises to define specified methods.
class State
attr_accessor :gumball_machine
def initialize(gumball_machine)
@gumball_machine = gumball_machine
end
def insert_quarter
raise NoMethodError, "define insert_quarter"
end
def eject_quarter
raise NoMethodError, "define eject_quarter"
end
def turn_crank
raise NoMethodError, "define turn_crank"
end
def dispense
raise NoMethodError, "define dispense"
end
end
class SoldState < State
def insert_quarter
puts "Please wait we are already giving you a gumball."
end
def eject_quarter
puts "Sorry you already turned the crank."
end
def turn_crank
puts "Turning twice does nothing."
end
def dispense
gumball_machine.count = gumball_machine.count - 1
gumball_machine.release_ball
if gumball_machine.count > 0
gumball_machine.state = gumball_machine.no_quarter
else
gumball_machine.state = gumball_machine.sold_out
end
end
end
class SoldOutState < State
def insert_quarter
puts "Hey there are no more gumballs"
end
def eject_quarter
puts "Sorry, you can't eject because you haven't inserted a quarter yet."
end
def turn_crank
puts "You turned but there are no Gumballs."
end
def dispense
puts "Sold out should never be the current state when dispensing."
end
end
class NoQuarterState < State
def insert_quarter
puts "You have inserted a quarter"
gumball_machine.state = gumball_machine.has_quarter
end
def eject_quarter
puts "You have not inserted a quarter"
end
def turn_crank
puts "You turned but there's no quarter."
end
def dispense
puts "You need to pay first."
end
end
class HasQuarterState < State
def insert_quarter
puts "You cannot insert another quarter."
end
def eject_quarter
puts "Quarter returned."
gumball_machine.state = gumball_machine.no_quarter
end
def turn_crank
puts "You turned the crank"
gumball_machine.state = gumball_machine.sold
gumball_machine.dispense
end
def dispense
puts "No Gumball Dispensed"
end
end
Now that we have abstracted all that expected behavior into other states we can use them inside of our GumballMachine class. This will clean up our class much better now and we no longer have messy logic.
class GumballMachine
attr_accessor :state, :count, :sold_out, :no_quarter, :has_quarter, :sold
def initialize(count=0)
@sold_out = SoldOutState.new(self)
@no_quarter = NoQuarterState.new(self)
@has_quarter = HasQuarterState.new(self)
@sold = SoldState.new(self)
@state = @sold_out
if count > 0
@state = @no_quarter
end
@count = count
end
def insert_quarter
state.insert_quarter
end
def eject_quarter
state.eject_quarter
end
def turn_crank
state.turn_crank
end
def releaseBall
puts "A ball comes rolling out..."
if count != 0
count = count - 1
end
end
end
We can finally run our code and see that everything works fine. As the
state of the GumballMachine changes so does the output. Go ahead and try out
GumballMachine.run
class GumballMachine
attr_accessor :state, :count, :sold_out, :no_quarter, :has_quarter, :sold
def initialize(count=0)
@sold_out = SoldOutState.new(self)
@no_quarter = NoQuarterState.new(self)
@has_quarter = HasQuarterState.new(self)
@sold = SoldState.new(self)
@state = @sold_out
if count > 0
@state = @no_quarter
end
@count = count
end
def insert_quarter
state.insert_quarter
end
def eject_quarter
state.eject_quarter
end
def turn_crank
state.turn_crank
end
def dispense
state.dispense
end
def to_s
puts "Gumball Machine"
puts "inventory #{count}"
if count > 0
puts "Machine is ready for your quarter"
else
puts "Ooops no more gumballs"
end
end
def refill
count = 10
end
def release_ball
puts "A ball comes rolling out."
end
def self.run
gumball_machine = GumballMachine.new(5)
puts gumball_machine.to_s
gumball_machine.insert_quarter
gumball_machine.turn_crank
puts gumball_machine.to_s
gumball_machine.insert_quarter
gumball_machine.turn_crank
gumball_machine.insert_quarter
gumball_machine.turn_crank
puts gumball_machine.to_s
gumball_machine.insert_quarter
gumball_machine.turn_crank
puts gumball_machine.to_s
gumball_machine.insert_quarter
gumball_machine.turn_crank
puts gumball_machine.to_s
end
end
Gumball Machine
inventory 5
Machine is ready for your quarter
You have inserted a quarter
You turned the crank
A ball comes rolling out.
Gumball Machine
inventory 4
Machine is ready for your quarter
You have inserted a quarter
You turned the crank
A ball comes rolling out.
You have inserted a quarter
You turned the crank
A ball comes rolling out.
Gumball Machine
inventory 2
Machine is ready for your quarter
You have inserted a quarter
You turned the crank
A ball comes rolling out.
Gumball Machine
inventory 1
Machine is ready for your quarter
You have inserted a quarter
You turned the crank
A ball comes rolling out.
Gumball Machine
inventory 0
Ooops no more gumballs
You can see from the output that as the number of Gumballs reach 0 the state changes and you have no more gumballs. All this without messy if else statements in the Gumball class! The state pattern is a great way to handle varying conditions when they depend upon a mutable state.