Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 4 – Functions, scope, closures and decorators

functools: cache, partial, reduce and more

Remember results with cache and lru_cache, pre-fill arguments with partial and the 3.14 Placeholder, and use reduce, singledispatch and cmp_to_key.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Use functools.cache and lru_cache to remember the results of pure functions
  • Decide which functions are safe to cache and read cache_info() to size a cache
  • Create partial functions with partial and the 3.14 Placeholder
  • Use reduce, singledispatch, total_ordering and cmp_to_key

Before you start

Counting the down-and-right routes across an 11 × 11 grid takes 369,511 calls of a plain recursive function and 120 calls with @cache, one decorator line from functools, the standard library’s module of tools that take a function and give you a better one. cache and lru_cache remember what a function returned for each set of arguments, so a repeated call costs one dictionary lookup instead of the work. partial pre-fills some arguments and hands back a function that needs fewer, and Python 3.14’s Placeholder can leave a positional slot open. reduce folds a sequence into one value.

Below you will step through a cache’s hits and misses and an LRU cache dropping entries, see what makes a function unsafe to cache, compute an attribute once with cached_property, fill a partial object’s slots, and meet singledispatch, total_ordering and cmp_to_key.

@cache answers a repeated call from memory

Start with a small grid. The routes to a cell are the routes to the cell above it plus the routes to the cell on its left, and a cell on the top or left edge has exactly one route. paths() prints a line each time its body runs, which with @cache happens only on a cache miss: A call whose arguments a cache has not stored yet, so the function runs and its result is stored. cache_info() counts them as misses.:

A cached function that says when it runs Python · small_grid.py
from functools import cache


@cache
def paths(r, c):
    print(f"  computing paths({r}, {c})")   # runs only on a miss
    if r == 0 or c == 0:
        return 1
    return paths(r - 1, c) + paths(r, c - 1)


print("routes:", paths(2, 2))
print(paths.cache_info())

Output

  computing paths(2, 2)
  computing paths(1, 2)
  computing paths(0, 2)
  computing paths(1, 1)
  computing paths(0, 1)
  computing paths(1, 0)
  computing paths(2, 1)
  computing paths(2, 0)
routes: 6
CacheInfo(hits=1, misses=8, maxsize=None, currsize=8)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 small_grid.py

Eight lines were printed for nine calls. Step through the calls in the order they happen and find the one that printed nothing:

Interactive · stage by stage The nine calls of paths(2, 2) with @cache Step through the calls in the order they happen. A miss runs the function and stores its answer; a hit returns a stored answer.
The nine calls of paths(2, 2) with @cache, step 9 of 9. Showing paths(2, 2), paths(1, 2), paths(0, 2), paths(1, 1), paths(0, 1), paths(1, 0), paths(2, 1), paths(1, 1) again, paths(2, 0).paths(2, 2)miss: needs (1, 2) and (2, 1)paths(1, 2)miss: needs (0, 2) and (1, 1)paths(0, 2)miss: an edge, 1 routepaths(1, 1)miss: needs (0, 1) and (1, 0)paths(0, 1)miss: an edge, 1 routepaths(1, 0)miss: an edge, 1 routepaths(2, 1)miss: needs (1, 1) and (2, 0)paths(1, 1) againhit: 2, from the cachepaths(2, 0)miss: an edge, 1 route

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. The first call. paths(2, 2) is not in the cache, a miss, so the function runs and prints its line. It needs the routes to the cell above, (1, 2), and to the cell on the left, (2, 1).
  2. Going up. paths(1, 2) is a miss too. It needs (0, 2) and (1, 1).
  3. An edge. (0, 2) is on the top edge, so there is one route. The answer 1 is stored under the key (0, 2).
  4. The middle cell. paths(1, 1) is a miss. It needs (0, 1) and (1, 0), two more cells on the edges.
  5. Two edges. (0, 1) and (1, 0) give 1 route each, and both are stored. So paths(1, 1) is 1 + 1 = 2, stored under (1, 1), and paths(1, 2) is 1 + 2 = 3.
  6. The other branch. Back in paths(2, 2), the second branch asks for paths(2, 1), a miss. It needs (1, 1) and (2, 0).
  7. The hit. paths(1, 1) is in the cache now, so the call returns the stored 2 at once. The function does not run, prints nothing, and the two calls it would make, for (0, 1) and (1, 0), never happen.
  8. The last edge. (2, 0) is on the left edge, 1 route. paths(2, 1) is 2 + 1 = 3, and paths(2, 2) is 3 + 3 = 6.
  9. The count. Eight misses printed eight lines and one hit printed none, which is what cache_info() reports, hits=1 and misses=8. Without the cache, paths(2, 2) would take 11 calls; for paths(10, 10), an 11 × 11 grid, the gap is 120 against 369,511.

The second request for paths(1, 1) was a cache hit: A call a cache answers from a result it stored before, without running the function. cache_info() counts them as hits.: the stored answer came back without running the function, and the calls below it never happened. On a bigger grid the saving grows fast:

Grid routes with and without a cache Python · grid_paths.py
from functools import cache

calls = {"plain": 0, "cached": 0}


def paths(r, c):
    """Routes from (0, 0) to (r, c) that move only down or right."""
    calls["plain"] += 1
    if r == 0 or c == 0:
        return 1
    return paths(r - 1, c) + paths(r, c - 1)


@cache
def paths_cached(r, c):
    calls["cached"] += 1
    if r == 0 or c == 0:
        return 1
    return paths_cached(r - 1, c) + paths_cached(r, c - 1)


print("plain: ", paths(10, 10), "after", f"{calls['plain']:,}", "calls")
print("cached:", paths_cached(10, 10), "after", calls["cached"], "calls")
print(paths_cached.cache_info())

# A bigger grid reuses the 120 answers already stored.
print(paths_cached(30, 30), paths_cached.cache_info())

Output

plain:  184756 after 369,511 calls
cached: 184756 after 120 calls
CacheInfo(hits=81, misses=120, maxsize=None, currsize=120)
118264581564861424 CacheInfo(hits=842, misses=960, maxsize=None, currsize=960)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 grid_paths.py

