Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Closures & Decorators  ›  Lesson

functools

functools 24 minThe standard toolkit for functions: bounded caches, pre-filled arguments and folds
You're building a piece ofQuarry — the middleware ring
This piece — cached_lookup(): Pays for each distinct lookup once, within a bounded memory budget.
Scenario Two million log rows name only thirty hosts, and each host-to-region lookup costs forty milliseconds.
Your task
Write cached_lookup(keys, maxsize). Decorate the provided resolve(key) with a fresh lru_cache(maxsize=maxsize), counting how often the real body runs, look up every key in order, and return {"work", "hits", "misses"}. maxsize may be None. Example: cached_lookup(["a", "b", "c", "a"], 2) → {"work": 4, "hits": 0, "misses": 4}.

functools

In short: a cache is a dict in a closure — remember each answer the first time, hand back the remembered answer after. functools.lru_cache is that idea, finished: a size bound with least-recently-used eviction, hit/miss stats via .cache_info(), and .cache_clear(). A function is only safe to cache when its result depends on nothing but its arguments — a global it reads can go stale in the cache without anyone noticing, and the arguments themselves must be hashable. functools.partial pre-fills some arguments into a new callable — a closure you don't have to write by hand — and functools.reduce folds a sequence into one value by repeated application.

A maxsize=2 cache table stepping through calls a, b, a, c, b: a hit moves an entry to "newest use", a miss on a full cache evicts whichever slot was used longest ago

Cache a slow host-to-region lookup

Resolving a host to a region means asking a slow directory — worth remembering once two million log rows only mention thirty distinct hosts:

python

The key is args, the tuple of arguments — it works because a tuple of strings is hashable. Hold on to that; it comes back below.

Bound the region cache with lru_cache

python

lru_cache is a decorator factory — the last lesson's shape — where maxsize is the setting. Over the hand-written version it adds a bound (past maxsize, the least recently used entry is evicted to make room, the "LRU"), statistics (cache_info()), and control (cache_clear()).

What does this print?
from functools import lru_cache

@lru_cache(maxsize=None)
def region_of(host):
    return host[:2]

for host in ["eu-1", "ap-1", "eu-1", "eu-2", "eu-1", "ap-1"]:
    region_of(host)

info = region_of.cache_info()
print(info.hits, info.misses)

A cache smaller than the set of keys cycling through it can be worse than no cache at all:

What does this print?
from functools import lru_cache

calls = []

@lru_cache(maxsize=2)
def lookup(key):
    calls.append(key)
    return key

for key in ["a", "b", "c", "a", "b"]:
    lookup(key)
print(len(calls))

Watching a bounded cache fill and evict

With maxsize=2, only two answers fit. Step through the calls and watch which key gets thrown out. It's the one used longest ago, not the one added earliest:

step through it
1def lru(maxsize):
2 store = {} # dicts keep insertion order: oldest use first
3 events = []
4 def lookup(key):
5 if key in store:
6 store[key] = store.pop(key) # move to the "most recent" end
7 events.append(f"hit {key}")
8 else:
9 if len(store) == maxsize:
10 oldest = next(iter(store))
11 del store[oldest]
12 events.append(f"evict {oldest}")
13 store[key] = key.upper()
14 events.append(f"miss {key}")
15 return store[key]
16 return lookup, events
17 
18lookup, events = lru(2)
19for key in ["a", "b", "a", "c", "b"]:
20 lookup(key)
21print(events)

Here's the decision the wrapper makes on each call:

Drawing diagram…

That thrashing example above is exactly what this diagram predicts: each key arrives right after its own entry was evicted, so every call takes the miss branch.

Cache a tax total safely

The cache key has to be hashable, because it's a dict key:

broken — fix itTypeError

Run it and read the error. Change the call so the argument can be used as a cache key.

The more serious rule is about meaning: a cache is only correct when the result depends on nothing but the arguments.

What does this print?
from functools import lru_cache

rate = 10

@lru_cache(maxsize=None)
def with_tax(price):
    return price + price * rate // 100

first = with_tax(200)
rate = 20
print(first, with_tax(200))

