Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~27 min· topic 5 of 9

Topic 9.5

functools

In one line

functools holds tools that build new functions from old ones: partial fixes some arguments, reduce folds a sequence into one value, lru_cache and cache remember results, cached_property computes an attribute once, singledispatch picks code by type, and wraps keeps a wrapper honest.

Think of it like this

A photocopier with settings. A partial is a copier with the paper size already chosen: you still press Start, but one choice is made for you. A cache is a notebook next to the phone where you write each answer you looked up, so the second time someone asks you read it instead of phoning again.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

partial
A new function made from an old one with some arguments already chosen.
reduce
Combine all the items of a sequence into a single value by applying a function again and again.
Cache
A store of earlier answers, so the same question is not answered twice.
Memoization
Caching a function's results by its arguments.
LRU
Least Recently Used: the rule that when the cache is full, the entry unused for the longest time is removed.
Pure function
A function whose result depends only on its arguments and which changes nothing outside itself.
Hashable
Can be turned into a fixed number so it can be a dictionary key. Numbers, strings and tuples of those are hashable; lists are not.
Dispatch
Choosing which code to run, here by looking at the type of the argument.

Step by step

01partial: fix some arguments

power(base, exponent) needs two arguments. partial(power, exponent=2) is a new function that needs only the base. The partial object remembers the original function (.func) and the fixed values (.keywords), which helps when debugging.

main.pywhole filepython
from functools import partial

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

square = partial(power, exponent=2)
print(square(5))        # 25

02reduce: many values into one

reduce keeps a running result. At each step it calls your function with the result so far and the next item. The starting value is the third argument. (lambda total, n: total + n is a small function with no name, Topic 5.6.) With an empty list it just returns the starting value, which is why it is the safe form.

main.pywhole filepython
from functools import reduce

print(reduce(lambda total, n: total + n, [1, 2, 3, 4], 0))   # 10
print(reduce(lambda total, n: total + n, [], 0))             # 0

03lru_cache: pay once

Decorate the function with @lru_cache. The first call with some arguments runs the body and stores the result. The next call with the same arguments returns the stored result without running the body. The example counts real calls to prove it.

cache_info() reports hits (answered from the cache), misses (had to compute), maxsize and currsize.

lru_cache: pay oncediagram
Rendering diagram…

04cache and recursion

Recursive functions (functions that call themselves, Topic 5.8) that repeat work, such as Fibonacci, become fast with @cache. Without it, fib(80) would make an astronomical number of calls. With it, each fib(n) is computed once, so there are about 80 calls. This idea (remember sub-answers) is called dynamic programming; the DSA course teaches it in depth at /dsa.

Python's default recursion limit (about 1000 frames) still applies, so very deep cached recursion can fail with RecursionError.

05Caching rules: hashable arguments, pure functions

The arguments become a dictionary key, so a list argument raises TypeError: unhashable type: 'list'. Convert it to a tuple first. The function must also be pure: a cached function that reads the clock or a file will keep returning the old answer.

For a lookup that can change (a price, a setting), use a cache with an expiry, or clear it with cache_clear() when the data changes.

06cached_property: compute once per object

The first read of report.total runs the method and stores the result in the instance; the second read just returns it. The print in the example runs only once.

It is the clean way to defer expensive setup (parsing a file, loading a model) until it is really needed.

07singledispatch and wraps

singledispatch replaces a chain of if isinstance(...) checks with one function per type, registered with @name.register and the type annotation of the first parameter (the : int in value: int, Topic 5.9). The undecorated base function is the fallback. Dispatch looks only at the first argument.

wraps is covered in the next topic, where it is used on every wrapper. Here is the key fact: it copies __name__, __doc__ and more from the wrapped function, and sets __wrapped__ so you can reach the original.