Without the cache, paths(10, 10) takes 369,511 calls; with it, 120, one for each cell that is ever asked for. The misses are the calls that ran the function, and the hits the calls answered from the cache, 81 here. The call paths_cached(30, 30) afterwards reused the 120 stored answers and added 840 more, a cache of 960 entries in all. Its answer is exact, because Python’s integers have no size limit: there are 118,264,581,564,861,424 routes. Press Edit, change paths_cached(30, 30) to paths_cached(40, 40) and run it: the cache grows to 1,680 entries.

Key idea

A cache runs the function once for each new set of arguments and answers every repeat with a lookup, so the work follows the number of distinct calls, not the number of calls.

An LRU cache keeps the most recently used results

functools.cache is lru_cache(maxsize=None): it never forgets, which makes it a little smaller and faster than a cache with a limit, and it grows for as long as new arguments arrive. functools.lru_cache(maxsize=128), the default size, keeps only the most recently used results. When a new result arrives and the cache is full, it drops the least recently used (LRU): The stored entry whose last use is the longest ago. A full lru_cache drops it to make room for a new result. entry, the one whose last call is the longest ago:

A call's arguments become the key: a hit returns the stored result; a miss runs the function, stores it, may drop the least recently used.a call:shelf_price("tea")the argumentsbecome the key(they must be hashable)key inthe cache?hit: return thestored result and markit most recently usedmiss: run thefunction andstore its resultovermaxsize?drop the leastrecently used entryreturn the resultyesnoyesno

What lru_cache does on every call

Text description of the diagram

The diagram is a flow chart from top to bottom.

  • A call such as shelf_price("tea") arrives, and its arguments become the cache key. That is why arguments must be hashable.
  • A decision asks whether the key is in the cache.
  • Yes, a hit: the stored result is returned at once and the entry becomes the most recently used one. The function does not run.
  • No, a miss: the function runs and its result is stored under the key.
  • After a miss, a second decision asks whether the cache now holds more than maxsize entries. If it does, the least recently used entry is dropped. Either way, the result is returned.

With maxsize=None, as functools.cache uses, the cache is never full, so nothing is ever dropped.

The program follows that flow with a cache of three entries. Its function prints a line only when it runs, on a miss:

A cache of three entries Python · lru_eviction.py
from functools import lru_cache


@lru_cache(maxsize=3)
def shelf_price(item):
    print(f"  looking up {item}")   # runs only on a miss
    return len(item) * 10


for item in ["tea", "rice", "tea", "salt", "dal", "rice", "tea"]:
    shelf_price(item)
print(shelf_price.cache_info())
print(shelf_price.cache_parameters())
shelf_price.cache_clear()
print(shelf_price.cache_info())

Output

  looking up tea
  looking up rice
  looking up salt
  looking up dal
  looking up rice
  looking up tea
CacheInfo(hits=1, misses=6, maxsize=3, currsize=3)
{'maxsize': 3, 'typed': False}
CacheInfo(hits=0, misses=0, maxsize=3, currsize=0)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lru_eviction.py

Step through the seven calls and watch the three entries, from the most to the least recently used:

Interactive · step by step A cache of three entries over seven calls Step through the calls of lru_eviction.py. The right column lists the stored items from most to least recently used.
A cache of three entries over seven calls, step 7 of 7. Showing the call (shelf_price('tea')), cache_info() (hits 1 · misses 6), dropped (salt), most recently used (tea), next (rice), least recently used (dal). In focus: most recently used, dropped, cache_info().the callshelf_price('tea')cache_info()hits 1 · misses 6droppedsaltmost recently usedteanextriceleast recently useddal

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. tea, a miss. The cache is empty, so shelf_price("tea") runs, prints "looking up tea" and stores its result.
  2. rice, a miss. rice is not stored either. It runs, and its entry becomes the most recently used.
  3. tea, a hit. tea is in the cache, so the stored result comes back and nothing is printed. Using tea makes it the most recently used entry again.
  4. salt, a miss. salt is new. The cache now holds three entries, as many as maxsize=3 allows.
  5. dal, a miss. dal is new and the cache is full, so the least recently used entry has to go. That is rice, used longest ago.
  6. rice again, a miss. rice was dropped a step ago, so it is a miss and runs again. To make room, tea, now the least recently used, is dropped.
  7. tea again, a miss. tea is gone too, so the last call is a miss as well, and salt is dropped. One hit in seven calls.

The second tea is a hit. When dal arrives, the cache holds rice, tea and salt, and rice was used longest ago, so it is dropped; the next rice is a miss again and drops tea, and the last tea drops salt. One hit in seven calls: a hit ratio, hits / (hits + misses), of about 14 %, which says this cache is too small for the way it is used. cache_info() is the number to watch when you choose maxsize; cache_parameters() shows the settings and cache_clear() empties the cache.

Checkpoint

Check: Remembering results with @cache · lru_cache and its maxsize

  1. Question 1 of 2 paths(r, c) from small_grid.py, under @cache, is called as paths(3, 3) on a fresh cache. How many misses does cache_info() report?

    Type a number.

    A hint

    Every cell a call asks for is a miss once. Which cells of the 4 × 4 grid are ever asked for?

    Show the answer: Question 1 of 2

    Answer: 15 misses

    The calls reach every cell (r, c) with r and c from 0 to 3 except (0, 0), which no call asks for: 16 − 1 = 15 cells, each worked out once. Every later request for one of them is a hit.

  2. Question 2 of 2 A function under @lru_cache(maxsize=2) is called with "x", "y", "x", "z", "x", in that order. How many of the five calls are hits?

    Type a number.

    A hint

    After each call, which two entries are kept, and which of them was used longest ago?

    Show the answer: Question 2 of 2

    Answer: 2 hits

    "x" and "y" are misses. The second "x" is a hit and makes "x" the most recently used. "z" is a miss and drops "y", the least recently used, so the last "x" is a hit again: two hits, three misses.

Cache only pure functions with hashable arguments

A cache returns the stored result instead of running the function, so it is only correct for a pure function: A function whose result depends only on its arguments and which changes nothing outside itself, such as gcd(a, b). Only such a function is safe to cache.: one whose result depends on nothing but its arguments, unlike one that reads the clock or a file, and which has no side effects, such as writing to a file or sending a message. Even such a function can trip over the cache. Step through the first trap, a cached function that returns a list:

Interactive · line by line A cached function that returns a list Step through the program and watch first and second. The cache keeps the list it returned the first time.

