Search Tech Journey

Find topics, journeys and posts

6-month learning plan7 / 130
back to blog
pythonbeginner 15m read

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)


(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:

SignatureCalls that workRule
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:

  1. add.__doc__ returns the docstring — this is what IDEs and help() show.
  2. bad_append proves the mutable-default trap; good_append fixes it.
  3. *nums collects extra positional args into a tuple; **meta collects extras into a dict.
  4. transfer refuses to be called positionally — you cannot swap the amount for the account.
  5. The @timed decorator prints elapsed time without you editing slow_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. -> None is documentation. -> Optional[User] warns callers to handle None.
  • Small, deep functions. John Ousterhout's "A Philosophy of Software Design" argues for functions whose interface is much simpler than their implementation.
  • functools toolkit. lru_cache for memoisation, partial for currying, wraps inside decorators, reduce sparingly.

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

  1. What's the difference between an argument and a parameter?
  2. Why is def f(items=[]) a bug, and how do you write it correctly?
  3. What does *args collect, and what does **kwargs collect?
  4. When would you make an argument keyword-only using * in the signature?
  5. What is a closure, and what does nonlocal do?

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:

  1. What is Functions? (one sentence, no jargon)
  2. When would you use it? (one concrete example)
  3. When would you NOT use it? (one anti-pattern)

What comes next


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 →
  1. 005Python Variables & Types — Mental Model of Memory
  2. 006Control Flow — if/else, loops, comprehensions
  3. 008Data Structures — list, tuple, dict, set (when to use what)
  4. 009Classes & Objects — the OOP Mental Model
  5. 010Inheritance, Composition & Polymorphism
  6. 011Errors, Exceptions & Debugging with pdb