A very handy pattern is the decorator pattern because it simply wraps an object and delegates all responsibilities to that object. It will also add onto that object without mutating the original object, it only decorates it.
Imagine we have a doughnut which is going to have a base cost and a base description. Then we define decorators to wrap this doughnut for every topping we add onto it. Thus adding onto it’s description and cost.
Defining our strategy
Let’s define a strategy for our doughnuts to be, well… doughnuts! We are going
to need only a couple of methods, cost
and description
. If you’re not
familiar with the Strategy pattern checkout one of my earlier posts on it. It’s
a way of enforcing a specified strategy onto other objects.
module Doughnut
def cost
raise NotImplementedError, "Hey, implement a method body for instance method cost."
end
def description
raise NotImplementedError, "Hey, implement a method body for instance method description."
end
end
class ChocolateDoughnut
include Doughnut
end
class FrenchCruller
include Doughnut
end
class PlainDoughnut
include Doughnut
end
ChocolateDoughnut.new.cost
If we run the above code we will get a NotImplementedError
. This is good
because it will force us to define a specific base cost and description.
♥ ruby doughnuts.rb
doughnuts.rb:3:in `cost': Hey, implement a method body for instance method cost. (NotImplementedError)
from doughnuts.rb:24:in `<main>'
Add a base cost and description
Great, now that we have some errors guiding us let’s add the base cost and description.
class ChocolateDoughnut
include Doughnut
def cost
0.99
end
def description
"Chocolate Doughnut"
end
end
class FrenchCruller
include Doughnut
def cost
1.25
end
def description
"French Cruller"
end
end
class PlainDoughnut
include Doughnut
def cost
0.79
end
def description
"Plain Doughnut"
end
end
Creating a Doughnut Decorator and some toppings
In order for us to decorate objects we need to start with a decorator parent
class for the toppings. The job of a decorator is to delegate the cost and
description calls to the doughnut object. This is why a strategy is important!
If we didn’t have a strategy then we could possibly get a NoMethodError
for
cost and description. Now we know if we import the Doughnut
module and
defined the expected methods then everything will work out fine.
class DoughnutDecorator
attr_reader :doughnut
def initialize(doughnut)
@doughnut = doughnut
end
def cost
doughnut.cost
end
def description
doughnut.description
end
end
Now that we have our DoughnutDecorator
lets start by making some toppings.
More specifically let’s make frosting and sugar decorators. In order to
carry over the overridden behavior we will call super
and add onto that.
class WithChocolateFrosting < DoughnutDecorator
def cost
super + 0.50
end
def description
super + " with chocolate frosting"
end
end
class WithSugarGlaze < DoughnutDecorator
def cost
super + 0.40
end
def description
super + " with sugar glaze"
end
end
class WithSugar < DoughnutDecorator
def cost
super + 0.50
end
def description
super + " with sugar"
end
end
chocolate_doughnut = ChocolateDoughnut.new
french_cruller = FrenchCruller.new
plain_doughnut = PlainDoughnut.new
puts "A #{chocolate_doughnut.description} costs #{chocolate_doughnut.cost}."
puts "A #{french_cruller.description} costs #{french_cruller.cost}."
puts "A #{plain_doughnut.description} costs #{plain_doughnut.cost}."
chocolate_doughnut_with_chocolate_frosting = WithChocolateFrosting.new(chocolate_doughnut)
french_cruller_with_sugar_glaze = WithSugarGlaze.new(french_cruller)
plain_doughnut_with_sugar = WithSugar.new(plain_doughnut)
puts "A #{chocolate_doughnut_with_chocolate_frosting.description} costs
#{chocolate_doughnut_with_chocolate_frosting.cost}.".gsub("\n", "")
puts "A #{french_cruller_with_sugar_glaze.description} costs
#{french_cruller_with_sugar_glaze.cost}.".gsub("\n", "")
puts "A #{plain_doughnut_with_sugar.description} costs
#{plain_doughnut_with_sugar.cost}.".gsub("\n", "")
Now when we run our code we get…
♥ ruby doughnuts.rb
A Chocolate Doughnut costs 0.99.
A French Cruller costs 1.25.
A Plain Doughnut costs 0.79.
A Chocolate Doughnut with chocolate frosting costs 1.49.
A French Cruller with sugar glaze costs 1.65.
A Plain Doughnut with sugar costs 1.29.
Awesome, as you can see we have just added onto the doughnut objects and have
not changed them in any kind of way. Let’s finish off with a final decorator
called AndSprinkles
.
...
class AndSprinkles < DoughnutDecorator
def cost
super + 0.25
end
def description
super + " and sprinkles"
end
end
...
puts "A #{plain_doughnut_with_chocolate_frosting_and_sprinkles.description} costs
#{plain_doughnut_with_chocolate_frosting_and_sprinkles.cost}.".gsub("\n", "")
Great, now when we run our code this is what we get.
♥ ruby doughnuts.rb
A Chocolate Doughnut costs 0.99.
A French Cruller costs 1.25.
A Plain Doughnut costs 0.79.
A Chocolate Doughnut with chocolate frosting costs 1.49.
A French Cruller with sugar glaze costs 1.65.
A Plain Doughnut with sugar costs 1.29.
A Plain Doughnut with chocolate frosting and sprinkles costs 1.54.
As you can see with decorators we can have many different combinations of doughnuts and toppings. The idea remains the same, a decorator will delegate all responsibility to it’s wrapped object. Thus you can keep wrapping with as many decorators as you choose.