Nested functions and lambdas can reference variables defined in enclosing scopes. This can create tricky bugs when the variable and the function
are defined in a loop. If the function is called in another iteration or after the loop finishes, it will see the variables' last value instead of
seeing the values corresponding to the iteration where the function was defined.
Capturing loop variables might work for some time but:
- it makes the code difficult to understand.
- it increases the risk of introducing a bug when the code is refactored or when dependencies are updated. See an example with the builtin "map"
below.
One solution is to add a parameter to the function/lambda and use the previously captured variable as its default value. Default values are only
executed once, when the function is defined, which means that the parameter’s value will remain the same even when the variable is reassigned in
following iterations.
Another solution is to pass the variable as an argument to the function/lambda when it is called.
This rule raises an issue when a function or lambda references a variable defined in an enclosing loop.
Noncompliant code example
def run():
mylist = []
for i in range(5):
mylist.append(lambda: i) # Noncompliant
def func():
return i # Noncompliant
mylist.append(func)
def example_of_api_change():
""""
Passing loop variable as default values also makes sure that the code is future-proof.
For example the following code will work as intended with python 2 but not python 3.
Why? because "map" behavior changed. It now returns an iterator and only executes
the lambda when required. The same is true for other functions such as "filter".
"""
lst = []
for i in range(5):
lst.append(map(lambda x: x + i, range(3))) # Noncompliant
for sublist in lst:
# prints [4, 5, 6] x 4 with python 3, with python 2 it prints [0, 1, 2], [1, 2, 3], ...
print(list(sublist))
Compliant solution
def run():
mylist = []
for i in range(5):
mylist.append(lambda i=i: i) # passing the variable as a parameter with a default value
def func(i=i): # same for nested functions
return i
mylist.append(func)
def example_of_api_change():
""""
This will work for both python 2 and python 3.
"""
lst = []
for i in range(5):
lst.append(map(lambda x, value=i: x + value, range(3))) # Passing "i" as a default value
for sublist in lst:
print(list(sublist))
Exceptions
No issue will be raised if the function or lambda is directly called in the same loop. This still makes the design difficult to understand but it
is less error prone.
def function_called_in_loop():
for i in range(10):
print((lambda param: param * i)(42)) # Calling the lambda directly
def func(param):
return param * i
print(func(42)) # Calling "func" directly