FreeCampus Python

Following Names Through Function Calls

Trace separate call frames and return points, follow Python’s name lookup rules, repair shadowing and assignment mistakes, and build small closures with independent state.
python-foundations functions-call-behavior scope closures
Open in Colab
  • Level: Python Foundations · Unit 5
  • Estimated time: 3.5–5 hours
  • You will learn: Follow local frames and return points, apply LEGB lookup, diagnose shadowing and UnboundLocalError, and use nonlocal state deliberately.
  • Practice in: Google Colab, JupyterLab, or a local editor

1. The same name can be safe in separate calls

This dispatch program uses message in more than one function:

def add_priority(message):
    message = f"PRIORITY: {message}"
    return message


def build_dispatch(destination, message):
    prepared = add_priority(message)
    return f"{destination} <- {prepared}"


report = build_dispatch("North Gate", "bridge closed")
assert report == "North Gate <- PRIORITY: bridge closed"

Changing message inside add_priority does not overwrite the caller’s local message. Each active call has its own frame: a workspace holding that call’s parameters and local names.

This lesson will help you answer:

  • Which frame owns a name at a particular moment?
  • Where does Python look when a name is not local?
  • Why can an assignment cause UnboundLocalError?
  • How can a returned function remember state from an enclosing call?

2. A call frame remembers where execution must return

When build_dispatch calls add_priority, the caller pauses. Python retains the caller frame and the location that should receive the returned value.

The active call is on top. Its return moves control and a value back to the suspended caller below it.

flowchart BT
  A["add_priority frame<br/>message: bridge closed"] -->|"returns prepared text"| B["build_dispatch frame<br/>destination: North Gate<br/>waiting at prepared assignment"]
  B -->|"returns report"| C["top-level caller<br/>waiting at report assignment"]

Trace the active frames:

Moment Active frame Important local state Waiting return point
1 build_dispatch destination, message top-level report = ...
2 add_priority its own message caller’s prepared = ...
3 build_dispatch new prepared value top-level assignment
4 top level new report value none

After a function returns, its ordinary local frame is removed. A later call gets a fresh frame.

3. Repeated calls receive independent local state

def count_words(text):
    count = len(text.split())
    return count


first = count_words("red moon")
second = count_words("quiet silver river")

assert first == 2
assert second == 3

The first call’s local text and count do not become inputs to the second. Identical parameter names describe roles within a call, not one shared storage box across all calls.

Recursion creates several frames for the same function at once. Lesson 6 will trace that special case. The rule remains the same: each active call owns its own parameters and locals.

4. Python looks for names through LEGB

When Python evaluates a name, it searches outward:

  1. Local — the current function call;
  2. Enclosing — any active enclosing function scope captured by a nested function;
  3. Global — the module or notebook cell namespace;
  4. Built-in — names such as len, sum, and print.
THEME = "moon"  # global


def make_label(prefix):  # prefix belongs to an enclosing function scope
    separator = ": "

    def label(name):  # name is local to this call
        return prefix + separator + name + " / " + THEME.upper()

    return label


warning_label = make_label("Warning")
assert warning_label("North Gate") == "Warning: North Gate / MOON"

Inside label, Python finds name locally, prefix and separator in the enclosing scope, THEME globally, and str.upper through the value’s type. A separate built-in example is len(name); len is found in built-ins when no closer scope defines it.

“Search outward” applies to reading a name. Assignment has an additional rule that we will make visible shortly.

5. Constants can be global; changing dependencies are clearer as inputs

A stable configuration constant is readable in uppercase:

MAX_LABEL_LENGTH = 20


def label_fits(text):
    return len(text) <= MAX_LABEL_LENGTH

A threshold that changes per dispatch is better passed explicitly:

def label_fits(text, max_length=20):
    return len(text) <= max_length


assert label_fits("North Gate") is True
assert label_fits("North Gate", max_length=5) is False

The second signature exposes the dependency and lets two callers use different rules without changing shared global state.

Checkpoint: call frames and lookup

6. Shadowing hides an outer name

A local name can temporarily hide a global name:

signal = "global signal"


def inspect_signal():
    signal = "local signal"
    return signal


assert inspect_signal() == "local signal"
assert signal == "global signal"

This is legal. It becomes confusing when the hidden name is needed. Shadowing a built-in is especially inconvenient:

numbers = [2, 3, 5]
sum = 10

# total = sum(numbers)

If the commented line runs, Python tries to call integer 10, not built-in sum. Rename the local value and remove the shadowing binding:

del sum
recorded_total = 10
calculated_total = sum(numbers)

assert recorded_total == calculated_total

Other common names to protect include list, str, min, max, and input. The problem is not a special prohibition; it is ordinary nearest-scope lookup finding the wrong object first.

7. Assignment normally makes a name local to the whole function

This code looks as though it reads a global counter and then updates it:

attempts = 3


def add_attempt():
    # attempts = attempts + 1
    return "uncomment the assignment to observe UnboundLocalError"

If attempts = attempts + 1 is uncommented and the function is called, Python raises UnboundLocalError. Because the body assigns to attempts, Python treats that name as local throughout the function. The right side then tries to read the local value before that local value has been assigned.

The order of the lines does not turn the earlier read into a global read. Local scope is determined when the function is compiled.

Prefer explicit data flow:

def increment_attempts(attempts):
    return attempts + 1


attempts = increment_attempts(attempts)
assert attempts == 4

The function can now be understood from its input and result.

8. global is real syntax, but it creates shared changing state

Python can explicitly rebind a global:

signal_count = 0


def record_signal():
    global signal_count
    signal_count += 1


record_signal()
assert signal_count == 1

