1.2.28 · D4Introduction to Programming (Python)

Exercises — Default parameters, keyword arguments

2,296 words10 min readBack to topic

Before we start, a one-line reminder of the two words we lean on constantly:


Level 1 — Recognition

Here you only need to spot whether something is legal and read off an output. No design yet.

For each definition line, say whether Python accepts it. If not, name the broken rule.

def a(x, y=1): ...        # (i)
def b(x=1, y): ...        # (ii)
def c(x, y=1, z=2): ...   # (iii)
def d(x=1, y=2): ...      # (iv)
Recall Solution L1.1

Rule 1 says: non-default parameters must come before default ones (defaults drift right).

  • (i) ✅ legal — x (no default) before y=1 (default).
  • (ii)SyntaxErrorx=1 (default) sits before y (no default). Order is backwards.
  • (iii) ✅ legal — one non-default, then two defaults, all in the right order.
  • (iv) ✅ legal — all parameters have defaults, so there is no "non-default after default" problem.

L1.2 — Read the output

def greet(name, greeting="Hello"):
    return greeting + ", " + name
 
print(greet("Ravi"))
print(greet("Ravi", "Namaste"))
print(greet("Ravi", greeting="Hi"))

Write the three printed lines.

Recall Solution L1.2
  • greet("Ravi")greeting is omitted, so its default "Hello" is used → Hello, Ravi.
  • greet("Ravi", "Namaste") → the second value fills greeting by positionNamaste, Ravi.
  • greet("Ravi", greeting="Hi") → the keyword names the slot directly → Hi, Ravi.

Level 2 — Application

Now you apply the rules to produce or fix calls.

L2.1 — Skip the middle

def power(base, exp=2, mod=None):
    r = base ** exp
    return r if mod is None else r % mod

Write a single call that computes 5 ** 2 and then takes it mod 7, without writing the number 2 anywhere. Give the numeric result.

Recall Solution L2.1

You want exp to keep its default 2, but you must set mod. Positionally you'd have to write power(5, 2, 7) — which forces you to type 2. The keyword lets you jump over exp:

power(5, mod=7)

Computation: exp stays 2, so r = 5 ** 2 = 25; then 25 % 7 = 4. Result = 4. This is the whole reason keyword arguments exist: reach a later knob while leaving earlier ones untouched.

L2.2 — Fix the broken call

This raises an error:

def box(width, height, x=0, y=0): ...
box(x=1, 5)

Explain the error and rewrite the call so it sets width=5 and x=1.

Recall Solution L2.2

The error: SyntaxError: positional argument follows keyword argument. Once you write x=1 (a keyword), the bare 5 after it has no known slot — Rule 2 says all positional arguments must come before any keyword argument. The fix: put positionals first.

box(5, x=1)      # width=5 (positional), x=1 (keyword)

Here width gets 5, height/y keep... wait — height has no default, so this would still fail with a missing-argument error. Correct, complete call:

box(5, 3, x=1)   # width=5, height=3, x=1, y keeps default 0

L2.3 — Multiple-values error

Predict the exact error:

def f(name, x=0): ...
f("Asha", name="Asha")
Recall Solution L2.3

The positional "Asha" already fills name. Then name="Asha" tries to fill the same slot again. Result: TypeError: f() got multiple values for argument 'name'. The values being equal does not save you — Python complains about the slot being assigned twice, not about the values disagreeing.


Level 3 — Analysis

Now you trace hidden behaviour and explain why it happens.

L3.1 — The mutable-default trap

def add(item, bag=[]):
    bag.append(item)
    return bag
 
print(add(1))
print(add(2))
print(add(3))

Predict all three lines and explain the mechanism.

Recall Solution L3.1

Output:

[1]
[1, 2]
[1, 2, 3]

Mechanism: the default value [] is created once, when the def line runs — not on each call. So every call that omits bag reuses the same list object, and .append keeps growing it. The picture: one shared bucket, not a fresh bucket per call. See the figure.

Figure — Default parameters, keyword arguments

L3.2 — Fix it and re-predict

Rewrite add using the None sentinel, then predict the same three calls.

Recall Solution L3.2
def add(item, bag=None):
    if bag is None:
        bag = []          # fresh list on THIS call
    bag.append(item)
    return bag

Now add(1)[1], add(2)[2], add(3)[3]. Each call that omits bag builds its own new list inside the body, so there is nothing shared. See how the buckets separate in the figure:

Figure — Default parameters, keyword arguments

L3.3 — Why is None, not == []?