Program · shared_result.py

from functools import cache


@cache
def tags_for(product):
    return ["new", "sale"]


first = tags_for("lamp")
first.append("clearance")
second = tags_for("lamp")
print(second, first is second)

Variables

After line 12: cache is cache(user_function, /); tags_for is <_lru_cache_wrapper object>; first is ['new', 'sale', 'clearance']; second is the same list as first.cachecache(user_function, /)tags_for<_lru_cache_wrapper object>first'new'0'sale'1'clearance'2secondsame list as first
  • changed in this step

Printed so far

['new', 'sale', 'clearance'] True

Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.

The drawing shows the variables when the program ends, and the program's 10 steps are listed below.

Line run 12
Next line end
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. from functools import cache loads cache.
  3. Line 4. Line 4 runs.
  4. Line 5. def tags_for(product): makes the function tags_for. Its body runs only when it is called.
  5. Line 4. Line 4 runs.
  6. Line 5. def tags_for(product): makes the function tags_for. Its body runs only when it is called. The name tags_for now refers to the cache's wrapper around the function.
  7. Line 9. first = tags_for("lamp") sets first to ['new', 'sale'].
  8. Line 10. first.append("clearance") adds 'clearance' to the end of first. The caller changes the list it was given. That is the very list object the cache stored for "lamp".
  9. Line 11. second = tags_for("lamp") makes second a second name for the list first names: one list, two names, not a copy. The bug The second call is a hit, so the cache hands back the same list, 'clearance' and all. second is not a copy; it is the list first names.
  10. Line 12. print(second, first is second) prints ['new', 'sale', 'clearance'] True. That was the last line, so the program ends. Every later caller of tags_for("lamp") now gets three tags. Return a tuple or a frozenset from a cached function, so that nobody can change the stored answer.

The cache stores the object it returned, not a copy, so the caller’s append() reached every later caller. That trap and three others, in one program:

Four ways to surprise a cache Python · cache_rules.py
from functools import lru_cache


@lru_cache(maxsize=None)
def tags_for(product):
    return ["new", "sale"] if product == "lamp" else ["new"]


first = tags_for("lamp")
first.append("clearance")       # a caller changes the list it was given ...
print(tags_for("lamp"))         # ... and the cached answer changed for every caller

try:
    tags_for(["lamp"])          # the arguments become the key, so they must be hashable
except TypeError as err:
    print("TypeError:", err)


@lru_cache(maxsize=None)
def area(width, height):
    return width * height


area(2, 3)
area(width=2, height=3)         # the same call to you, another key to the cache
area(height=3, width=2)
print(area.cache_info())


@lru_cache(maxsize=None)
def half(x):
    return x / 2


@lru_cache(maxsize=None)
def add(x, y):
    return x + y


half(4)
half(4.0)                       # one int argument: a separate entry even without typed=True
add(1, 2)
add(1.0, 2.0)                   # two arguments: (1, 2) and (1.0, 2.0) share one entry
print(half.cache_info().currsize, add.cache_info().currsize)


@lru_cache(maxsize=None, typed=True)
def add_typed(x, y):
    return x + y


add_typed(1, 2)
add_typed(1.0, 2.0)             # typed=True: separate entries for int and float, always
print(add_typed.cache_info().currsize)

Output

['new', 'sale', 'clearance']
TypeError: unhashable type: 'list'
CacheInfo(hits=0, misses=3, maxsize=None, currsize=3)
2 1
2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cache_rules.py

  1. A mutable result is shared. tags_for("lamp") returns the same list object on every call, so one caller’s append() changed the answer for everyone. Return a tuple or a frozenset, or a copy.
  2. The arguments are the key, so they must be hashable: A value that can be a dict key or a set item, because its hash never changes while it exists: numbers, strings and tuples of hashable values are hashable; lists, dicts and sets are not.. A list argument raises TypeError: unhashable type. Pass a tuple or a frozenset instead.
  3. The same call can be spelt in different ways. area(2, 3), area(width=2, height=3) and area(height=3, width=2) are three entries: the documentation says that different argument patterns may be cached separately, and here they were.
  4. Equal values of different types. With typed=True, an int and an equal float always get separate entries. Without it, the documentation says they are usually treated as one call, with exceptions such as int and str, and the run shows both cases: a single argument, 4 and then 4.0, got two entries anyway, while the pairs (1, 2) and (1.0, 2.0) shared one. Code should not depend on either.

Two more rules follow from the documentation. The cache keeps references to its arguments and results until they are dropped or the cache is cleared, so an unbounded cache in a program that runs for weeks keeps everything it has ever seen. And a cached method includes self in its key, so the cache keeps every instance alive; the Programming FAQ discusses the options. The caches are thread-safe, in that the stored data stays consistent, but two threads that ask for a new value at the same moment may both run the function.

A cached property is computed once per instance

@cached_property turns a method into an attribute that is computed the first time it is read and then stored in the instance like any other attribute:

An attribute computed once Python · cached_property_demo.py
import statistics
from functools import cached_property


class Marks:
    def __init__(self, values):
        self.values = values

    @cached_property
    def summary(self):
        print("  (computing the summary)")
        return statistics.mean(self.values), max(self.values)


m = Marks([62, 75, 81, 94, 58])
print(m.summary)
print(m.summary)                 # read from the instance: no second computation
print("summary" in vars(m))      # the value now lives in the instance's __dict__

m.values.append(100)
print(m.summary)                 # stale: the cache does not know the list changed
del m.summary                    # deleting the attribute clears it
print(m.summary)

Output

  (computing the summary)
(74, 94)
(74, 94)
True
(74, 94)
  (computing the summary)
(78.33333333333333, 100)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cached_property_demo.py

Step through the reads of m.summary and watch where the value lives:

