Polyglot
Languages Python functions
Python § functions

Functions

Functions are first-class objects in Python: they may be passed as arguments, returned, stored in data structures, and dynamically created. The function-definition surface is rich — positional, keyword, default, positional-only, keyword-only, *args, **kwargs — admitting flexible call conventions. Lambda expressions admit anonymous one-expression functions. Closures admit functions that capture variables from their enclosing scope. The combination — first-class functions, lexical closures, varied parameter forms — admits substantial functional and higher-order programming.

This page covers function definition, the parameter forms, lambdas, closures, and the conventional patterns. The decorator surface is in Decorators; the broader functional facilities (map, filter, functools) are in Functional.

Function definition

The def keyword introduces a function:

def square(n: int) -> int:
    return n * n

def greet(name: str, age: int = 0) -> str:
    return f"Hello, {name}, age {age}"

The parts:

  • Name — the function’s identifier.
  • Parameter list — comma-separated parameters, optionally with defaults, type hints, and special markers.
  • Return type — optional annotation after ->.
  • Body — indented block; return produces a value (otherwise None).
  • Docstring — an optional string literal as the first statement; conventional for documentation.

A typical function:

def fibonacci(n: int) -> int:
    """Return the nth Fibonacci number."""
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

The docstring is accessible via fibonacci.__doc__ and is consumed by help(), IDEs, and documentation generators.

Parameter forms

Python admits substantial parameter flexibility:

Positional and keyword

def add(x, y):
    return x + y

add(1, 2)             # positional: x=1, y=2
add(x=1, y=2)         # keyword
add(1, y=2)           # mixed
add(y=2, x=1)         # keyword in any order

Every parameter (by default) admits both positional and keyword call. The choice is the caller’s.

Default values

def greet(name, greeting="Hello"):
    return f"{greeting}, {name}"

greet("alice")                     # "Hello, alice"
greet("alice", "Hi")              # "Hi, alice"
greet("alice", greeting="Hey")    # "Hey, alice"

Defaults make the parameter optional. Defaults are evaluated once, at function-definition time — a common pitfall:

# WRONG: the default is shared across calls
def append_item(item, items=[]):
    items.append(item)
    return items

append_item(1)        # [1]
append_item(2)        # [1, 2]    — the SAME list, mutated
append_item(3)        # [1, 2, 3]

# Right: use None as the sentinel
def append_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

The conventional defence: never use a mutable default; use None and create the mutable inside the body.

Variadic positional (*args)

def total(*args):
    return sum(args)

total(1, 2, 3)         # 6
total(1, 2, 3, 4, 5)   # 15

The *args collects extra positional arguments into a tuple. The conventional name is args; any name works.

To unpack a sequence as positional arguments:

xs = [1, 2, 3, 4]
total(*xs)             # equivalent to total(1, 2, 3, 4)

Variadic keyword (**kwargs)

