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.
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
print(square(5)) # 2502reduce: 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.
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)) # 003lru_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.
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
Watch the cache work
In the second example, add
embed("hello")three more times before thecache_info()line. Predict the newhitsnumber (it goes up by 3), and check thatreal calls madestays at 2. - 2
Shrink the cache
Change
maxsize=128tomaxsize=1and callembed("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
reduce without a start value
Run
reduce(lambda a, b: a + b, [])and read the error. Then add, 0and run it again.
Code & diagrams
Make a third partial, for example `ten_times = partial(power, 10)`, and predict ten_times(3).
Expected output
square(5): 25
cube(2): 8
partial remembers: power {'exponent': 2}
reduce sum: 10
reduce longest: bbbb
reduce empty with start: 0cache needs Python 3.9 or later. Remove @lru_cache from embed and see the call count go up to 3.
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.
from functools import lru_cache
@lru_cache
def total(numbers):
return sum(numbers)
print(total((1, 2, 3)))
print(total([1, 2, 3]))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_cacheis 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)andf(1.0)hit the same entry (they compare equal and have equal hashes), unless you passtyped=True. This can return anintresult where you expected afloat. Keyword arguments are part of the key in the order written, sof(1, b=2)andf(b=2, a=1)are different entries (two misses), andf(1)andf(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()showsmaxsizeandtyped, andf.__wrapped__is the undecorated function for tests. Pickmaxsizefrom 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_propertystores 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. Apartialobject has no__name__and no docstring of its own; callfunctools.update_wrapper(p, func)if a tool needs them.singledispatchfollows inheritance, so aboolgoes to theintimplementation unless you registerboolseparately; for methods usesingledispatchmethod, and for dispatch on the second argument you need a different design. - ▸
Caching an instance method with
@lru_cachekeepsselfalive for as long as the cache holds it, which can cause memory leaks. Prefercached_propertyfor 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_cacheis the smallest version of that design.
Remember this
- 1
functools.partial(func, *args, **kwargs)(the stars collect extra arguments, Topic 5.3) makes a new function that callsfuncwith 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 thekey=ofsortedor a callback. Keyword arguments are clearer than positional ones here. - 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 aninitialvalue whenever the input might be empty: without it, an empty iterable raisesTypeError. For sums, joins and maxima, built-ins (sum,"".join,max) are clearer;reduceearns its place for custom combinations. - 3
Caching.
@lru_cache(maxsize=128)is a decorator, a line starting with@above adefthat 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 mostmaxsizeresults 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, andfib.cache_clear()empties it. - 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
selfalive. For methods or data that changes, think before caching. - 5
@cached_propertyturns 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
@singledispatchlets one function name run different code depending on the type of its first argument, with@describe.registerfor each type. Finally,functools.wrapsis 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
What does functools.partial do and when is it useful?
How does lru_cache work, what are its requirements, and what can go wrong?
When would you use cached_property instead of lru_cache?
Why does reduce need an initial value, and when would you avoid reduce completely?
What does singledispatch do, how does it choose an implementation, and what are its limits?
Practice
Use partial to make to_binary from int so that to_binary("101") returns 5. (Hint: int(text, base=2).)
Use reduce to multiply [1, 2, 3, 4, 5] together (the factorial of 5), starting from 1.
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.
partialandreduceshorten 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
reducewith an initial value and name a built-in that is clearer.Done when you can add
@lru_cacheor@cache, readcache_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_propertyfor an expensive attribute.