Functions, closures, and scoping

Positional-only and keyword-only args, defaults, closures, and LEGB scope in one mental model.

๐Ÿ Module 1 7 min read Not started

Why this matters

Functions are the unit of reuse. Python gives you positional / keyword / default / variadic / keyword-only / positional-only arguments, plus closures and first-class callables. Senior code uses these intentionally for clean APIs.

Learning objectives

  1. Use every kind of argument correctly.
  2. Apply type hints to function signatures.
  3. Use closures and nonlocal correctly.
  4. Pick between named functions and lambda.
  5. Use functools essentials (partial, lru_cache, reduce, wraps).

1. Anatomy

python
def greet(name: str, greeting: str = "Hello") -> str:
    """Return a greeting for `name`."""
    return f"{greeting}, {name}!"
  • Type hints (name: str โ†’ str): help readers, IDEs, mypy. Not enforced at runtime.
  • Default: only one of each name; positional after default is illegal.
  • Docstring: first statement; triple-quoted; PEP 257 conventions.
  • Return: explicit or implicit None.

2. The five argument kinds (in order)

python
def f(pos_only, /, normal, *, kw_only, **kwargs): ...
def f(a, b, c=10, *args, d, e=20, **kwargs): ...
MarkerMeaning
/ (3.8+)Everything before is positional-only
*argsVariadic positional โ†’ tuple
* aloneEverything after is keyword-only
**kwargsVariadic keyword โ†’ dict
python
def make_request(
    url: str,                        # positional or keyword
    *,                               # keyword-only marker
    timeout: float = 5.0,
    headers: dict | None = None,
) -> Response:
    ...

make_request("https://example.com", timeout=10)   # ok
make_request("https://example.com", 10)           # TypeError: timeout is keyword-only

Keyword-only arguments are a public-API best practice โ€” adding new parameters won't silently rebind earlier positional arguments.

Positional-only (/) is useful for short utility functions where parameter names are implementation detail (abs(x), len(obj)).


3. *args and **kwargs

python
def trace(*args, **kwargs):
    print(args, kwargs)

trace(1, 2, key="value")
# args=(1, 2), kwargs={'key': 'value'}

# Forwarding (proxy pattern)
def wrapper(*args, **kwargs):
    log_call(args, kwargs)
    return real_function(*args, **kwargs)

# Unpacking on call
def f(a, b, c): ...
f(*[1, 2, 3])
f(**{"a": 1, "b": 2, "c": 3})

*args is a tuple; **kwargs is a dict. Names are convention โ€” *items, **opts is fine.


4. Defaults โ€” the mutable trap

python
def append(x, target=[]):              # BUG: target is shared
    target.append(x)
    return target

append(1)                              # [1]
append(2)                              # [1, 2] โ€” surprising

The default is evaluated once at function-definition time.

Fix:

python
def append(x, target=None):
    if target is None:
        target = []
    target.append(x)
    return target

Hold this rule in your head. It catches everyone exactly once.


5. Scope โ€” LEGB

(Recap from Lesson 0.2.)

  • Local: inside the function.
  • Enclosing: outer function (closure).
  • Global: module top-level.
  • Built-in: print, len, etc.
python
counter = 0

def inc():
    global counter        # required to assign to module-level
    counter += 1

def make_counter():
    n = 0
    def inc():
        nonlocal n        # required to assign to enclosing
        n += 1
        return n
    return inc

Avoid global. Pass things in / return them out.


6. Closures and first-class functions

Functions are objects. Pass them around, store them, return them.

python
def adder(x):
    def add(y):
        return x + y           # closes over x
    return add

add5 = adder(5)
add5(3)                        # 8
add5.__closure__               # contains the captured cell

Closures power: decorators, partial application, callbacks, lazy initialisation, memoisation.

The "late binding in for-loop" gotcha

python
fns = [lambda: i for i in range(3)]
[f() for f in fns]             # [2, 2, 2]   โ€” i captured by reference

Fix:

python
fns = [lambda i=i: i for i in range(3)]   # default arg binds value at definition

Or use a real factory:

python
def make(i):
    return lambda: i
fns = [make(i) for i in range(3)]

7. Lambdas

Single-expression anonymous function.

python
key = lambda r: r["age"]
records.sort(key=key)

# Or inline
records.sort(key=lambda r: r["age"])