This works. It also means any call may change state used elsewhere, so the function’s behavior depends on call history. A parameter and return value are usually easier for ordinary calculations. global is not forbidden; it is a strong coupling choice that should be visible in the contract.

9. A closure remembers an enclosing value

A nested function may survive after its enclosing call has returned:

def make_channel_label(channel):
    prefix = f"Channel {channel}"

    def label(message):
        return f"{prefix}: {message}"

    return label


north_label = make_channel_label("North")
south_label = make_channel_label("South")

assert north_label("ready") == "Channel North: ready"
assert south_label("ready") == "Channel South: ready"

label is returned as a function object. Each returned function retains access to the enclosing prefix created by its own make_channel_label call. That retained relationship is a closure.

Definition and call times matter:

  1. Calling make_channel_label("North") creates prefix and defines label.
  2. Returning label does not call it.
  3. Calling north_label("ready") later creates a new local frame for message.
  4. Name lookup finds remembered prefix in the enclosing scope.

Checkpoint: shadowing and assignment

10. nonlocal updates remembered enclosing state

Without nonlocal, assigning to count would create a new local name in record. Declare that the assignment targets the enclosing binding:

def make_counter(label):
    count = 0

    def record():
        nonlocal count
        count += 1
        return f"{label}: {count}"

    return record


north_count = make_counter("North")
south_count = make_counter("South")

assert north_count() == "North: 1"
assert north_count() == "North: 2"
assert south_count() == "South: 1"
assert north_count() == "North: 3"

The two closures have independent state because two calls to make_counter created two enclosing count bindings. State persists between calls to one returned closure, but it is not one global counter shared by every instance.

Use closure state when it makes a small behavior cohesive. If callers need to inspect and coordinate many pieces of state, Unit 11’s objects may offer a more explicit design.

11. Closures created in a loop can share the final loop value

Names in closures are looked up when the returned function runs, not copied automatically on every loop iteration:

def make_bad_labels():
    labels = []
    for channel in ["North", "South"]:
        labels.append(lambda: channel)
    return labels


bad_labels = make_bad_labels()
assert [label() for label in bad_labels] == ["South", "South"]

Both functions close over the same channel binding. After the loop, its value is "South". A beginner-readable repair creates a new enclosing binding through a factory call:

def make_fixed_label(channel):
    def label():
        return channel

    return label


labels = []
for channel in ["North", "South"]:
    labels.append(make_fixed_label(channel))

assert [label() for label in labels] == ["North", "South"]

You may also encounter a default-argument capture, but the named factory makes the new binding and its timing easier to explain.

12. Lab: trace a dispatch center

Complete the pipeline and counter factory:

def normalize_message(message):
    """Return message stripped and converted to sentence case."""
    raise NotImplementedError


def route_message(channel, message, *, prefix="Dispatch"):
    """Return one routed message using explicit configuration."""
    raise NotImplementedError


def build_dispatch(channel, message, *, prefix="Dispatch"):
    """Normalize and route one message."""
    raise NotImplementedError


def make_dispatch_counter(channel):
    """Return a function that records and reports this channel's call count."""
    raise NotImplementedError

Use the assertions as call-frame evidence:

assert normalize_message("  BRIDGE CLOSED  ") == "Bridge closed"
assert route_message("North", "Bridge closed") == (
    "Dispatch North: Bridge closed"
)
assert build_dispatch("North", "  BRIDGE CLOSED  ") == (
    "Dispatch North: Bridge closed"
)
assert build_dispatch("South", "clear", prefix="Moon Base") == (
    "Moon Base South: Clear"
)

north = make_dispatch_counter("North")
south = make_dispatch_counter("South")
assert north() == "North dispatch 1"
assert north() == "North dispatch 2"
assert south() == "South dispatch 1"
assert north() == "North dispatch 3"

Before implementing, draw the stack while build_dispatch waits for normalize_message. After it returns, mark which frame owns clean_message.

Hint: distinguish the pipeline’s locals from remembered counter state

build_dispatch keeps clean_message only for that call and passes it to route_message. The factory creates count once; its nested function declares nonlocal count before incrementing it.

Show one complete solution after attempting the lab
def normalize_message(message):
    """Return message stripped and converted to sentence case."""
    return message.strip().capitalize()


def route_message(channel, message, *, prefix="Dispatch"):
    """Return one routed message using explicit configuration."""
    return f"{prefix} {channel}: {message}"


def build_dispatch(channel, message, *, prefix="Dispatch"):
    """Normalize and route one message."""
    clean_message = normalize_message(message)
    return route_message(channel, clean_message, prefix=prefix)


def make_dispatch_counter(channel):
    """Return a function that records and reports this channel's call count."""
    count = 0

    def record():
        nonlocal count
        count += 1
        return f"{channel} dispatch {count}"

    return record

Checkpoint: closures and independent state

13. Explain the frames before moving on

Use your lab to answer:

  1. At what line does build_dispatch pause during its helper call?
  2. Which names are local to route_message?
  3. Why is prefix explicit instead of read from changing global state?
  4. When is each counter’s enclosing count created?
  5. What observable evidence proves the counters are independent?

Restart the notebook and call north() once. A clean restart creates the factory and its state again; it should report 1, not remember an earlier run.

Key points

TipKey points
  • Each active function call has a separate frame containing parameters, local names, and a return point.
  • Python reads names through local, enclosing, global, and built-in scopes.
  • Shadowing makes the nearest binding win, even when it hides a needed built-in.
  • Assignment normally makes a name local throughout a function; reading it first can raise UnboundLocalError.
  • global rebinds module state; explicit parameters and returns usually reveal changing dependencies more clearly.
  • A closure retains access to enclosing bindings, and nonlocal can update one deliberately.
  • Each factory call can create independent closure state.

References

Back to top