Python Functions and Decorators


  • Description: def, default arguments, positional/keyword-only parameters, *args/**kwargs, argument unpacking, lambda, closures, decorators (with and without arguments), functools.wraps, and useful built-in decorators
  • My Notion Note ID: K2A-D1-8
  • Created: 2022-07-28
  • Updated: 2026-05-11
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. def

def greet(name: str, greeting: str = "Hello") -> str:
    return f"{greeting}, {name}!"

greet("Yu")                       # "Hello, Yu!"
greet("Yu", greeting="Hi")        # "Hi, Yu!"
  • Functions are first-class, store in variables, pass around, return
  • No separate function-pointer syntax

1.1 Default Arguments

  • Defaults evaluated once at def time
  • Safe for immutable values (int, str, tuple)
  • For mutable values (list, dict) it's the classic mutable-default bug, use None sentinel and create inside

1.2 Keyword-Only and Positional-Only Parameters

def write(data, *, encoding="utf-8", errors="strict"):
    #                ^ everything after * is keyword-only
    ...

write(data, encoding="ascii")     # OK
write(data, "ascii")              # TypeError, would be positional

def pow(x, n, /, mod=None):
    #         ^ everything before / is positional-only (3.8+)
    ...
  • Keyword-only → forces self-documenting call sites
  • Positional-only → library authors keep parameter-name freedom

2. *args and **kwargs

  • *args → extra positional args collected into a tuple
  • **kwargs → extra keyword args collected into a dict
def log(level, *args, **kwargs):
    msg = " ".join(str(a) for a in args)
    print(f"[{level}] {msg}", **kwargs)

log("INFO", "user", user_id, file=sys.stderr)
  • Closest C++ analog: variadic templates, but *args/**kwargs are runtime and untyped

3. Argument Unpacking at the Call Site

  • * and ** also work in reverse at a call site, spread a sequence into positionals, a mapping into keywords
def add(a, b, c): return a + b + c

xs = [1, 2, 3]
add(*xs)                       # add(1, 2, 3)

opts = {"a": 1, "b": 2, "c": 3}
add(**opts)                    # add(a=1, b=2, c=3)
  • Standard pattern to forward all arguments: wrapped(*args, **kwargs)

4. lambda

  • Anonymous, single-expression function
square = lambda x: x * x
sorted(words, key=lambda w: w.lower())
  • Limits: one expression only; no statements, no annotations
  • C++ lambdas (arbitrary body) are closer to a def plus closure than to lambda

5. Closures

  • Nested functions capture names from the enclosing scope by reference
  • Reading captured names is free; rebinding requires nonlocal
def make_adder(n):
    def add(x):
        return x + n        # n captured from enclosing frame
    return add

add5 = make_adder(5)
add5(10)                    # 15

Late-binding gotcha, closures in a loop see the final value of the loop variable:

fns = [lambda: i for i in range(3)]
[f() for f in fns]         # [2, 2, 2], not [0, 1, 2]

# Fix: bind via a default argument
fns = [lambda i=i: i for i in range(3)]
[f() for f in fns]         # [0, 1, 2]

6. Decorators

  • A callable that takes a function (or class) and returns a replacement
  • @decorator is sugar for f = decorator(f)
import time

def timed(fn):
    def wrapper(*args, **kwargs):
        t0 = time.perf_counter()
        result = fn(*args, **kwargs)
        print(f"{fn.__name__} took {time.perf_counter() - t0:.3f}s")
        return result
    return wrapper

@timed
def slow():
    time.sleep(0.1)

slow()                  # "slow took 0.103s"
  • No native C++ equivalent, closest is wrapping with a function template or attaching attributes

6.1 functools.wraps

  • Without wraps, the wrapper loses __name__, __doc__, and signature
  • Tools (introspection, debuggers, help()) rely on the metadata, always apply on the inner wrapper
from functools import wraps

def timed(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        ...
    return wrapper

6.2 Decorators With Arguments

  • A decorator factory returns a decorator, three layers (factory → decorator → wrapper)
def retry(times):
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception:
                    if attempt == times - 1:
                        raise
        return wrapper
    return decorator

@retry(times=3)
def fetch(url): ...

6.3 Useful Built-Ins: lru_cache, cached_property, singledispatch

from functools import lru_cache, cached_property, singledispatch

@lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

class Document:
    @cached_property
    def word_count(self):
        return len(self.text.split())   # computed once per instance

@singledispatch
def serialize(obj): raise TypeError

@serialize.register
def _(obj: int):  return str(obj)
@serialize.register
def _(obj: list): return "[" + ",".join(serialize(x) for x in obj) + "]"
  • Other standard decorators: @staticmethod, @classmethod, @property, @dataclass, @abstractmethod

7. Type Hints

def parse(text: str, *, strict: bool = False) -> dict[str, int]:
    ...
  • Annotations stored on the function object (fn.__annotations__)
  • Used by static checkers; no runtime effect by default