Interactive · stage by stage The life of a cached_property, m.summary Step through the reads of m.summary in cached_property_demo.py and watch where the value lives.
The life of a cached_property, m.summary, step 6 of 6. Showing First read of m.summary, The method runs, The value is stored, The second read, m.values.append(100), del m.summary. In focus: del m.summary.First read of m.summarynot in m.__dict__ yetThe method runsprints, returns (74, 94)The value is storedin m.__dict__['summary']The second readfound there: no callm.values.append(100)summary is now staledel m.summarythe next read runs it again

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The first read. m.summary is read for the first time. The instance's __dict__ has no entry called summary, so the decorator's code takes over.
  2. The method runs. The method body runs once, prints "(computing the summary)" and returns the mean and the maximum, (74, 94).
  3. Stored in the instance. The result is put into m.__dict__ under the name summary, like any attribute you assign. That is why "summary" in vars(m) printed True.
  4. The second read. The next read finds summary in the instance's __dict__ and returns it at once. Nothing is printed, because the method does not run.
  5. Stale. Appending 100 to m.values changes the marks, but the stored summary does not know. The third read still gives (74, 94).
  6. Cleared. del m.summary removes the stored value, so the next read runs the method again and gives (78.33333333333333, 100).

The second read printed nothing: the value was already in the instance’s __dict__. Because it is only computed once, it does not notice that values changed later; deleting the attribute clears it, and the next read computes it again. It needs a writable __dict__, so it does not work on a class with __slots__ that leaves __dict__ out. Since Python 3.12 it takes no lock, so two threads may both compute the value the first time. For a method whose result depends on its arguments, not just on the instance, the Programming FAQ’s advice on caching method calls applies.

Checkpoint

Check: What is safe to cache · cached_property

  1. Question 1 of 2 What does this program print?

    Read the code, then choose one answer.

    from functools import cache
    
    @cache
    def defaults():
        return {"theme": "light"}
    
    a = defaults()
    a["theme"] = "dark"
    print(defaults()["theme"])
    Show the answer: Question 1 of 2

    Answer: dark

    @cache keeps the object the function returned and hands that same object to every caller. Changing it changes the cached answer for everyone, so cached functions should return immutable values, or copies.

  2. Question 2 of 2 After m.values.append(100), m.summary still shows (74, 94). What makes the next read of m.summary compute it again?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: del m.summary

    cached_property computes the value on the first read and stores it in the instance under the attribute's name. It never notices later changes; del m.summary removes the stored value, and the next read computes it afresh.

partial() pre-fills arguments, and Placeholder keeps a slot open

partial(func, *args, **keywords) returns a partial object: What functools.partial() returns: a callable that holds a function and some of its arguments, in .func, .args and .keywords, and adds them to the arguments of each call., a callable that calls func with those arguments already filled in, followed by whatever you pass when you call it:

partial() with keywords, and with Placeholder Python · partial_placeholder.py
from functools import Placeholder, partial

square = partial(pow, exp=2)        # fixes a keyword argument
from_binary = partial(int, base=2)
print(square(12), from_binary("101101"))
print(square.func, square.args, square.keywords)

# str.replace takes its arguments by position only, so keywords cannot pre-fill them.
try:
    partial(str.replace, old=" ", new="-")("learn python")
except TypeError as err:
    print("TypeError:", err)

# New in 3.14: Placeholder keeps a positional slot open for the call to fill.
slugify = partial(str.replace, Placeholder, " ", "-")
print(slugify("learn python fast"))
cube = partial(pow, Placeholder, 3)
print(cube(4), cube)

Output

144 45
<built-in function pow> () {'exp': 2}
TypeError: replace() takes at least 2 positional arguments (0 given)
learn-python-fast
64 functools.partial(<built-in function pow>, Placeholder, 3)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 partial_placeholder.py

partial(pow, exp=2) is a squaring function and partial(int, base=2) reads binary numbers. The partial object keeps the function and the arguments in func, args and keywords, and the values are fixed when you create it, which is why it fixes the late-binding trap of functions built in a loop.

Keywords cannot fill parameters that are positional-only, such as the old and new of str.replace(), as the TypeError shows. Positional arguments are filled from the left, so before Python 3.14 partial could not pre-fill the second and third while leaving the first open. functools.Placeholder, new in 3.14, keeps a slot open. Step through slugify("learn python fast") and watch the slots:

Interactive · step by step How partial() with Placeholder fills its slots Step through slugify("learn python fast"). The partial object and the call are on the left, the three arguments on the right.
How partial() with Placeholder fills its slots, step 5 of 5. Showing the partial object, slugify(…), the result ('learn-python-fast'), 1st argument ('learn python fast'), 2nd argument, 3rd argument. In focus: 1st argument, 2nd argument.fills the open slotcalls str.replacethe partial objectfunc: str.replaceslugify(…)'learn python fast'the result'learn-python-fast'1st argument'learn python fast'2nd argument' '3rd argument'-'

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. partial() stores. partial(str.replace, Placeholder, " ", "-") stores the function and three positional slots. Placeholder marks the first one as open; the other two hold a space and a hyphen.
  2. The call. slugify("learn python fast") brings one positional argument, the text to work on.
  3. The open slot is filled. The call's first argument goes into the open slot. The slots after it keep the values partial() stored.
  4. The full call. The partial object calls str.replace("learn python fast", " ", "-"), which returns 'learn-python-fast', the line the program printed.
  5. Without Placeholder. Positional arguments fill from the left, so partial(str.replace, " ", "-") would make the space the text to work on. Before Python 3.14, partial() could not leave the first slot open.

partial(str.replace, Placeholder, " ", "-") waits for the string to work on and replaces its spaces with hyphens, and partial(pow, Placeholder, 3) cubes whatever it is given.

Version note

Python 3.14 also made partial objects behave like functions when you put them in a class body: read through an instance, they are now bound to it, the way a method is, so each call passes the instance on, right after the arguments the partial object already holds. Wrap the partial object in staticmethod() to keep the behaviour of older versions.

reduce() folds a sequence into one value

reduce(function, iterable, initial) produces a fold: Combining a sequence into one value two items at a time: reduce(add, [3, 8, 1]) works out (3 + 8) + 1. sum(), max() and math.prod() are folds with names of their own. More in “Lambdas and higher-order functions”: it combines the first two items with the function, then that result with the next item, until one value is left:

Folding digits and settings Python · reduce_fold.py
import operator
from functools import reduce

digits = [4, 0, 9, 6]
print(reduce(lambda number, d: number * 10 + d, digits))   # ((4*10 + 0)*10 + 9)*10 + 6

settings = [{"theme": "light", "font": 14}, {"font": 16}, {"theme": "dark"}]
print(reduce(operator.or_, settings, {}))                  # later dicts win

