Functions
In Python a function is not a description of code the compiler substitutes somewhere — it is an object, created when the def statement executes and bound to a name. Almost the whole topic follows from that: why you can put a function in a dict and pass it to sorted, why def f(x=[]) accumulates state across calls, and why three closures built in a loop return the same number. The traps here are not "language quirks" but consequences of two models: the object model and the name-binding model.
Three mechanisms are Python-specific, and each is probed separately. First, argument passing — neither by value nor by reference but call by sharing; the parameter is bound to the very object the caller passed. Second, default values — evaluated once when the def runs and stored on the function object in __defaults__, so a mutable default is shared by every call. Third, capturing the variable, not the value — a nested function holds a cell object and reads its contents at call time, which is where late binding comes from. Work through each mechanism in the layers below.
Topic map
- First-class functions — a function is an object of type
function; you can assign it, pass it, return it and put it in a container. - The function object — what actually lives on a function object —
__name__,__defaults__,__closure__,__code__and user attributes. - Argument passing — call by sharing — rebinding a parameter is invisible to the caller, mutating the shared object is not.
- Variadic arguments —
*argscollects positionals into atuple,**kwargskeywords into adict, while/and*markers fix how arguments may be passed. - Mutable default arguments — the default is evaluated once at
defand lives on the function; theNonesentinel is the only honest fix. - Nested functions and scopes — the LEGB rule,
UnboundLocalError,globalversusnonlocal. - Closures and cells — cell objects, capturing the variable rather than the value, and the late-binding loop trap.
- lambda and its limits — a body of one expression, hence
SyntaxErroronraise, and the rule for choosing betweenlambdaanddef.
Common mistakes and traps
| Mistake | Consequence |
|---|---|
| Believing Python copies arguments "by value" | A function that calls lst.append silently corrupts the caller's list — there never was a copy |
| Expecting a rebound parameter to reach the caller | lst = [1] changes only the local name; to be visible the change needs lst[:] = [1] or lst.append |
Writing def f(x=[]) or def f(d={}) | The object is built once at def and reused — state leaks between calls |
Fixing a mutable default with x = x or [] | A passed empty list, 0 or "" is silently replaced by a fresh object — the correct sentinel is if x is None |
| Thinking a closure copies the variable's value | Every function built in the loop returns the counter's final value — the classic [2, 2, 2] |
Treating *args as a list and absent *args/**kwargs as None | args is a tuple with no append, and with nothing passed it is an empty tuple/dict, so an is None check never fires |
| Assigning to a name that is read earlier in the same function | The name becomes local for the whole function, and the earlier read fails with UnboundLocalError |
Putting raise, return or an assignment inside a lambda body | SyntaxError while the module compiles — a lambda body must be an expression |
What interviews check
The topic comes early and works as a filter. The first question is almost always the same — how arguments are passed in Python. "By value" and "by reference" are equally wrong and immediately turn the rest into a review of your mistakes; the right answer is "by assignment, call by sharing", backed by two examples — rebinding a parameter is invisible outside, append is not. Then come two code questions where you predict the output: def add(item, bucket=[]) called twice, and [lambda: i for i in range(3)]. Both probe evaluation time — the default is computed at def, the closure reads its cell at call time.
The second half is signatures and scopes. *args/**kwargs is a junior-level question, and the trap is in the types: tuple and dict, not list and not None. At middle level the / and * markers are added. For scopes you are given code that reads then assigns the same name and asked for UnboundLocalError, with the explanation that the decision is made when the function is compiled, not at the point of use; the typical mistake is naming NameError or blaming line order. Deeper questions cover nonlocal versus global, the contents of __closure__, and why lambda is not faster than def. All three close with one answer — a function is an object, and every part of its state can be inspected by hand.