Decorator pattern

By Martin McBride, 2021-11-01
Tags: structural pattern decorator single responsibility principle
Categories: design patterns


Decorator pattern is a structural design pattern. It can be used to modify the functionality of an existing class without the need to modify that class.

  • It can add functionality to an object dynamically, even if you do not own that class and cannot easily create a subclass.
  • Similarly, it can limit the behaviours of existing classes.
  • The augmented object has the same interface as the original, so can be used as a drop-in replacement.
  • Decorators can be chained, allowing several new behaviours to be added.
  • The technique follows the single responsibility principle.

The decorator pattern provides a flexible and efficient alternative to subclassing for enhancing an object's functionality.

Decorator pattern vs Python decorators

Python uses the term decorator to describe a technique for wrapping functions and methods using the @<decorator name> notation. This is separate from the decorator pattern described here, and the two should not be confused.

Example logging class

As a simple example, consider a simple logging class that:

  • Provides a method that can be called to write a message to the log, including a severity (Error, Warning or Info).
  • Provides a restart method that can be used to start a new log file.

For simplicity, we will write log messages to the console. Here is our ILogger interface:

class ILogger:

    def message(self, severity, message):
        pass

    def restart(self):
        pass

Here is a concrete Logger class based on this interface:

class Logger(ILogger):

    def message(self, severity, message):
        print(severity, message)

    def restart(self):
        print('------------')


logger = Logger()
logger.message('Info', 'Looking good')
logger.message('Warning', 'Something is not quite right')
logger.restart()
logger.message('Info', 'Another day, another dollar')
logger.message('Error', 'Whoops!')

In this example, we create a Logger and try it out. Here is the result:

Info Looking good
Warning Something is not quite right
------------
Info Another day, another dollar
Error Whoops!

Basis of the decorator pattern

The decorator pattern aims to enhance the Logger functionality without doing anything to the class itself.

As an example, we would like to add timestamps to the log messages. The way we do that is:

  • Create a new class, TimeLogger, that has an ILogger interface.
  • The TimeLogger stores a normal Logger object.
  • The TimeLogger object modifies the messages and passes them on to the Logger.

Since TimeLogger has an ILogger interface, it can be dropped in as a replacement for a Logger. Since it uses a Logger to log the messages, it picks up all the normal Logger functionality. But it can add new functionality of its own.

Here is how the classes relate to each other:

The TimeLogger

So here is how we might implement a simple TimeLogger:

class TimeLogger(ILogger):

    def __init__(self, logger):
        self.logger = logger

    def message(self, severity, message):
        timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        message = ' - '.join([timestamp, message])
        self.logger.message(severity, message)

    def restart(self):
        self.logger.restart()


base_logger = Logger()
logger = TimeLogger(base_logger)

logger.message('Info', 'Looking good')
logger.message('Warning', 'Something is not quite right')
logger.restart()
logger.message('Info', 'Another day, another dollar')
logger.message('Error', 'Whoops!')

Here we implement ILogger again, but this time our TimeLogger has a reference to a Logger object. So:

  • The TimeLogger implementation of the message method adds a timestamp that calls the original logger message.
  • The TimeLogger implementation of the restart just calls the original logger restart. We need to implement all the methods even if we don't change the behaviour.

Here is how we create our time logger:

base_logger = Logger()
logger = TimeLogger(base_logger)

base_logger is a Logger instance. We pass that in when we create logger. So now, the application can just use logger because it is an ILogger object, but we have injected our new functionality. Here is how the classes interact:

Here is the output:

Info 2021-11-01 11:02:09 - Looking good
Warning 2021-11-01 11:02:09 - Something is not quite right
------------
Info 2021-11-01 11:02:09 - Another day, another dollar
Error 2021-11-01 11:02:09 - Whoops!

The formatting could use a bit of improvement, but we have achieved the basic objective - adding functionality without the application or the original Logger class requiring any change (other than constructing the logger object in a slightly different way, of course).