Before reaching for @lru_cache, check: the function is pure (no global, file, clock or database read), its arguments are hashable, the result is safe to share (a cached list is the same list handed to every caller — one mutating it affects the rest), and memory is bounded — set a real maxsize unless the key space is small and known.

One-liner: partial and reduce

partial pre-fills some arguments and hands back a new callable — a closure you don't have to write yourself:

python

reduce folds a sequence into one value by applying a two-argument function repeatedly — useful for composing a pipeline of steps, though a plain sum() or "".join() reads clearer for those specific jobs:

python

A cache's hit rate climbs toward 100% only once lookups start repeating — 100 * (x - k) / x, for x total lookups and k distinct keys held entirely in the cache. Few distinct keys and many repeats is exactly where a cache earns its memory; many distinct keys with few repeats is the thrashing case above.

Quick practice

What does this print?
from functools import partial

def power(base, exp):
    return base ** exp

square = partial(power, exp=2)
cube = partial(power, exp=3)
print(square(5), cube(2))
What does this print?
from functools import lru_cache

@lru_cache(maxsize=None)
def slow(n):
    return n * n

slow(2); slow(2); slow(3)
before = slow.cache_info().hits
slow.cache_clear()
after = slow.cache_info().hits
print(before, after)

🎯 Your turn

Write cached_lookup(keys, maxsize). Inside it, decorate the provided resolve(key) with a fresh lru_cache(maxsize=maxsize), count how many times the real resolve body runs, look up every key in order, and return:

{"work": <real calls>, "hits": <cache hits>, "misses": <cache misses>}

  • cached_lookup(["a", "b", "a", "a"], 2) → {"work": 2, "hits": 2, "misses": 2}
  • cached_lookup(["a", "b", "c", "a"], 2) → {"work": 4, "hits": 0, "misses": 4} (the "a" was evicted)

maxsize may be None, meaning unbounded.

Hint: count the real work with a small closure around resolve (a nonlocal counter, as in lesson 01), then cache = lru_cache(maxsize=maxsize)(counting_resolve) makes a fresh cache every run. Read hits and misses from cache.cache_info().

Press ▶ Run, then ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upA cache by hand

Write a decorator remember(func) that keeps a dict of results keyed by the argument tuple and only calls func on a miss. real_calls(keys) wraps the provided slow_square and returns how many times its body actually ran for the given keys.

functoolsclosures
DrillPre-filled arguments with partial

Given apply_rate(rate, amount) returning round(amount * (1 + rate / 100), 2), write taxed_all(rate, amounts) which uses functools.partial to fix the rate and returns the list of taxed amounts.

functoolspartial
BuildAn LRU cache by hand

Write lru_by_hand(maxsize) returning a lookup(key) function that caches key.upper() for at most maxsize keys, evicting the least recently used key when full. Record each eviction. evictions(keys, maxsize) looks up every key and returns the evicted keys in order.

functoolslru-cacheclosures
BossMake the cache key honest

price_with_rate must be cached, but the rate changes during a run. Write quote_prices(requests) where each request is [price, rate]. Use a cached function whose arguments include the rate, so a changed rate can never return a stale answer. Return {"quotes": [...], "misses": n} where each quote is price + price * rate // 100.

functoolslru-cache
CapstoneA pipeline from parts

Write run_pipeline(steps, text). Each step is [name, arg]: ["strip", null] strips, ["lower", null] lower-cases, ["replace", [old, new]] replaces, ["split", sep] splits, ["take", n] keeps the first n items. Build one function per step (use functools.partial for the ones with arguments), compose them with functools.reduce, and return the result.

functoolspartialreducefirst-class-functions
ExplainWhen @lru_cache is the wrong answer

A teammate wants to put @lru_cache(maxsize=None) on every function in Quarry "because it's free speed". Explain the conditions under which caching a function is safe, and describe two concrete ways it could make Quarry wrong or worse.

cached_lookup.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
cached_lookup(keys, maxsize) → dictLook up every key through a fresh bounded cache and report real work, hits and misses.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.