So the last article was written rather hastily during a bout of multi-tasking. I'd like to revisit the idea of aspect-oriented programming using Python's decorators.
There are a lot of good introductions to decorators, but I think Bruce Eckel's two-part series (Part One and Part Two) were the most useful for me when trying to understand how to create a decorator out of a class. And of course there is the ubiquitous Wikipedia article for those who want a fuller definition of AOP.
The basic idea behind AOP is that our code is often a stew
of infrastructure and business logic. The canonical example is logging: every
developer understands the importance of logging and well-instrumented code,
even though we often don't expose it to the user. The most ubiquitous C
debugger is, after all, printf
.
What's a bit frustrating, though, is that logging code rarely has anything to do with the actual application we're writing, and it's tedious to read code that uses it. It's embarrassing to admit it, but there have been plenty of times in my career where I'd write code that looked like this:
if (<some condition>) { printf ("Break here: %s, %d\n", __FILE__, __LINE__); }
Decorators don't exactly offer us line-by-line control the way that injecting
printf
s into the code does, but they do allow us to shift logic away from
the business function by writing functions that return functions.
A classic example in Python is an entry-exit decorator that allows us to trace function execution:
def entex (func): def wrapped_func (*args, **kwargs): print ("Entering " + func.__name__) res = func (*args, **kwargs) print ("Exited " + func__name__) return res return wrapped_func
(There are a lot of times such a function could be helpful. In my previous work, we spent a lot of time working on syntax trees--at least of a sort--that were pretty complicated. Being able to trace snippets after the fact would have been pretty helpful, since stepping through a hundred functions was a bit of a pain. Post-mortem analysis was usually easier.)
Now then:
def hello_world (name): print ("Hello, " + name)
is about the simplest function you can write in Python. Let's instrument the entry and exit points:
@ent_ex def hello_world (name): print ("Hello, " + name) >>> hello_world("Ethan") Entering hello_world Hello, Ethan Exited hello_world
The @ent_ex
prefix is the Python decorator syntax, which is just a
bit of sugar for hello_world = entex (hello_world)
. It's a boring decorator
of course, but we can imagine there are other cases that could be more
interesting. It's a bit unpythonic, but we could imagine imposing strict type
checking:
@expects(str) def hello_world (name): print ("Hello, " + name) >>> hello_world(1) <type 'int'> argument should be a <type 'str'>
This can be accomplished with a little wizardry:
def expects (*types): def wrap (func): def wrapped_func (*args, **kwargs): for arg, typ in zip(args, types): if type(arg) is not typ: print (str(type(arg)) + " argument should be a " + str(typ)) return False else: return func (*args, **kwargs) return wrapped_func return wrap
This is a little more complicated than the first example because the decorator function takes arguments. In this case, we need to have an enclosing scope to hold the arguments to the decorator prior to wrapping the function passed to it.
Naturally, decorators can be composed:
@entex @expects(str) def hello_world(name): print ("Hello, " + name)
This gets a little ugly, which any Pythonista will tell you:
Entering wrapped_func Hello, Ethan Exited wrapped_func
What we'd like, of course, is for entex
to work on hello_world
, but it
instead applies to the expects
function. There is a module, functools
,
that includes a decorator for just this purpose (wraps
).
Functools is super helpful
and has a lot of utility (partial
is really handy for IronPython event
handling). Read the documentation for more, but here is a quick and dirty
illustration:
from functools import wraps def entex(func): @wraps(func) def wrapped_func (*args, **kwargs): print ("Entering " + func.__name__) res = func (*args, **kwargs) print ("Exited " + func.__name__) return res return wrapped_func def expects (*types): def wrap (func): @wraps(func) def wrapped_func (*args, **kwargs): for arg, typ in zip(args, types): if type(arg) is not typ: print (str(type(arg)) + " argument should be a " + str(typ)) return False res = func (*args, **kwargs) return res return wrapped_func return wrap @entex @expects(str) def hello_world(name): print("Hello, " + name) if __name__ == '__main__': hello_world ("Ethan") hello_world (1) Entering hello_world Hello, Ethan Exited hello_world Entering hello_world <type 'int'> argument should be a <type 'str'> Exited hello_world
As with any conversation about decorators, there's a lot left unsaid here. The idea of modifying function behavior is quite powerful: it allows us to extend the concept of decoupling components to decoupling functional logic. Code becomes a bit tidier and easier to read, which is never a bad thing, and it also frees developers to think more concisely about The Problem At Hand without being distracted by infrastructural concerns from the outset.