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
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
TimeLoggerstores a normal
TimeLoggerobject modifies the messages and passes them on to the
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:
So here is how we might implement a simple
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:
TimeLoggerimplementation of the
messagemethod adds a timestamp that calls the original logger
TimeLoggerimplementation of the
restartjust 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).
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()
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.
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
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.
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.