Functions
Arguments, *args/**kwargs, closures, lambda, and the mutable-default trap.
12 questions
JuniorTheoryVery commonWhat are *args and **kwargs, and when are they used?
What are *args and **kwargs, and when are they used?
*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. If none are passed they are an empty tuple/dict, not None. They enable variadic or forwarding signatures.
Common mistakes
- ✗Believing
*argsis alistrather than atuple - ✗Thinking absent
*args/**kwargsareNoneinstead of empty containers - ✗Assuming the names
args/kwargsare required keywords rather than conventions
Follow-up questions
- →How do you forward
*args/**kwargsunchanged to another function call? - →What does a bare
*in a signature do to following parameters?
JuniorCodeVery commonWhat does this mutable default argument print?
What does this mutable default argument print?
[1] then [1, 2]. The default [] is created once, at function-definition time, and reused across calls, so state leaks between them. Fix with a sentinel: def add(item, bucket=None): bucket = [] if bucket is None else bucket.
Common mistakes
- ✗Thinking default arguments are re-evaluated on each call
- ✗Expecting a fresh list per call
- ✗Not knowing the None-sentinel fix
Follow-up questions
- →Why are default arguments evaluated at definition time rather than at call time?
- →How exactly does the
Nonesentinel pattern fix this?
JuniorTheoryCommonWhat does it mean that functions are first-class objects in Python?
What does it mean that functions are first-class objects in Python?
Functions are objects of type function: you can assign them to variables, pass them as arguments, return them, store them in containers, and attach attributes. The object is created once when its def statement runs.
Common mistakes
- ✗Believing functions can't be passed around or stored in containers
- ✗Thinking a new function object is created on every call rather than at
def - ✗Assuming functions have no type and cannot carry attributes
Follow-up questions
- →What is the difference between referencing
fand callingf()? - →How does first-class function support enable decorators?
JuniorTheoryCommonWhat is a lambda, and what are its limits?
What is a lambda, and what are its limits?
A lambda is an anonymous function whose body is a single expression, not statements like pass, raise, or assignments. It returns that expression's value implicitly and is often passed to map, filter, or sorted.
Common mistakes
- ✗Thinking a
lambdacan hold multiple statements or assignments - ✗Believing a
lambdais faster than an equivalentdef - ✗Expecting an explicit
returninside alambdabody
Follow-up questions
- →Why might a named
defbe preferred over alambdafor readability? - →How do you give a
lambdaa default argument or capture a loop variable?
JuniorTheoryCommonCan you define a function inside another, and where is it visible?
Can you define a function inside another, and where is it visible?
Yes. A nested inner function is visible only inside the enclosing function's local scope; outside code can't reach it unless it is returned or assigned out. A fresh inner object is created each time the outer runs.
Common mistakes
- ✗Believing an inner function is added to the global or module namespace
- ✗Thinking the inner object is reused rather than recreated each call
- ✗Assuming outside code can call an inner function without it being returned
Follow-up questions
- →How can a nested function read or modify a variable of the enclosing scope?
- →What changes if the outer function returns its inner function?
MiddleTheoryCommonHow are arguments passed to functions in Python?
How are arguments passed to functions in Python?
Python passes by assignment, also called call by sharing: the parameter is bound to the same object the caller passed. Rebinding the name inside doesn't affect the caller, but mutating a mutable object does.
Common mistakes
- ✗Believing Python is pure call-by-value and copies every argument
- ✗Thinking rebinding a parameter name changes the caller's variable
- ✗Assuming the passing rule differs between mutable and immutable types
Follow-up questions
- →How can a function mutate a caller's
listbut not rebind its variable? - →Why does reassigning a parameter never propagate back to the caller?
MiddleTheoryCommonWhat is a closure in Python?
What is a closure in Python?
A closure is an inner function that captures references, via cell objects, to variables from its enclosing scope, keeping them alive after the outer function returns. Each outer call makes a new closure with its own bindings.
Common mistakes
- ✗Thinking a closure copies values instead of capturing references via cells
- ✗Believing all closures share one global binding rather than per-call bindings
- ✗Assuming captured variables die when the outer function returns
Follow-up questions
- →Why do closures in a loop often capture the same final loop value?
- →When do you need the
nonlocalkeyword inside a closure?
MiddleTheoryCommonWhy are mutable default arguments a trap, and how do you fix it?
Why are mutable default arguments a trap, and how do you fix it?
Default values are evaluated once when the def runs and stored on the function object, so a mutable default like def f(x=[]) is shared and accumulates across calls. Fix: default to None and create a fresh object inside.
Common mistakes
- ✗Believing defaults are re-created on every call rather than once at
def - ✗Thinking only
listdefaults are affected, not all mutable objects - ✗Assuming the bug is about speed rather than shared accumulating state
Follow-up questions
- →When can a mutable default actually be useful, e.g. for caching?
- →Why is
if x is None: x = []preferred overx = x or []?
MiddleCodeOccasionalWhat does this list of lambdas print?
What does this list of lambdas print?
[2, 2, 2]. The closures capture the variable i, not its value at creation time (late binding). By the time the lambdas run, the loop has finished and i == 2. Fix by binding per-iteration with a default arg: [lambda i=i: i for i in range(3)].
Common mistakes
- ✗Believing closures capture the value rather than the variable
- ✗Expecting
[0, 1, 2]from value-at-creation semantics - ✗Not knowing the default-argument binding fix
Follow-up questions
- →Why does a default-argument binding capture the current value of
i? - →How does this differ between a generator expression and a list comprehension?
MiddleCodeOccasionalWhat does this function raise, and why?
What does this function raise, and why?
UnboundLocalError: local variable 'x' referenced before assignment. Because x is assigned anywhere in the function (line 2), Python treats it as local for the entire function, so the read on line 1 fails. Fix: add global x, or don't shadow it. This is the LEGB scoping rule.
Common mistakes
- ✗Thinking name binding is decided at the point of use rather than for the whole function
- ✗Expecting line 1 to read the global
x - ✗Confusing UnboundLocalError with a plain NameError
Follow-up questions
- →How do
globalandnonlocalchange which scope an assignment targets? - →What are the four scopes in Python's LEGB lookup order?
SeniorTheoryOccasionalWhy is lambda x: raise Exception(x) a SyntaxError?
Why is lambda x: raise Exception(x) a SyntaxError?
A lambda body must be an expression, but raise (like pass, return, and assignments) is a statement, which a lambda body can't contain. So it fails at parse/compile time with SyntaxError, before any call ever runs.
Common mistakes
- ✗Thinking it is a runtime error rather than a parse-time
SyntaxError - ✗Believing
raiseis allowed inside alambdabody - ✗Assuming the error is about the exception name, not the statement form
Follow-up questions
- →How can you make a
lambdaraise using only expressions? - →Which other constructs are statements that a
lambdacan't contain?
SeniorTheoryRareInside a function, why does lst[:] = [1] reach the caller while lst = [1] does not?
Inside a function, why does lst[:] = [1] reach the caller while lst = [1] does not?
lst[:] = [1] is slice-assignment: it mutates the existing object the caller still references, so the change is visible. lst = [1] rebinds only the local name to a new object; the caller's binding is untouched. The distinction is mutation of the shared object vs rebinding a local name.
Common mistakes
- ✗Confusing
lst[:] = [1]withlst = [1]and expecting both to reach the caller - ✗Believing rebinding the parameter leaks the new object to the caller
- ✗Not realizing slice-assignment mutates the existing shared object
Follow-up questions
- →How does
lst.clear()compare tolst[:] = []for the caller's object? - →Why does
lst += [1]reach the caller butlst = lst + [1]does not?