The Decorator Design Pattern: A Gateway to Flexible Code Customization

Tobias Lang
6 min readMay 7, 2023

In this blog article, we’ll dive deep into the Decorator design pattern, a powerful technique for extending the functionality of an object without modifying its structure.

The Decorator design pattern is a structural pattern that allows us to add new behavior to an object without changing its existing structure. It achieves this by wrapping the original object with a decorator class that implements the same interface. This decorator class can then override or extend the original object’s methods, thereby adding or modifying its functionality.

And as always, the sample code shown in the article can be found on my Github repository.

A cup of cappucino.
Photo by allison griffith from Unsplash

Decorator Design Pattern

Its design looks a little more complicated than the Singleton. But you will see, that it is quite simple.

UML for the Decorator pattern
Trashtoy, Public domain, via Wikimedia Commons

This pattern is particularly useful when you need to extend the behavior of an object, but don’t want to modify the class itself. It promotes the Single Responsibility Principle, as each decorator class is responsible for one specific behavior.

To illustrate its usage, let’s consider a simple example where we have a Coffee class, and we want to add various add-ons like milk, sugar, and whipped cream without modifying the Coffee class itself. Moreover, we want to avoid subclassing each combination (e.g. CoffeeWithMilk, CoffeeWithMilkAndSugar etc.), as this obviously would lead to a hard-to-maintain explosion of classes (you can find the original Java-based idea in Head First Design Patterns by Eric Freeman, Elisabeth Robson, Bert Bates, and Kathy Sierra).

from abc import ABC, abstractmethod

class Coffee(ABC):
@abstractmethod
def get_cost(self) -> float:
pass

@abstractmethod
def get_description(self) -> str:
pass

class BasicCoffee(Coffee):
def get_cost(self) -> float:
return 2.0

def get_description(self) -> str:
return "Basic Coffee"

class CoffeeDecorator(Coffee):
def __init__(self, coffee: Coffee):
self._coffee = coffee

@abstractmethod
def get_cost(self) -> float:
pass

@abstractmethod
def get_description(self) -> str:
pass

class MilkDecorator(CoffeeDecorator):
def get_cost(self) -> float:
return self._coffee.get_cost() + 0.5

def get_description(self) -> str:
return self._coffee.get_description() + ", Milk"

class SugarDecorator(CoffeeDecorator):
def get_cost(self) -> float:
return self._coffee.get_cost() + 0.25

def get_description(self) -> str:
return self._coffee.get_description() + ", Sugar"

coffee = BasicCoffee()
coffee = MilkDecorator(coffee)
coffee = SugarDecorator(coffee)

print(coffee.get_description()) # Output: Basic Coffee, Milk, Sugar
print(coffee.get_cost()) # Output: 2.75

We start with the abstract componentCoffee, which represents a general coffee object. It defines two abstract methods (aka the operations): get_cost() and get_description().

The BasicCoffee class represents the concrete implementation of the abstract component, realizing a basic coffee with a fixed cost and description (we could create more concrete implementations, like Decaffeinated, Espresso, …).

Now comes the Decorator CoffeeDecorator class is an abstract class that serves as the base for all coffee decorator classes. It inherits from the Coffee class and has a constructor that takes a Coffee object as an argument. This class defines the methods it can decorate: get_cost() and get_description(). And finally, we create two concrete decorators: the MilkDecorator and SugarDecorator, able to manipulate the original BasicCoffee, altering its cost and description.

Decorating using the @ symbol

In a lot of languages, the Decorator pattern can be implemented using the @ symbol, which is commonly used for decorating functions or methods. Function decorators allow you to modify or extend the behavior of a function without changing its code. They are essentially higher-order functions that take a function as input and return a new function with additional or altered functionality. This approach offers a more elegant and Pythonic way to implement the Decorator pattern compared to the class-based example previously discussed. To use the @ symbol for decorators, you simply define a decorator function and apply it to the target function by placing the @ symbol, followed by the decorator function’s name, just before the target function definition. This way, you can easily add or remove decorators to modify a function’s behavior while keeping the code clean and concise.

Here’s a short example demonstrating the use of the Decorator pattern with the @ symbol in Python:

from typing import Callable


# pylint: disable=too-few-public-methods
class BoldDecorator:
def __init__(self, func: Callable[[str], str]) -> None:
self._func = func

def __call__(self, *args: str) -> str:
return f"<b>{self._func(*args)}</b>"


class ItalicDecorator:
def __init__(self, func: Callable[[str], str]) -> None:
self._func = func

