← Home

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: ...  # fails

The 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 call

He 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.0

lru_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 cache

partial — 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)    # 27

The 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

TypeHashableWorks with lru_cache
int, str, floatYesYes
tupleYes (if contents hashable)Yes
listNoNo — raises TypeError
dictNoNo — 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}"

Resources