1

I am new to python decorators and I tried to count calls with primitive counter and it failed:

def logging_calc(func):
    counter = 0

    def inner(a,b):
        counter += 1
        print(counter)
        return func(a,b)
    return inner

but if I set counter to be a dictionary it succeed.

def logging_calc(func):
    counter.calls = 0
    def inner(a,b):
        counter.calls += 1
        print(counter.calls)
        return func(a,b)
    return inner

help me understand please :)

yogevye
  • 11
  • 2
  • 1
    The important thing to understand is there are no primitive types. You are doing two different things, in one case you try to modify a nonlocal variable, in the other, you mutate an object – juanpa.arrivillaga Mar 18 '20 at 23:50
  • 1
    First example contains an *assignment* to `counter`, which makes it a local variable by default - entirely unrelated to the `counter` in the outer function. (Adding a `nonlocal counter` declaration would fix this). In the second example, you aren't assigning to `counter`, merely accessing an attribute of it, so it's allowed to be found in an outer scope (there's no other place it could possibly come from). – jasonharper Mar 18 '20 at 23:52

1 Answers1

2

Use nonlocal so the symbol table lookup resolves to the enclosing scope:

def logging_calc(func):
    counter = 0

    def inner(a, b):
        nonlocal counter
        counter += 1
        print(counter)
        return func(a, b)

    return inner

This avoids the UnboundLocalError: local variable 'counter' referenced before assignment error.

The docs for nonlocal say:

The nonlocal statement causes the listed identifiers to refer to previously bound variables in the nearest enclosing scope excluding globals. This is important because the default behavior for binding is to search the local namespace first. The statement allows encapsulated code to rebind variables outside of the local scope besides the global (module) scope.

Names listed in a nonlocal statement, unlike those listed in a global statement, must refer to pre-existing bindings in an enclosing scope (the scope in which a new binding should be created cannot be determined unambiguously).

Names listed in a nonlocal statement must not collide with pre-existing bindings in the local scope.

The reason the second version with the dictionary works is because you only modify a key (or property) of the dict. There's no attempt to bind the dict variable itself locally at the block scope before definition. Basically, avoid += and there's no problem:

def fn():
    counter = 0

    def inner():
        print(counter) # this works--no assignment
        #counter += 1  # this doesn't work; UnboundLocalError

    inner()    

if __name__ == "__main__":
    fn()

As an aside, it's not possible to use counter.calls = 0 for a dictionary key value set. It has to be counter["calls"] = 0, so I'm not sure what counter is.

You might also want to use varargs so that your logging counter function is a bit more generic.

def logging_calc(func):
    counter = 0

    def wrapper(*args, **kwargs):
        nonlocal counter
        counter += 1
        print(func, "has been called", counter, "times")
        return func(*args, **kwargs)

    return wrapper

@logging_calc
def hello(): pass

@logging_calc
def goodbye(a, b, c): pass
    
if __name__ == "__main__":
    hello()
    hello()
    hello()
    goodbye(1, 2, 3)
    goodbye(1, 2, 3)

Output:

<function hello at 0x0000027B14A8BB80> has been called 1 times
<function hello at 0x0000027B14A8BB80> has been called 2 times
<function hello at 0x0000027B14A8BB80> has been called 3 times
<function goodbye at 0x0000027B14A8BCA0> has been called 1 times
<function goodbye at 0x0000027B14A8BCA0> has been called 2 times
Community
  • 1
  • 1
ggorlen
  • 44,755
  • 7
  • 76
  • 106