print(reduce(operator.add, [], initial=0))                 # initial by keyword: 3.14

Output

4096
{'theme': 'dark', 'font': 16}
0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reduce_fold.py

Folding the digits 4, 0, 9 and 6 with number * 10 + d builds the number 4,096 one digit at a time. Folding a list of settings dictionaries with operator.or_, the | operator, merges them so that later settings win. Step through the merge:

Interactive · stage by stage reduce() merging three settings dictionaries Step through reduce(operator.or_, settings, {}). Each stage is one | between the result so far and the next dictionary.
reduce() merging three settings dictionaries, step 5 of 5. Showing start with {}, {} | settings[0], … | settings[1], … | settings[2], reduce() returns it. In focus: reduce() returns it.start with {}the initial value{} | settings[0]theme light, font 14… | settings[1]theme light, font 16… | settings[2]theme dark, font 16reduce() returns itlater settings won

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The initial value. reduce() starts from the initial value, an empty dict. An empty list of settings would return it as it is, with no error.
  2. The first merge. operator.or_ is the | operator. {} | settings[0] gives a new dict with theme light and font 14.
  3. A later font. The next dictionary sets font to 16. With |, a key on the right replaces the same key on the left, so 16 wins over 14.
  4. A later theme. The last dictionary sets theme to dark, which replaces light. font keeps 16, because nothing later sets it.
  5. The result. reduce() returns {'theme': 'dark', 'font': 16}, the second line the program printed. The order of the list decides which setting wins.

The initial value {} makes an empty list give an empty dictionary instead of an error, and it takes the place of a first item, so the merge makes one | for each of the three dictionaries. Since Python 3.14, initial can also be given by keyword, as the program’s last line does. When a built-in does the job, as sum(), max(), math.prod() and "".join() do, it is clearer than reduce().

Checkpoint

Check: partial() and Placeholder · Folding with reduce()

  1. Question 1 of 2 What does this program print on Python 3.14?

    Read the code, then choose one answer.

    from functools import partial, Placeholder
    
    power_of_two = partial(pow, 2)
    cube = partial(pow, Placeholder, 3)
    print(power_of_two(5), cube(5))
    Show the answer: Question 1 of 2

    Answer: 32 125

    Positional arguments stored by partial() fill from the left; Placeholder leaves a slot open for the call. So power_of_two(5) is pow(2, 5) = 32 and cube(5) is pow(5, 3) = 125.

  2. Question 2 of 2 What does this program print?

    Read the code, then choose one answer.

    import operator
    from functools import reduce
    
    layers = [{"lang": "en"}, {"lang": "hi", "size": 14}, {"size": 16}]
    print(reduce(operator.or_, layers, {}))
    Show the answer: Question 2 of 2

    Answer: {'lang': 'hi', 'size': 16}

    reduce() starts from {} and merges each layer in turn: {} | {"lang": "en"}, then that | {"lang": "hi", "size": 14}, then that | {"size": 16}. Later layers win, which gives {'lang': 'hi', 'size': 16}.

singledispatch picks an implementation by the argument’s type

@singledispatch turns a function into a generic function: A function with one implementation per type, chosen at each call from the type of an argument. functools.singledispatch makes one that dispatches on the first argument.: it chooses which implementation to run from the type of its first argument. You register an implementation for a type with @pretty.register, and the type comes from the annotation of the first parameter:

A pretty-printer that dispatches on type Python · singledispatch_pretty.py
from functools import singledispatch


@singledispatch
def pretty(value, indent=0):
    """Fallback for any type without its own implementation."""
    return " " * indent + repr(value)


@pretty.register
def _(value: int, indent=0):
    return " " * indent + f"{value:,}"


@pretty.register
def _(value: list, indent=0):
    inner = [pretty(item, indent + 2) for item in value]
    return "\n".join([" " * indent + "[", *inner, " " * indent + "]"])


@pretty.register
def _(value: dict, indent=0):
    lines = [" " * indent + "{"]
    for key, item in value.items():
        lines.append(" " * (indent + 2) + f"{key}:")
        lines.append(pretty(item, indent + 4))
    lines.append(" " * indent + "}")
    return "\n".join(lines)


print(pretty({"orders": [1200, 75000], "city": "Pune", "paid": True}))
print("bool uses the int version:", pretty.dispatch(bool) is pretty.dispatch(int))
print("str falls back to object:", pretty.dispatch(str) is pretty.dispatch(object))
print(sorted(t.__name__ for t in pretty.registry))

Output

{
  orders:
    [
      1,200
      75,000
    ]
  city:
    'Pune'
  paid:
    1
}
bool uses the int version: True
str falls back to object: True
['dict', 'int', 'list', 'object']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 singledispatch_pretty.py

Step through the values of the order and the implementation each one reaches:

Interactive · step by step Which pretty() runs for each value Step through pretty() on the order data. Each value on the left is sent to the implementation for its type on the right.
Which pretty() runs for each value, step 6 of 6. Showing the order dict, [1200, 75000], 1200 and 75000, 'Pune', True, for dict, for list, for int, the fallback. In focus: for int.no str versionbool is an intthe order dicttype dict[1200, 75000]type list1200 and 75000type int'Pune'type strTruetype boolfor dictone line per keyfor listone line per itemfor intdigits grouped: 1,200the fallbackregistered for object

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The dict. pretty() looks at the type of its first argument. The outer value is a dict, so the dict implementation runs, and it calls pretty() again on each value it holds.
  2. The list. The value of "orders" is a list, so that call reaches the list implementation, which calls pretty() on each item.
  3. The numbers. 1200 and 75000 are ints, so the int implementation formats them with grouped digits, 1,200 and 75,000.
  4. A string. Nothing is registered for str, so the call falls back to the original function, the one registered for object, which prints repr('Pune').
  5. A bool. True is a bool, and bool is a subclass of int. With nothing registered for bool, dispatch takes the closest registered class, int, and prints 1.
  6. The registry. pretty.registry lists the four registered types, dict, int, list and object. Registering an implementation for bool would win for True, because the most specific type is chosen.

The dictionary, its list and its integers each reached their own implementation, and the string fell back to the original function, the one registered for object. Look at paid: True was printed as 1, because bool is a subclass of int and dispatch follows subclasses. Registering an implementation for bool would fix it, since the most specific type wins. pretty.registry lists every registered type. For methods, functools.singledispatchmethod does the same and dispatches on the first argument after self.

