Lesson 12 / 18
Scope and Lifetime
Name lookup order, shadowing, writing to global state, the lifetime of local variables, and closures.
Contents
The previous lesson showed that a function can modify objects from outside. This raises a natural question: which variable does a name written inside a function body refer to, and are names defined there visible from outside?
The subject of this lesson is scope — the region where a name is valid — and lifetime — the interval during which a value exists. The two are often confused; one concerns visibility, the other duration.
Local Scope
Names bound inside a function body are local: they are visible only within that body and disappear when the function returns.
def summarize(measurements: list[int]) -> float: total = 0 # local name for measurement in measurements: # the loop variable is local too total += measurement return total / len(measurements) print(summarize([12, 18])) # 15.0 # print(total) -> error: 'total' is not defined here
This is a direct consequence of the memory layout lesson in the previous course: local variables live in the call frame, released on return. The benefit of locality is isolation — two separate functions can use the same name without affecting each other.
Name Lookup Order
When a name is used in a body, the language searches for it in a specific order:
- Local scope — the body of this function.
- Enclosing scope — the body of the outer function, in nested definitions.
- Global scope — the outermost level of the module.
- Built-in names — the names the language itself provides.
The search stops at the first place the name is found; if a name is defined both locally and globally, the local one wins inside the body.
LIMIT = 20 # global def count_over(measurements: list[int]) -> int: count = 0 # local for measurement in measurements: if measurement > LIMIT: # read from the global name count += 1 return count print(count_over([12, 25, 30])) # 2
Reading from a global name is unrestricted; writing to one is different, and is the subject of the next section.
Shadowing
Using the same name locally as an outer name is called shadowing. The outer name becomes inaccessible; it does not disappear, it is merely not visible in that region.
LIMIT = 20 def incorrect_use(measurements: list[int]) -> int: LIMIT = 5 # shadows the outer name return len([m for m in measurements if m > LIMIT]) print(incorrect_use([12, 25, 30])) # 3 — not the expected 2 print(LIMIT) # 20 — the global value did not change
Shadowing is not always a mistake; it can be used deliberately in short bodies. It becomes a source of errors when it goes unnoticed: the reader assumes the outer value while the code uses the local one. The criterion from the naming lesson applies here too — distinct concepts get distinct names.
Writing to Global State
A function modifying a global variable falls into the side effect category from the previous lesson and is generally avoided. Most languages make this deliberately awkward: writing to a global name requires an explicit declaration; otherwise the assignment creates a new local name.
The rationale for avoiding this has three parts. The function’s behavior stops depending only on its arguments; the same call can return a different result depending on the global state’s history. Testing becomes harder, since a test must reset state on every run. When two threads concurrently modify the same global value, the result depends on ordering.
The alternative is to take the needed value as an argument and return the result. This makes the function independent and testable.
Constants are an exception to this rule: global values that do not change can be shared and improve readability.
Lifetime and Closures
Scope specifies visibility, lifetime specifies duration. The two usually coincide: a local variable arrives with the frame and leaves with it.
There is one case where this coincidence breaks down: if a function defined inside another accesses a local variable of the outer function, that variable must continue to exist even after the outer function returns. This structure is called a closure.
def limit_checker(limit: int): """Produces a function that decides based on the given limit.""" def exceeds(measurement: int) -> bool: return measurement > limit # the outer 'limit' was captured return exceeds over_twenty = limit_checker(20) over_ten = limit_checker(10) print(over_twenty(25), over_twenty(15)) # True False print(over_ten(15)) # True
When the call to limit_checker ends, its frame is released, but the value of limit
continues to exist: the produced function accesses it. In the terms of the previous
course, the captured value lives on the heap, not the stack, and it is not collected as
long as the accessing function exists.
Modifying the captured value from the inner function requires a separate declaration. The rule is the same as for global names: an assignment without the declaration creates a new local name instead of updating the outer one. This is a common source of errors in closures that keep a counter — the counter appears to increase, but starts from zero on every call.
Closures are a simple way of producing behavior through a parameter; the object-oriented approach meets the same idea by storing state in an object. The comparison of the two will be made in the paradigms topic.
Modules and Namespaces
Global scope is not as broad as it appears: every source file has its own namespace. A name defined in one file is not automatically visible in another; it must be imported.
Importing establishes a new binding to the name; there are two forms, and their effect on readability differs:
import math # access through the module name print(math.sqrt(16)) # 4.0 from math import sqrt # bring the name directly into our own namespace print(sqrt(16)) # 4.0
In the first form, where sqrt comes from is visible in the call. In the second, the
call is shorter, but the name’s source is no longer visible, and two sources carrying
the same name can shadow each other.
Namespaces are the structural way of preventing collisions: two different modules can use the same name with different meanings. The larger-scale version is package and namespace hierarchies, detailed in the language curricula.
The rule concerning global scope applies here too: mutable state at the module level is accessible from anywhere in the file, producing the same testing and concurrency problems.
Keeping a Variable’s Lifetime Short
One practical rule summarizes this entire lesson: a name is defined in the narrowest region it needs. A variable used inside a loop is defined inside the loop; a variable belonging to a function is defined inside the function.
This rule turns scope into a design tool. Narrow scope has three benefits: fewer names for the reader to keep in mind, a bounded region where a name can change, which makes debugging easier, and a lower probability of shadowing and name collisions.
Summary
- Names bound in a function body are local and disappear along with the call frame.
- Name lookup starts at local scope, continues through enclosing and global scope, and ends with built-in names; the first one found wins.
- Shadowing makes the outer name inaccessible; its danger lies in going unnoticed.
- Writing to global state makes a function independent of its arguments and complicates testing; the value is taken as an argument and the result is returned.
- A closure is an inner function that captures a local variable from the outer function; the captured value continues to exist after the outer call ends.
- Names are defined in the narrowest region they need.
Next Step
A function calling itself creates an interesting situation on the call stack: multiple frames of the same function exist at the same time. The next lesson takes up recursion — the base case, the reduction step, and stack depth.
To keep your progress and take notes, Log in
My notes
Log in to take notes.