The previous article in this series introduced Python function attributes and discussed how they are implemented under the hood. This article shifts focus to using function attributes to implement a (sort of) practical task. It also introduces an alternative to the use of function attributes: callable class instances which wrap functions.

For a brief recap, recall that functions in Python, as first-class objects, may have attributes attached to the function object itself, within a function definition or dynamically at runtime, and these attributes are visible externally. An illustration:

def foo():
    pass

foo.some_attribute = 'a function attribute'

A Semi-Practical Application

An area where function attributes may be useful is in storing metadata about a function. Say, for example, you want to capture and store all input sent to a function. Four approaches to solving this problem will be discussed in this section. The first two are inelegant and not appropriately generalized, but are used as stepping stones to more robust solutions.

Approach #1 - Naive

Here is an unthoughtful way to gather input, sans function attributes:

# Note foo could be in some other module
def foo(bar):
    """Pretend it does something..."""
    print('in foo')

all_foo_input = []

def foo_with_stored_input(bar):
    """Add bar to all_foo_input and call foo with bar"""
    all_foo_input.append(bar)
    foo(bar)

foo_with_stored_input(5)
foo_with_stored_input('spam')
print(all_foo_input)

Output:

in foo
in foo
[5, 'spam']

This approach works in the strictest sense, but can be vastly improved upon. Chief among its issues is the complete lack of modularity and reusability. The function name and the single argument are hardcoded; to do the same process with a different function would require a new list and a new "wrapper" function, and each new list and function would be needlessly cluttering the module scope. Additionally, moving or omitting the all_foo_input instantiation would result in a NameError when calling the pseudo-wrapper function. Overall, this approach is useless in a general case.

Approach #2 - Still Naive, but with a Function Attribute

def foo(bar):
    """Pretend it does something..."""
    # Instaniates all_input as empty if it does not yet exist
    foo.all_input = foo.__dict__.get('all_input', []) 
    foo.all_input.append(bar)

    print('in foo')

foo(5)
foo('spam')
print(foo.all_input)

This attempt at introducing function attributes helps very little. The data is tied to the function rather than the module, which is nice, but this solution works by modifying the original function, which would need to be done on any other function for which one wished to gather input. Like the previous one, this approach is not modular or reusable, so it fails as a general solution.

Approach #3 - Decorator and Function Attribute

The task of creating or updating the all_input function attribute of the previous approach can be refactored out as it is common to each occurrence of the input gathering goal. The goal, then, becomes adding an identical chunk of code to any function.

The concept of slightly extending the capability of a function without altering it should cause many an astute reader to reach for a particular pennant.

Homer decorators pennant image
You know the one.

A solution using a decorator to add the function attribute is illustrated below:

def collect_input(func):
    """
    A decorator which adds an all_input attribute to the wrapped function.
    This attribute collects any input passed to the function.
    """
    def wrapper(*args, **kwargs):
        wrapper.all_input.append(*args)
        return func(*args, **kwargs)

    wrapper.all_input = []
    return wrapper

@collect_input
def foo(bar):
    print('in foo')

foo(5)
foo('spam')

print(foo.all_input)

Output:

in foo
in foo
[5, 'spam']

Assuming an arbitrary requirement of using a function attribute, this approach is likely the ideal one. It works on any function without requiring modification of the original function, and is quick and painless for an end-user, assuming the decorator is already written.

Additionally, this approach resolves the problem of function attributes not being permitted on built-in functions. This is illustrated in the following example:

from math import sqrt

def collect_input(func):
    """
    A decorator which adds an all_input attribute to the wrapped function.
    This attribute collects any input passed to the function.
    """
    def wrapper(*args, **kwargs):
        wrapper.all_input.append(*args)
        return func(*args, **kwargs)

    wrapper.all_input = []
    return wrapper


# Attributes can't be added directly to built-in functions
try:
    sqrt.all_input = []
except AttributeError as e:
    print(e)

# In the decorator case, the attribute is added to the wrapper, not the
# original function, so it succeeds.
sqrt = collect_input(sqrt)

print(sqrt(25))
print(sqrt.all_input)

Output:

'builtin_function_or_method' object has no attribute 'all_input'
5.0
[25]

There are weaknesses to this approach - can you spot any? Several will be discussed in the next article of this series.

Approach #4 - Class-based Alternative

The decorator solution might appear to end this discussion - it works in the general case and it's fairly elegant. It could likely be adapted to whatever function metadata task one might need.

A challenger appears:

class InputCollector:
    def __init__(self, func):
        self.all_input = []
        self.func = func

    def __call__(self, *args, **kwargs):
        self.all_input.append(*args)
        return self.func(*args, **kwargs)

def foo(bar):
    print('in foo')

foo = InputCollector(foo)

foo(5)
foo('spam')
print(foo.all_input)

Output:

in foo
in foo
[5, 'spam']

The class InputCollector works in a similar manner to the decorator of the previous example, except the all_input attribute is added to the class instance rather than a wrapper function. There is no function attribute used. An original function is still "wrapped" and replaced in scope in one line, so the experience to the end-user is functionally identical. The use of the __call__ magic method means the instance of InputCollector that replaces foo is still callable just as foo was - it behaves like a function.

This approach highlights a major criticism of function attributes. Anything one may wish to do with them can very likely be accomplished using a class. This issue was a major sticking point when the addition of function attributes to Python was first being discussed. This can be seen in the dissenting opinion section of PEP 232 and the discussion to which it links.

Conclusion

On the surface, it likely seems that the class-wrapper method and the decorator/function attribute method are very similar in their capabilities, and essentially interchangable. In simple cases, this is mostly true. However, edge cases are where things get more interesting. The next article in this series will compare these approaches in-depth and posit some final thoughts about function attributes in Python.