Functions: Arguments, Scope, Closures โ and the Default That Remembers
Published:
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']
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.
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
defexecutes: it builds a function object and evaluates defaults once. Mutable defaults are therefore shared across calls โ use aNonesentinel.*argscollects positional arguments into a tuple,**kwargskeyword 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/nonlocalredirect 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
- Python Software Foundation. Defining Functions.
- Python Software Foundation. Function definitions โ language reference.
- Python Software Foundation. Execution model: naming and binding.
- Hettinger, R., & Storchaka, S. PEP 570 โ Python Positional-Only Parameters.
- van Rossum, G., Lehtosalo, J., & Langa, ล. PEP 484 โ Type Hints.
- Goodger, D., & van Rossum, G. PEP 257 โ Docstring Conventions.
- Python Software Foundation.
functoolsโ higher-order functions.