Four comparisons from one method, and a key from a comparison

@total_ordering is a class decorator: give a class __eq__() and one ordering method such as __lt__(), and it supplies the other three comparisons. cmp_to_key() adapts an old-style comparison function: A function of two items that returns a negative number, zero or a positive number for less, equal or greater. functools.cmp_to_key() turns one into a key function., one that takes two items and returns a negative number, zero or a positive number, into a key function that sorted() accepts:

Comparisons filled in, and a comparison function as a key Python · ordering_tools.py
from functools import cmp_to_key, total_ordering


@total_ordering
class Version:
    """Version numbers such as 3.9 and 3.14, compared part by part."""

    def __init__(self, text):
        self.text = text
        self.parts = tuple(int(p) for p in text.split("."))

    def __eq__(self, other):
        return self.parts == other.parts

    def __lt__(self, other):
        return self.parts < other.parts

    def __repr__(self):
        return self.text


a, b = Version("3.9"), Version("3.14")
print(a < b, a <= b, a > b, a >= b)          # <=, > and >= were filled in
print(sorted([Version("3.14"), Version("3.9"), Version("3.10.2"), Version("3.10")]))

counts = {"compare": 0, "key": 0}


def compare(x, y):   # an old-style comparison: negative, zero or positive
    counts["compare"] += 1
    px, py = tuple(map(int, x.split("."))), tuple(map(int, y.split(".")))
    return (px > py) - (px < py)


def version_key(v):
    counts["key"] += 1
    return tuple(map(int, v.split(".")))


versions = [f"{major}.{minor}" for major in range(1, 5) for minor in (12, 3, 0, 7, 10)]
assert sorted(versions, key=cmp_to_key(compare)) == sorted(versions, key=version_key)
print(len(versions), "versions:", counts)

Output

True True False False
[3.9, 3.10, 3.10.2, 3.14]
20 versions: {'compare': 62, 'key': 20}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ordering_tools.py

Version defines only __eq__() and __lt__(), yet <=, > and >= work, and 3.10 correctly sorts after 3.9, which plain text comparison gets wrong. The counts show why a key function is the better choice whenever one exists:

A key function or a comparison function, on the 20 versions of ordering_tools.py
Criterion Key functionComparison function with cmp_to_key()
Takes One itemTwo items
Returns A value to compare, such as a tuple of intsA negative number, zero or a positive number
Calls for 20 versions 20, once per item62, once per comparison
When to choose The order follows from one value per itemThe order can only be told by comparing two items, or a library hands you a comparison

Use cmp_to_key() when the order can only be described by comparing two items with each other, or when a library hands you a comparison function, as locale.strcoll() does for language-aware text.

Checkpoint

Check: singledispatch · total_ordering and cmp_to_key()

  1. Question 1 of 2 pretty from singledispatch_pretty.py has implementations for object, int, list and dict. Which one runs for pretty(3.5)?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The fallback, the original function registered for object

    singledispatch looks for the argument's class among the registered ones, then for its parent classes in order. float's only registered ancestor is object, so the fallback runs and prints repr(3.5).

  2. Question 2 of 2 Sorting 1,000 version strings with key=version_key, as in ordering_tools.py, calls version_key how many times?

    Type a number.

    A hint

    Is a key function called per item or per comparison?

    Show the answer: Question 2 of 2

    Answer: 1000 calls

    A key function is called once per item, so 1,000 items take 1,000 calls, whatever the sort compares. A comparison function through cmp_to_key() is called once per comparison: 62 calls for 20 versions in ordering_tools.py, and many thousands for 1,000.

Practise: count routes and build the largest number

The exercise has two functions. count_paths() counts the down-and-right routes across a grid with blocked cells, and needs a cache, because one sample test uses an 18 × 18 grid with more than two billion routes. largest_number() lays price tags side by side to make the largest number and needs a comparison function through cmp_to_key():

Exercise · Medium · Python

Count grid routes with a cache, then build the largest number

Write two functions in tools.py:

  • count_paths(rows, cols, blocked=frozenset()): a robot starts in the top-left cell (0, 0) of a grid with rows rows and cols columns and moves one cell at a time, only down or right, to the bottom-right cell (rows - 1, cols - 1). blocked is a frozenset of (row, col) cells it may not enter. Return how many different routes there are, and 0 when the start or the end is blocked. Cache a recursive helper with functools.cache or functools.lru_cache: the tests include an 18 × 18 grid, which has more than two billion routes, so counting them one by one would never finish.
  • largest_number(numbers): price tags, each showing a whole number, are laid side by side to make one long number. Return the largest number they can make, as a string. largest_number([7, 70, 76, 4, 9]) is "9776704". The list holds at least one number, and every number is 0 or more; tags that are all 0 make "0". Sort with a comparison function through functools.cmp_to_key.

Starter code · tools.py

from functools import cache, cmp_to_key, lru_cache


def count_paths(rows, cols, blocked=frozenset()):
    """Count the down-and-right routes from (0, 0) to (rows - 1, cols - 1) that avoid the blocked cells."""
    return 0


def largest_number(numbers):
    """Return, as a string, the largest number the numbers make when written side by side."""
    return "".join(str(n) for n in sorted(numbers, reverse=True))
The sample tests · test_tools.py
from tools import count_paths, largest_number


def test_small_grids():
    """counts the routes of small open grids"""
    assert count_paths(1, 1) == 1
    assert count_paths(2, 2) == 2
    assert count_paths(3, 3) == 6
    assert count_paths(3, 7) == 28


def test_blocked_cells():
    """routes avoid the blocked cells"""
    assert count_paths(3, 3, frozenset({(1, 1)})) == 2
    assert count_paths(3, 3, frozenset({(0, 1), (1, 0)})) == 0
    assert count_paths(4, 4, frozenset({(1, 2), (2, 1)})) == 2


def test_blocked_start_or_end():
    """a blocked start or end leaves no route"""
    assert count_paths(3, 3, frozenset({(0, 0)})) == 0
    assert count_paths(3, 3, frozenset({(2, 2)})) == 0


