S007 · Functions — arguments, scope, closures
The atomic unit of reusable code.
Module M01: Python Foundations · Session 7 of 130 · Track: SW 🎯
What you'll be able to do after this session
- Explain Functions to a friend in one minute, out loud, no notes.
- Recognise it when you see it in real code / systems / papers.
- Complete the hands-on exercise at the end without looking up help.
Prerequisites
If you can't answer these in 30 seconds each, redo the linked session first:
Pre-read (skim before the session)
- 🎥 Intuition (5–15 min) · Python Functions — Corey Schafer — Clean walk-through of
def, args, return values. - 🎥 Deep dive (30–60 min) · Python *args, **kwargs, and Beyond — Trey Hunner — The definitive talk on positional-only, keyword-only,
*,**, and unpacking. - 🎥 Hands-on demo (10–20 min) · Closures — Corey Schafer — Closures explained with runnable examples.
- 📖 Canonical article · Python Docs — Defining Functions (§4.7) — The official reference for function signatures.
- 📖 Book chapter / tutorial · Real Python — Defining Your Own Python Function — Beginner-friendly deep dive with worked examples.
- 📖 Engineering blog · Google Testing Blog — Small Functions Considered Beneficial — How Google's testability standards drive small, pure functions.
(a) Intuition · 5 min
The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.
In one sentence: The atomic unit of reusable code.
A function is the smallest reusable unit of code. It's a named box that takes inputs, does something, and (optionally) returns an output. The whole point of programming — the difference between a 50-line script and a 50 000-line system — is being able to build big things out of small, named, testable functions instead of one giant pile.
Three ideas make functions powerful. Arguments let the caller pass values in. Return values let the function pass a result back. Scope means the variables inside the function are private — they can't accidentally clobber variables outside — which is why the same short name (i, x) can be reused freely.
Python has a rich argument grammar: positional (f(1, 2)), keyword (f(x=1, y=2)), defaults (def f(x=0)), variadic (*args, **kwargs), keyword-only (def f(*, key)), and positional-only (def f(x, /)). You don't need all of it on day one, but recognise the syntax when you see it.
Closures — functions that "remember" variables from the scope where they were defined — are how Python handles decorators, callbacks, and lots of functional patterns. They feel magical at first, then obvious.
Gotcha to carry forever: default argument values are evaluated once, at def time, and shared across every call. def f(x=[]) is almost always a bug (S005 covered this). Use None + guard inside. Also: a function that returns None implicitly is easy to miss — always be explicit about return values.
(b) Visual walkthrough · 15 min
Diagrams, worked examples, step-by-step visuals.
Anatomy of a function:
Argument-syntax cheat sheet:
| Signature | Calls that work | Rule |
|---|---|---|
def f(a, b): | f(1, 2) · f(a=1, b=2) · f(1, b=2) | Standard. |
def f(a, b=10): | f(1) · f(1, 2) · f(1, b=99) | Defaults must come after non-defaults. |
def f(*args): | f() · f(1, 2, 3) | Collects extras into a tuple. |
def f(**kwargs): | f() · f(x=1, y=2) | Collects extras into a dict. |
def f(a, /, b, *, c): | f(1, 2, c=3) · f(1, b=2, c=3) | / = pos-only before; * = kw-only after. |
Worked example 1 — a small function decomposed:
def compound_interest(principal: float, rate: float, years: int,
*, compounded_per_year: int = 1) -> float:
'''Return the final amount for a compounded investment.'''
n = compounded_per_year
return principal * (1 + rate / n) ** (n * years)
compound_interest(1000, 0.08, 10) # 2158.92...
compound_interest(1000, 0.08, 10, compounded_per_year=12) # 2219.64...Notice: types on every argument, * forcing the last arg to be keyword-only (so at the call site the reader immediately knows what 12 means), a docstring, and a single return.
Worked example 2 — a closure counter:
def make_counter(start=0):
count = start
def next_value():
nonlocal count # without this, `count = count + 1` creates a local
count += 1
return count
return next_value
a = make_counter()
b = make_counter(100)
print(a(), a(), a()) # 1 2 3
print(b(), b()) # 101 102
print(a()) # 4 (a and b have INDEPENDENT captured `count`)The inner next_value "remembers" the outer count even after make_counter has returned. Each call to make_counter() creates a fresh, independent counter. This is the mental model behind decorators, event handlers, and much of asyncio.
(c) Hands-on · 20 min
Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.
Type each function and call it. Predict the output first.
# s007_functions.py
from functools import wraps
import time
# 1. Simple function with type hints and docstring.
def add(x: int, y: int) -> int:
'''Return the sum of x and y.'''
return x + y
print(add(2, 3))
print(add.__doc__)
# 2. Default arguments — safe and unsafe.
def greet(name: str, greeting: str = "hello") -> str:
return f"{greeting}, {name}"
print(greet("dinesh"))
print(greet("dinesh", greeting="namaste"))
# Unsafe default (the classic bug from S005).
def bad_append(item, bag=[]):
bag.append(item); return bag
print(bad_append(1)) # [1]
print(bad_append(2)) # [1, 2] <-- shared default!
# Safe default.
def good_append(item, bag=None):
if bag is None: bag = []
bag.append(item); return bag
print(good_append(1), good_append(2)) # [1] [2]
# 3. *args and **kwargs.
def summarise(*nums: float, prefix: str = "sum=", **meta: str) -> str:
total = sum(nums)
tags = " ".join(f"{k}={v}" for k, v in meta.items())
return f"{prefix}{total} {tags}".strip()
print(summarise(1, 2, 3, source="cli", user="dinesh"))
# 4. Keyword-only argument with `*`.
def transfer(from_acct: str, to_acct: str, *, amount: float) -> str:
return f"{amount} from {from_acct} → {to_acct}"
print(transfer("A", "B", amount=100)) # cannot call as transfer("A","B",100) — SAFER
try:
transfer("A", "B", 100) # TypeError
except TypeError as e:
print("blocked:", e)
# 5. Return multiple values (really a tuple).
def stats(nums):
return min(nums), max(nums), sum(nums) / len(nums)
lo, hi, avg = stats([1, 2, 3, 4, 5])
print(lo, hi, avg)
# 6. Closure — a private counter.
def make_counter(start=0):
count = start
def inc():
nonlocal count
count += 1
return count
return inc
next_id = make_counter()
print(next_id(), next_id(), next_id())
# 7. A decorator (functions that wrap functions).
def timed(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
result = fn(*args, **kwargs)
print(f"{fn.__name__} took {(time.perf_counter()-t0)*1000:.2f}ms")
return result
return wrapper
@timed
def slow_add(x, y):
time.sleep(0.05)
return x + y
print(slow_add(2, 3))
# 8. First-class functions — pass them around.
ops = {"+": lambda a, b: a + b, "*": lambda a, b: a * b}
print(ops["+"](2, 3), ops["*"](2, 3))Checklist — what to observe:
add.__doc__returns the docstring — this is what IDEs andhelp()show.bad_appendproves the mutable-default trap;good_appendfixes it.*numscollects extra positional args into a tuple;**metacollects extras into a dict.transferrefuses to be called positionally — you cannot swap the amount for the account.- The
@timeddecorator prints elapsed time without you editingslow_add. That's the power of closures.
Try this modification: write a @retry(times=3) decorator that re-runs the wrapped function up to times times if it raises. Hint: you'll need one more layer of nesting (a factory that returns a decorator) so you can pass an argument to the decorator itself.
(d) Production reality · 10 min
How this shows up in real systems, gotchas, war stories, common bugs.
War story — the 4000-line function. A legacy trading system had a single process_order() function that had grown to 4000 lines over 8 years. Nobody could change it without breaking something. A three-month refactor split it into ~120 named functions of 10–40 lines each, each with a docstring and unit test. Bug rate dropped 60 %. Rule at Google: functions should fit on a screen (roughly < 40 lines). If yours doesn't, extract sub-functions.
War story — the silent partial failure. An API endpoint had def save_user(u): db.insert(u); analytics.track(u); email.notify(u). analytics.track occasionally 500'd, raising, which meant the email never sent — but the DB row was already saved. Users complained about missing welcome emails. Fix: separate side effects, wrap each in try/except with an explicit failure policy, and return a result object. This is why "one function, one job" matters.
Pure functions are the gold standard. A pure function has (1) no side effects, and (2) the same output for the same input. Pure functions are trivially testable, cacheable (functools.lru_cache), and safe to parallelise. Not all your code can be pure — I/O has to happen somewhere — but push the impure parts to the edges. This is the "functional core, imperative shell" pattern used by Stripe, Basecamp, and most modern Python teams.
How top teams design functions:
- Type hints everywhere (S014). Signature is the contract; tools enforce it.
- Docstrings on every public function. Google and NumPy style are the two conventions.
- Explicit return types.
-> Noneis documentation.-> Optional[User]warns callers to handleNone. - Small, deep functions. John Ousterhout's "A Philosophy of Software Design" argues for functions whose interface is much simpler than their implementation.
functoolstoolkit.lru_cachefor memoisation,partialfor currying,wrapsinside decorators,reducesparingly.
Perf note: function-call overhead in Python is real (~100 ns). It rarely matters, but in tight numeric loops (millions of calls) it adds up. NumPy / vectorisation removes it; functools.lru_cache can turn a recursive tree into iterative-fast. Profile before you optimise (Session S011 covers cProfile).
Anti-patterns to notice in code review: functions that return different types depending on inputs (return list or None or False), functions that mutate their arguments and return a value (pick one), and functions with more than ~4 positional arguments (make them keyword-only).
(e) Quiz + exercise · 10 min
- What's the difference between an argument and a parameter?
- Why is
def f(items=[])a bug, and how do you write it correctly? - What does
*argscollect, and what does**kwargscollect? - When would you make an argument keyword-only using
*in the signature? - What is a closure, and what does
nonlocaldo?
Stretch problem (connects to S006): write a function count_by(items, key) that takes a list and a key function (like str.lower or lambda u: u["role"]) and returns a dict[str, int] of counts. Then use it to count words in a sentence, ignoring case: count_by("The the THE cat cat dog".split(), str.lower) should return {"the": 3, "cat": 2, "dog": 1}.
Explain-out-loud test
If you can't teach these 3 things to a friend without notes, redo the session:
- What is Functions? (one sentence, no jargon)
- When would you use it? (one concrete example)
- When would you NOT use it? (one anti-pattern)
What comes next
- Next session (S008): Data Structures — list, tuple, dict, set (when to use what)
- Previous (S006): Control Flow — if/else, loops, comprehensions
- Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) quiz + exercise. Duration: 60 minutes.
More from M01 · Python Foundations
all modules →