Recipe 20.7. Adding Functionality to a Class by Enriching All Methods
Credit: Stephan Diehl, Robert E. Brewer
Problem
You need to add functionality to an existing class without changing
the source code for that class. Specifically, you need to enrich all
methods of the class, adding some extra functionality
"around" that of the existing
methods.
Solution
Recipe 20.6 previously
showed a way to solve this task for one method by writing a closure
that builds and applies a wrapper, exemplified by function
add_tracing_prints_to_method in that
recipe's Solution. This recipe generalizes that one,
wrapping methods throughout a class or hierarchy, directly or via a
custom metaclass.
Module inspect lets you
easily find all methods of an existing class, so you can
systematically wrap them all:
import inspectIf you need to ensure that such wrapping applies to all methods of
def add_tracing_prints_to_all_methods(class_object):
for method_name, v in inspect.getmembers
(class_object, inspect.ismethod):
add_tracing_prints_to_method(class_object, method_name)
all classes in a whole hierarchy, the simplest way may be to insert a
custom metaclass at the root of the hierarchy, so that all classes in
the hierarchy will get that same metaclass. This insertion does
normally need a minimum of
"invasiveness"placing a
single statement
_ _metaclass_ _ = MetaTracerin the body of that root class. Custom metaclass
MetaTracer is, however, quite easy to write:
class MetaTracer(type):Even such minimal invasiveness sometimes is unacceptable, or you need
def _ _init_ _(cls, n, b, d):
super(MetaTracer, cls)._ _init_ _(n, b, d)
add_tracing_prints_to_all_methods(cls)
a more dynamic way to wrap all methods in a hierarchy. Then, as long
as the root class of the hierarchy is new-style, you can arrange to
get function add_tracing_prints_to_all_methods
dynamically called on all classes in the hierarchy:
def add_tracing_prints_to_all_descendants(class_object):The inverse function unwrapfunc, in Recipe 20.6, may also be similarly
add_tracing_prints_to_all_methods(class_object)
for s in class_object._ _subclasses_ _( ):
add_tracing_prints_to_all_descendants(s)
applied to all methods of a class and all classes of a hierarchy.
Discussion
We could code just about all functionality of such a powerful
function as add_tracing_prints_to_all_descendants in
the function's own body. However, it would not be a
great idea to bunch up such diverse functionality inside a single
function. Instead, we carefully split the functionality among the
various separate functions presented in this recipe and previously in
Recipe 20.6. By this
careful factorization, we obtain maximum reusability without code
duplication: we have separate functions to dynamically add and remove
wrapping from a single method, an entire class, and a whole hierarchy
of classes; each of these functions appropriately uses the simpler
ones. And for cases in which we can afford a tiny amount of
"invasiveness" and want the
convenience of automatically applying the wrapping to all methods of
classes descended from a certain root, we can use a tiny custom
metaclass.add_tracing_prints_to_all_descendants cannot apply
to old-style classes. This limitation is inherent in the old-style
object model and is one of the several reasons you should always use
new-style classes in new code you write: classic classes exist only
to ensure compatibility in legacy programs. Besides the problem with
classic classes, however, there's another issue with
the structure of
add_tracing_prints_to_all_descendants: in cases of
multiple inheritance, the function will repeatedly visit some
classes.Since the method-wrapping function is carefully designed to avoid
double wrapping, such multiple visits are not a serious problem,
costing just a little avoidable overhead, which is why the function
was acceptable for inclusion in the
"Solution". In other cases in which
we want to operate on all descendants of a certain root class,
however, multiple visits might be unacceptable. Moreover, it is
clearly not optimal to entwine the functionality of getting all
descendants with that of applying one particular operation to each of
them. The best idea is clearly to factor out the recursive structure
into a generator, which can avoid duplicating visits with the memo
idiom:
def all_descendants(class_object, _memo=None):Adding tracing prints to all descendants now simplifies to:
if _memo is None:
_memo = { }
elif class_object in _memo:
return
yield class_object
for subclass in class_object._ _subclasses_ _( ):
for descendant in all_descendants(subclass, _memo):
yield descendant
def add_tracing_prints_to_all_descendants(class_object):In Python, whenever you find yourself with an iteration structure of
for c in all_descendants(class_object):
add_tracing_prints_to_all_methods(c)
any complexity, or recursion, it's always worthwhile
to check whether it's feasible to factor out the
iterative or recursive control structure into a separate, reusable
generator, so that all iterations of that form can become simple
for statements. Such separation of concerns can
offer important simplifications and make code more maintainable.
See Also
Recipe 20.6 for details on
how each method gets wrapped; Library
Reference and Python in a Nutshell
docs on module inspect and the _
_subclasses_ _ special method of new-style classes.