Rules:

  • One expression only. No statements (no assignment except walrus, no return).
  • Prefer named functions for anything > a trivial expression.

ruff (rule E731) discourages f = lambda: ... โ€” use def f(): ....


8. functools โ€” essential helpers

python
from functools import partial, reduce, lru_cache, cache, wraps, cached_property

partial

Bind arguments now, call later.

python
from functools import partial
import logging

log_warn = partial(logging.log, logging.WARNING)
log_warn("disk low")

reduce

Fold a binary function over an iterable.

python
from functools import reduce
reduce(lambda acc, x: acc + x, [1, 2, 3, 4], 0)      # 10

For sums and max/min, use built-ins. Reduce shines for non-obvious folds.

lru_cache / cache

Memoise pure functions.

python
@lru_cache(maxsize=1024)
def slow_compute(x: int) -> int:
    ...

@cache       # 3.9+, unlimited
def fib(n: int) -> int:
    return n if n < 2 else fib(n-1) + fib(n-2)

Memoised fib(100) runs in microseconds. Caveat: arguments must be hashable.

wraps

When you write a decorator, preserve the wrapped function's __name__, __doc__, etc.

python
from functools import wraps

def my_decorator(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

(More on decorators in Phase 3.1.)

cached_property

A property computed once per instance.

python
from functools import cached_property

class Dataset:
    @cached_property
    def stats(self):
        return expensive_compute(self.data)

9. Type hints โ€” the signature is documentation

python
from collections.abc import Iterable, Callable

def median(xs: list[float]) -> float: ...
def map2(fn: Callable[[int], int], xs: Iterable[int]) -> list[int]:
    return [fn(x) for x in xs]

# Optional / union (3.10+)
def find(uid: int) -> User | None: ...

# Generic
from typing import TypeVar
T = TypeVar("T")
def first(xs: list[T]) -> T:
    return xs[0]

Type hints don't affect runtime behaviour. They power mypy/pyright and IDE autocompletion. Phase 3.5 covers the full typing module.


10. Function objects โ€” peek inside

python
def f(x, y=2): "compute"; return x + y

f.__name__          # "f"
f.__doc__           # "compute"
f.__defaults__      # (2,)
f.__kwdefaults__    # for keyword-only
f.__code__.co_varnames
f.__annotations__   # type hints dict
import inspect
inspect.signature(f)

inspect is your tool for introspection (used in test fixtures, dependency injection, plugin systems).


11. Worked example

python
from collections.abc import Iterable
from functools import lru_cache

@lru_cache
def is_prime(n: int) -> bool:
    if n < 2:
        return False
    if n < 4:
        return True
    if n % 2 == 0:
        return False
    return all(n % i for i in range(3, int(n ** 0.5) + 1, 2))

def primes_up_to(n: int) -> Iterable[int]:
    return (i for i in range(2, n + 1) if is_prime(i))

print(sum(primes_up_to(100)))      # 1060

Hands-on lab (1.5 hours)

  1. Write compose(f, g) returning a function h(x) = f(g(x)). Generalise to composeN(*fns).
  2. Implement partial from scratch (signature-preserving via functools.wraps).
  3. Memoise the slow Fibonacci; time before/after.
  4. Use keyword-only arguments and a sensible signature for a make_request(url, *, timeout=5, retries=3).
  5. Trigger the mutable-default bug; fix it.
  6. Trigger the late-binding closure bug; fix three different ways.
  7. Bonus: write curry(fn) such that curry(add)(3)(4) returns 7.

Common pitfalls

  1. Mutable default arguments.
  2. Late binding in for-loop closures.
  3. Using lambda where def would be clearer.
  4. Returning multiple values as a tuple, then forgetting to unpack at the call site.
  5. Mutating an argument when caller doesn't expect it ("returns through arguments").

Self-check

  1. Order of arguments: positional-only, positional, default, *args, keyword-only, **kwargs.
  2. Why is def f(x=[]) dangerous?
  3. State the LEGB rule.
  4. When use partial?
  5. Difference between @lru_cache and @cache.

References

  • Fluent Python, Ramalho โ€” Chapter 7.
  • Effective Python, Slatkin โ€” Items 19-30.
  • PEP 3102 โ€” Keyword-only arguments.
  • PEP 570 โ€” Positional-only parameters.
  • functools module docs.

Sign in to save your progress and earn badges.