Functions, closures, and scoping
Positional-only and keyword-only args, defaults, closures, and LEGB scope in one mental model.
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
- Use every kind of argument correctly.
- Apply type hints to function signatures.
- Use closures and
nonlocalcorrectly. - Pick between named functions and
lambda. - Use
functoolsessentials (partial,lru_cache,reduce,wraps).
1. Anatomy
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)
def f(pos_only, /, normal, *, kw_only, **kwargs): ...
def f(a, b, c=10, *args, d, e=20, **kwargs): ...| Marker | Meaning |
|---|---|
/ (3.8+) | Everything before is positional-only |
*args | Variadic positional โ tuple |
* alone | Everything after is keyword-only |
**kwargs | Variadic keyword โ dict |
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-onlyKeyword-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
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
def append(x, target=[]): # BUG: target is shared
target.append(x)
return target
append(1) # [1]
append(2) # [1, 2] โ surprisingThe default is evaluated once at function-definition time.
Fix:
def append(x, target=None):
if target is None:
target = []
target.append(x)
return targetHold 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.
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 incAvoid global. Pass things in / return them out.
6. Closures and first-class functions
Functions are objects. Pass them around, store them, return them.
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 cellClosures power: decorators, partial application, callbacks, lazy initialisation, memoisation.
The "late binding in for-loop" gotcha
fns = [lambda: i for i in range(3)]
[f() for f in fns] # [2, 2, 2] โ i captured by referenceFix:
fns = [lambda i=i: i for i in range(3)] # default arg binds value at definitionOr use a real factory:
def make(i):
return lambda: i
fns = [make(i) for i in range(3)]7. Lambdas
Single-expression anonymous function.
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
from functools import partial, reduce, lru_cache, cache, wraps, cached_propertypartial
Bind arguments now, call later.
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.
from functools import reduce
reduce(lambda acc, x: acc + x, [1, 2, 3, 4], 0) # 10For sums and max/min, use built-ins. Reduce shines for non-obvious folds.
lru_cache / cache
Memoise pure functions.
@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.
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.
from functools import cached_property
class Dataset:
@cached_property
def stats(self):
return expensive_compute(self.data)9. Type hints โ the signature is documentation
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
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
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))) # 1060Hands-on lab (1.5 hours)
- Write
compose(f, g)returning a functionh(x) = f(g(x)). Generalise tocomposeN(*fns). - Implement
partialfrom scratch (signature-preserving viafunctools.wraps). - Memoise the slow Fibonacci; time before/after.
- Use keyword-only arguments and a sensible signature for a
make_request(url, *, timeout=5, retries=3). - Trigger the mutable-default bug; fix it.
- Trigger the late-binding closure bug; fix three different ways.
- Bonus: write
curry(fn)such thatcurry(add)(3)(4)returns 7.
Common pitfalls
- Mutable default arguments.
- Late binding in for-loop closures.
- Using
lambdawheredefwould be clearer. - Returning multiple values as a tuple, then forgetting to unpack at the call site.
- Mutating an argument when caller doesn't expect it ("returns through arguments").
Self-check
- Order of arguments: positional-only, positional, default, *args, keyword-only, **kwargs.
- Why is
def f(x=[])dangerous? - State the LEGB rule.
- When use
partial? - Difference between
@lru_cacheand@cache.
References
- Fluent Python, Ramalho โ Chapter 7.
- Effective Python, Slatkin โ Items 19-30.
- PEP 3102 โ Keyword-only arguments.
- PEP 570 โ Positional-only parameters.
functoolsmodule docs.
Sign in to save your progress and earn badges.