In L3.2 we tested if bag is None. Why not if bag == []?

Recall Solution L3.3

is None asks: "was the caller silent?" — did nothing get passed? == [] asks: "is whatever they passed empty?" — a very different question. If a caller legitimately passes their own empty list they intend you to fill, == [] would wrongly throw it away and substitute a new one. is None respects the caller's list. So: is None distinguishes absence from an empty-but-real value.


Level 4 — Synthesis

Now you design functions that satisfy several constraints at once.

L4.1 — Design a logger

Write log(msg, level="INFO", timestamp=None) that returns a string like "[INFO] hello". If timestamp is given (a string), prepend it: "2024 [INFO] hello". Make sure the common case log("hello") stays tiny.

Recall Solution L4.1
def log(msg, level="INFO", timestamp=None):
    head = f"[{level}] {msg}"
    return head if timestamp is None else f"{timestamp} {head}"
  • log("hello")"[INFO] hello" (both defaults used — the tiny 80% case).
  • log("bad", level="ERROR")"[ERROR] bad" (keyword skips nothing here but names the knob clearly).
  • log("hi", timestamp="2024")"2024 [INFO] hi" (jump over level using a keyword — same trick as L2.1). Note timestamp=None is the safe sentinel default; level="INFO" is a safe immutable string default.

L4.2 — Design without the trap

Write record(name, entries=None) that appends name to a list and returns it, so that repeated calls do not share state. Then show record("a") and record("b") give independent lists.

Recall Solution L4.2
def record(name, entries=None):
    entries = [] if entries is None else entries
    entries.append(name)
    return entries
  • record("a")["a"]
  • record("b")["b"] (independent — no shared bucket)
  • record("c", ["x"])["x", "c"] (a caller's own list is respected and extended). This is the DRY, safe pattern: one line of guard, then normal logic. It respects the DRY principle - Don't Repeat Yourself by keeping the fresh-list logic in exactly one place.

Level 5 — Mastery

Full-integration problems. Combine ordering rules, keywords, sentinels, and scope reasoning.

L5.1 — Trace everything

def build(a, b=10, c=None):
    if c is None:
        c = []
    c.append(a + b)
    return c
 
x = build(1)
y = build(2, 20)
z = build(3, c=x)
print(x)
print(y)
print(z)

Predict x, y, z, and explain why z looks the way it does.

Recall Solution L5.1

Step by step:

  • build(1): b=10 (default), c omitted → new list [], append 1+10=11x = [11].
  • build(2, 20): b=20 positionally, c omitted → new list, append 2+20=22y = [22].
  • build(3, c=x): b=10 (default), c is the same object x. Append 3+10=13 into x → the list becomes [11, 13]. So z is x. Output:
[11, 13]      # x, mutated by the last call
[22]          # y, independent
[11, 13]      # z is the same object as x

Key insight: no default was misused (we used the None sentinel correctly). The sharing here is deliberate — the caller passed x in on purpose, so z is x. This connects to Mutable vs immutable objects: passing a mutable object means the callee can change it.

L5.2 — Repair a specification

A teammate wants: render(items, sep=", ", prefix=[]) that joins items after storing them in prefix. It "sometimes remembers old items". Diagnose the bug, then rewrite so it never leaks, while still letting a caller supply their own prefix list.

Recall Solution L5.2

Diagnosis: prefix=[] is a mutable default created once at def time. If render does prefix.extend(items) and a caller omits prefix, every such call mutates the same shared list — that is the "remembers old items" symptom. Rewrite:

def render(items, sep=", ", prefix=None):
    prefix = [] if prefix is None else prefix
    out = list(prefix)          # copy so we never mutate the caller's list either
    out.extend(items)
    return sep.join(out)
  • render(["a", "b"])"a, b" (fresh, no memory).
  • render(["c"], sep=" | ")"c" (single item, keyword sep used).
  • render(["z"], prefix=["p"])"p, z" (caller's prefix respected, and copied so their list stays untouched). Using list(prefix) is the extra-safe touch: even when a real list is passed, we don't surprise the caller by mutating their object.

L5.3 — Explain the design choice

Why does Python evaluate defaults once (at definition) instead of every call? Give the trade-off in one line.

Recall Solution L5.3

Evaluating once is cheaper and predictable: the default expression runs a single time, so a default like heavy_computation() isn't re-run on every call. The trade-off is the mutable-default trap — a shared object survives between calls. Python chose speed/predictability and asks you to use the None sentinel for the mutable case. So: one evaluation buys performance; you pay with the discipline of None defaults.



Connections