JEPA4Japan · tutorials

Function Design, Scope, and Call Patterns

1,129 words 6 min read #Python

Design focused functions and understand functions as values, defaults, keyword arguments, scope, closures, and side effects.

Course progress Course outline 24 of 24 lessons available

Give each helper one job

Chapter 8 made a study report. A long recipe works, but small helpers are easier to understand and test. A function should have one clear job and a small contract: what goes in, what comes back, and what it changes.

def session_label(topic, minutes=30, *, excited=False):
    label = f"{topic}: {minutes} minutes"
    if excited:
        return label.upper() + "!"
    return label


print(session_label("Python"))
print(session_label("Git", minutes=45, excited=True))

Output:

Python: 30 minutes
GIT: 45 MINUTES!

topic is required, minutes has a default, and everything after * must be named. excited=True is clearer than a mysterious second True.

The contract is simple: accept a topic and minutes, return text, and change nothing outside the function. Returning data keeps the calculation separate from the visible side effect made by print().

Functions are values too. Chapter 8 passed minutes_in to sorted() as a key. Pass the function without (); parentheses would call it immediately.

A mutable default is a shared backpack

Python evaluates a default when it runs the def statement, not each time the function is called. A list can therefore carry old things into the next call.

  1. One backpack the default list is made
  2. Add Python the backpack keeps it
  3. Add Git Python is still inside
A mutable default object is reused by later calls.

This example is intentionally wrong:

def collect(topic, topics=[]):
    topics.append(topic)
    return topics


print(collect("Python"))
print(collect("Git"))

Output:

['Python']
['Python', 'Git']

Use None as a “please make a fresh list” sign:

def collect(topic, topics=None):
    if topics is None:
        topics = []
    topics.append(topic)
    return topics

Now two omitted calls start independently. Edge reminder: a list supplied by the caller is still changed. If the contract promises no mutation, copy it first with topics = list(topics).

Look for names from the inside out

When Python sees a name, it searches LEGB: Local, Enclosing, Global, Built-in.

  1. Local this function call
  2. Enclosing an outer function
  3. Global the top of this file
  4. Built-in names such as len
Python stops at the first matching name.
course = "Python"                 # Global


def make_reader():
    prefix = "Study"              # Enclosing

    def label(minutes):
        suffix = "minutes"        # Local
        return f"{prefix} {course}: {minutes} {suffix}"

    return label


reader = make_reader()
print(reader(40))                  # print is Built-in

Output:

Study Python: 40 minutes

Assignment inside a function normally makes a local name. Avoid global just to update a total; accept the old total and return the new one. Hidden shared changes make tests depend on which call ran first.

A closure can remember one small thing

The returned reader above still remembers prefix. A function bundled with remembered enclosing names is a closure. Use nonlocal only when the inner function must replace an enclosing value:

def make_counter(start=0):
    total = start

    def add(minutes):
        nonlocal total
        total += minutes
        return total

    return add


counter = make_counter(10)
print(counter(20))
print(counter(15))

Output:

30
45

Without nonlocal, total += minutes would try to use a new local total before it had a value. Closures are good for tiny private memory, not for hiding an entire program.

A pure transformation uses its inputs and returns a result without changing outside state. Printing, writing a file, and changing a caller’s list are side effects. Keep transformations in the middle and side effects at the edge whenever you can.

Build a tiny analysis pipeline

  1. Normalize make clean new records
  2. Analyze total and rank
  3. Format return report text
  4. Print one visible side effect
Small contracts pass data along a clear pipeline.

Save this complete project as study_pipeline.py:

def normalize_session(session):
    return {
        "topic": session["topic"].strip(),
        "minutes": session["minutes"],
    }


def minutes_by_topic(sessions):
    totals = {}
    for session in sessions:
        topic = session["topic"]
        totals[topic] = totals.get(topic, 0) + session["minutes"]
    return totals


def minutes_in(item):
    return item[1]


def analyze(sessions, target=60):
    clean = [normalize_session(session) for session in sessions]
    totals = minutes_by_topic(clean)
    ranking = sorted(totals.items(), key=minutes_in, reverse=True)
    total = 0
    for session in clean:
        total += session["minutes"]
    return {"total": total, "met": total >= target, "ranking": ranking}


def format_report(analysis, *, heading="Study report"):
    status = "met" if analysis["met"] else "not met"
    lines = [heading, f"Total: {analysis['total']} minutes", f"Target: {status}"]
    for number, (topic, minutes) in enumerate(analysis["ranking"], start=1):
        lines.append(f"{number}. {topic}: {minutes} minutes")
    return "\n".join(lines)


def run_pipeline(sessions, formatter=format_report, *, target=60):
    return formatter(analyze(sessions, target=target))


sessions = [
    {"topic": " Python ", "minutes": 45},
    {"topic": "Git", "minutes": 40},
    {"topic": "Python", "minutes": 30},
]
before = [session.copy() for session in sessions]
report = run_pipeline(sessions, target=100)

assert sessions == before
assert "Python: 75 minutes" in report

print("Checks passed.")
print(report)

Run python3 study_pipeline.py:

Checks passed.
Study report
Total: 115 minutes
Target: met
1. Python: 75 minutes
2. Git: 40 minutes

formatter receives a function value. All calculations return data; only the final two print() calls create visible side effects.

Three tiny missions

  1. Write format_duration(minutes, *, compact=False) and return either 45 minutes or 45m.
  2. Call the safe collect() twice with no list and prove that the results are independent.
  3. Start a closure at 100, add 20 and 5, and predict both answers before running it.

Ready for Chapter 10?

  • I can state a function’s input, return value, and side effects.
  • I know why a list should not usually be a default value.
  • I can search for a name in LEGB order.
  • I can explain what a closure remembers and what nonlocal changes.
  • I can keep calculation separate from printing.
  • I ran the pipeline and its checks passed.

Next, you will protect these function contracts by rejecting bad input with precise exceptions and validation.