Chapter 13 of 24 | Python
Scope, lifetime, and visibility
Scope describes where a name can be used. Python resolves a name using the LEGB rule: Local, Enclosing, Global, Built-in. Understanding this rule helps explain functions, nested functions, and closures.
message = "module level"
def show():
message = "local"
print(message)
show() # local
print(message) # module level
Assignment and the global/nonlocal statements
Assigning to a name anywhere in a function normally makes it local throughout that function, unless declared global or nonlocal. Reading the name before its local assignment raises UnboundLocalError. Avoid using global to share mutable application state; pass data explicitly or use an object that owns it.
nonlocal lets a nested function rebind a name in the nearest enclosing function scope. It is useful for closures, but a small class can be clearer when the state has several fields.
def make_counter():
value = 0
def increment():
nonlocal value
value += 1
return value
return increment
counter = make_counter()
print(counter(), counter()) # 1 2
Closures capture bindings
A closure retains access to names from its enclosing scope after the outer function returns. It captures the binding, not a frozen snapshot of the value. In loops, functions created in the loop may all observe the final value unless the value is bound at creation time.
functions = []
for number in range(3):
functions.append(lambda number=number: number)
print([function() for function in functions]) # [0, 1, 2]
Comprehension and class scopes
Comprehension variables have their own scope in modern Python and do not leak into the containing scope. Class bodies execute in a namespace, but that namespace is not an enclosing lexical scope for methods. A method must access class attributes through the class or instance rather than expecting to capture a class-body local name.
Lifetime, cycles, and finalization
Names and objects have different lifetimes. Removing a local binding does not guarantee immediate destruction if another reference remains. CPython uses reference counting plus cyclic garbage collection, but Python does not promise a universal collection schedule. Use context managers for resources rather than relying on object finalization.
Beginner-to-master progression
- Beginner: distinguish a local name from a module-level name.
- Practitioner: understand closures, rebinding, and object reachability.
- Advanced: reason about descriptors, class creation, weak references, cyclic GC, and implementation-specific frame behavior without treating CPython details as language guarantees.
Exercises
- Write a nested function that reads an enclosing value, then one that updates it with
nonlocal. - Demonstrate the loop-lambda late-binding issue and fix it with a default argument.
- Draw references between objects in a cyclic structure and explain why deleting one name may not free the objects immediately.
Global declarations and module boundaries
A module-level assignment creates or rebinds a name in that module's namespace. A function can read a global name, but assignment makes the name local unless declared global. This rule is determined by the function body, not by the branch taken at runtime.
count = 0
def increment():
global count
count += 1
increment()
print(count)
This works, but global mutable state couples callers and makes isolated tests harder. A class or explicit argument/return value often communicates ownership better. Use a global constant for configuration that truly does not change, and avoid using global as an easy substitute for program design.
Closures, late binding, and decorators
A nested function looks up an enclosing variable when it is called. The binding can change between creation and invocation. This “late binding” explains why callbacks created in a loop can all use the loop's final value. Binding a value through a default argument is one fix; a factory function that creates a new closure per iteration is another.
def make_multiplier(factor):
def multiply(value):
return value * factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
Closures are a foundation of decorators and functional composition. They can also retain large objects longer than expected. If a callback or wrapper is stored in a long-lived registry, inspect which outer values it captures and whether that retention is intentional.
Class namespaces and descriptors
A class body executes to create a namespace of attributes, which the metaclass turns into a class object. A method function stored in the class becomes a bound method when accessed through an instance; the descriptor protocol supplies the instance automatically. Class variables are shared attributes unless shadowed on an instance. A mutable class variable is therefore shared state, not a per-instance default.
class Example:
labels = [] # shared by all instances: usually a bug
def __init__(self):
self.own_labels = [] # separate list for each instance
Mastery checkpoint
- Explain the LEGB resolution of each name in a nested function.
- Find and fix an unintended closure capture that retains a large object.
- Demonstrate the difference between a class attribute and an instance attribute.
- Use
disto inspect bytecode as an implementation experiment, then explain why bytecode is not a stable language contract.