110. Functools Module
Caching and partial application with functools
110. Functools Module
Rohan adds a feature: “show me what RAM usage looks like at each percentile.” The percentile computation is expensive — it scans all historical snapshots. He calls it 100 times to build a chart, and it takes 4 seconds.
He discovers @lru_cache:
from functools import lru_cache
# Without cache: recomputes from scratch every call
def percentile(history: list, p: int) -> float:
sorted_vals = sorted(history)
idx = int(len(sorted_vals) * p / 100)
return sorted_vals[idx]
# With cache: lru_cache stores (history, p) → result
# BUT: history is a list — lists are not hashable → TypeError
@lru_cache(maxsize=128)
def percentile(history: list, p: int) -> float: ... # failsThe fix — use a tuple (immutable, hashable):
@lru_cache(maxsize=128)
def percentile(history: tuple, p: int) -> float:
sorted_vals = sorted(history)
idx = int(len(sorted_vals) * p / 100)
return sorted_vals[idx]
# Convert before calling:
history_tuple = tuple(snap["percent"] for snap in snapshots)
result = percentile(history_tuple, 95) # cached after first callHe also uses functools.partial to build pre-configured alert checkers:
from functools import partial
def check_threshold(percent: float, threshold: float) -> bool:
return percent >= threshold
is_warning = partial(check_threshold, threshold=80.0)
is_critical = partial(check_threshold, threshold=90.0)
is_warning(85.0) # True — partial pre-fills threshold=80.0
is_critical(85.0) # False — partial pre-fills threshold=90.0lru_cache rule: arguments must be hashable — no lists, no dicts. Convert to tuples or frozensets first. The cache has bounded size (
maxsize=128) — when full, it discards the least-recently-used entry.
💡 Fun fact: @lru_cache (Least Recently Used cache) was added in Python 3.2 (2011). In Python 3.9+, there’s also @cache which is the same but without a size limit (equivalent to @lru_cache(maxsize=None)). The Fibonacci sequence is the classic example: naive recursive Fibonacci has exponential time complexity O(2^n), but with memoization it becomes O(n) because each subproblem is computed only once.
⚠️ Watch out: @lru_cache keeps references to all cached return values — if your function returns large objects (like big lists or images), the cache can accumulate significant memory. Use maxsize=128 (or any small number) to limit the cache size, or call func.cache_clear() periodically. Also, cached functions must NOT have side effects — if the same arguments should produce different results (e.g., a function that reads from a changing database), caching will give stale results.
🤔 Think about it: functools.partial creates a new function by pre-filling some arguments. Is this the same as using a lambda: lambda x: apply_discount(x, 0.10)? What’s the difference in how the resulting function behaves? When would you choose partial over a lambda?
Learning objectives
- Apply @lru_cache to memoize expensive recursive functions
- Understand that lru_cache requires hashable arguments (use tuples, not lists)
- Use functools.partial to create specialized functions from general ones
- Inspect cache performance with .cache_info() and reset with .cache_clear()
Key concepts
- @functools.lru_cache — memoization decorator
- Hashable arguments required for lru_cache
- functools.partial — partial application of arguments
- .cache_info() — inspect cache hits and misses
- tuple vs list for hashable sequences
Try it
Concept detail
functools — Higher-Order Function Utilities
lru_cache — Memoization
@lru_cache stores the results of function calls and returns cached results for repeated calls with the same arguments. Arguments must be hashable (no lists or dicts).
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2: return n
return fibonacci(n-1) + fibonacci(n-2)
fibonacci(50) # instant with cache; exponential time without it
fibonacci.cache_info() # CacheInfo(hits=48, misses=51, maxsize=128, currsize=51)
fibonacci.cache_clear() # reset the cachepartial — Pre-fill Arguments
functools.partial creates a new function with some arguments already filled in:
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2) # pre-fill exponent=2
cube = partial(power, exponent=3)
square(5) # 25
cube(3) # 27The pre-filled keyword argument name must exactly match the parameter name in the original function — a wrong name raises TypeError at call time.
Hashable Arguments for lru_cache
| Type | Hashable | Works with lru_cache |
|---|---|---|
int, str, float | Yes | Yes |
tuple | Yes (if contents hashable) | Yes |
list | No | No — raises TypeError |
dict | No | No — raises TypeError |
Convert lists to tuples before caching:
result = cached_fn(tuple(my_list))Solution
import functools
@functools.lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
"""Return the nth Fibonacci number using memoization."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Fix BUG 1: use a tuple (hashable) instead of a list
@functools.lru_cache(maxsize=128)
def cached_sum(numbers: tuple) -> int:
"""Return the sum of a tuple of numbers (with caching)."""
return sum(numbers)
def make_multiplier(factor: int):
"""Return a function that multiplies its argument by factor."""
# Fix BUG 2: use the correct keyword name 'factor' matching the lambda parameter
return functools.partial(lambda x, factor: x * factor, factor=factor)
def apply_discount(price: float, discount: float) -> float:
"""Return price after applying a fractional discount (0.0 to 1.0)."""
return round(price * (1 - discount), 2)
def make_ten_percent_discount():
"""Return a function that applies a 10% discount to any price."""
return functools.partial(apply_discount, discount=0.10)Tests
def test_fibonacci_correct_values():
assert fibonacci(0) == 0
assert fibonacci(1) == 1
assert fibonacci(10) == 55
assert fibonacci(20) == 6765
def test_fibonacci_cached_is_fast():
# Call once to populate cache, call again — cache_info should show hits
fibonacci.cache_clear()
fibonacci(30)
info = fibonacci.cache_info()
assert info.hits > 0, f"lru_cache should record cache hits, got: {info}"
def test_cached_sum_with_tuple():
# Must accept a tuple (hashable), not a list
result = cached_sum((1, 2, 3, 4, 5))
assert result == 15, f"Expected 15, got {result}"
def test_cached_sum_list_raises_type_error():
# Passing a list to lru_cache function should raise TypeError
try:
cached_sum([1, 2, 3])
# If we reach here, the bug is present — list was accepted
assert False, "cached_sum([1,2,3]) should raise TypeError for unhashable list"
except TypeError:
pass # expected — lists are not hashable
def test_make_multiplier_returns_callable():
double = make_multiplier(2)
assert callable(double), "make_multiplier should return a callable"
def test_make_multiplier_correct_result():
double = make_multiplier(2)
triple = make_multiplier(3)
assert double(5) == 10, f"double(5) should be 10, got {double(5)}"
assert triple(4) == 12, f"triple(4) should be 12, got {triple(4)}"
def test_apply_discount_correct():
result = apply_discount(100.0, 0.20)
assert result == 80.0, f"Expected 80.0, got {result}"
def test_make_ten_percent_discount():
discount_fn = make_ten_percent_discount()
result = discount_fn(200.0)
assert result == 180.0, f"Expected 180.0 after 10% discount, got {result}"