Try it yourself

  1. 1

    Watch the cache work

    In the second example, add embed("hello") three more times before the cache_info() line. Predict the new hits number (it goes up by 3), and check that real calls made stays at 2.

  2. 2

    Shrink the cache

    Change maxsize=128 to maxsize=1 and call embed("a"), embed("b"), embed("a"). The last call is a miss, because the single slot held "b". The least recently used entry was evicted.

  3. 3

    reduce without a start value

    Run reduce(lambda a, b: a + b, []) and read the error. Then add , 0 and run it again.

Code & diagrams

partial and reduce New tab

Make a third partial, for example `ten_times = partial(power, 10)`, and predict ten_times(3).

Sign in to run this example in your browser.

Expected output

square(5): 25
cube(2):   8
partial remembers: power {'exponent': 2}
reduce sum:     10
reduce longest: bbbb
reduce empty with start: 0
Caching, cached_property and singledispatch Python 3.9+ New tab

cache needs Python 3.9 or later. Remove @lru_cache from embed and see the call count go up to 3.

Sign in to run this example in your browser.

Expected output

5 5 2
real calls made: 2
CacheInfo(hits=1, misses=2, maxsize=128, currsize=2)
fib(80): 23416728348467685
  (computing total)
6
6
an int: 7
a list of 2 items
something: 'text'

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Cache a function with a list argument

Call a cached function with a list.

main.pywhole filepython
from functools import lru_cache

@lru_cache
def total(numbers):
    return sum(numbers)

print(total((1, 2, 3)))
print(total([1, 2, 3]))
terminal
$ python main.py
── what you'll see ──
6
Traceback (most recent call last):
File "main.py", line 8, in <module>
print(total([1, 2, 3]))
~~~~~^^^^^^^^^^
TypeError: unhashable type: 'list'

Myth vs fact

Myth

@cache always makes code faster.

Fact

It makes repeated calls faster and costs memory for every stored result. For functions that are called with many different arguments, or are already fast, it can be slower and wasteful.

Myth

A cached function reflects changes in the outside world.

Fact

It returns the stored answer even if a file, a database row or a price changed. Cache only functions whose answer depends on the arguments alone.

Myth

reduce is the Pythonic way to sum or join.

Fact

sum, "".join, max, any and all are clearer and faster. Use reduce when no built-in matches.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    lru_cache is implemented in C and is thread-safe in the sense that its internal data structures stay consistent, but the wrapped function may run more than once for the same arguments if two threads miss at the same time. Do not rely on it as a lock.

  • ▸

    Cache keys are built from the argument values, so f(1) and f(1.0) hit the same entry (they compare equal and have equal hashes), unless you pass typed=True. This can return an int result where you expected a float. Keyword arguments are part of the key in the order written, so f(1, b=2) and f(b=2, a=1) are different entries (two misses), and f(1) and f(a=1) are different too. Normalise calls before the cache if that matters.

  • ▸

    A cached function returns the same object every time. If it returns a list or dictionary and a caller changes it, every later caller sees the change, because the cache still holds that object. Return tuples or frozen data, or copy on the way out. Exceptions are not cached (a failing call runs again next time), f.cache_parameters() shows maxsize and typed, and f.__wrapped__ is the undecorated function for tests. Pick maxsize from how many distinct inputs you expect and how big each result is; None (the same as @cache) is only safe when the set of inputs is small and fixed.

  • ▸

    cached_property stores its value in the instance __dict__ under the same name, which is why later reads skip the method completely (it works like a property, Topic 8.4, but without a setter and without re-running). To refresh, del obj.total. Before Python 3.12 it also held a per-class lock, which could serialise threads and even deadlock; 3.12 removed it, so two threads may now compute the value twice. A partial object has no __name__ and no docstring of its own; call functools.update_wrapper(p, func) if a tool needs them. singledispatch follows inheritance, so a bool goes to the int implementation unless you register bool separately; for methods use singledispatchmethod, and for dispatch on the second argument you need a different design.

  • ▸

    Caching an instance method with @lru_cache keeps self alive for as long as the cache holds it, which can cause memory leaks. Prefer cached_property for per-instance values, or a cache stored on the instance.

  • ▸

    In AI applications the same ideas appear as prompt caches and embedding caches. The principle is identical: a stable key (the text, plus the model name), a stored value, a size or time limit, and a way to clear it. A function-level lru_cache is the smallest version of that design.

