Why do I use a Decorator or Strategy?
Or any design pattern, for that matter? In general, design patterns are created and used to promote object composition over inheritance, allow for (black-box) code reuse, better enforce the single responsibility principle, and reduce the size and unwieldiness of your code's classes in general.
Like any design pattern, Decorators and Strategies have specific scenarios when they are there are beneficial to use. These two patterns can have similar use cases, and it can be easy to confuse when you should use one or the other. According to [1], both Decorators and Strategies can be employed when your goal is an "alternative to extending functionality by subclassing". Strategies can also be used when you have classes with algorithmic dependencies, and Decorators can be used to easily alter the functionality of a class which itself is not easily changed.
So what actually are they and how do you use them?
Let's get specific. Decorators and Strategies are just extra classes I write to alter/extend the functionality of a class I have already written. The way they alter this functionality is different, and therefore they are useful in different scenarios. First, let's look at the class diagrams [1]:
Decorator
What is this saying? If you haven't looked at too many class diagrams, or you are only familiar with dynamically typed languages, it can be confusing. Each box with a bold title represents a class. In Ruby, we won't actually be programming all of the classes we would in Java or C++, for example.
All of the boxes which do not say Concrete are the architecture of our inter-class APIs that we are designing. In statically typed languages, we have to define these in code as Interfaces, Prototypes, Abstract Classes, etc. depending on the language. In dynamically typed languages, they are defined in the documentation and in our minds as the engineers of the class ecosystem. There are ups and down to both statically and dynamically typed languages; statically typed classes error check for you, help you be more precise and cleaner in your code, and ensure you are designing your system correctly, while dynamically typed languages require less code and can be a lot simpler to create and read IF designed well and properly documented. If designed badly, they can be much harder to read and debug.
But now getting back to the Decorator
Class Diagram; basically, this diagram tells us that we have a class ConcreteComponent
and we want to extend its functionality. This class has the functions and properties as defined by Component
. So, we make another class with the same API as ConcreteComponent
(i.e. it conforms to the Component
design) except that it has one extra reference: a Component
(which is going to be the original class you wanted to extend the functionality of or any decorator it is already wearing), and we call this new design Decorator
. We can make as many different types of Decorator
classes, as long as it adheres to this design.
Above you can see that the Component
can do an Operation()
. So, our Decorator
also has to be able to do an Operation()
. Since our Decorator
has a reference to Component
, it just looks at how Component
does Operation()
, mimics that output, and then changes what it needs to and returns this new-and-improved output from its Operation()
method. That is what the dotted lines are showing you above.
Pseudo-Code:
class NumberComponent
func operation(x){
return x
}
end
class BinaryNumberDecorator
constructor(component){
this.c = component
}
func operation(x){
return this.c.operation(x).toBinary()
}
end
c = new NumberComponent()
d = new BinaryNumberDecorator(c)
x = 2
c.operation(x) // outputs what your original class returns
d.operation(x) // extends functionality of NumberComponent using the same function call
I'll show more complex, working ruby code below.
Strategy
Here, the Context
is the class I want to extend the functionality of. So, I make a Strategy
class that can perform that function. It is interchangeable because each ConcreteStrategy
conforms to the same Strategy
interface. Note that unlike the Decorator
, the Strategy
does not need to share an interface with Context
. This design pattern is convenient when the functionality I want is complex and can be implemented with different algorithms. Let's say I am creating a class which needs to perform the Fast Fourier Transform (FFT). Since the FFT can be calculated in different ways, and I may want to switch to a faster/better algorithm in the future, I can implement a Strategy to perform the calculation.
Pseudo-Code:
class DataContext
constructor(data, fftStrategy = new Radix2Strategy()){
this.data = data
this.fftStrategy = fftStrategy
}
func fft(){
return this.fftStrategy.fft(this.data)
}
end
class BruteForceFFTStrategy
func fft(data){
...perform brute force fft
return calculated_fft
}
end
class Radix2Strategy
func fft(data){
...perform radix 2 strategy
return calculated_fft
}
end
Intuition
Decorators
are kind of like Russian nesting dolls. You take an object you want and nest it inside of your class which uses the inner functionality and adds onto it to makes it more functional. So actually, decorators
are more like in MIB when the little Arquillian is controlling the humanoid body inside the head. He is like the original Component
and the body is like a Decorator
. He could then decorate himself further by having his human decoration operate some sort of exoskeleton. At that point, the little alien and his exterior would have the same methods, like moveArm()
or push()
, but the decorated exoskeleton would have a much different output (i.e. he could push harder). As such, the decorator
has to have the same interfaces as the underlying class. It eats the original Component
and allows the world to interact with it the same way it would interact with the original Component
.
Strategies, on the other hand, are kind of like replaceable cards in a robot. Imagine you pulled off the back plate and there were slots with digital cards inserted for each function such as eating, walking, talking, etc. You could take any of them out and replace them when an algorithm was updated or you wanted to change the behavior of the robot.
In general, if you are wondering whether to use a Strategy
or Decorator
design pattern, a rule of thumb is to keep the base class simple in a decorator
. If the Component
is too complicated, you will have to mirror too many methods in each decorator
, and it will be better to use a strategy
.
Also, decorators are widely used when you want to dynamically add functionality to an object, not necessarily an entire class (and perhaps withdraw this functionality at a later time). Strategies are commonly used to perform a function which is very complicated and can be performed in different ways (perhaps with different time/space complexities).
Code Example
Decorators:
class IceCream
def initialize(price: 1.0)
@price = price
end
def price=(amt)
@price = amt
end
def price
return @price
end
def ingredients
"Ice Cream"
end
end
class WithJimmies
def initialize(item)
@item = item
end
def price
@item.price + 0.5
end
def ingredients
@item.ingredients + ", Jimmies"
end
end
class WithChocolateSyrup
def initialize(item)
@item = item
end
def price
@item.price + 0.2
end
def ingredients
@item.ingredients + ", Chocolate Syrup"
end
end
class WithOreos
def initialize(item)
@item = item
end
def price
@item.price + 1.0
end
def ingredients
@item.ingredients + ", Oreos"
end
end
class FroYo
def initialize(price: 1.5)
@price = price
end
def price=(amt)
@price = amt
end
def price
return @price
end
def ingredients
"Froyo"
end
end
treat = IceCream.new
treat = WithJimmies.new(treat)
treat = WithOreos.new(treat)
treat = WithChocolateSyrup.new(treat)
puts treat.ingredients # "Ice Cream, Jimmies, Oreos, Chocolate Syrup"
puts treat.price # 2.7
another_treat = FroYo.new
another_treat = WithJimmies.new(another_treat)
puts treat.ingredients # "Froyo, Jimmies"
# Froyo and Ice Cream (Components) and all of the Decorators have #price and #ingredients
# methods, making them conform to the same interface.
Strategies:
class ChocolateSyrup
def price
0.2
end
def ingredients
"Chocolate Syrup"
end
end
class Jimmies
def price
0.5
end
def ingredients
"Jimmies"
end
end
class Oreos
def price
1.0
end
def ingredients
"Oreos"
end
end
class IceCream
attr_accessor :toppings, :price_strategy
def initialize(toppings: , price_strategy: CalculatePriceStandardStrategy )
@toppings = toppings
@price_strategy = price_strategy.new
end
def price
self.price_strategy.calculate(self.toppings)
end
def ingredients
self.toppings.length > 0 ? "Ice Cream, " + self.toppings.map(&:ingredients).join(", ") : "Ice Cream"
end
end
class CalculatePriceStandardStrategy
def calculate(toppings)
return 1.0 + toppings.reduce(0){ |cum, curr| cum + curr.price }
end
end
class CalculatePriceRewardsMemberStrategy
def calculate(toppings)
return 0.5 + 0.8 * (toppings.reduce(0){ |cum, curr| cum + curr.price })
end
end
toppings = [ChocolateSyrup.new, Jimmies.new, Oreos.new]
strategy = CalculatePriceStandardStrategy
treat = IceCream.new(toppings: toppings, price_strategy: strategy)
puts treat.ingredients # Ice Cream, Chocolate Syrup, Jimmies, Oreos
puts treat.price # 2.7
treat.price_strategy = CalculatePriceRewardsMemberStrategy.new
puts treat.price # 1.86
That covers the basics. You can see more in-depth descriptions and discussion in the reference below.
References:
[1] DESIGN PATTERNS Elements of Reusable Object-Oriented Software, Gamma, Helm, Johnson, Vlissides
Top comments (0)