def test_large_grid():
    """an 18 x 18 grid finishes quickly with a cache"""
    assert count_paths(18, 18) == 2_333_606_220
    assert count_paths(18, 18, frozenset({(9, 9), (3, 14)})) == 1_707_404_420


def test_largest_number():
    """orders the tags so that the number is as large as possible"""
    assert largest_number([7, 70, 76, 4, 9]) == "9776704"
    assert largest_number([12, 121]) == "12121"
    assert largest_number([5]) == "5"
    assert largest_number([10, 2]) == "210"


def test_zeros():
    """tags that are all 0 make "0" """
    assert largest_number([0, 0, 0]) == "0"
    assert largest_number([0, 1]) == "10"
A hint

For count_paths, define the helper inside the function, so that each call gets its own cache and blocked stays fixed while the helper runs: ways(r, c) is 0 outside the grid or on a blocked cell, 1 at (0, 0), and otherwise ways(r - 1, c) + ways(r, c - 1). For largest_number, compare two tags as text: a goes first when a + b is greater than b + a, so the comparison returns -1 for that case, 1 for the opposite and 0 when they are equal.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Permutation & Combination Calculator An open grid of r rows and c columns has as many down-and-right routes as ways to choose r − 1 of the r + c − 2 moves: check count_paths against the binomial coefficient.

Finally, practise on caches, partial, folds and dispatch. The questions start where your answers so far are weakest, move up a level after each right answer, and explain each choice as soon as you make it:

Practice that adapts to you

Practise this lesson

20 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 20 What does @cache keep, and what does a repeated call cost?

    Choose one answer.

    Show the answer: Question 1 of 20

    Answer: Each result, under the arguments of the call that produced it; a repeated call costs one dictionary lookup

    @cache, which is lru_cache(maxsize=None), stores every result in a dictionary keyed by the arguments. A miss runs the function and stores what it returned; a hit returns the stored value without running the function.

  2. Question 2 of 20 grid_paths.py reports CacheInfo(hits=81, misses=120) after paths_cached(10, 10). How many calls of paths_cached were made in all, hits and misses together?

    Type a number.

    Show the answer: Question 2 of 20

    Answer: 201 calls

    Every call is either a hit or a miss: 81 + 120 = 201 calls. Only the 120 misses ran the body; the plain version ran it 369,511 times.

  3. Question 3 of 20 A service decorates lookup(user_id) with @cache, and new users arrive every day. What happens over weeks of running?

    Choose one answer.

    Show the answer: Question 3 of 20

    Answer: The cache keeps one entry for every user ever looked up and keeps growing, because @cache never drops anything

    @cache suits a bounded set of inputs, such as the states of a recursion. For open-ended input, a sized lru_cache keeps memory in check; cache_info() shows whether that size gives enough hits.

  4. Question 4 of 20 How many results does @lru_cache keep when maxsize is not given?

    Type a number.

    Show the answer: Question 4 of 20

    Answer: 128 results

    lru_cache's default is maxsize=128. Once 128 results are stored, every new one drops the least recently used.

  5. Question 5 of 20 A function decorated with @lru_cache(maxsize=2) is called with "a", "b", "a", "c", "b", in that order. How many of the five calls are hits?

    Type a number.

    Show the answer: Question 5 of 20

    Answer: 1 hits

    "a" and "b" are misses. The second "a" is a hit and makes "a" the most recently used. "c" is a miss and drops "b", the least recently used, so the last "b" is a miss again: one hit, four misses.

  6. Question 6 of 20 cache_info() reports hits=30 and misses=120. What is the hit ratio, in per cent?

    Type a number.

    A hint

    The hit ratio is hits / (hits + misses).

    Show the answer: Question 6 of 20

    Answer: 20 %

    30 / (30 + 120) = 30 / 150 = 0.2, a hit ratio of 20 %. Four calls in five still run the function, so this cache may be too small for the way it is used, as lru_eviction.py's 1 hit in 7 calls was.

  7. Question 7 of 20 @cache is on def total(prices). What happens when you call total([120, 80])?

    Choose one answer.

    Show the answer: Question 7 of 20

    Answer: TypeError, because a list is not hashable and the arguments are the key

    The cache keeps its results in a dictionary keyed by the arguments, so they must be hashable. Pass a tuple, total((120, 80)), or turn the list into a tuple inside a small uncached wrapper.

  8. Question 8 of 20 Which functions are safe to decorate with @cache?

    Choose every answer that is right.

    Show the answer: Question 8 of 20

    Answer:

    • def gcd(a, b): the greatest common divisor of two integers
    • def word_count(text): the number of words in a string

    A cached function must return the same result for the same arguments and do nothing else. The time changes from call to call, and a cached send_reminder would send the email only the first time.

  9. Question 9 of 20 get_rate(currency) reads a file of exchange rates that is rewritten every hour. Is @cache a good idea?

    Choose one answer.

    Show the answer: Question 9 of 20

    Answer: No, because the result depends on the file as well as the argument, so the cache would keep the first answers

    Cache only pure functions: the result depends on nothing but the arguments, and the call has no side effects. A function that reads a changing file, the clock or the network breaks that rule.

  10. Question 10 of 20 Where does @cached_property keep the value after the first read?

    Choose one answer.

    Show the answer: Question 10 of 20

    Answer: In the instance's __dict__, under the attribute's name

    cached_property computes the value on the first read and stores it in the instance like any attribute you assign, so later reads find it there without calling the method. It needs a writable __dict__ to do so.

  11. Question 11 of 20 What does this program print?

    Read the code, then choose one answer.

    from functools import cached_property
    
    class Box:
        def __init__(self, n):
            self.n = n
    
        @cached_property
        def double(self):
            return self.n * 2
    
    b = Box(4)
    print(b.double)
    b.n = 10
    print(b.double)
    Show the answer: Question 11 of 20

    Answer: 8 8

    b.double is computed on the first read, 4 × 2 = 8, and stored in the instance. Changing b.n afterwards changes nothing the second read looks at, so it prints 8 again.

  12. Question 12 of 20 What does partial(int, base=2) return?

    Choose one answer.

    Show the answer: Question 12 of 20

    Answer: A callable that calls int() with base=2 and the arguments it is given

    partial(func, args, *keywords) returns a callable that calls func with the stored arguments first, then the call's own arguments. partial(int, base=2)("101101") gives 45.

  13. Question 13 of 20 What does this print on Python 3.14?

    Read the code, then choose one answer.

    from functools import partial, Placeholder
    label = partial("{} of {}".format, Placeholder, "seats")
    print(label(42))
    Show the answer: Question 13 of 20

    Answer: 42 of seats

    Placeholder keeps the first positional slot open, and the call fills it with 42, so the call is "{} of {}".format(42, "seats").

  14. Question 14 of 20 Why does partial(str.replace, old=" ", new="-")("learn python") raise TypeError, and what does work?

    Choose one answer.

    Show the answer: Question 14 of 20

    Answer: old and new are positional-only, so keywords cannot fill them; partial(str.replace, Placeholder, " ", "-") works

    A positional-only parameter cannot be filled by a keyword, so partial() can only pre-fill it by position. Placeholder, new in 3.14, keeps the first slot open for the text and stores the space and the hyphen after it.

  15. Question 15 of 20 What does this program print?

    Read the code, then choose one answer.

    from functools import reduce
    print(reduce(lambda number, d: number * 10 + d, [3, 1, 4]))
    Show the answer: Question 15 of 20

    Answer: 314

    reduce() calls the lambda on 3 and 1, giving 31, then on 31 and 4, giving 314: the digits are built into a number from left to right, as reduce_fold.py does with 4, 0, 9 and 6.

  16. Question 16 of 20 Why does reduce_fold.py pass {} as the initial value when it merges the settings?

    Choose one answer.

    Show the answer: Question 16 of 20

    Answer: So that an empty list of settings gives an empty dict instead of a TypeError

    The initial value is where the fold starts and the answer for an empty sequence. With {}, an empty list of settings simply gives {}.

  17. Question 17 of 20 pretty from singledispatch_pretty.py has implementations for int, list and dict. Which one runs for pretty(False)?

    Choose one answer.

    Show the answer: Question 17 of 20

    Answer: The int implementation, because bool is a subclass of int

    Dispatch follows the class hierarchy, so a bool uses the closest registered class, int, and prints 0. Registering an implementation for bool would win, because the most specific type is chosen.

  18. Question 18 of 20 How does a function decorated with @singledispatch choose which implementation to run?

    Choose one answer.

    Show the answer: Question 18 of 20

    Answer: From the type of its first argument, the most specific registered class winning

    @singledispatch turns a function into a generic function. @pretty.register adds an implementation for the type in the first parameter's annotation, and each call runs the one for the closest registered class of its first argument.

  19. Question 19 of 20 Why does sorting with key=cmp_to_key(compare) usually call compare more often than a key function is called?

    Choose one answer.

    Show the answer: Question 19 of 20

    Answer: Because a comparison function is called for each pair the sort compares, while a key is called once per item

    A sort of n items makes roughly n log n comparisons, each of them a call of the comparison function. A key function is called once per item and its results are compared directly: 62 calls against 20 in ordering_tools.py.

  20. Question 20 of 20 Version from ordering_tools.py defines only __eq__ and __lt__ and is decorated with @total_ordering. What does Version("3.10") >= Version("3.9") give?

    Choose one answer.

    Show the answer: Question 20 of 20

    Answer: True, because total_ordering supplies >= from the two methods the class has

    @total_ordering fills in the comparisons a class leaves out from __eq__ and one ordering method. Version compares its parts as numbers, so 3.10 is greater than 3.9, which plain text comparison gets wrong.