The FilterLogger

Now we will add another type of logger, for two reasons. First to demonstrate using a decorator to remove functionality, but also to show how decorators can be chained.

This logger filters out info messages, so they won't appear in the log:

class FilterLogger(ILogger):

    def __init__(self, logger):
        self.logger = logger

    def message(self, severity, message):
        if severity != 'Info':
            self.logger.message(severity, message)

    def restart(self):
        self.logger.restart()

Here, the message method checks the severity value, and only calls the base logger method if it is not an info message. This means that info messages are effectively discarded. (As an aside, it would be far better to use an enum or similar as the severity classifier, we are using a string to keep things simple in the example code).

Substituting the a FilterLogger for a TimeLogger in the previous code will create a log with the info messages discarded.

Chaining decorators

What if we wanted to combine both functionalities - add timestamps and filter info messages?

We could create a decorator that does both, but there is a better way. We can chain decorators.

In the original code, we introduced a TimeLogger between the application and the real logger, and everything worked fine because the application thought it was talking to a Logger, and the Logger thought it was being driven by the application. The TimeLogger sat in the middle, keeping both sides happy. What if we add an extra stage, a FilterLogger:

This also works fine. Each stage of the pipeline uses the ILogger interface, and everyone is happy. Here is how we set up the chain:

base_logger = Logger()
time_logger = TimeLogger(base_logger)
logger = FilterLogger(time_logger)

logger.message('Info', 'Looking good')
logger.message('Warning', 'Something is not quite right')
logger.restart()
logger.message('Info', 'Another day, another dollar')
logger.message('Error', 'Whoops!')

Here the application feeds the FilterLogger that feeds the TimeLogger that feeds the actual logger. Here is the output:

Warning 2021-11-01 11:50:52 - Something is not quite right
------------
Error 2021-11-01 11:50:52 - Whoops!

The info messages have been filtered out, and the timestamps have been added to the remaining messages.

The order of the decorators can be important. In this case, we apply the filter first because there is no point in adding a timestamp to messages that are about to be discarded. If we had applied the decorators in reverse order, the output would have been the same, but the process would have been less efficient.

But in some cases, the order can be very important. For example, imagine we had a third decorator that applied a line count to the message log. If we applied this before the filter, we would give the messages line numbers 1 to 4, but then discard some of the messages. So the output data would be incorrectly numbered.

Summary

We have seen how to use decorators to modify the behaviour of an object at runtime, without changing the base class of the object, and also how to add more than one behaviour change by chaining decorators.

See also

If you found this article useful, you might be interested in the book NumPy Recipes or other books by the same author.

Popular tags

2d arrays abstract data type alignment and angle animation arc array arrays bar chart bar style behavioural pattern bezier curve built-in function callable object chain circle classes clipping close closure cmyk colour combinations comparison operator comprehension context context manager conversion count creational pattern data science data types decorator design pattern device space dictionary drawing duck typing efficiency ellipse else encryption enumerate fill filter font font style for loop formula function function composition function plot functools game development generativepy tutorial generator geometry gif global variable gradient greyscale higher order function hsl html image image processing imagesurface immutable object in operator index inner function input installing iter iterable iterator itertools join l system lambda function len lerp line line plot line style linear gradient linspace list list comprehension logical operator lru_cache magic method mandelbrot mandelbrot set map marker style matplotlib monad mutability named parameter numeric python numpy object open operator optimisation optional parameter or pandas partial application path pattern permutations pie chart polygon positional parameter print product programming paradigms programming techniques pure function python standard library radial gradient range recipes rectangle recursion reduce regular polygon repeat rgb rotation roundrect scaling scatter plot scipy sector segment sequence setup shape singleton slice slicing sound spirograph sprite square str stream string stroke structural pattern subpath symmetric encryption template text text metrics tinkerbell fractal transform translation transparency triangle truthy value tuple turtle unpacking user space vectorisation webserver website while loop zip zip_longest