Remember this

  1. 1

    functools.partial(func, *args, **kwargs) (the stars collect extra arguments, Topic 5.3) makes a new function that calls func with some arguments already filled in. square = partial(power, exponent=2) gives a one-argument function. It is useful to adapt a function to a place that expects fewer arguments, such as the key= of sorted or a callback. Keyword arguments are clearer than positional ones here.

  2. 2

    functools.reduce(function, iterable, initial) folds a sequence into one value by applying a two-argument function from left to right: reduce(add, [1, 2, 3, 4], 0) is (((0+1)+2)+3)+4. Give an initial value whenever the input might be empty: without it, an empty iterable raises TypeError. For sums, joins and maxima, built-ins (sum, "".join, max) are clearer; reduce earns its place for custom combinations.

  3. 3

    Caching. @lru_cache(maxsize=128) is a decorator, a line starting with @ above a def that wraps the function (Topic 8.3 introduced them; Topic 9.6 explains how they work). It wraps the function so that repeated calls with the same arguments return the stored answer instead of running again. It keeps at most maxsize results and throws away the Least Recently Used one when full. @cache (Python 3.9) is the same with no limit. Use it for pure functions (same input, same output, no side effects) that are slow or expensive, such as a paid embedding lookup. fib.cache_info() shows hits and misses, and fib.cache_clear() empties it.

  4. 4

    The arguments to a cached function must be hashable, because they are the dictionary key inside the cache (Topic 4.5 explains hashing). Lists, dicts and sets are not hashable, so pass tuples or frozen values. Caching also keeps references alive: cached results (and arguments) are not freed while they are in the cache, and caching a method keeps self alive. For methods or data that changes, think before caching.

  5. 5

    @cached_property turns a method into an attribute that is computed once per instance, the first time it is read, and then stored in the instance. It is lazy initialisation with no extra code. It is a cousin of the properties from Topic 8.4. It needs the instance to have a __dict__ (so not __slots__, Topic 8.12, unless a __dict__ slot is listed), and the stored value will not update unless you delete it.

  6. 6

    @singledispatch lets one function name run different code depending on the type of its first argument, with @describe.register for each type. Finally, functools.wraps is the helper you will use in every decorator (Topic 9.6): it copies the name, docstring and other metadata from the original function onto the wrapper so that debugging, help and introspection still show the right thing.

Explain it without notes

01

What does functools.partial do and when is it useful?

02

How does lru_cache work, what are its requirements, and what can go wrong?

03

When would you use cached_property instead of lru_cache?

04

Why does reduce need an initial value, and when would you avoid reduce completely?

05

What does singledispatch do, how does it choose an implementation, and what are its limits?

Practice

01

Use partial to make to_binary from int so that to_binary("101") returns 5. (Hint: int(text, base=2).)

02

Use reduce to multiply [1, 2, 3, 4, 5] together (the factorial of 5), starting from 1.

03

Write slow_square(n) with @lru_cache(maxsize=2) that records each real call in a list. Call it with 1, 2, 1, 3, 1 in that order, then print the list and slow_square.cache_info(). Predict first which value gets evicted when 3 arrives.

Trade-offs

  • ↔

    Speed versus memory and freshness. Caching trades memory and the risk of stale answers for speed. Set a maxsize, and clear or avoid the cache where data changes.

  • ↔

    Cleverness versus clarity. partial and reduce shorten code but can hide intent. A small named function or a plain loop is often easier for the next reader.

Done when you can

  • Done when you can build a one-argument function from a two-argument one with partial.

  • Done when you can use reduce with an initial value and name a built-in that is clearer.

  • Done when you can add @lru_cache or @cache, read cache_info(), and explain the hashable-argument rule.

  • Done when you can say when a cached result goes stale and how to clear it.

  • Done when you can use cached_property for an expensive attribute.