Functions: Arguments, Scope, Closures โ€” and the Default That Remembers

6 minute read

Published:

TL;DR: def is a statement that runs, binding a name to a function object and evaluating the defaults once, at definition time โ€” which is why a mutable default is shared across every call. Arguments can be positional, keyword, variadic (*args, **kwargs), positional-only (before /) or keyword-only (after *). Name lookup follows LEGB: local, enclosing, global, built-in. Functions are ordinary objects, so they can be passed, returned, and can capture their enclosing scope โ€” by reference, not by value.

Defining and calling

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

print(greet("Ada"))                        # -> Hello, Ada!
print(greet("Ada", "Hi"))                  # -> Hi, Ada!
print(greet(greeting="Hi", name="Bo"))     # -> Hi, Bo!

The names in the def line are parameters; the values supplied at the call site are arguments. Keyword arguments may be given in any order, and mixing is allowed as long as positional ones come first.

A function with no return, or with a bare return, yields None:

def noret():
    pass

print(noret())   # -> None

The mutable default argument trap

Defaults are evaluated once, when the def statement executes โ€” not on each call. For an immutable default such as "Hello" that is invisible. For a list it is not:

def bad(item, basket=[]):
    basket.append(item)
    return basket

print(bad("a"))   # -> ['a']
print(bad("b"))   # -> ['a', 'b']     the same list, still there
print(bad("c"))   # -> ['a', 'b', 'c']

There is one list, created when the module was imported, and it accumulates forever. You can inspect it directly โ€” bad.__defaults__ is (['a', 'b', 'c'],) after those three calls, since the defaults live on the function object.

The fix is a None sentinel and a fresh object inside the body:

