Search code examples
pythonpython-decorators

Reassigning parameters within decorators in Python


Consider a simple Python decorator with parameters:

def decorator_factory(a=None):
    def decorator(func):
        def wrapper(*args, **kws):
            return func(*args, **kws) + a
        return wrapper
    return decorator

Sometimes, it is useful to reassign the value of a parameter based on its actual value. This is a common design pattern in Python, especially given the issue with default parameter mutability, but it can be used in other situations, e.g.:

def foo(a, b=None):
    if b is None:
        b = a
    return a + b

However, similar code mimicking an analogous design pattern with decorators, for example the following toy code:

def decorator_factory(a=None):
    def decorator(func):
        def wrapper(*args, **kws):
            y = func(*args, **kws)
            if a is None:
                a = y
            return y + a
        return wrapper
    return decorator

will raise the following:

UnboundLocalError: local variable 'a' referenced before assignment

How could this be solved?


Solution

  • This is a scoping issue. By reassigning the name, the Python interpreter reserves the reassigned name for local usage, thus shadowing the previous value from the outer scopes, which results in the name being unbound if used before the first assignment.

    The simplest solution to this is to never reassign a decorator parameter's name inside the wrapper(). Just use a different name throughout.

    For example:

    def decorator_factory(a=None):
        def decorator(func):
            def wrapper(*args, **kws):
                y = func(*args, **kws)
                a_ = y if a is None else a
                return y + a_
            return wrapper
        return decorator
    
    
    @decorator_factory()
    def foo(x):
        return 2 * x
    
    
    print(foo(2))
    # 8
    print(foo(3))
    # 12
    

    Note: the nonlocal statement would avoid raising the UnboundLocalError, BUT the value of the parameter will persist across multiple function calls, e.g.:

    def decorator_factory(a=None):
        def decorator(func):
            def wrapper(*args, **kws):
                nonlocal a
                y = func(*args, **kws)
                a = y if a is None else a
                return y + a
            return wrapper
        return decorator
    
    
    @decorator_factory()
    def foo(x):
        return 2 * x
    
    
    print(foo(2))
    # 8
    print(foo(3))
    # 10
    

    The last foo() call gives 10 because the value of a=4 inside the decorated function comes from the previous foo() call.