def configure(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

configure(host="localhost", port=8080, timeout=30)

The **kwargs collects extra keyword arguments into a dict. The conventional name is kwargs.

To unpack a dict as keyword arguments:

config = {"host": "localhost", "port": 8080}
connect(**config)      # equivalent to connect(host="localhost", port=8080)

Positional-only and keyword-only

The / separator makes the preceding parameters positional-only; the * separator makes the subsequent parameters keyword-only:

def divide(num, denom, /):
    """num and denom must be positional"""
    return num / denom

divide(10, 2)             # OK
divide(num=10, denom=2)   # ERROR; positional-only

def fetch(url, *, timeout=30, retries=3):
    """timeout and retries must be keyword"""
    pass

fetch("http://example.com", timeout=60)        # OK
fetch("http://example.com", 60)                 # ERROR; keyword-only

The mechanisms admit clearer API design: the parameters that are conventionally named (timeout, retries) require the keyword form; the parameters that are conventionally positional (url) admit either.

The full form combining all:

def func(pos1, pos2, /, pos_or_kw, *args, kw_only, **kwargs):
    pass

The / separator makes pos1, pos2 positional-only. The *args (or * alone) makes the parameters after it keyword-only.

Return values

Functions return a value with return:

def square(n):
    return n * n

def maybe_compute(x):
    if x < 0:
        return None        # explicit return of None
    return x * 2

Without a return (or with bare return), the function returns None:

def announce(message):
    print(message)
    # implicitly returns None

result = announce("hello")    # None

For multiple values, return a tuple:

def divmod_self(a, b):
    return a // b, a % b

quotient, remainder = divmod_self(10, 3)

The tuple is the conventional Python form for multiple return values; named tuples or dataclasses for cases with more than two.

Lambda expressions

A lambda is an anonymous one-expression function:

square = lambda x: x ** 2
add = lambda x, y: x + y

square(5)        # 25
add(3, 4)        # 7

The form: lambda <params>: <expression>. The body must be a single expression; statements are not admitted.

Lambdas are first-class values; they may be passed, returned, and stored:

sorted(items, key=lambda x: x.priority)
list(map(lambda n: n ** 2, range(10)))

# As a callback:
button.on_click = lambda: print("clicked")

The conventional Python style limits lambdas to short, single-expression bodies. For substantial bodies, a named def is conventionally clearer.

Closures

A function defined inside another function captures the enclosing scope’s variables:

def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

c = make_counter()
c()          # 1
c()          # 2
c()          # 3

The nonlocal declaration is required to modify the enclosing variable; reading admits the implicit lookup. The mechanism admits stateful closures.

For read-only captures:

def make_multiplier(factor):
    def multiply(x):
        return x * factor    # factor is captured from the enclosing scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
double(5)     # 10
triple(5)     # 15

The closure captures factor by reference — modifying factor after make_multiplier returns is invisible (because the surrounding function has already returned). For mutable captures, nonlocal is required.

First-class functions

Functions in Python are objects with attributes:

def square(n):
    """Compute the square of n."""
    return n * n

square.__name__            # 'square'
square.__doc__              # 'Compute the square of n.'
square.__module__           # the module where defined
square.__defaults__         # tuple of default values

# Functions can be assigned, passed, returned:
fn = square
print(fn(5))               # 25

# Stored in a dict:
operations = {
    "square": square,
    "double": lambda n: n * 2,
}
operations["square"](5)    # 25

The mechanism is the foundation of higher-order programming, decorators, callbacks, and dispatch tables.

Higher-order functions

Functions that take functions as arguments or return functions:

def apply_twice(f, x):
    return f(f(x))

apply_twice(square, 3)     # square(square(3)) = 81

def make_adder(n):
    return lambda x: x + n

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

The standard library provides several:

list(map(square, [1, 2, 3, 4]))           # [1, 4, 9, 16]
list(filter(lambda n: n > 0, nums))
sorted(items, key=lambda x: x.name)
max(items, key=lambda x: x.value)

The treatment is in Functional.

Decorators

A decorator is a function that takes a function and returns a (typically wrapped) function. The @ syntax admits compact application:

def with_logging(f):
    def wrapped(*args, **kwargs):
        print(f"calling {f.__name__}")
        result = f(*args, **kwargs)
        print(f"  → {result}")
        return result
    return wrapped

@with_logging
def square(n):
    return n * n

square(5)
# calling square
#   → 25

The @with_logging is equivalent to square = with_logging(square) after the function’s definition. The full treatment is in Decorators.

Type hints on functions

Modern Python admits type hints on parameters and return values:

def greet(name: str, age: int = 0) -> str:
    return f"{name} is {age}"

def map_strings(strings: list[str], f: Callable[[str], str]) -> list[str]:
    return [f(s) for s in strings]

The hints are checked by external tools (mypy, pyright); the runtime ignores them. Treated in Type hints.

Common patterns

Default-value with None sentinel

def process(items=None, options=None):
    if items is None:
        items = []
    if options is None:
        options = {}
    # ...

The conventional defence against the mutable-default trap.

Variadic forwarding

def with_logging(f):
    def wrapped(*args, **kwargs):
        log_call(f, args, kwargs)
        return f(*args, **kwargs)
    return wrapped

The *args, **kwargs admits forwarding any signature; the wrapped function works for any function.

Configurable default

def fetch(url: str, *, timeout: int = 30, retries: int = 3):
    # timeout and retries must be passed as keyword
    pass

fetch("http://example.com", timeout=60)

The keyword-only enforcement makes the API self-documenting at the call site.

Closure-based state

def make_authenticator(secret_key):
    def check(token):
        return validate_token(token, secret_key)
    return check

verify = make_authenticator("my-secret")
verify("token-123")

The closure captures secret_key; the returned function is parameterised by it.

Generator-based iteration

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

for n in fibonacci():
    if n > 1000:
        break
    print(n)

The yield makes the function a generator; treated in Iterators and generators.

Function as data

def add(x, y): return x + y
def sub(x, y): return x - y
def mul(x, y): return x * y

operations = {"+": add, "-": sub, "*": mul}

def calculate(op, x, y):
    return operations[op](x, y)

calculate("+", 2, 3)        # 5
calculate("*", 4, 5)        # 20

The pattern admits dispatch tables — clearer than long if/elif chains for value-to-operation maps.

Single-dispatch via functools.singledispatch

from functools import singledispatch

@singledispatch
def serialise(value):
    raise NotImplementedError(f"no serialiser for {type(value).__name__}")

@serialise.register
def _(value: int):
    return str(value)

@serialise.register
def _(value: str):
    return f'"{value}"'

@serialise.register
def _(value: list):
    return "[" + ",".join(serialise(item) for item in value) + "]"

print(serialise(42))           # '42'
print(serialise("hello"))      # '"hello"'
print(serialise([1, 2, 3]))    # '[1,2,3]'

The mechanism admits ad-hoc polymorphism — different implementations for different argument types.

A note on the absence of overloading

Python does not have function overloading at the language level; a function name binds to one implementation. The conventional alternatives:

  • Default arguments and *args/**kwargs — handle multiple call shapes in one function.
  • isinstance checks — dispatch on argument type.
  • functools.singledispatch — type-based dispatch.
  • @overload in typing — type-checker-only overloading (the runtime sees one function).
from typing import overload

@overload
def process(value: int) -> int: ...
@overload
def process(value: str) -> str: ...

def process(value):
    if isinstance(value, int):
        return value * 2
    return value.upper()

The @overload form admits documenting multiple type signatures for the type checker while the actual implementation is one function.

A note on lambda versus def

The conventional Python style:

  • Use def for functions with substantive bodies, multiple statements, or names that aid debugging.
  • Use lambda for short callbacks where the body is a single expression and the name would not add clarity.
  • Avoid lambda assignmentsadd = lambda x, y: x + y is conventionally written def add(x, y): return x + y.

The lambda form is principally useful as an inline argument; assignment to a name almost always prefers the def form.