# Variables and Scope

## 1. Names Are Bindings, Not Boxes

A Python "variable" is not a typed memory slot — it is a name bound to an object in some namespace. Assignment binds (or rebinds) a name; it never copies the object.

```python
x = [1, 2, 3]
y = x          # y is bound to the SAME list object as x
y.append(4)
print(x)       # [1, 2, 3, 4] — x sees the mutation because there's only ONE list
```

## 2. The LEGB Rule

Name resolution looks through four scopes, in this order: **L**ocal, **E**nclosing, **G**lobal, **B**uilt-in.

```python
x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)   # "local" — found in Local scope first

    inner()
    print(x)       # "enclosing" — outer's own local

print(x)           # "global"
print(len)         # found in Builtin scope — never shadowed here
```

| Scope | Where | Example |
|---|---|---|
| Local | current function body | a variable assigned inside `inner()` |
| Enclosing | any enclosing function (not module level) | `x` in `outer()` as seen from `inner()` |
| Global | module top level | `x` defined outside any function |
| Built-in | the `builtins` module | `len`, `print`, `Exception` |

## 3. Assignment Makes a Name Local — Even Before the Assignment Line Runs

Python decides whether a name is local to a function **at compile time**, by scanning the whole function body for assignment targets — not by tracing execution order.

```python
x = "global"

def broken():
    print(x)   # UnboundLocalError, NOT "global"!
    x = "local"

broken()
```

Because `x = "local"` appears anywhere in `broken`'s body, the compiler marks `x` as local to the *entire* function. The `print(x)` on the line before the assignment tries to read that local before it has a value, raising `UnboundLocalError` — Python never silently "falls back" to the global `x` mid-function.

## 4. `global` and `nonlocal`

```python
counter = 0

def increment():
    global counter
    counter += 1     # without `global`, this would be UnboundLocalError

def make_counter():
    count = 0
    def increment():
        nonlocal count   # binds to make_counter's `count`, not module global
        count += 1
        return count
    return increment
```

`global` says "this name refers to the module's global scope, don't make it local." `nonlocal` says "this name refers to the nearest enclosing function scope, not local and not global." Both exist because *reading* an outer-scope variable works automatically (LEGB lookup), but *assigning* to a name always makes it local unless you explicitly opt out.

## 5. Mutating vs Rebinding — a Frequent Source of Confusion

```python
def append_item(lst, item):
    lst.append(item)   # mutation — no `global`/`nonlocal` needed, no new binding

def replace_list(lst):
    lst = []            # rebinding — only changes the LOCAL name `lst`,
                         # the caller's list is untouched
```

`append_item` works on the caller's actual list because `.append()` mutates the object both names point to. `replace_list` does *not* affect the caller — it just points the local name `lst` at a brand-new empty list, leaving the original object (and the caller's reference to it) unchanged.

## 6. Comprehensions Have Their Own Scope

```python
x = "outer"
squares = [x for x in range(5)]
print(x)        # "outer" — the comprehension's `x` did NOT leak out (Python 3)
print(squares)  # [0, 1, 4, 9, 16]
```

Unlike a plain `for` loop (whose loop variable *does* leak into the enclosing scope), list/set/dict comprehensions and generator expressions run in their own implicit function scope in Python 3.

## 7. Under the Hood: How Scope Becomes Bytecode

### The compiler decides the opcode per name, per scope, ahead of time

CPython's compiler performs static analysis of every function body before generating bytecode, classifying each name as local, cell (captured by/from a nested scope), global, or unbound. This produces different opcodes for what looks like "the same" variable access:

```python
def f():
    x = 1
    return x
```

compiles `x` accesses to `LOAD_FAST` / `STORE_FAST` — direct, fixed-index slots in a fast local array on the frame, no dictionary lookup at all. Compare to:

```python
y = 1
def g():
    return y
```

where `y` inside `g` compiles to `LOAD_GLOBAL`, which looks `y` up in the module's `__dict__` (and falls back to `builtins.__dict__` if not found there).

### Local variables live in a fixed-size array, not a dict

A function's local variables are **not** stored in a dictionary at runtime. The compiler assigns each local name a fixed integer slot (visible as `co_varnames` on the function's code object), and the frame holds a plain array of `PyObject*` pointers indexed by slot. This is precisely why local variable access is one of the fastest operations in CPython — `LOAD_FAST` is an array index, not a hash lookup — while `LOAD_GLOBAL` and `LOAD_NAME` (used at module/class scope) genuinely hash into a `dict`.

```python
def f(a, b):
    c = a + b
    return c

print(f.__code__.co_varnames)  # ('a', 'b', 'c') — fixed slot order
```

### Closures use cell objects, not a shared frame

When a name is assigned in an enclosing function *and* referenced by a nested function, the compiler can't use a plain fast-local slot for it — the nested function needs to keep reading and writing it after the enclosing call returns. Instead, that variable is stored in a heap-allocated **cell object**, and both functions access it through `LOAD_DEREF` / `STORE_DEREF` instead of `LOAD_FAST`.

```python
def make_counter():
    count = 0          # captured by inner() -> becomes a cell, not a fast local
    def inner():
        nonlocal count
        count += 1
        return count
    return inner

print(make_counter.__code__.co_cellvars)  # ('count',)
```

![count lives in a heap cell object; make_counter's frame and inner's function object both reference it through LOAD_DEREF / STORE_DEREF](diagrams/01-cell-object.svg)

### Global and module scope are just a `dict`

A module's namespace, accessible via `globals()`, genuinely is a plain `dict` — assigning a module-level variable is a real dictionary insertion, and `LOAD_GLOBAL` is a real hash lookup (with a small-cache optimization in modern CPython to speed up repeated lookups of the same name). This is why `globals()['x'] = 5` and `x = 5` at module level are equivalent — one is just the explicit dict-mutation view of the other.

## 8. Common Pitfalls

1. **`UnboundLocalError` from assigning anywhere in a function** — if a name is assigned *anywhere* in a function body, it's local for the *whole* function, even on lines that execute before the assignment. Add `global`/`nonlocal` if you meant to reuse an outer name.
2. **Mutable default arguments interacting with scope** — a `def f(items=[])` default is created once at function-definition time and reused across calls; see the Functions topic for the full trap.
3. **Assuming `for` loop variables are scoped to the loop** — they are not; `for i in range(3): pass` leaves `i` bound at `2` in the enclosing scope after the loop. Comprehensions, unlike loops, don't have this leak.
4. **Forgetting `nonlocal` when mutating an enclosing count/flag** — without it, `count += 1` inside a nested function raises `UnboundLocalError`, because `+=` is a read-then-assign, and the assignment half makes `count` local.

## 9. Quick Reference

```python
global x          # inside a function: bind assignments to module scope
nonlocal x        # inside a nested function: bind assignments to enclosing function scope
globals()         # dict of the module's global namespace
locals()           # snapshot dict of current local namespace (don't mutate to affect state)
f.__code__.co_varnames  # fast-local names, in slot order
f.__code__.co_cellvars  # names captured as cells (closed over by nested functions)
```
