Worked Example: Building a Bounded Cache Decorator¶
Page Maps¶
graph LR
family["Python Programming"]
program["Python Meta-Programming"]
section["Function Wrappers Transparent Decorators"]
page["Worked Example: Building a Bounded Cache Decorator"]
capstone["Capstone evidence"]
family --> program --> section --> page
page -.applies in.-> capstone
flowchart LR
orient["Orient on the page map"] --> read["Read the main claim and examples"]
read --> inspect["Inspect the related code, proof, or capstone surface"]
inspect --> verify["Run or review the verification path"]
verify --> apply["Apply the idea back to the module and capstone"]
The five core lessons in Module 04 become much easier to trust when they all show up in one wrapper that is useful, tempting, and clearly not production-ready.
A small cache decorator is perfect for that job because it sits right on the boundary between:
- thin wrapper mechanics
- stateful semantic drift
- metadata preservation
- explicit policy and reset needs
That makes it the right worked example for this module.
The incident¶
Assume a team wants a bounded cache decorator for small demos and local experiments.
The decorator should:
- cache results by arguments
- expose visible state for inspection and reset
- preserve function identity for tools
- stay honest about its limitations
That last goal matters most. This is where many wrappers go wrong: they look tiny and end up behaving like unreviewed framework features.
The design boundary¶
This worked example is deliberately bounded, not production-grade.
That means the design will:
- preserve metadata with
functools.wraps - expose
cache_clear()for explicit reset - keep cache state on the wrapper for visibility
- remain single-threaded and intentionally limited
Those choices are not accidental. They make the wrapper more inspectable and easier to review as a small policy surface.
Step 1: make the decorator factory timing explicit¶
The cache uses a factory so configuration happens once at definition time:
This means:
cache(maxsize=3)runs once- it returns the real decorator
- that decorator wraps
fib
That definition-time sequence matters because maxsize is configuration, not per-call
input.
Step 2: admit that caching is stateful policy¶
Caching is not a thin wrapper. It changes semantics across calls:
- later calls may skip execution
- result meaning now depends on wrapper state
- argument keying rules affect correctness
- reset behavior matters to tests and long-running processes
That is why this worked example belongs after the stateful-wrapper core, not inside the thin-wrapper page.
Step 3: preserve metadata and expose state deliberately¶
The wrapper should keep the original function inspectable:
- use
functools.wraps - store state on explicit wrapper attributes
- expose a
cache_clear()hook
That combination gives both transparency and testability.
A bounded implementation¶
import functools
from typing import Any, Callable, Dict, Hashable, Optional
def cache(maxsize: Optional[int] = 128) -> Callable:
if maxsize is not None and maxsize < 0:
raise ValueError("maxsize must be >= 0 or None")
if maxsize == 0:
def decorator_no_cache(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> Any:
return func(*args, **kwargs)
def cache_clear() -> None:
pass
wrapper.cache_clear = cache_clear
return wrapper
return decorator_no_cache
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> Any:
try:
key: Hashable = (args, frozenset(kwargs.items()))
except TypeError as exc:
raise TypeError(
"cache() only supports hashable arguments in its canonical form"
) from exc
if key in wrapper._cache:
wrapper._cache_order.remove(key)
wrapper._cache_order.append(key)
return wrapper._cache[key]
result = func(*args, **kwargs)
if maxsize is not None and len(wrapper._cache_order) >= maxsize:
oldest = wrapper._cache_order.pop(0)
del wrapper._cache[oldest]
wrapper._cache[key] = result
wrapper._cache_order.append(key)
return result
wrapper._cache: Dict[Hashable, Any] = {}
wrapper._cache_order: list = []
def cache_clear() -> None:
wrapper._cache.clear()
wrapper._cache_order.clear()
wrapper.cache_clear = cache_clear
return wrapper
return decorator
Why this version is useful for review¶
This cache is intentionally not pretending to be perfect.
It is useful because it makes the right tradeoffs visible:
wrapskeeps the callable inspectable- wrapper attributes make state explicit
cache_clear()makes reset behavior explicit- the hashability rule is named rather than hidden
- capacity and eviction behavior are reviewable in code
That transparency is more important here than raw cleverness.
The limitations are part of the lesson¶
This decorator is bounded because it leaves real limitations visible:
- it is not concurrency-safe
- it uses a simple O(n) eviction path
- it requires hashable arguments in its canonical key form
- it is not a substitute for
functools.lru_cache
Those limitations are not embarrassing leftovers. They are the proof that the wrapper is scoped honestly.
Order and policy still matter¶
If you stack this cache with other decorators, semantics change:
- logging outside the cache logs every call attempt
- logging inside the cache logs only cache misses
- timing outside the cache times both hits and misses
- timing inside the cache mostly times uncached work
That reinforces the module's central point: stacked decorator order is semantic, not ornamental.
What this example makes clear about Module 04¶
This worked example ties the module together:
- nested wrappers and rebinding mechanics still underlie the design
- definition-time factory behavior stays separate from call-time cache behavior
- stateful wrappers deserve policy-level review
functools.wrapsand explicit state surfaces keep the wrapper inspectable
That is the durable takeaway. The cache is not here as a production recommendation. It is here as a clear specimen of where transparency starts to give way to policy.
The review loop to keep¶
When you inherit or design a stateful decorator, run this loop:
- identify the state and the semantic rule it now owns
- make reset and inspection surfaces explicit
- preserve metadata so tooling can still see the logical callable
- document limitations instead of letting the wrapper pretend to be more general than it is
If you can do that here, Module 04 has done its job and the next decorator module can take on heavier policy and typing concerns with less ambiguity.
Continue through Module 04¶
- Previous: Wraps and Signature Transparency
- Next: Exercises
- Reference: Exercise Answers
- Terms: Glossary