Functions#
A function in Python is a first-class object. Define it with
def (or lambda for one-expression anonymous forms),
assign it to a name, pass it as an argument, return it from
another function, store it in a dict, or attach it to a class.
The function’s signature is part of its public contract; Python
checks it at call time and matches arguments by position, keyword,
or both.
This page is the reference for function definition, parameter forms, closures, decorators, lambdas, generators, and the expression-style sister forms (comprehensions, generator expressions). For the language’s structural-pattern dispatch see Control flow. For the typing surface around function signatures see Types.
Definition#
A function is declared with def, has a docstring, accepts
arguments, and returns a value. The -> type annotation is
optional and not enforced at runtime; it documents intent for
the reader and for type checkers (mypy, pyright).
Define total, a variadic function with one keyword-only
default.
def total(*nums: int, scale: float = 1.0) -> float:
"""Sum *nums* and multiply by *scale*."""
return sum(nums) * scale
Call with positional arguments only.
total(1, 2, 3) # 6.0
Call with the keyword-only scale.
total(1, 2, 3, scale=2) # 12.0
A function without an explicit return returns None.
Parameters#
Five argument forms compose in one signature.
Form |
Behaviour |
|---|---|
Positional |
Passed by position. |
Default |
Optional with a fallback. |
Variadic positional |
|
Keyword |
Passed by name. |
Variadic keyword |
|
Two separators tune the rules: / for positional-only,
* for keyword-only.
def f(pos1, pos2, /, normal, *, kw_only):
...
Variadic capture pulls every positional and keyword argument
into args and kwargs.
def g(*args, **kwargs):
print(args, kwargs)
Mutable default trap#
def f(xs=[]): is one of Python’s oldest sharp edges; the
default object is constructed once at function-definition time
and shared across every call that uses it. Concurrent calls see
each other’s writes.
def f(items=[]):
items.append(1)
return items
f() # [1]
f() # [1, 1] - same list!
Use None as the sentinel and replace inside the body.
def f(items: list[int] | None = None) -> list[int]:
items = items if items is not None else []
items.append(1)
return items
Return#
A function can return a single value, a tuple (the standard
multiple-return idiom), or nothing (implicit None).
Return a tuple, unpack at the call site for multi-value return.
def stats(xs):
return min(xs), max(xs), sum(xs) / len(xs)
lo, hi, avg = stats([1, 2, 3, 4])
Early-return on the failure path, fall through to the success path.
def maybe(x):
if x < 0:
return None
return x ** 0.5
Early returns are idiomatic. Prefer a series of
if-condition-return over deep nesting.
Lambdas#
A lambda is an anonymous, single-expression function. Use it
for short callbacks; reach for def the moment the body needs
a statement.
Bind a lambda to a name.
inc = lambda x: x + 1
Pass a lambda as a sort key; multi-key by returning a tuple.
sorted(words, key=lambda w: (-len(w), w.lower()))
Pass a lambda as a predicate to filter.
list(filter(lambda x: x % 2, range(10)))
Closures#
Inner functions close over names in their enclosing scope. The
operator reads the closed-over names with regular access; rebinds
require nonlocal.
def counter():
n = 0
def step():
nonlocal n
n += 1
return n
return step
tick = counter()
tick(); tick(); tick() # 1, 2, 3
Closures are the operator’s substrate for callbacks, partial application, and decorators.
Decorators#
A decorator wraps a function or class. The @dec syntax above
a def is sugar for f = dec(f). Decorators run at
definition time, not call time.
import time
from functools import wraps
def timed(f):
@wraps(f) # copy __name__, __doc__, etc.
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
result = f(*args, **kwargs)
print(f"{f.__name__}: {time.perf_counter() - t0:.3f}s")
return result
return wrapper
@timed
def slow():
...
Parameterised decorators are a decorator factory.
import logging
log = logging.getLogger(__name__)
def retry(times, *, exc=Exception):
def deco(f):
@wraps(f)
def wrapper(*args, **kwargs):
for attempt in range(times - 1):
try:
return f(*args, **kwargs)
except exc as e:
log.warning("%s failed (attempt %d): %s",
f.__name__, attempt + 1, e)
return f(*args, **kwargs)
return wrapper
return deco
@retry(times=3, exc=ConnectionError)
def flaky(): ...
Stacking decorators#
Decorators stack bottom-up; the bottom decorator is applied first.
@app.route("/api/scan")
@login_required
@timed
def scan():
...
The same call expressed without @ sugar.
scan = app.route("/api/scan")(login_required(timed(scan)))
Class decorators#
Decorators on classes work the same way; they receive and return the class. Useful for plugin registration and frame-walking auto-discovery.
def register(cls):
PLUGINS[cls.__name__] = cls
return cls
@register
class HttpScanner:
...
Common library decorators#
Decorator |
Effect |
|---|---|
|
Computed attribute on a class. |
|
Method that does not receive |
|
Method that receives |
|
Unbounded memoisation by argument tuple (3.9+). |
|
Bounded LRU memoisation. |
|
Used inside decorator factories to preserve identity. |
|
Auto-generate |
|
Turn a generator into a context manager. |
|
Async variant of the above. |
See OOP for @property, @staticmethod,
@classmethod, and @dataclass in context;
Errors for @contextmanager.
Generators#
A generator is a function that yield s, producing an
iterator without implementing the __iter__ / __next__
protocol by hand. Lazy: nothing happens until the consumer pulls
the next value.
def first_n_primes(n):
count = 0
k = 2
while count < n:
if is_prime(k):
yield k
count += 1
k += 1
for p in first_n_primes(10):
print(p)
Generators preserve local state between yields, which makes them the right tool for streaming, pipelines, and “produce items only as needed” problems.
yield from delegates to another iterable.
def flatten(nested):
for x in nested:
if isinstance(x, list):
yield from flatten(x)
else:
yield x
list(flatten([1, [2, [3, 4]], 5])) # [1, 2, 3, 4, 5]
Generators are bi-directional through .send() / .throw()
/ .close() but the operator rarely needs those outside
coroutine plumbing.
Generator expressions#
Lazy, like a list comprehension but with () and no
intermediate list. The natural input to any function that takes
an iterable.
Pass a generator expression directly into a consumer like
sum.
total = sum(x * x for x in numbers)
Use with any / all for short-circuited tests.
if any(line.startswith("ERROR") for line in lines):
alert()
itertools covers the standard lazy combinators (chain,
islice, groupby, product, combinations,
accumulate, count, cycle, repeat).
Comprehensions#
List, dict, set, and generator comprehensions transform iterables inline. The form is uniform across all four.
List comprehension; the workhorse form.
squares = [x * x for x in range(10)]
Filter inside the comprehension.
evens = [x for x in xs if x % 2 == 0]
Pair every element with its index using enumerate.
pairs = [(i, c) for i, c in enumerate(text)]
Dict comprehension from a key-deriving function.
word_lengths = {w: len(w) for w in words}
Set comprehension to deduplicate.
unique = {x for x in xs}
Generator expression; lazy and constant-memory.
stream = (x * 2 for x in numbers)
Reading order. Inside a comprehension, for clauses read
top-to-bottom (outer-most first); the leading expression is
evaluated last. Three nested levels is the comfort ceiling; past
that, prefer a generator function for readability.
matrix = [[1, 2], [3, 4], [5, 6]]
flat = [x for row in matrix for x in row]
Higher-order functions#
Functions are objects; assign them to names, store them in
dicts, return them from other functions. map, filter,
and sorted are the built-in trio for working with them;
functools adds the heavy hitters.
handlers = {
"get": lambda req: ...,
"post": lambda req: ...,
"delete": lambda req: ...,
}
response = handlers[req.method.lower()](req)
map applies a function to every element; the comprehension
form is usually clearer.
doubled = list(map(lambda x: x * 2, xs))
filter keeps elements where the predicate is truthy.
evens = list(filter(lambda x: x % 2 == 0, xs))
functools.reduce folds an iterable into one value; reach for
sum / min / max / math.prod first when those
apply.
from functools import reduce
total = reduce(lambda a, b: a + b, xs, 0)
functools.partial binds arguments now, leaves the rest for
later.
from functools import partial
say = partial(print, sep=" | ", end="\n")
say("a", "b", "c") # a | b | c
functools.cache memoises a pure function by its argument
tuple.
from functools import cache
@cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
Most map / filter calls read better as a comprehension;
reach for them only when the function is already a name.
Pure functions and immutability#
Functions that depend only on their arguments and produce no
side effects are easier to test, parallelise, and reason about.
Prefer immutable types (tuple, frozenset, frozen
dataclasses) when the value should not change after creation.
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Point:
x: float
y: float
def translate(self, dx, dy):
return Point(self.x + dx, self.y + dy)
Recursion#
Python supports recursion but does not tail-call optimise.
Default recursion limit is 1000 (sys.getrecursionlimit());
deep recursion blows the stack with RecursionError. Convert
to iteration or use an explicit stack when depth is unbounded.
Recursive walk over a tree.
def depth(tree):
if not tree.children:
return 1
return 1 + max(depth(c) for c in tree.children)
Bump the recursion limit when the operator knows the depth is bounded but exceeds 1000.
import sys
sys.setrecursionlimit(5000)
References#
Syntax for the
def/lambda/yield/returnkeywords this page builds on.Control flow for the loop forms generators replace.
OOP for
@staticmethod/@classmethod/@propertyand how methods extend the function model.Types for parameter and return annotations.
Errors for the
@contextmanagerdecorator and exception flow inside generators.Libraries for
functoolsanditertools.PEP 8, the style guide (naming, signatures, line length).