def __call__(self, *args: str) -> str:
return f"<i>{self._func(*args)}</i>"

@BoldDecorator
@ItalicDecorator
def greet(name):
return f"Hello, {name}!"

formatted_greeting = greet("John")
print(formatted_greeting) # Output: <b><i>Hello, John!</i></b>

We define two classes, BoldDecorator and ItalicDecorator, which implement the __init__() and __call__() methods. The __init__() method takes a function as input and stores it as an instance variable. The __call__() method is responsible for wrapping the original function's output in the corresponding HTML tags.

We use the @ symbol to apply these class-based decorators to the greet() function, which is first wrapped with ItalicDecorator and then with BoldDecorator. As a result, when we call the greet() function, the output string is wrapped in both bold and italic HTML tags. And as we can see, just decorating the greet function call using the @ symbol helps with code readability.

Decorating with parameters

Decorators in Python can also accept parameters, adding another layer of flexibility and customization to the decorated functions. To create a decorator that accepts parameters, you need to define a function that takes the desired parameters and returns the actual decorator function. This outer function wraps the decorator function, which in turn wraps the target function. Here’s an example of a parameterized decorator that multiplies the result of a function by a given factor:

from typing import Callable

def multiply_decorator(factor: int) -> Callable[[Callable[[int, int], int]], Callable[[int, int], int]]:
def decorator(func: Callable[[int, int], int]) -> Callable[[int, int], int]:
def wrapper(*args: int) -> int:
result = func(*args)
return result * factor
return wrapper
return decorator

@multiply_decorator(2)
def add(a: int, b: int) -> int:
return a + b

print(add(2, 3)) # Output: 10

This has some Inception vibe to it, due to multiple encapsulation. However, it enables to create powerful decorators.

Advantages and Disadvantages

Let us look at some of the advantages and also pitfalls of using the Decorator pattern.

Advantages

  • Flexible extension: The Decorator pattern enables the expansion of a class’s functionality without relying on inheritance, providing a more flexible and modular approach to extending behavior.
  • Single Responsibility Principle: Each decorator class is responsible for one specific behavior, making it easier to maintain and understand.
  • Composability: Decorators can be combined and stacked to create different combinations of behavior.

Disadvantages

  • Code Complexity: The Decorator pattern can introduce a lot of small classes, making the code more complex and harder to understand. Therefore, the pattern is not considered beginner-friendly because it introduces a level of complexity that can be challenging for newcomers to grasp.
  • Increased number of objects: The pattern can lead to an increased number of objects being created in memory, as each decorator wraps an existing object to add or modify its functionality. This accumulation of objects can potentially result in performance issues, especially in resource-constrained environments or when dealing with a large number of instances. As a consequence, developers should be mindful of the trade-off between flexibility and performance when using the Decorator pattern.

Real-life Use of the Decorator Pattern

In Python, the Decorator pattern is commonly used with file-like objects, such as the io.BufferedIOBase class, which wraps a file-like object to provide buffering capabilities:

import io

# The underlying file-like object
byte_stream = io.BytesIO(b"Hello, Decorator!")
# Wrapping the file-like object with a buffered reader
buffered_reader = io.BufferedReader(byte_stream)
# Reading data from the buffered reader
print(buffered_reader.read()) # Output: b'Hello, Decorator!'

Other well-known examples are the @classmethod and @staticmethod decorators in Python. They are used to define class methods and static methods, respectively. These decorators allow you to create methods within a class that do not depend on a specific instance's state but are instead tied to the class itself or have no dependency on either the class or its instances.

class Circle:
def __init__(self, radius):
self.radius = radius

def area(self):
return 3.14159 * self.radius ** 2

@classmethod
def from_diameter(cls, diameter):
return cls(diameter / 2)

@staticmethod
def validate_radius(radius):
return radius > 0

# Using the regular constructor
circle1 = Circle(5)
print(circle1.area()) # Output: 78.53975

# Using the alternative constructor (classmethod)
circle2 = Circle.from_diameter(10)
print(circle2.area()) # Output: 78.53975

# Using the utility function (staticmethod)
print(Circle.validate_radius(5)) # Output: True
print(Circle.validate_radius(-5)) # Output: False

Conclusion

The Decorator design pattern is a powerful and flexible technique for extending the behavior of an object without modifying its structure. With practical applications in both Python and Java standard libraries, it’s an essential tool for any developer to understand and use. While it does introduce some complexity and potential performance concerns, its benefits often outweigh these drawbacks in situations where flexibility and adherence to the Single Responsibility Principle are paramount.

--

--