Functions, scope and the mutable-default trap
Positional and keyword arguments, star-args, the default argument that is evaluated once, the LEGB scope rule, closures, and where lambda is appropriate.
Functions in Python are objects. Once that lands, decorators, callbacks and key= arguments all
stop being special cases.
Defining and calling
def readiness(marks, weight=1.0, *, label="score"):
return f"{label}: {marks * weight:.1f}"
print(readiness(80)) # score: 80.0
print(readiness(80, 1.25)) # score: 100.0
print(readiness(80, weight=0.5)) # score: 40.0
print(readiness(80, label="aptitude")) # aptitude: 80.0
print(readiness(80, 1.0, "x")) # TypeErrorThe bare * in the signature means everything after it is keyword-only. That is a deliberate
API choice: it stops callers writing readiness(80, 1.0, "x"), where the third argument's meaning
is invisible at the call site.
The mirror image is /, which forces the arguments before it to be positional-only:
def distance(x, y, /):
return abs(x - y)
distance(3, 9) # fine
distance(x=3, y=9) # TypeErrorYou will not write these often. You should recognise them, because the standard library's documentation is full of both.
*args and **kwargs
*args collects extra positional arguments into a tuple; **kwargs collects extra keyword
arguments into a dict:
def log(level, *args, **kwargs):
print(level, args, kwargs)
log("INFO", 1, 2, user="raja", retry=True)
# INFO (1, 2) {'user': 'raja', 'retry': True}The same two stars unpack at a call site, which is the more useful direction:
nums = [3, 1, 2]
print(max(*nums)) # same as max(3, 1, 2)
opts = {"sep": " | ", "end": "!\n"}
print("a", "b", **opts) # a | b!The names args and kwargs are convention only — *things works identically. The stars are the
syntax.
The mutable default argument
This is the most famous Python trap and it is asked constantly:
def add(item, basket=[]):
basket.append(item)
return basket
print(add("a")) # ['a']
print(add("b")) # ['a', 'b'] ← not a fresh list
print(add("c")) # ['a', 'b', 'c']Default values are evaluated once, when the def statement runs — not on each call. So there
is exactly one list, created at definition time, shared by every call that does not pass its own.
You can see it stored on the function object:
print(add.__defaults__) # (['a', 'b', 'c'],)The fix is None as the sentinel:
def add(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basketThe same applies to {}, set() and anything else mutable. An immutable default — a number, a
string, a tuple, None — is safe, because there is nothing to mutate.
Scope: the LEGB rule
When Python resolves a name, it looks in four places, in order:
Local → Enclosing → Global → Built-in.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # local
inner()
print(x) # enclosing
outer()
print(x) # global
print(len) # built-inAssignment makes a name local for the whole function, which produces a confusing error:
count = 0
def bump():
count += 1 # UnboundLocalErrorcount += 1 is a read and a write. The write makes count local to bump, so the read happens
before the local has a value. Two keywords fix it, and both are usually a sign you should return a
value instead:
count = 0
def bump():
global count # rebind the module-level name
count += 1
def outer():
total = 0
def inner():
nonlocal total # rebind the name in the enclosing function
total += 1
inner()
return totalNote that mutating needs neither keyword — only rebinding does:
items = []
def push(x):
items.append(x) # mutation, no `global` neededClosures
A nested function remembers the variables of the scope it was defined in, even after that scope has returned:
def multiplier(n):
def multiply(x):
return x * n # n comes from the enclosing scope
return multiply
double = multiplier(2)
triple = multiplier(3)
print(double(5), triple(5)) # 10 15double carries n = 2 with it. That is a closure, and it is the mechanism behind decorators:
def timed(fn):
def wrapper(*args, **kwargs):
import time
start = time.perf_counter()
result = fn(*args, **kwargs)
print(f"{fn.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
@timed
def slow_sum(n):
return sum(range(n))
slow_sum(1_000_000)@timed is exactly slow_sum = timed(slow_sum). Being able to say that one sentence is usually
the whole decorator question.
The classic closure trap, worth recognising:
fns = [lambda: i for i in range(3)]
print([f() for f in fns]) # [2, 2, 2] — all see the final i
fns = [lambda i=i: i for i in range(3)]
print([f() for f in fns]) # [0, 1, 2] — bound at definition timeThe closure captures the variable, not its value at the time.
lambda
A lambda is a single-expression anonymous function. It is the right tool in exactly one place:
as a small key or callback passed to something else.
students = [("raja", 87), ("anu", 92)]
print(sorted(students, key=lambda s: s[1], reverse=True))
print(sorted(["bb", "a", "ccc"], key=len)) # no lambda needed
print(sorted(students, key=lambda s: (-s[1], s[0]))) # lambda earns its placeIf you find yourself naming a lambda — f = lambda x: ... — use def. It is the same thing with
a real name in tracebacks.
Returning more than one value
There is no special syntax; you return a tuple and unpack it:
def stats(xs):
return min(xs), max(xs), sum(xs) / len(xs)
low, high, avg = stats([1, 2, 3])A function with no return returns None. So does a bare return. This is why
xs = xs.sort() gives you None — sort mutates and returns nothing.
What to take into an interview
- Default arguments are evaluated once at definition. Never use a mutable default; use
None. - Name lookup follows Local → Enclosing → Global → Built-in.
- Assigning to a name anywhere in a function makes it local throughout;
globalandnonlocalrebind an outer name. Mutation needs neither. - A closure captures the variable, not its value — hence the
lambda i=i:idiom. @decoratoronfis exactlyf = decorator(f).- Functions are objects: they can be stored, passed and returned.
- Any function without an explicit
returnreturnsNone.