Lesson 07 · 10 min read · Python for placements

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"))             # TypeError

The 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)      # TypeError

You 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 basket

The 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-in

Assignment makes a name local for the whole function, which produces a confusing error:

count = 0

def bump():
    count += 1          # UnboundLocalError

count += 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 total

Note that mutating needs neither keyword — only rebinding does:

items = []

def push(x):
    items.append(x)     # mutation, no `global` needed

Closures

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 15

double 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 time

The 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 place

If 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 Nonesort 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; global and nonlocal rebind an outer name. Mutation needs neither.
  • A closure captures the variable, not its value — hence the lambda i=i: idiom.
  • @decorator on f is exactly f = decorator(f).
  • Functions are objects: they can be stored, passed and returned.
  • Any function without an explicit return returns None.
Lesson 7 of 12 · all 12 are free to read, no account. Course contents