Functions — arguments, scope, closures
Understand five parameter kinds, name binding, closure state, decorators and bounded retry and memoization contracts.
Original publication date unrecordedUpdated
Lesson preparation & details
Level: beginner
By the end, you should be able to
- Defaults evaluate once, closures resolve free variables through cells, and default-value capture stores a value rather than creating a closure cell
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 22 sections
Review boundary and corrected contracts
Defaults evaluate once, closures resolve free variables through cells, and default-value capture stores a value rather than creating a closure cell. Decorator timing is a local observation only. Rest arguments and positional-/keyword-only boundaries are preserved.
The examples below teach the stated input contracts; fragments and deliberately incorrect counterexamples are exercises, not production implementations. Complexity counts elementary operations unless a bit-cost or allocation caveat is stated. Practice times are suggestions, not measured performance or hiring guarantees.
Learning objective: Write functions that read well, argue clearly, and don't have hidden state — plus read anyone else's function and predict what it will do.
Why this session exists
Functions look simple until they don't. Argument binding, scope and defaults interact in ways worth tracing explicitly. And decorators — the thing that lets @app.route and @lru_cache exist — are built on callable transformations of decorated objects. This session develops those mechanisms with runnable examples and failure cases.
You will be able to
-
Design a function signature with the right mix of positional / keyword-only / defaults / *args / **kwargs on purpose.
-
Trace the LEGB name-resolution rule for any nested function without running the code.
-
Recognise a closure, know when Python captures by reference (surprise!), and fix the late-binding bug.
-
Write your own decorator from scratch, including one that takes arguments.
-
Predict what
f(*args, **kwargs)will bind to, given a specific call site.
Prerequisites
- S005 — Python Variables & Types — the memory model is essential.
- S006 — Control Flow — you'll use branches inside functions.
Pre-read · skim before the session (30 min total)
Further reading: Python Tutorial · Defining Functions
Further reading: Primer on Python Decorators
Further reading: PEP 3102 — Keyword-Only Arguments
How to use the readings. Read defining functions through function annotations, then trace one wrapped call and one decorator factory. PEP 3102 explains the keyword-only binding contract; the secondary decorator tutorial is optional practice.
Optional video pointers carried forward from the original lesson. These URLs and inherited titles are navigation aids only; video identity, duration and contents were not reverified in this review. The complete mechanisms and worked answers are in this lesson and the official sources below.
- Programming Terms: First-Class Functions
- Reuven M. Lerner - Practical decorators - PyCon 2019
- Python Tutorial for Beginners 8: Functions
(a) Intuition · 5 min
A function is a contract, not a code block
Real: A restaurant menu item is a contract: ‘give me a burger and $12, get back a burger with fries.’ The kitchen (implementation) can change — same beef, different sauce — but the contract with the customer is stable. A function is exactly that. The signature is the contract; the body is the kitchen. A good signature (clear names, sensible defaults, no surprise inputs) lets you swap the body a hundred times without breaking anyone.
Formally: a function is a first-class object with a __call__. It has parameters and optional annotations, a new local namespace per call, access to globals and any retained enclosing bindings, and executable code. You can pass it around, store it in dicts, return it from other functions.
That last property is what makes closures, decorators, callbacks, and higher-order functional programming possible in Python — the same feature.
The four argument shapes
Four teaching groups; five formal parameter kinds
The last group contains two separate kinds: variadic positional and variadic keyword parameters. A default is a value associated with a parameter, not a sixth kind.
-
Positional-only — before
/in the signature. A keyword cannot supply that parameter. If the signature also accepts **kwargs, the same spelling can appear there as an unrelated keyword; request("x", url="extra") demonstrates the distinction. -
Positional-or-keyword — the default. Can be passed either way.
-
Keyword-only — after
*or*argsin the signature. Callers MUST use the name. Great for boolean flags. -
Var-args (
*args,**kwargs) — arbitrary numbers of positional/keyword args. Use sparingly; explicit signatures beat implicit ones.
The famous late-binding closure bug
late_funcs = [lambda: i for i in range(3)]
assert [f() for f in late_funcs] == [2, 2, 2]
early_funcs = [lambda i=i: i for i in range(3)]
assert [f() for f in early_funcs] == [0, 1, 2]
assert late_funcs[0].__closure__[0] is late_funcs[1].__closure__[0]
assert early_funcs[0].__closure__ is NoneThe first functions share the comprehension’s i binding and read it when called. The second functions store separate default argument references at definition time. A module-level for-loop callback instead reads a global i, not a closure cell. Neither technique deep-copies a mutable captured object.
A quick history so you know the shape
Python’s public history begins in 1991. Decorator syntax shipped in Python 2.4 in 2004 (PEP 318), not 2001. Keyword-only parameters shipped in Python 3.0 in 2008 (PEP 3102), distinct from the PEP’s proposal year. PEP 484 standardized type hints for Python 3.5 in 2015. User-defined positional-only parameters using / arrived in Python 3.8 in 2019 (PEP 570), not 2020. These additions make contracts more expressive without making annotations enforce types automatically.
(b) Reading function contracts and resolving names · 15 min
The full function signature grammar
Python’s signature features let an API constrain how callers supply values. Read the signature from left to right, watching how the slash and star change the rules for the parameters around them.
Figure text equivalent — labels and relationships
- Node “def f(” (square).
- Node “positional-only a, b” (square).
- Node “/” (square).
- Node “positional-or-keyword c=1, d=2” (square).
- Node “*args” (square).
- Node “keyword-only e=None, f=True” (square).
- Node “**kwargs” (square).
- Node “)” (square).
- “def f(” leads to “positional-only a, b”. Line: normal; ends: arrow point.
- “positional-only a, b” leads to “/”. Line: normal; ends: arrow point.
- “/” leads to “positional-or-keyword c=1, d=2”. Line: normal; ends: arrow point.
- “positional-or-keyword c=1, d=2” leads to “*args”. Line: normal; ends: arrow point.
- “*args” leads to “keyword-only e=None, f=True”. Line: normal; ends: arrow point.
- “keyword-only e=None, f=True” leads to “**kwargs”. Line: normal; ends: arrow point.
- “**kwargs” leads to “)”. Line: normal; ends: arrow point.
The separators define the calling contract rather than steps the function executes. In the request example below, that means the URL must be positional while the timeout must be named.
A complete local formatter example (it performs no network request):
def request(url, /, method="GET", *headers, timeout=30, **params):
return f"{method} {url} timeout={timeout} headers={headers} params={params}"
# url is positional-only
# method is positional-or-keyword with a default
# *headers gathers any extra positional args into a tuple
# timeout is keyword-only (because it comes after *headers)
# **params gathers any extra keyword args into a dictThe LEGB scope resolution rule
Once the arguments have been bound, the function body still needs to resolve the names it reads. The next diagram changes perspective from the caller’s contract to the lookup order inside the function.
Figure text equivalent — labels and relationships
- Node “Classified as local in ordinary function code?” (diamond).
- Node “Read local binding” (square).
- Node “UnboundLocalError no global fallback” (square).
- Node “Enclosing function bindings if applicable” (square).
- Node “Defining module globals” (square).
- Node “Built-ins if global absent” (square).
- “Classified as local in ordinary function code?” leads to “Read local binding”: yes. Line: normal; ends: arrow point.
- “Read local binding” leads to “UnboundLocalError no global fallback”: not yet bound. Line: normal; ends: arrow point.
- “Classified as local in ordinary function code?” leads to “Enclosing function bindings if applicable”: no; lexical binding rules. Line: normal; ends: arrow point.
- “Enclosing function bindings if applicable” leads to “Defining module globals”. Line: normal; ends: arrow point.
- “Defining module globals” leads to “Built-ins if global absent”. Line: normal; ends: arrow point.
For ordinary function code, LEGB describes the relevant scopes, but Python first determines whether a name is local from bindings anywhere in the function. Reading such a local before it is assigned raises UnboundLocalError; lookup does not continue to a same-named global. nonlocal requires a binding in an enclosing function scope; global refers to the defining module, not every module in the process. Class bodies and Python 3.12 annotation scopes have additional rules, so LEGB is not a universal description of every Python lookup.
scope_value = "global"
def unbound_example():
result = scope_value
scope_value = "local"
return result
try:
unbound_example()
except UnboundLocalError:
pass
else:
raise AssertionError("local binding must prevent global fallback")The five kinds of ‘function-like’ things
Name: def
Tagline: The default
-
Named
-
Any signature
-
Can have docstring
-
Debuggable
-
Use unless there's a specific reason not to
Name: lambda
Tagline: Anonymous, one-expression
-
Unnamed
-
One expression only (no statements)
-
Great for sort keys and small callbacks
-
Bad for anything more complex
-
PEP 8: don't assign to a name — use def
Name: @decorator
Tagline: A callable that transforms the decorated object
-
Decorator application replaces f with deco(f); the complete timed and repeat examples below show the indented wrapper definitions.
-
Powers @app.route, @lru_cache, @staticmethod
-
Use functools.wraps to preserve __name__
-
Can be parameterised: @deco(arg)
Name: functools.partial
Tagline: Freeze some arguments
-
greet_bob = partial(greet, 'bob')
-
Cleaner than a lambda
-
Keeps the callable available as .func; it does not automatically copy its
__name__or__doc__ -
Great for callbacks needing a specific signature
Name: class __call__
Tagline: A callable OBJECT
-
Class with __call__ method
-
Instances behave like functions
-
Can carry state between calls
-
A general callable-object protocol; framework-specific dispatch can add other behavior
Closures — the ‘function remembers its home’ trick
Define an inner function that uses an outer variable
Desc: The make_counter implementation in the hands-on lab creates count, defines inc with nonlocal count, and returns inc. Nested compound statements require real indented lines, not a semicolon-only definition.
Return the inner function
Desc: The outer function returns inc. Without a retained reference, local state can become unreachable when the call finishes.
Python attaches count to inc.__closure__
Desc: The cell object keeps count alive as long as any reference to inc exists.
Every call to inc() reads/writes the same cell
Desc: Two separate make_counter() calls give two independent counters — each has its own cell.
The mental model to hold
Every function is a two-part object
A signature states binding rules; the documented contract must also specify effects, errors and return semantics. Design both the interface and the behavior, then test the implementation against them.
Myth: "A default argument is evaluated each time the function is called — so def f(items=[]) gives me a fresh empty list on every call."
Truth: Default values are evaluated once, when the def statement executes, and stored on the function object: positional defaults in __defaults__, keyword-only defaults in __kwdefaults__. Every call that omits the argument gets the same object. A mutable default is shared state with a very long lifetime.
Why: Because ordinary immutable defaults do not expose cross-call mutation. def f(n=0), def f(s=""), def f(flag=False) do not expose shared mutable state, because you cannot mutate these objects — you can only rebind, which is local. The moment the default is a list, dict, or a call like datetime.now(), the two models diverge and the function starts remembering things across calls. It looks like a haunting: the second call returns data from the first, and there is nothing in the function body that stores anything.
Test: inspect the shared object, then construct a fresh default per call.
def add_shared(x, bucket=[]):
bucket.append(x)
return bucket
assert add_shared(1) == [1]
assert add_shared(2) == [1, 2]
assert add_shared.__defaults__ == ([1, 2],)
def add_fresh(x, bucket=None):
bucket = [] if bucket is None else bucket
bucket.append(x)
return bucket
assert add_fresh(1) == [1]
assert add_fresh(2) == [2]Question: Why does a closure capture the variable rather than its value — so that every function created in a loop ends up seeing the loop's final value? This is a Python language choice; trace its consequences rather than claiming that no other closure design is possible.
Premise: A nested function must be able to read names from its enclosing scope, otherwise you could not write a helper that uses its parent's arguments.
Because: lexical scoping is the whole point of nesting a function inside another
Premise: The enclosing function's locals can change after the inner function is defined — the outer code keeps running, loops keep incrementing.
Because: definition is just another statement; execution continues past it
Premise: So the language must choose: snapshot the value at definition time, or hold a reference to the variable's storage cell. Snapshotting would break the common case where an inner function is defined before the outer function finishes computing what it needs.
Because: a decorator or callback defined early must still see values assigned later
Premise: Therefore Python stores a reference to the cell holding the variable, and resolves the name when the closure is called, not when it is created.
Because: Python uses lexical bindings resolved when the inner function runs; other languages can make different capture choices
Premise: An ordinary loop within one enclosing function scope reuses that scope’s loop-variable cell across iterations, so all N closures created in the loop share that single cell — and when called after the loop, they see its final value. A call during the loop can observe an earlier value.
Because: the loop variable is one name in one scope, not a new name per iteration
Conclusion: Therefore "all my lambdas return 9" is not a bug, it is late binding working exactly as specified. Verify with f.__closure__[0].cell_contents — you can see the shared cell.And note what this predicts: the fix must be to capture a value per definition, which is what a default argument does (lambda x, i=i: ...) because defaults are evaluated at definition time — the same mechanism that caused the mutable-default trap now saves you. It also explains functools.partial(f, i): it stores the current object reference as an argument, not a copy of that object and not a lookup of the original variable name.
Name: A function is an object with a signature contract
Picture: Stop picturing def as "declaring a subroutine". def is an executable name-binding statement: it builds a function object — carrying code, defaults, closure cells, annotations, a name — and binds it to a name. That object can be stored in a dict, passed as an argument, returned, and given attributes, exactly like a list can.The signature is the object's public contract: which arguments are positional, which are keyword, which have defaults. Everything painful about Python functions — mutable defaults, late binding, decorators losing metadata, *args/**kwargs — follows from the object being built once and called many times.
-
Use None plus construction inside the body when you need fresh per-call state. A deliberately shared or definition-time default can be valid, but that contract must be explicit.
-
Arguments are passed by assignment: the parameter becomes another name for the caller's object. Rebinding is local; mutating is visible to the caller.
-
Use * in the signature to force keyword-only arguments for anything a reader couldn't decode at the call site — booleans especially. f(data, dropna=True) beats f(data, True).
-
Separating effects from computation can clarify APIs, but it is not a Python law: list.pop mutates and returns a value. list.sort returns None to signal in-place sorting; document each function’s actual contract.
Trigger: Fire this model the moment you see: a function that "remembers" its previous call · every closure in a loop giving the same answer · a decorated function whose __name__ is wrapper · a timestamp default that is always the import time · a caller's list changing unexpectedly.
Decision: A function has grown to eight parameters. Do you keep the flat signature, take a config object, or split it into several functions?
Name: Keep the flat signature (keyword-only)
Gain: every input is explicit and discoverable via help() and IDE completion; type checkers can check annotated arguments; explicit names help reveal coupling
Cost: call sites get long, adding a required parameter may require caller changes, and related parameters that must be set together are not visibly grouped
Pick: the parameters are genuinely independent and explicit names make the call readable; there is no universal seven-parameter working-memory limit
Name: Config object / dataclass
Gain: related settings travel together. Explicit validation and serialization can be added at a boundary; a dataclass alone does not validate types or automatically serialize arbitrary fields. Adding a required field can break callers
Cost: indirection — you must open a second definition to know what is available; the object can arrive half-populated; it invites unrelated settings to accumulate
Pick: the same parameter bundle is passed through three or more layers, or the settings need to be persisted, logged, or diffed — which is the normal case for training and pipeline configs
Name: Split into several functions
Gain: each function does one thing with a short signature, becomes independently testable, and boolean flags that switched behaviour disappear entirely
Cost: more names to know, and shared setup logic must be factored out or duplicated; callers now orchestrate what one call used to do
Pick: a boolean parameter selects between two largely disjoint code paths — that flag is a function boundary wearing a disguise
Verdict: Look at the parameters before counting them. Flags that fork the body mean you have two functions. Parameters that always move together mean you have a missing type. Only genuinely independent knobs justify a wide signature — and those should be keyword-only so call sites stay readable.In data and ML work the config-object answer wins more often than in general software, for a reason unrelated to code quality: a serialisable config is a record of the run. If the parameters exist only as positional arguments in a notebook cell, recovering the run configuration is harder. A saved config helps, but reproducibility also needs code, data, dependencies and relevant randomness/environment state.
(c) Hands-on · 25 min
Save as s007_functions.py, run, and PREDICT each output before it prints.
"""s007_functions.py — the full menu of Python function features."""
from __future__ import annotations
from functools import wraps, partial, lru_cache
import time
from typing import Callable, Any
# =========================
# Part 1 · Signature grammar
# =========================
print("--- Part 1 ---")
def request(url, /, method="GET", *headers, timeout=30, **params):
return f"{method} {url} timeout={timeout} headers={headers} params={params}"
print(request("https://x", "POST", "H1", "H2", timeout=5, page=2, sort="new"))
# Try to call `request(url="x")` — it fails: url is positional-only.
# =========================
# Part 2 · Default arg trap (revisited from S005)
# =========================
print("\n--- Part 2 ---")
def bad(item, bin=[]):
bin.append(item)
return bin
def good(item, bin=None):
bin = [] if bin is None else bin
bin.append(item)
return bin
print(bad("a"), bad("b")) # both arguments reference ["a", "b"] by print time
print(good("a"), good("b")) # fresh each call
# =========================
# Part 3 · Scope (LEGB) and nonlocal / global
# =========================
print("\n--- Part 3 ---")
x = "global"
def outer():
x = "enclosing"
def inner_read():
# reads x from Enclosing (LEGB)
return x
def inner_write_local():
# this creates a new LOCAL x — doesn't touch enclosing
x = "local"
return x
def inner_write_nonlocal():
nonlocal x
x = "changed by nonlocal"
return x
print("read:", inner_read())
print("write local:", inner_write_local(), "outer x:", x)
print("write nonlocal:", inner_write_nonlocal(), "outer x:", x)
outer()
print("module x:", x)
# =========================
# Part 4 · The late-binding closure bug
# =========================
print("\n--- Part 4 ---")
# Bug — all three lambdas capture the SAME i
funcs = [lambda: i for i in range(3)]
print("bug:", [f() for f in funcs])
# Fix 1 — default argument snapshot
funcs = [lambda i=i: i for i in range(3)]
print("fix1:", [f() for f in funcs])
# Fix 2 — factory function
def make(i):
def f():
return i
return f
funcs = [make(i) for i in range(3)]
print("fix2:", [f() for f in funcs])
# =========================
# Part 5 · Closures with state
# =========================
print("\n--- Part 5 ---")
def make_counter(start: int = 0) -> Callable[[], int]:
count = start
def inc() -> int:
nonlocal count
count += 1
return count
return inc
c1 = make_counter()
c2 = make_counter(100)
print(c1(), c1(), c1())
print(c2(), c2())
# =========================
# Part 6 · Decorator from scratch
# =========================
print("\n--- Part 6 ---")
def timed(func: Callable) -> Callable:
@wraps(func) # preserves __name__, __doc__
def wrapper(*args: Any, **kwargs: Any) -> Any:
t0 = time.perf_counter()
result = func(*args, **kwargs)
elapsed = (time.perf_counter() - t0) * 1000
print(f" {func.__name__} took {elapsed:.2f} ms")
return result
return wrapper
@timed
def slow_add(a: int, b: int) -> int:
"""Add two numbers slowly, for demo."""
time.sleep(0.05)
return a + b
print(slow_add(2, 3))
print("still named correctly:", slow_add.__name__) # 'slow_add' thanks to @wraps
# Parameterised decorator
def repeat(n: int) -> Callable:
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> list:
return [func(*args, **kwargs) for _ in range(n)]
return wrapper
return decorator
@repeat(3)
def greet(name: str) -> str:
return f"hi {name}"
print(greet("ada"))
# =========================
# Part 7 · Higher-order plumbing
# =========================
print("\n--- Part 7 ---")
# functools.partial
add = lambda a, b: a + b
add5 = partial(add, 5)
print(add5(10))
# functools.lru_cache — a real-world decorator
@lru_cache(maxsize=128)
def fib(n: int) -> int:
return n if n < 2 else fib(n - 1) + fib(n - 2)
print([fib(i) for i in range(10)])
print("cache stats:", fib.cache_info())
# =========================
# Part 8 · Callable classes
# =========================
print("\n--- Part 8 ---")
class Multiplier:
"""A callable that carries state."""
def __init__(self, factor: int) -> None:
self.factor = factor
def __call__(self, x: int) -> int:
return x * self.factor
times3 = Multiplier(3)
print(times3(10), times3(4))
print("is it callable?", callable(times3))What each block teaches
Anatomy of the exercises
Name: Part 1 · signature grammar
Desc: positional-only (before /), keyword-only (after *), *args, **kwargs. All five formal parameter kinds in one signature.
Name: Part 2 · mutable defaults
Desc: The bug you saw in S005, in function form. The = [] if x is None else x idiom is muscle memory.
Both calls in print(bad("a"), bad("b")) finish before print formats either argument. Both returned references therefore show the final two-item list. The good calls return independent one-item lists.
Name: Part 3 · LEGB in action
Desc: This example’s inner_read uses an enclosing binding. Simple-name assignment makes a local unless declared nonlocal/global; reading that local before assignment does not fall through.
Name: Part 4 · late binding
Desc: Closures capture VARIABLES not values. Default-arg trick or a factory function fixes it. Design review classic.
Name: Part 5 · closures with state
Desc: make_counter shows that each call to the outer produces a NEW enclosing scope, so counters are independent.
Name: Part 6 · decorators
Desc: Applying a decorator binds wrapped = deco(f). functools.wraps copies selected metadata and exposes __wrapped__. A decorator factory takes configuration and returns the decorator; that decorator then returns the wrapper.
Name: Part 7 · higher-order stdlib
Desc: partial stores argument references; lru_cache memoizes results under hashable-argument and retention contracts.
Name: Part 8 · callable classes
Desc: An instance whose class supplies __call__ is callable, but is not necessarily a function object. Use it when state and a call interface belong together.
What to observe
Part 3's inner_write_local() returns "local" but the outer x stays "enclosing". Local writes don't touch outer scope by default.
Part 4 bug prints [2, 2, 2], not [0, 1, 2]. The default-argument fix binds a distinct local parameter for each function.
Part 5 shows two counters that don't interfere. Each closure has its own cell.
For the displayed calls fib(0) through fib(9), cache_info reports 16 hits and 10 misses in a fresh run. Caching computes each reached n once while retained; n must be a nonnegative integer, recursion depth is still bounded by the interpreter, and large integer arithmetic is not unit-cost.
Goal: Write your own @retry(times=3, delay=0.5) decorator
Hint: A parameterised decorator is a function that RETURNS a decorator. Three levels of def: outer takes params, middle takes the function, inner takes call-time *args/**kwargs.
Write a decorator @retry(times=3, delay=0.5) that:
- Calls the wrapped function.
- If it raises, sleeps
delayseconds and retries, up totimestotal attempts. - After the last failure, re-raises the exception.
- On success, returns the result.
Test with a function that fails the first 2 calls and succeeds the third. Verify functools.wraps preserves the name.
Worked retry solution and attempt contract
times means total attempts, not retries after the first attempt. This synchronous teaching wrapper retries Exception by default, not KeyboardInterrupt/SystemExit. For real operations narrow exceptions to transient failures and require idempotency or deduplication before retrying effects. Sleeping happens only between failed attempts, never after the final failure; async functions need a different wrapper.
import math
import time
from functools import wraps
def retry(*, times=3, delay=0.5, exceptions=(Exception,)):
if isinstance(times, bool) or not isinstance(times, int) or times < 1:
raise ValueError("times must be a positive integer")
if (isinstance(delay, bool) or not isinstance(delay, (int, float))
or not math.isfinite(delay) or delay < 0):
raise ValueError("delay must be finite and nonnegative")
if (not isinstance(exceptions, tuple) or not exceptions
or any(not isinstance(t, type) or not issubclass(t, Exception)
for t in exceptions)):
raise ValueError("exceptions must contain Exception subclasses")
def decorate(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except exceptions:
if attempt + 1 == times:
raise
time.sleep(delay)
return wrapper
return decorate
attempts = []
@retry(times=3, delay=0, exceptions=(ValueError,))
def eventually():
attempts.append(len(attempts) + 1)
if len(attempts) < 3:
raise ValueError("temporary demonstration failure")
return "ok"
assert eventually() == "ok"
assert attempts == [1, 2, 3]
assert eventually.__name__ == "eventually"The outer call validates configuration and returns a decorator. Applying it returns a wrapper carrying that configuration and the original function. Each invocation starts a new attempt loop. A bare raise preserves the final failure rather than replacing it with a success-shaped None.
(d) Production reality · 15 min
Three state-lifetime scenarios
These are executable illustrations, not verified Django incidents, hiring statistics or Gunicorn deployment advice.
Default filters must not retain request data. Preserve the intended default active flag and distinguish None from an explicitly empty mapping. Copy caller-owned settings if this function will add fields. A MappingProxyType is a read-only view of a mapping, not a deep freeze: changes to the underlying mapping remain visible.
from types import MappingProxyType
def filters_for(user, filters=None):
result = {"active": True} if filters is None else dict(filters)
result["user"] = user
return result
assert filters_for("ada") == {"active": True, "user": "ada"}
assert filters_for("linus", {}) == {"user": "linus"}
caller = {"active": False}
assert filters_for("ada", caller) == {"active": False, "user": "ada"}
assert caller == {"active": False}
backing = {"active": True}
view = MappingProxyType(backing)
backing["active"] = False
assert view["active"] is FalseGlobal lookup is not a closure cell. In module-level code the callbacks below read the same global i at call time. The list-comprehension version in the lab instead shares an enclosing comprehension binding. The default-argument and factory fixes both preserve the intended current object for each callback, without deep copying it.
callbacks = []
for i in range(3):
callbacks.append(lambda: i)
assert [cb() for cb in callbacks] == [2, 2, 2]
assert callbacks[0].__closure__ is None
fixed_callbacks = [lambda i=i: i for i in range(3)]
assert [cb() for cb in fixed_callbacks] == [0, 1, 2]Closure state lasts as long as it remains reachable. It is not necessarily per-call: the returned make_counter closure retains its count across many calls. Independent processes do not automatically synchronize ordinary Python module globals or closure cells. A multiprocess service that needs shared state requires explicit coordination, such as an appropriate shared-memory primitive or external transactional store. This review does not run workers or prove a deployment’s process model.
Where this shows up in the rest of the plan
Root: Functions are the atom every future session builds on
Label: S009 · Classes & objects
Desc: Bound instance methods supply the instance argument automatically; static methods and class methods have different binding contracts.
Label: S010 · Inheritance
Desc: MRO orders class attribute lookup through inheritance; it is separate from LEGB name resolution.
Label: S013 · Testing
Desc: Fixture registration and injection are framework contracts; decorators and scope are useful prerequisites, not a complete model of pytest.
Label: Later topic · Web framework call contracts
Desc: Route decorators, middleware and dependency injection have framework-specific invocation/lifetime contracts. The function model helps read them but does not make these mechanisms interchangeable.
Label: Later topic · Coroutines
Desc: async functions are functions too — with await and coroutines. Same signature grammar.
Label: Later topic · Code review
Desc: Review both the signature and effects, errors, state lifetime and return-value contract.
(e) Recall + stretch · 10 min
Self-check (answer follows each question).
Recall — questions with model answers
Q: What's the difference between *args and **kwargs?
A: *args collects extra POSITIONAL arguments into a tuple. **kwargs collects extra KEYWORD arguments into a dict. Both are just names — the convention is args/kwargs, but any name after * / ** works.
Q: How do you make an argument keyword-only?
A: Place it after * or *args in the signature: def f(a, *, b, c): …. Now f(1, b=2, c=3) works but f(1, 2, 3) raises. Great for boolean flags to prevent enable_x=True being confused for enable_y=True.
Q: Explain LEGB in one sentence.
A: Ordinary function name resolution uses local, enclosing-function, module-global and built-in scopes, after compile-time binding classification. A classified local read before assignment raises UnboundLocalError instead of falling through; class and annotation scopes have additional rules.
Q: What does nonlocal do, and how does it differ from global?
A: nonlocal x in a nested function lets a WRITE bind to the ENCLOSING function's x, not create a local. global x binds to the MODULE-level x. Without either, assignment to a simple name in ordinary function code makes that name local; item or attribute assignment instead acts on the referenced object. nonlocal needs an existing enclosing-function binding.
Q: Fix funcs = [lambda: i for i in range(3)] so calling each function returns 0, 1, 2.
A:
funcs = [lambda i=i: i for i in range(3)]
assert [f() for f in funcs] == [0, 1, 2]
# Alternative: one enclosing binding per factory invocation
funcs = [(lambda i: lambda: i)(i) for i in range(3)]
assert [f() for f in funcs] == [0, 1, 2]Q: Write a minimal @timed decorator that prints the elapsed time of the wrapped function.
A: This wrapper measures a successful synchronous call and returns its result. It does not log when the call raises; use try/finally if logging failures is part of the contract.
from functools import wraps
import time
def timed_answer(f):
@wraps(f)
def w(*a, **k):
t = time.perf_counter()
r = f(*a, **k)
print(f"{f.__name__}: {time.perf_counter()-t:.4f}s")
return r
return wQ: Why should you use functools.wraps inside a decorator?
A: wraps copies selected metadata such as __name__, __doc__ and __module__, and sets __wrapped__ for introspection. Without it, users inspect wrapper metadata instead. It does not rewrite the wrapper code object, remove wrapper traceback frames or make every framework compatible automatically.
Q: What's the difference between partial(f, 5) and lambda x: f(5, x)?
A: Both can supply 5 as the first argument in a simple one-argument call. partial accepts further positional and keyword arguments and stores its callable as .func; it does not automatically copy the callable’s metadata. Standard pickle can serialize a partial only when its callable and stored arguments are themselves picklable. Ordinary lambdas are not supported by standard pickle’s by-name function protocol.
Q: When is a callable class better than a function?
A: When the callable needs to carry state between calls AND expose configuration you'd want to inspect/replace. The Multiplier class above is a minimal example whose factor can be inspected or changed. Otherwise, a plain function or closure is simpler.
Q: Stretch — write a decorator @memoize that caches results based on args and kwargs (without using functools.lru_cache). What data structure do you use for the cache key?
Stretch: true
A: Use a tuple key only under an explicit contract: positional arguments and keyword values must be hashable, equal keys must imply equivalent results (including any bool/int or other cross-type equality), and keyword insertion order must not affect its result. Sorting kwargs normalizes order but does not make an unhashable value hashable. Equivalent positional/keyword calls need not share a cache entry. This unbounded synchronous example has no eviction or cross-thread single-flight guarantee.
from functools import wraps
def memoize(f):
cache = {}
@wraps(f)
def w(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key not in cache:
cache[key] = f(*args, **kwargs)
return cache[key]
return wCached mutable return values remain shared. Exceptions are not stored, because assignment occurs only after f returns. For production use, select a cache policy and normalization appropriate to the API rather than claiming this decorator handles every Python call.
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- What's a closure? (one sentence + one example)
- What's the difference between
nonlocalandglobal? (one rule each) - Write the shape of a decorator that takes arguments. (three levels of def)
Model answers: (1) A closure is a function with retained bindings from an enclosing function scope, such as make_counter returning inc with its count cell. (2) nonlocal rebinds an existing enclosing-function name; global rebinds a name in the defining module. (3) An outer configuration function returns a decorator taking f; that decorator returns a wrapper taking call-time args/kwargs and invoking f.
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.
Corrective source notes
- Control-flow tutorial, execution model and programming FAQ — argument binding, defaults, local classification and late binding.
- functools and pickle — wraps, partial, cache contracts and serialization limits.
- Python 2.4, 3.0, 3.8 and PEP 484 — actual shipped-feature versions, distinct from proposal dates.
Versioned Python 3.12 contracts were checked on 2026-10-09; later-version history is labeled separately. Local examples are bounded teaching programs, not service or performance certification. The review packet retains exact source quotations, executed code, negative controls and coverage limits.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.