Closures, Decorators & Functional Style
- Treat functions as values: store them, pass them, return them from other functions
- Explain what a closure captures and predict what an inner function will see
- Write decorators (timing, retry) using *args/**kwargs and functools.wraps
- Apply functools.lru_cache and measure the speedup on a recursive function
- Use sorted with key= and judge when lambda helps versus hurts readability
| Spaced-rep warm-up: due cards (LEGB from Day 3 will resurface) | 10 min |
| Concept study: ELI5 + tech β desugar one @ by hand on paper | 20 min |
| Guided: closures, @timed build, @retry + lru_cache | 45 min |
| Practice: the @logged decorator | 20 min |
| Project: wrappers.py toolkit | 15 min |
| Quiz + flashcards | 10 min |
Builds on: Day 3 β Functions, scope, *args/**kwargs Β· Day 10 β You already used decorators: @dataclass, @property Β· Day 11 β Laziness and wrapping behavior
A plain gift is a watch. A wrapped gift is still a watch β but now opening it involves ribbon, paper, and a card that says who it's from. The watch inside is untouched; the *experience around it* changed. A decorator gift-wraps a function: same function inside, but now calling it also starts a stopwatch, or retries on failure, or writes a log line. The wrapping is reusable β one @timed wrapper can wrap any function in your codebase, because the wrapper doesn't care what's inside the box.
Two ideas make the wrapping possible. First: in Python, functions are things β values you can put in a variable, pass to another function, or hand back as a return value, exactly like numbers and lists. Second: a closure β when a function is created inside another function, it keeps a backpack containing the variables from its birthplace, even after the birthplace has returned. The wrapper function keeps the original function in its backpack; that's how the gift stays inside the wrapping. You've already been *using* decorators (@dataclass, @property yesterday) β today you find out they were never magic, just backpacks and wrapping paper.
Cross-cutting behavior β timing, retries, caching, logging, auth β is the same everywhere: you need it on many functions without editing any of them. Decorators are Python's answer, and the ecosystem runs on them: FastAPI routes are decorators (Day 41), pytest fixtures (Day 19), and your Day 107 LLM client wrapper is essentially today's @retry with backoff attached β network calls to model APIs fail routinely, and retry-wrapping is the difference between a flaky demo and a robust one. Caching via lru_cache is the one-line performance fix interviewers love, and closures explain half the "why does this variable have that value?!" mysteries in real callback-heavy code.
Guided practice
Functions in boxes, functions in backpacks
15 min- Create
week-02/closures_lab.pywith the starter code. Section 1: functions as values β alias one, store two in a dict, dispatch by key. Notice shout vs shout(): the function itself versus the result of calling it. Mixing those up is a daily-life bug. - Section 2: run make_counter twice and confirm the two counters are independent β separate backpacks, packed at creation time.
- Remove nonlocal, rerun, and read the UnboundLocalError. Connect it in a comment to Day 3's shadowing rule (assignment makes a local unless told otherwise).
- Section 3: make_multiplier β the classic closure factory. Build double and triple from ONE definition. What exactly is in each backpack? Print double.__closure__[0].cell_contents to literally look inside.
- Write one sentence: why does the backpack survive after make_multiplier has returned? (The inner function holds a reference; Day 4's object-lifetime rules apply to variables too.)
Wrap your first gifts β @timed, properly
15 min- Create
decorators_lab.py. Build @timed in three steps, feeling each problem before fixing it. Step 1: write the naive version WITHOUT *args/**kwargs (wrapper() taking nothing) and wrap a function that takes an argument. Watch it explode. Fix with the collectors. - Step 2: forget the return (call fn but don't return its result). Wrap a function that returns a value, print the result: None. Fix it. These two bugs are 90% of all broken decorators ever written.
- Step 3: print slow_add.__name__ β it says "wrapper". Add @functools.wraps(fn) and check again. Now the finished decorator matches the starter code below.
- Desugar once by hand: define a function plain(), then wrap it without the @ sign: plain = timed(plain). Verify @ was pure sugar.
- Wrap three different functions β one fast, one slow (time.sleep(0.3)), one taking kwargs β with the SAME @timed. One wrapper fits all: that's what *args/**kwargs bought.
@retry and @lru_cache β wrappers that earn money
15 min- Still in decorators_lab.py: build @retry from the starter code. The flaky() function fails randomly ~60% of the time β exactly how networks behave. Run it several times; watch retries save the call.
- Note the decorator-with-arguments shape: retry(times=3) is a function returning a decorator returning a wrapper β three layers. Trace which layer runs at decoration time vs call time (add prints if unsure). This shape is everywhere in libraries; recognize it rather than memorizing it.
- lru_cache: implement naive_fib(n), time naive_fib(32) with your own @timed. Then add @functools.lru_cache(maxsize=None) above it and time again. Record both numbers β you should see roughly a thousand-fold difference. One line. (Day 27 explains the exponential tree you just pruned; Day 34 builds memoization by hand.)
- sorted-with-key reps: given tasks = [("rope", 2), ("map", 1), ("tent", 3)], sort by the number with key=lambda t: t[1], then descending. Then sort your Day 9 Flashcards by due_in.
- Judgment line in a comment: one place lambda was perfect here, and one kind of place it wouldn't be (multi-line logic β give it a def and a name).
On your own
The @logged decorator
20 minBuild the third member of your wrapper toolkit, unguided.
Goal: @logged appends one line per call to calls.log (Day 5's append mode): timestamp, function name, arguments, and the returned value β or the exception type if it raised (re-raise it after logging; never swallow). Then stack it: put @timed AND @logged on one function and check both effects fire.
Constraints: use functools.wraps; the log line must include the real function name even when stacked under @timed; exceptions must propagate after logging.
Hints (only if stuck): repr(args) and repr(kwargs) make loggable strings. For stacking order: decorators apply bottom-up β @timed above @logged wraps the logged version. Try both orders and read the log to see the difference (this exact stacking question is a favorite interview aside).
wrappers.py β your instrumentation toolkit
Assemble today's work into a module you will import for the rest of the program: wrappers.py containing @timed, @retry(times, delay), and @logged, each with docstrings and functools.wraps, plus a demo under a main guard exercising all three (including a stacked example and a flaky function saved by retry). Then put it to work: wrap your streamstats parsing run (Day 11) with @timed, and wrap the tracker's save function with @logged. Commit. On Day 107 you will meet this module's grown-up sibling β an LLM API client wrapped with retry/backoff/timing β and recognize every moving part.
Common mistakes & misconceptions
- Writing shout when you mean shout() or vice versa. Without parens you have the function object; with them, its result. Passing shout() to sorted's key= calls it immediately and passes the result β usually a crash.
- A wrapper that forgets to return fn(...)'s result. Every function you wrap silently starts returning None. Always capture and return.
- Wrapper signature that isn't (*args, **kwargs). Your decorator only fits functions with one exact shape and breaks on everything else.
- Skipping functools.wraps. Tracebacks, help(), and logs all report "wrapper" for every decorated function β debugging misery on a delay timer.
- Assigning to a closed-over variable without nonlocal. You get UnboundLocalError or a shadow local β Day 3's rule operating at the enclosing level.
- lru_cache on functions with list/dict arguments (unhashable β TypeError) or on functions with side effects (the effect fires once, then never again). Cache pure computations only.
Q1. @timed above def slow(): is exactly equivalent toβ¦
Q2. make_counter() returns bump, which uses count. After make_counter returns, count isβ¦
Q3. A wrapped function returns None even though its body returns a value. Most likely cause?
Go deeper β curated resources
- Decorators with state: class-based decorators β A class with __call__ can decorate too, holding state in self (call counts, rate limits). Ties Day 9 to today β try rewriting @timed as a class in ten lines.
- Both closure factories built; backpack inspected via __closure__
- @timed built through all three deliberate-bug steps
- fib timing recorded with and without lru_cache
- @logged works stacked, with exceptions logged and re-raised
- wrappers.py committed and imported by two real scripts; quiz β₯ 2/3
β Back: Closures are Day 3's LEGB rule bearing fruit β the E finally matters. *args/**kwargs from Day 3 became load-bearing. And @dataclass/@property from Day 10 just lost their mystery: functions transforming functions/classes, nothing more.
Forward β: pytest fixtures (Day 19) and FastAPI routes (Day 41) are decorators; Day 27 revisits lru_cache when recursion trees get pruned; Day 34 builds memoization by hand. Day 107's production LLM client is @retry with exponential backoff and a budget β today's wrapper, promoted to revenue-critical.
Unlocks: D27 Recursion & Divide/Conquer Β· D34 Dynamic Programming Intro Β· D40 Concurrency & Async Python