Applying the Decorator Pattern to Enhance Object Functionality
Written on
Overview
This article illustrates how the Decorator Pattern can be utilized in practical scenarios rather than just theoretical examples.
The Decorator Pattern serves two primary design objectives: - Extensibility - Reusability
This pattern allows us to add new functionalities to objects without modifying their existing class structures. A formal definition will be provided after the demonstration.
Demo Application
Consider a menu management system for a pizzeria that handles various aspects, including pizza pricing.
Pizza components can be categorized into two main types: - The base, representing a straightforward pizza offering with specific ingredients (e.g., Farmhouse Pizza, Peppy Paneer Pizza, Margherita Pizza). - Toppings, which can be added to the base pizza. Examples include olives, onions, and jalapenos.
The pizza's price is determined by the selected base and the toppings added.
Class Structure
An abstract class is created to represent a pizza.
from abc import ABCMeta, abstractmethod
class Pizza(object, metaclass=ABCMeta):
@abstractmethod
def get_price(self):
pass
Each specific pizza variant should inherit from the Pizza class and implement the get_price method.
class Margherita(Pizza):
def get_price(self):
return 1.0class FarmHouse(Pizza):
def get_price(self):
return 1.5class PeppyPaneer(Pizza):
def get_price(self):
return 1.75
Assume the pizzeria allows one or more of the following toppings, with their respective prices: - Jalapenos: 0.2 - Onion: 0.25 - Olive: 0.3
The pizza price changes based on the selected topping(s). For instance, a Margherita Pizza with onions would cost 1.25, while a Farmhouse pizza with both jalapenos and olives would be priced at 2.0 (1.5 + 0.2 + 0.3).
Creating all combinations of classes, each with its own get_price method, would result in:
class MargheritaWithOnion(Pizza):
def get_price(self):
return 1.25class FarmHouseWithJalapenosAndOlive(Pizza):
def get_price(self):
return 2.0
Understanding the Problem
This design has a significant flaw, leading to a combinatorial class explosion. Soon, classes like MargheritaWithOlive, MargheritaWithJalapenos, and others would need to be created, complicating maintenance.
Consider the potential issues when the price of olives increases; modifying get_price for multiple classes like MargheritaWithOlive and FarmhouseWithOlive would be cumbersome.
The Decorator Pattern provides a solution!
Decorator Pattern
We can start with a pizza and "decorate" it with toppings.
The Decorator Pattern separates the problem into two key components: - Decorated objects - Decorator objects (also referred to as "wrappers")
In the MargheritaWithOlive scenario, the Margherita object serves as the decorated object, while the Olive object functions as the decorator or wrapper.
The decorator must match the type of the object it decorates, meaning both must share the same interface and methods. This is a crucial aspect of the decorator pattern.
Toppings like Onion, Olive, and Jalapenos act as decorators, each needing a get_price method.
Thus, a Margherita pizza wrapped in an Onion remains a Margherita, allowing the get_price method to be invoked seamlessly. This compatibility means that clients can interact with either the original or decorated object without issues.
Decorator Class
We will create a superclass named Topping to represent the toppings, with all specific toppings inheriting from it.
As decorators need to implement the same methods as the objects they wrap, it makes sense for Topping to extend Pizza.
A decorator encompasses an object, necessitating an attribute to reference the decorated object—hence, a decorator has a decorated pizza.
We can establish a module called toppings.py, containing classes for Jalapenos, Onion, and Olive.
from menu import Pizza
class Topping(Pizza):
def __init__(self, pizza):
self.pizza = pizza
Since Pizza is an abstract class and Topping does not implement the get_price method, it too is an abstract class.
Now, let's create concrete classes for each topping.
class Jalapenos(Topping):
def get_price(self):
return 0.2 + self.pizza.get_price()class Onion(Topping):
def get_price(self):
return 0.25 + self.pizza.get_price()class Olive(Topping):
def get_price(self):
return 0.3 + self.pizza.get_price()
Notice how the get_price() method in each topping delegates to the pizza's get_price() method.
This means the topping (decorator) effectively delegates to the same method of the decorated object.
Driver Code
Now, let's integrate everything. Assume we want to apply onion as a topping to a Margherita pizza.
from menu import Margherita
margherita = Margherita()
margherita.get_price() # Returns 1.0
Now, let's decorate the Margherita with an Onion topping.
from toppings import Onion
onion_margherita = Onion(margherita)
onion_margherita.get_price() # Returns 1.25
If we pass the margherita around in client code and later replace it with onion_margherita, the get_price() method will still function correctly, showcasing the benefit of decorators implementing the same interface.
Multiple toppings can also be applied.
from menu import FarmHouse
from toppings import Jalapenos, Olive
farmhouse = FarmHouse()
jalapenos_farmhouse = Jalapenos(farmhouse)
olive_jalapenos_farmhouse = Olive(jalapenos_farmhouse)
olive_jalapenos_farmhouse.get_price() # Returns 2.0, adding the price of jalapenos and olives to the farmhouse pizza.
We can nest decorations as needed.
Type Annotation
Type annotations improve code readability and provide enhanced editor support.
We can annotate the pizza parameter in Topping.__init__ to clarify the type of this attribute.
class Topping(Pizza):
def __init__(self, pizza: Pizza):
self.pizza = pizza
This makes it clear that the decorator is working with a pizza that it is decorating.
Design Principle
The Decorator Pattern exemplifies the Open-Closed Principle, which states:
Classes should be open for extension but closed for modification.
The Pizza class is open for extension, as Topping extends it, adding new responsibilities to the get_price() method without entirely replacing it. It still calls the superclass's get_price, thus adding functionality without modifying existing behavior.
Formal Definition
To conclude, here's a formal definition:
The Decorator Pattern dynamically attaches additional responsibilities to an object without altering the underlying class.
We successfully added the capability to include topping prices on pizza without changing the Pizza class.
Voilà!