Decorating code (Part 1)
|
Author: Fabián Ezequiel Gallina |
In this article i'm going to write about how to code decorators in our favourite language.
A decorator is basically a callable [1] that wraps another and which allows us to modify the behavior of the wrapped one. This might sound complicated at the beginning but it is really easier than it seems.
Now, without any further ado we are going to cook some tasty home-baked wrapped callables.
Ingredients
- a callable
- a decorator
- coffee (optional)
- sugar (opcional)
Our to-be-wrapped callable for example will look like this:
def yes(string='y', end='\n'): """outputs `string`. This is similar to what the unix command `yes` does. Default value for `string` is 'y' """ print(string, sep='', end=end)
Our decorator used to wrap the callable looks like this:
def log_callable(callable): """Decorates callable and logs information about it. Logs how many times the callable was called and the params it had. """ if not getattr(log_callable, 'count_dict', None): log_callable.count_dict = {} log_callable.count_dict.setdefault( callable.__name__, 0 ) def wrap(*args, **kwargs): callable(*args, **kwargs) log_callable.count_dict[callable.__name__] += 1 message = [] message.append( """called: '{0}' '{1} times'""".format( callable.__name__, log_callable.count_dict[callable.__name__] ) ) message.append( """Arguments: {0}""".format( ", ".join(map(str, args)) ) ) message.append( """Keyword Arguments: {0}""".format( ", ".join(["{0}={1}".format(key, value) \ for (key, value) in kwargs.items()]) ) ) logging.debug("; ".join(message)) return wrap
The coffee is just to stay awake while coding late at night and the sugar can be used with the coffee, however this is not mandatory, since everybody knows that sugar can also be eaten by the spoonful.
Preparation
Once we have our callable and our decorator, we proceed to mix them up in a bowl.
With Python we have 2 totally valid ways to mix them.
The first one, sweet, with syntactic sugar:
@log_callable def yes(string='y', end='\n'): [...]
The second one [2], just for diabetics:
yes = log_callable(yes)
Voilá, our callable is now decorated.
Coming up next I'll talk about the basic anatomy of a decorator so our greengrocer can't cheat us at the moment of choosing one.
How does a decorator look like
Classes and functions can be decorators. These can also receive or not arguments (apart from the original callable arguments).
So we have two big groups:
- Decorator functions
- Without arguments.
- With arguments.
- Decorator classes
- Without arguments.
- With arguments.
The decorated callable must be called explicitly if the programmer wants the decorated callable to be executed. If this doesn't happens the decorator will be prevent the execution of it.
Example:
def disable(callable): """Decorates callable and prevents executing it.""" def wrap(*args, **kwargs): logging.debug("{0} called but its execution has been prevented".format( callable.__name__) ) return wrap @disable def yes(string='y', end='\n'): [...]
Decorator functions without arguments
In a decorator function that doesn't receive arguments, its first and only parameter is the callable to be decorated. In the nested function is where decorated callable positional and keyword arguments are received.
This can be seen in any of previous examples.
Decorator functions with arguments
Now we'll look an example of a decorator function that receives arguments. The example will be based on our previous log_callable and will allow us to specify if we really want to count the number of calls.
log_callable with arguments example:
def log_callable(do_count): if not getattr(log_callable, 'count_dict', None) and do_count: log_callable.count_dict = {} if do_count: log_callable.count_dict.setdefault( callable.__name__, 0 ) def wrap(callable): def inner_wrap(*args, **kwargs): callable(*args, **kwargs) message = [] if do_count: log_callable.count_dict.setdefault( callable.__name__, 0 ) log_callable.count_dict[callable.__name__] += 1 message.append( u"""called: '{0}' '{1} times'""".format( callable.__name__, log_callable.count_dict[callable.__name__], ) ) else: message.append(u"""called: '{0}'""".format(callable.__name__)) message.append(u"""Arguments: {0}""".format(", ".join(args))) message.append( u"""Keyword Arguments: {0}""".format( ", ".join(["{0}={1}".format(key, value) \ for (key, value) in kwargs.items()]) ) ) logging.debug("; ".join(message)) return inner_wrap return wrap
A decorator function with arguments receives the params that are passed explicitly to the decorator. The callable is received in the first nested function and finally the decorated callable arguments are received by the deeper nested function (in our case called inner_wrap)
The way to use this decorator will be as follows:
@log_callable(False) def yes(string='y', end='\n'): [...]
Decorator classes without arguments
As we said before, the decorator and the callable don't need to be functions, they can be classes too.
Here is a class version of our log_callable (without arguments):
class LogCallable(object):
"""Decorates callable and logs information about it.
Logs how many times the callable was called and the params it had.
"""
def __init__(self, callable):
self.callable = callable
if not getattr(LogCallable, 'count_dict', None):
LogCallable.count_dict = {}
LogCallable.count_dict.setdefault(
callable.__name__, 0
)
def __call__(self, *args, **kwargs):
self.callable(*args, **kwargs)
LogCallable.count_dict[self.callable.__name__] += 1
message = []
message.append(
"""called: '{0}' '{1} times'""".format(
self.callable.__name__,
LogCallable.count_dict[self.callable.__name__]
)
)
message.append(
"""Arguments: {0}""".format(
", ".join(map(str, args))
)
)
message.append(
"""Keyword Arguments: {0}""".format(
", ".join(["{0}={1}".format(key, value) \
for (key, value) in kwargs.items()])
)
)
logging.debug("; ".join(message))
In a decorator class that doesn't receive parameters, the first param of __init__ method is the callable to be decorated. The __call__ method receives the arguments of the decorated callable.
The most interesting difference with the function version is that by using a class decorator we have avoided the need of a nested function.
The way to use this decorator is the same as we do with decorator functions:
@LogCallable def yes(string='y', end='\n'): [...]
Decorator classes with arguments
Understanding the 3 previous cases it is possible to guess how a decorator class with arguments should be.
LogCallable with params example:
class LogCallable(object): """Decorates callable and logs information about it. Logs how many times the callable was called and the params it had. """ def __init__(self, do_count): self.do_count = do_count if not getattr(LogCallable, 'count_dict', None) and do_count: LogCallable.count_dict = {} def __call__(self, callable): def wrap(*args, **kwargs): callable(*args, **kwargs) message = [] if self.do_count: LogCallable.count_dict.setdefault( callable.__name__, 0 ) LogCallable.count_dict[callable.__name__] += 1 message.append( u"""called: '{0}' '{1} times'""".format( callable.__name__, LogCallable.count_dict[callable.__name__], ) ) else: message.append(u"""called: '{0}'""".format(callable.__name__)) message.append( u"""Arguments: {0}""".format( ", ".join(map(str, args)) ) ) message.append( u"""Keyword Arguments: {0}""".format( ", ".join(["{0}={1}".format(key, value) \ for (key, value) in kwargs.items()]) ) ) logging.debug("; ".join(message)) return wrap
In a decorator class with parameters, these are passed to the __init__ method. The decorated callable is received by the __call__ method and the arguments of it are received by the nested function (called wrap in our example).
The way to use it is exactly the same as in the case of decorator functions with params:
@LogCallable(False) def yes(string='y', end='\n'): [...]
Ending
Decorators open a world of possibilities allowing us to make code simpler and more readable, it is a matter of analizing our current needs to figure out if they are what we really need. So in our probable next part we'll see more practical examples and we'll take a look to class decorators (do not confuse it with decorator classses ;-)
| [1] | Class or function name (simplified version: without adding it parens so it doesn't get executed :) |
| [2] | The first way is the recommended way you should take unless you are decorating classes in Python < 2.6. |