# Functions and Scope

### 1. Strategic Overview

**Functions and Scope** define where names (variables, functions, constants) are visible and how they are resolved during execution. Together, they form the core of Python’s execution model:

* Functions create **scope boundaries**
* Scope determines **where a name can be read or written**
* Name resolution follows a deterministic search order

In production systems, misunderstanding scope leads to:

* Subtle bugs
* Leaky state
* Hard-to-debug side effects
* Unexpected interactions between modules

> Functions encapsulate behavior; scope encapsulates *where* names live and *how* they are resolved.

***

### 2. Enterprise Significance

Poor scope handling results in:

* Accidental dependence on globals
* Name collisions across modules
* Hidden mutations and side effects
* Non-deterministic behavior during refactors

Disciplined use of functions and scope enables:

* Predictable behavior boundaries
* Safer refactoring of large codebases
* Clear ownership of state
* Easy reasoning about variable lifetimes
* Clean API and module design

***

### 3. The LEGB Rule: Name Resolution Order

Python resolves names according to the **LEGB rule**:

1. **L – Local**: Inside the current function (or comprehension)
2. **E – Enclosing**: In any enclosing function scopes (for nested functions)
3. **G – Global**: At module level
4. **B – Built-in**: Python’s built-in namespace (`len`, `print`, etc.)

When you reference a name, Python searches in that order.

Example:

```python
x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)

    inner()

outer()  # prints "local"
```

***

### 4. Function Scope: Local Namespace

A function call creates a **local scope**:

```python
def process_order(order_id):
    status = "PENDING"  # Local to process_order
    print(order_id, status)
```

Properties:

* Names defined inside a function are not visible outside it.
* They live for the duration of the function call.
* Different calls get independent local scopes.

This is critical for:

* Avoiding cross-request contamination in web apps
* Keeping logic isolated per call

***

### 5. Global Scope: Module Namespace

Names at the **top level of a module** live in the module’s global scope:

```python
TIMEOUT_SECONDS = 30  # Global (module-level)

def connect():
    print(TIMEOUT_SECONDS)
```

Key points:

* “Global” in Python means **“global to the module”**, not global across the entire program.
* Each module has its own global namespace.
* Other modules see your globals through imports, not directly.

***

### 6. Built-in Scope

The **built-in scope** is provided by Python and includes:

* Core functions: `len`, `print`, `sum`, `min`, `max`, etc.
* Core exceptions: `ValueError`, `TypeError`, etc.

You can see them via:

```python
import builtins
dir(builtins)
```

Best practice:

* Avoid defining your own variables with the same names as built-ins (`list`, `str`, `id`, `len`), or you’ll shadow them.

***

### 7. Enclosing (Nonlocal) Scope and Nested Functions

**Nested functions** introduce **enclosing scopes**:

```python
def outer():
    message = "outer"

    def inner():
        print(message)  # message from enclosing scope

    inner()
```

The `inner` function can read `message` from `outer`’s scope (E in LEGB).

This is the foundation of:

* Closures
* Decorators
* Factory functions
* Function-based configuration

***

### 8. Closures: Functions Capturing Scope

A **closure** is a function that “remembers” variables from its **enclosing scope**, even after that scope has finished executing.

Example:

```python
def multiplier(factor):
    def inner(x):
        return x * factor  # factor captured from enclosing scope
    return inner

double = multiplier(2)
print(double(10))  # 20
```

Key properties:

* `inner` retains access to `factor` after `multiplier` returns.
* Captured variables are stored in the function’s `__closure__`.

This is crucial for:

* Building configurable, reusable behavior
* Implementing decorators
* Encapsulating configuration/state without classes

***

### 9. Reading vs Writing Variables: Scope Rules

**Reading** a name:

* Python follows LEGB search order.

**Writing/assigning** to a name inside a function:

* By default, assignment creates or updates a **local** variable.

Example:

```python
x = 10

def func():
    x = 20  # This creates a local x, does NOT change global x

func()
print(x)  # Still 10
```

To write to **global** or **enclosing** variables, you must explicitly declare intent (`global`, `nonlocal`).

***

### 10. `global` Keyword: Modifying Module-Level State

```python
counter = 0  # Global (module-level)

def increment():
    global counter
    counter += 1
```

Behavior:

* `global counter` tells Python that `counter` refers to the module-level variable.
* Assignments go to the global scope, not a local variable.

Use cases:

* Module-level caches
* Singletons or shared counters
* Rare: performance-critical global registries

Cautions:

* Globals introduce tight coupling.
* They make testing and refactoring harder.
* In multi-threaded contexts, they raise concurrency issues.

***

### 11. `nonlocal` Keyword: Modifying Enclosing Scope

`nonlocal` allows writes to variables in **enclosing (non-global) scopes**:

```python
def outer():
    count = 0

    def inner():
        nonlocal count
        count += 1
        return count

    print(inner())  # 1
    print(inner())  # 2
```

Key points:

* `nonlocal` targets variables in nearest enclosing function scope.
* It does not touch globals or built-ins.

Use cases:

* Closures that maintain state across calls
* Stateful decorators
* Small, function-scoped state machines

Use with care; too much `nonlocal` can make flows harder to reason about.

***

### 12. Shadowing: Name Collisions Across Scopes

Shadowing occurs when an inner scope declares a variable with the same name as an outer scope variable.

Example:

```python
value = "global"

def func():
    value = "local"  # Shadows global
    print(value)

func()       # local
print(value) # global
```

Risks:

* Confusing behavior if developers expect the outer variable to change.
* Hard-to-debug bugs when shadowed names accidentally hide globals or built-ins.