def good(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket

print(good("a"))   # -> ['a']
print(good("b"))   # -> ['b']
The classic trap โ€” never use a mutable default. def f(x, acc=[]), def f(x, cache={}) and def f(t=datetime.now()) are all the same bug: the default object is built once at definition time and shared by every call for the lifetime of the process. It is also worth knowing the deliberate exception โ€” def f(x, _cache={}) is an old idiom for a memoisation table, where the sharing is the point. Use functools.lru_cache instead and let the reader off. Linters flag this as B006 (flake8-bugbear); leave that rule on.

*args and **kwargs

A parameter prefixed with * collects surplus positional arguments into a tuple; one prefixed ** collects surplus keyword arguments into a dict.

def show(*args, **kwargs):
    print(args, kwargs)

show(1, 2, x=3)   # -> (1, 2) {'x': 3}
show()            # -> () {}

The same symbols at a call site do the reverse โ€” unpacking a sequence into positional arguments and a mapping into keyword arguments:

def f(a, b, c, sep=","):
    return sep.join(map(str, (a, b, c)))

nums = [1, 2, 3]
opts = {"sep": "-"}
print(f(*nums, **opts))   # -> 1-2-3

The names args and kwargs are pure convention; the stars carry the meaning.

Positional-only / and keyword-only *

The full parameter grammar is def f(pos_only, /, standard, *, kw_only). Everything before / (PEP 570, Python 3.8+) can only be passed positionally; everything after a bare * can only be passed by keyword.

def div(a, b, /, *, precision=2):
    return round(a / b, precision)

print(div(7, 3))                 # -> 2.33
print(div(7, 3, precision=4))    # -> 2.3333

div(a=7, b=3)
# TypeError: div() got some positional-only arguments passed as keyword arguments: 'a, b'
div(7, 3, 4)
# TypeError: div() takes 2 positional arguments but 3 were given

Both markers exist to make an APIโ€™s contract explicit. / frees you to rename a and b later without breaking callers; * forces call sites to spell out flags, so nobody writes resize(img, True, False) and leaves the reader guessing.

Scope: the LEGB rule

A bare name is resolved by searching four scopes in order: Local (this function), Enclosing (any surrounding function), Global (module level), Built-in.

x = "global"

def outer():
    x = "enclosing"
    def inner():
        print(x)      # not local -> found in the enclosing scope
    inner()

outer()   # -> enclosing

Assignment is what makes a name local, and it does so for the whole function body, from the first line. So reading a global and then assigning it in the same function raises UnboundLocalError, not a fallback to the global value. Two keywords override this:

x = "global"

def rebind():
    global x          # assignments here hit the module-level name
    x = "changed"

rebind()
print(x)   # -> changed

nonlocal does the same for the nearest enclosing function scope, which is how a closure keeps mutable state:

def counter():
    n = 0
    def inc():
        nonlocal n
        n += 1
        return n
    return inc

c = counter()
print(c(), c(), c())   # -> 1 2 3

Note that global at module level does nothing useful, and neither keyword creates a variable โ€” they redirect where assignment writes.

First-class functions, closures and lambda

Functions are objects: you can bind them to names, put them in lists, pass them as arguments and return them. Passing one as an argument is what sorted(key=...), map and filter all rely on.

print(sorted(["bb", "a", "ccc"], key=len))   # -> ['a', 'bb', 'ccc']
print(list(map(lambda x: x * 2, [1, 2])))    # -> [2, 4]
print(list(filter(None, [0, 1, 2, None, 3])))  # -> [1, 2, 3]

A closure is a function that captures names from an enclosing scope and keeps them alive after that scope has returned:

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

add5 = make_adder(5)
print(add5(3))                            # -> 8
print(add5.__closure__[0].cell_contents)  # -> 5

The captured value lives in a cell attached to the function object, which is why add5 still works long after make_adder returned.

lambda builds a small anonymous function limited to a single expression โ€” no statements, no annotations, no docstring. Use it for a throwaway key or callback. PEP 8 explicitly discourages f = lambda x: ..., because a def gives the same thing plus a useful __name__ in tracebacks.

Key Insight โ€” closures capture the variable, not its value: the body is not evaluated until the function is called, and the name is looked up then. So a loop that builds functions captures the loop variable itself:
fs = [lambda: i for i in range(3)]
print([f() for f in fs])   # -> [2, 2, 2]   not [0, 1, 2]
All three closures share one i, which finished at 2. Force early binding with a default argument, which is evaluated at definition time โ€” the very behaviour that causes the mutable-default bug, used here on purpose:
gs = [lambda i=i: i for i in range(3)]
print([g() for g in gs])   # -> [0, 1, 2]
functools.partial(operator.add, i) does the same job more explicitly.

Type hints and docstrings

Annotations (PEP 484) record the intended types. Python does not enforce them โ€” they are metadata for readers, editors and static checkers such as mypy or pyright:

def area(w: float, h: float = 1.0) -> float:
    return w * h

print(area("ab", 3))          # -> ababab   no error; str * int is legal
print(area.__annotations__)
# -> {'w': <class 'float'>, 'h': <class 'float'>, 'return': <class 'float'>}

Useful vocabulary: list[int], dict[str, int] and tuple[int, ...] for containers (built-in generics since 3.9), int | None for optionals (3.10+, previously Optional[int]), Callable[[int], str] for a function parameter, and Any when you genuinely do not know.

A docstring is a string literal as the first statement in the body. It becomes __doc__ and is what help() prints:

def area(w: float, h: float = 1.0) -> float:
    """Return the area of a rectangle.

    Args:
        w: width in metres.
        h: height in metres, defaults to 1.0.

    Returns:
        The area in square metres.
    """
    return w * h

print(area.__doc__.splitlines()[0])   # -> Return the area of a rectangle.

PEP 257 sets the conventions: a one-line imperative summary, a blank line, then detail. With type hints present, the docstring should explain meaning and units rather than repeat the types.

Next: comprehensions and generators, where functions learn to pause.

Recap

  • def executes: it builds a function object and evaluates defaults once. Mutable defaults are therefore shared across calls โ€” use a None sentinel.
  • *args collects positional arguments into a tuple, **kwargs keyword ones into a dict; the same stars unpack at a call site.
  • / makes preceding parameters positional-only; a bare * makes following ones keyword-only.
  • Names resolve Lโ†’Eโ†’Gโ†’B; assigning anywhere in a body makes the name local throughout it, and global/nonlocal redirect that.
  • Closures capture variables by reference and resolve them at call time, so loop-built lambdas all see the final value.
  • Type hints are unenforced metadata for readers and static checkers; docstrings follow PEP 257 and explain meaning, not types.

References

  1. Python Software Foundation. Defining Functions.
  2. Python Software Foundation. Function definitions โ€” language reference.
  3. Python Software Foundation. Execution model: naming and binding.
  4. Hettinger, R., & Storchaka, S. PEP 570 โ€” Python Positional-Only Parameters.
  5. van Rossum, G., Lehtosalo, J., & Langa, ล. PEP 484 โ€” Type Hints.
  6. Goodger, D., & van Rossum, G. PEP 257 โ€” Docstring Conventions.
  7. Python Software Foundation. functools โ€” higher-order functions.