Decorator pattern
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 anILogger interface
. - The
TimeLogger
stores a normalLogger
object. - The
TimeLogger
object modifies the messages and passes them on to theLogger
.
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 themessage
method adds a timestamp that calls the original loggermessage
. - The
TimeLogger
implementation of therestart
just calls the original loggerrestart
. 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
Join the PythonInformer Newsletter
Sign up using this form to receive an email when new content is added:
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 latex 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 pil pillow polygon pong 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 tex text text metrics tinkerbell fractal transform translation transparency triangle truthy value tuple turtle unpacking user space vectorisation webserver website while loop zip zip_longest