Best practice:

* Choose descriptive names.
* Avoid reusing names from outer scopes unless intentional and obvious.

***

### 13. Scope and Default Parameter Values

Default arguments are **evaluated once at function definition time**, in the **defining scope**, not call-time.

```python
DEFAULT_TIMEOUT = 10

def connect(timeout=DEFAULT_TIMEOUT):
    return timeout
```

Important implications:

* Default captures value of `DEFAULT_TIMEOUT` at definition time.
* Later changes to `DEFAULT_TIMEOUT` do not affect existing default.

Anti-pattern (mutable default):

```python
def add_item(item, items=[]):  # BAD
    items.append(item)
    return items
```

Fix:

```python
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items
```

***

### 14. Scope in Comprehensions (Python 3+)

In Python 3, variables defined in **list/set/dict comprehensions** are **local to the comprehension**, not leaked into the surrounding scope:

```python
squares = [x * x for x in range(5)]

print("x" in globals())  # False in Python 3
```

Generator expressions also use their own scope.

This reduces accidental pollution of surrounding namespaces.

***

### 15. Scope and Lambdas

Lambdas, like nested functions, close over variables from enclosing scope:

```python
funcs = []
for i in range(3):
    funcs.append(lambda: i)

print([f() for f in funcs])  # [2, 2, 2], not [0, 1, 2]
```

Why?

* Lambdas capture the variable `i`, not its value.
* By the time they execute, `i` is `2`.

Fix using default argument capture:

```python
funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)

print([f() for f in funcs])  # [0, 1, 2]
```

This is a subtle but critical scope behavior in real systems.

***

### 16. Scope and Modules: Import Patterns

Modules themselves are scopes. Import style affects visibility and maintainability.

```python
# a.py
VALUE = 10

# b.py
from a import VALUE
import a
```

In `b.py`:

* `VALUE` refers to the imported name.
* `a.VALUE` refers to the attribute on module `a`.

Good practice:

* Prefer `import module` over `from module import *`.
* Avoid polluting global namespace with star imports.

```python
from a import *  # BAD in production
```

Star imports:

* Obscure where names come from
* Increase risk of name collisions
* Break tools and static analysis

***

### 17. Functions as Scope Boundaries for Refactoring

Strategic use of functions to **introduce new scopes**:

Before:

```python
# 100 lines of script-level code with many globals
```

After:

```python
def main():
    # move logic into function
    ...

if __name__ == "__main__":
    main()
```

Benefits:

* Localizes variables to `main` instead of module globals.
* Improves testability (you can call `main()` from tests).
* Reduces risk of accidental state leakage between imports and runs.

***

### 18. Scope and Testing: Isolating State

Tests benefit from **local, controlled scopes**:

* Avoid test-dependent globals.
* Avoid singletons relying on module-level state unless carefully managed.
* Use functions and classes to encapsulate state.

Pattern:

```python
def build_service(config):
    # use config scoped within this function
    return Service(config)
```

Test multiple configurations without cross-test contamination.

***

### 19. Scope-Aware API Design

When designing functions and modules:

* Minimize reliance on **global** or module-level mutable state.
* Prefer explicit parameters over hidden dependencies.
* If you must use global configuration, encapsulate access:

```python
_config = {}

def set_config(cfg):
    global _config
    _config = cfg

def get_config():
    return _config
```

Better yet: pass config explicitly to functions and objects.

***

### 20. Scope in Decorators

Decorators rely heavily on nested functions and closures:

```python
def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper
```

Here:

* `wrapper` sees `func` via enclosing scope.
* `func` is captured as part of the closure.

Use `nonlocal` if decorator needs to maintain mutable state across calls:

```python
def count_calls(func):
    count = 0

    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        print(f"{func.__name__} called {count} times")
        return func(*args, **kwargs)

    return wrapper
```

Understanding closures and `nonlocal` is essential for robust decorator design.

***

### 21. Common Scope Anti-Patterns

| Anti-Pattern                                  | Risk/Impact                                     |
| --------------------------------------------- | ----------------------------------------------- |
| Overuse of `global` for shared state          | Tight coupling, unpredictable behavior          |
| Hidden dependencies on module-level variables | Hard-to-test, fragile refactors                 |
| Name shadowing of built-ins (e.g., `list`)    | Confusing errors, broken expectations           |
| Complex nested closures with `nonlocal` webs  | Hard-to-reason-about state, maintenance hazards |
| Relying on default scope leaks (Py2 habits)   | Wrong mental model, subtle bugs in Py3          |

***

### 22. Governance Model: Functions + Scope

You can think of functions and scope governance as:

```
Intent → Function Boundary → Scope Definition → Name Resolution (LEGB)
      → State Ownership (local/enclosing/global) → Mutation Rules (local/global/nonlocal)
      → Testability & Refactorability
```

Every functions+scope decision should be:

* Explicit about where state lives
* Intentional about where names are read/written
* Designed for long-term maintainability

***

### 23. Summary

**Functions and Scope** are core to how Python executes and organizes code:

* Functions define **behavior boundaries** and introduce **local scopes**.
* Name resolution follows **LEGB** (Local → Enclosing → Global → Built-in).
* `global` and `nonlocal` allow you to step outside local scope but must be used judiciously.
* Closures and nested scopes power decorators, factories, and configurable behavior.
* Proper scope discipline reduces bugs, clarifies ownership, and scales to large codebases.

In enterprise-grade Python, understanding and governing scope is not academic—it is a practical necessity for reliability, testability, and safe evolution of systems.

***