Interview questions

Fresher to 2–5 years · What is the difference between functools.cache and functools.lru_cache?

Both are decorators that store a function’s results in a dictionary keyed by its arguments, so a repeated call with the same arguments returns the stored result without running the function. functools.cache, added in Python 3.9, is the same as lru_cache(maxsize=None): it never drops anything, which makes it smaller and faster, but it grows as long as new arguments arrive. lru_cache keeps at most maxsize results, 128 by default, and drops the least recently used one when it is full; typed=True caches arguments of different types separately. Both offer cache_info() and cache_clear(), need hashable arguments, and suit only functions whose results depend on their arguments alone. Choose cache for a bounded set of inputs, such as the states of a recursion, and a sized lru_cache for open-ended input.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A class Report decorates its method total(self) with @lru_cache(maxsize=None). The service creates thousands of reports a day, and memory never goes down, even after the reports are discarded. Why, and what are the fixes?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    get_permissions(user_id) is decorated with @cache and returns a set. One request handler adds "admin" to the set it got back while checking a temporary rule, and from then on every request of that user is treated as an admin. Explain the bug and fix it so that it cannot happen again.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • @cache remembers every result and never forgets; @lru_cache(maxsize=n) keeps the n most recently used, and cache_info()’s hits, misses and hit ratio tell you whether a cache helps.
  • Cache only pure functions with hashable arguments, return immutable results, and remember that the cache keeps its arguments and results, and the instances of cached methods, alive.
  • @cached_property computes an attribute once per instance; delete the attribute to recompute it.
  • partial() fixes arguments now and returns a function that needs fewer; Placeholder, new in 3.14, leaves a positional slot open.
  • reduce() folds a sequence; singledispatch picks an implementation by type; total_ordering fills in comparisons; cmp_to_key() turns a comparison function into a key, at the price of more calls.

Terms in this lesson

cache hit
A call a cache answers from a result it stored before, without running the function. cache_info() counts them as hits.
cache miss
A call whose arguments a cache has not stored yet, so the function runs and its result is stored. cache_info() counts them as misses.
comparison function
A function of two items that returns a negative number, zero or a positive number for less, equal or greater. functools.cmp_to_key() turns one into a key function.
generic function
A function with one implementation per type, chosen at each call from the type of an argument. functools.singledispatch makes one that dispatches on the first argument.
hashable
A value that can be a dict key or a set item, because its hash never changes while it exists: numbers, strings and tuples of hashable values are hashable; lists, dicts and sets are not.
least recently used (LRU)
The stored entry whose last use is the longest ago. A full lru_cache drops it to make room for a new result.
partial object
What functools.partial() returns: a callable that holds a function and some of its arguments, in .func, .args and .keywords, and adds them to the arguments of each call.
pure function
A function whose result depends only on its arguments and which changes nothing outside itself, such as gcd(a, b). Only such a function is safe to cache.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.