Decorando código (Parte 1)
|
Autor: Fabián Ezequiel Gallina |
En este artículo voy a hablar sobre cómo escribir decoradores en nuestro lenguaje favorito.
Un decorador (o decorator en inglés) es básicamente un callable [1] que envuelve a otro y que permite modificar el comportamiento de aquel envuelto. Esto puede sonar enredado en un principio pero es realmente más sencillo de lo que parece.
Ahora sí, sin más preámbulos vamos a ponernos a cocinar unos ricos callables envueltos caseros.
Ingredientes
- un callable
- un decorador
- café (opcional)
- azúcar a gusto (opcional)
Nuestro callable, que va a ser envuelto, se va a ver más o menos como esto:
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)
Nuestro decorador, que va a envolver al callable y se va a ver más o menos como esto:
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
El café es solo para mantenerse despierto mientras se codea y el azúcar podría usarse con el café, lo cuál no es totalmente obligatorio porque todos sabemos que el azúcar puede comerse sólo, a cucharadas.
Preparación
Una vez que contamos con nuestro callable y nuestro decorador, procedemos a mezclarlos en un bol.
En Python tenemos 2 formas de mezclarlos, que son totalmente válidas.
La primera, bien dulce, con azúcar sintáctico:
@log_callable def yes(string='y', end='\n'): [...]
La segunda [2], sólo para diabéticos:
yes = log_callable(yes)
Voilá, tenemos nuestro callable envuelto (decorado).
A continuación hablaremos un poco de la anatomía básica de un decorador para que nuestro verdulero amigo no nos estafe al momento de elegirlo.
Cómo se ve un decorador
Tanto una clase como una función pueden ser un decorador. A su vez estos decoradores pueden o no recibir argumentos (aparte de los argumentos con los que se llama el callable).
Por lo que tendremos 2 grandes grupos:
- Funciones decoradoras
- Sin argumentos.
- Con argumentos.
- Clases decoradoras
- Sin argumentos.
- Con argumentos.
La llamada del callable en un decorador debe ser explícita, un decorador podría por ejemplo hacer que no se ejecute la función decorada.
Ejemplo:
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'): [...]
Funciones decoradoras sin argumentos
En una función decoradora que no recibe argumentos su primer y único parámetro es el callable decorado en cuestión. Ya en la función anidada es dónde se reciben los argumentos posicionales y de palabra clave que se utilizaron para llamar al callable decorado.
Esto puede verse en cualquiera de los decoradores mostrados anteriormente.
Funciones decoradoras con argumentos
Veremos ahora un ejemplo de función decoradora que recibe argumentos propios. Vamos a trabajar en un equivalente de log_callable que permita especificar si queremos contar o no el número de llamadas.
Ejemplo log_callable con argumentos:
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
Una función decoradora con argumentos recibe los parámetros que se le pasa explícitamente al decorador. El callable es recibido por la primer función anidada y finalmente, los argumentos pasados a ese callable son recibidos por la siguiente función anidada (en este caso llamada inner_wrap).
La forma de usar el decorador sería:
@log_callable(False) def yes(string='y', end='\n'): [...]
Clases decoradoras sin argumentos
Como dijimos antes, tanto el decorador como el callable no tienen que ser precisamente funciones, también pueden ser clases.
Aquí hay una versión de nuestro decorador log_callable sin argumentos como clase:
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))
En una clase decoradora que no recibe argumentos, el primer parámetro del __init__ es el callable. Los parámetros de __call__ son los argumentos posicionales y de palabra clave que se utilizaron para llamar al callable.
La diferencia más interesante a marcar es que a través del uso del __init__ y de __call__ nos hemos evitado usar una función anidada.
La forma de usar el decorador es la misma que aquella de la función decoradora sin argumento:
@LogCallable def yes(string='y', end='\n'): [...]
Clases decoradoras con argumentos
Entendiendo bien los 3 casos de decoradores anteriores es posible inferir cómo sería una clase decoradora que recibe argumentos.
Ejemplo de LogCallable con parámetro:
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
En el caso de una clase decoradora con argumentos, dichos argumentos son pasados en el __init__. El callable decorado es recibido por el método __call__ y los argumentos usados para llamar al callable son recibidos en la función anidada de __call__ (wrap en este caso).
La forma de usarlo es exactamente la misma que en el caso de la función decoradora con parámetros:
@LogCallable(False) def yes(string='y', end='\n'): [...]
Finalizando
Los decoradores nos abren un mundo de posibilidades a la hora de hacer nuestro código más legible y sencillo, es cuestión de analizar cada caso de uso particularmente. Por lo que en la probable próxima entrega vamos a darle un poco más de hincapié a la decoración de clases y a ver ejemplos más prácticos.
| [1] | Nombre de función o clase (en criollo: sin ponerle los paréntesis así no se ejecuta :) |
| [2] | La primera es la forma recomendada y es la que seguramente elegirás, salvo que andes decorando clases en Python < 2.6 |