Codetail

Article 5 of 8

Functions, Evolved

Signatures, walrus, cache. The details that matter.

20 min read

Positional-only parameters with / (3.8)

Before 3.8, Python had no way to say "this parameter cannot be passed as a keyword." Every parameter was callable either way, which meant callers could write f(x=1) instead of f(1) and you could not stop them. That made parameter names part of your public API whether you intended it or not.

Python
1# Python 3.8+: parameters before / are positional-only
2def circle_area(radius, /, precision=2):
3 import math
4 return round(math.pi * radius ** 2, precision)
5
6# OK: positional
7print(circle_area(5)) # 78.54
8
9# OK: precision as keyword
10print(circle_area(5, precision=4)) # 78.5398
11
12# Error: radius as keyword
13circle_area(radius=5) # TypeError: positional-only

The * separator forces everything after it to be keyword-only. You can use both in one signature:

Python
1def transfer(from_account, to_account, /, *, amount, currency="USD"):
2 # from_account and to_account: positional-only (avoid ambiguity with 'from' keyword)
3 # amount and currency: keyword-only (must be explicit for clarity)
4 print(f"Transfer {amount} {currency} from {from_account} to {to_account}")
5
6transfer("ACC-001", "ACC-002", amount=500)
7# transfer(from_account="ACC-001", to_account="ACC-002", amount=500) # TypeError
8# transfer("ACC-001", "ACC-002", 500) # TypeError: amount is keyword-only

The walrus operator := (3.8)

:= assigns and returns a value in a single expression. It removes the pattern of assigning a value, checking it on the next line, then using it again.

Python
1import re
2
3# Before: assign, check, use -- three separate lines
4line = "Error: disk full at /var/log"
5match = re.search(r"Error: (.+)", line)
6if match:
7 print(match.group(1))
8
9# After: assign and check in one expression
10if match := re.search(r"Error: (.+)", line):
11 print(match.group(1))

While loops that process chunks

Python
1import io
2
3data = b"hello world this is a stream of bytes for testing purposes"
4stream = io.BytesIO(data)
5
6# Before: sentinel pattern, duplicated read call
7chunk = stream.read(8)
8while chunk:
9 print(chunk)
10 chunk = stream.read(8)
11
12print("---")
13stream.seek(0)
14
15# After: walrus keeps the read in the condition
16while chunk := stream.read(8):
17 print(chunk)

List comprehensions with expensive calls

Python
1# Before: call process() twice -- once to filter, once to use
2def process(n: int) -> int | None:
3 return n * 2 if n % 3 == 0 else None
4
5results = [process(n) for n in range(10) if process(n) is not None]
6print(results)
7
8# After: call once, keep the result
9results = [y for n in range(10) if (y := process(n)) is not None]
10print(results)

@functools.cache and @lru_cache (3.9)

Memoisation before 3.9 meant writing your own dict cache, or using @lru_cache(maxsize=None) which required the awkward maxsize=None argument to disable the size limit. Python 3.9 added @cache as a clean alias for the unbounded case.

Python
1import functools
2import time
3
4# Manual cache: the old way
5_fib_cache: dict[int, int] = {}
6
7def fib_manual(n: int) -> int:
8 if n in _fib_cache:
9 return _fib_cache[n]
10 if n < 2:
11 return n
12 result = fib_manual(n - 1) + fib_manual(n - 2)
13 _fib_cache[n] = result
14 return result
15
16# 3.9: @cache -- same semantics, no boilerplate
17@functools.cache
18def fib(n: int) -> int:
19 if n < 2:
20 return n
21 return fib(n - 1) + fib(n - 2)
22
23print(fib(50))
24print(fib.cache_info())

@lru_cache with a size limit

Python
1from functools import lru_cache
2
3# Keep only the 128 most recent results
4@lru_cache(maxsize=128)
5def fetch_user_from_db(user_id: int) -> dict:
6 # Simulating a DB call
7 print(f" [DB] fetching user {user_id}")
8 return {"id": user_id, "name": f"User{user_id}"}
9
10print(fetch_user_from_db(1))
11print(fetch_user_from_db(2))
12print(fetch_user_from_db(1)) # cache hit, no DB call
13print(fetch_user_from_db.cache_info())

Rule: use @cache for pure functions with no side effects and unbounded inputs (maths, parsing). Use @lru_cache(maxsize=N) when you care about memory: a large key space would grow without bound otherwise. Arguments must be hashable for either decorator to work.

@singledispatch: type-based function routing

When a function needs to behave differently for different types, the naive solution is a chain of isinstance checks. It works, but adding new types means editing the original function.@singledispatch lets you register handlers separately and extend without modifying the base.

Python
1# Before: isinstance ladder that grows with every new type
2def serialize(obj):
3 if isinstance(obj, int):
4 return str(obj)
5 elif isinstance(obj, float):
6 return f"{obj:.6g}"
7 elif isinstance(obj, list):
8 return "[" + ", ".join(serialize(x) for x in obj) + "]"
9 elif isinstance(obj, dict):
10 pairs = ", ".join(f"{k}: {serialize(v)}" for k, v in obj.items())
11 return "{" + pairs + "}"
12 else:
13 raise TypeError(f"Cannot serialize {type(obj)}")
Python
1from functools import singledispatch
2
3@singledispatch
4def serialize(obj):
5 raise TypeError(f"Cannot serialize {type(obj)}")
6
7@serialize.register(int)
8def _(obj: int) -> str:
9 return str(obj)
10
11@serialize.register(float)
12def _(obj: float) -> str:
13 return f"{obj:.6g}"
14
15@serialize.register(list)
16def _(obj: list) -> str:
17 return "[" + ", ".join(serialize(x) for x in obj) + "]"
18
19@serialize.register(dict)
20def _(obj: dict) -> str:
21 pairs = ", ".join(f"{k}: {serialize(v)}" for k, v in obj.items())
22 return "{" + pairs + "}"
23
24print(serialize(42))
25print(serialize(3.14159))
26print(serialize([1, 2.5, 3]))
27print(serialize({"a": 1, "b": [2, 3]}))

The real benefit: if a library defines serialize, you can add support for your own types without touching its source. Just import the function and register your handler.