FreeCampus Python

Breaking a Program into Small Functions

Split one useful program into cohesive calculations and presentation steps with explicit inputs, returned results, side effects, and empty-input policies.
python-foundations functions-call-behavior decomposition side-effects
Open in Colab
  • Level: Python Foundations · Unit 5
  • Estimated time: 3.5–5 hours
  • You will learn: Give each function one coherent responsibility, compose returned values, and keep calculation separate from deliberate printing or mutation.
  • Practice in: Google Colab, JupyterLab, or a local editor

1. One long report is difficult to change safely

Imagine a trail game that records checkpoints reached by each player:

records = [
    {"player": "Ari", "distances": [3, 4, 2]},
    {"player": "Bo", "distances": [5, 1]},
    {"player": "Cy", "distances": []},
]

lines = []
for record in records:
    if not record["player"]:
        continue
    distance = sum(record["distances"])
    if distance >= 8:
        status = "pathfinder"
    elif distance > 0:
        status = "explorer"
    else:
        status = "ready"
    lines.append(f'{record["player"]}: {distance} km — {status}')

headline = f"Trail report: {len(lines)} players"
print(headline)
for line in lines:
    print(line)

The block works, but validation, calculation, classification, formatting, and display are tangled together. Changing the status boundary risks disturbing the format. Reusing the totals without printing means copying part of the block.

This lesson answers four practical questions:

  • Where should one function end and another begin?
  • Which values should cross each function boundary?
  • How does returning data differ from printing or mutating it?
  • How can small functions form one understandable program?

2. Describe the transformations before extracting functions

Start with observable examples, not arbitrary function names:

Input or situation Required result
[3, 4, 2] total distance 9
total 9 status "pathfinder"
name "Ari", total 9, status "pathfinder" one complete report line
empty distance list total 0, status "ready"
complete source records unchanged after building the report

The table reveals a data path:

distances → total → status ─┐
name ───────────────────────┼→ formatted line → displayed report
record count ───────────────┘

This is enough decomposition for now. Unit 7 develops a fuller method for ambiguous problems and algorithm choices. Here the problem is understood; the goal is to give its parts useful boundaries.

3. A contract says more than a function name

Before writing a body, record five facts:

Contract part Question Example
Purpose What one useful job does it own? Classify one distance total.
Inputs What values must the caller supply? A non-negative number.
Result What value comes back? A status string.
Side effects What outside state changes? None.
Boundary What happens for zero? Return "ready".

The contract for classify_distance can appear as a docstring plus examples:

def classify_distance(distance):
    """Return the trail status for one non-negative distance total."""
    if distance >= 8:
        return "pathfinder"
    if distance > 0:
        return "explorer"
    return "ready"


assert classify_distance(9) == "pathfinder"
assert classify_distance(6) == "explorer"
assert classify_distance(0) == "ready"

The docstring states the supported domain. The assertions provide concrete acceptance evidence. Unit 13 will turn this idea into a systematic pytest test suite; these local assertions simply keep the lesson contract visible.

4. Calculation and presentation deserve different boundaries

Compare these two total functions:

def show_total(distances):
    print(sum(distances))


def total_distance(distances):
    return sum(distances)

Both calculate. Only total_distance gives data back to its caller:

distances = [3, 4, 2]
total = total_distance(distances)

assert total == 9
assert classify_distance(total) == "pathfinder"

show_total has a visible side effect but implicitly returns None. It cannot conveniently feed the next calculation. Returning a value keeps options open: the caller may compare it, store it, format it, or eventually print it.

Printing is not wrong. It belongs at the point where display is the intended effect:

def display_report(report):
    """Print a prepared report and return None."""
    print(report)

The command-style name and docstring make that effect explicit.

5. Pure functions are easy to reuse, but effects still have a place

A pure function produces its result only from its inputs and does not change outside state:

def add_bonus(score, bonus):
    return score + bonus


assert add_bonus(10, 2) == 12
assert add_bonus(10, 2) == 12

The same inputs give the same result. Contrast a mutation:

def add_badge(badges, badge):
    """Append one badge to badges; return None."""
    badges.append(badge)


earned = ["starter"]
result = add_badge(earned, "pathfinder")

assert earned == ["starter", "pathfinder"]
assert result is None

Mutation is the stated job, so the contract names it. Unit 6 explains aliases, identity, and copies in detail. For now, inspect both the returned result and the supplied collection whenever a function may mutate.

Hidden global dependencies are harder to see:

STATUS_LIMIT = 8


def hidden_status(distance):
    return "pathfinder" if distance >= STATUS_LIMIT else "explorer"

A fixed module constant can be reasonable. A changing rule is clearer as an input:

def status_for(distance, pathfinder_at=8):
    return "pathfinder" if distance >= pathfinder_at else "explorer"


assert status_for(7) == "explorer"
assert status_for(7, pathfinder_at=6) == "pathfinder"

The caller can now see and control the dependency.

Checkpoint: responsibilities and contracts

6. Queries return information; commands perform an effect

A query asks for information:

def report_headline(player_count):
    return f"Trail report: {player_count} players"

A command asks the program to do something visible:

def announce_headline(player_count):
    print(report_headline(player_count))

The distinction is a design aid, not an absolute law. A file-writing function may both perform an effect and return the number of bytes written. What matters is that its caller can discover both parts of the promise without reading every line of its body.

For beginner programs, calculate first and place the final effect near the outside boundary:

headline = report_headline(3)
display_report(headline)

This ordering makes accidental repeated output less likely and lets you inspect the completed string before displaying it.

7. Compose helpers through visible intermediate values

Functions become useful when one returned result becomes another input:

def build_player_line(record):
    distance = total_distance(record["distances"])
    status = classify_distance(distance)
    return f'{record["player"]}: {distance} km — {status}'


ari = {"player": "Ari", "distances": [3, 4, 2]}
line = build_player_line(ari)
assert line == "Ari: 9 km — pathfinder"

Trace the data, not just the output:

Step Expression Result kept by build_player_line
1 record["distances"] [3, 4, 2]
2 total_distance(...) 9 in distance
3 classify_distance(distance) "pathfinder" in status
4 formatted string complete line returned to caller

This could be compressed into nested calls, but intermediate names are valuable when each result has meaning or may need inspection. Concision is not the same as clarity.

8. Pass only what a helper needs

Suppose a formatter receives all records even though it uses one name and two summary values. That broad input hides its true contract. Prefer:

def format_player_line(name, distance, status):
    return f"{name}: {distance} km — {status}"


assert format_player_line("Ari", 9, "pathfinder") == (
    "Ari: 9 km — pathfinder"
)

Now formatting can be checked without reconstructing an entire game. The caller owns the data flow:

distance = total_distance(ari["distances"])
status = classify_distance(distance)
line = format_player_line(ari["player"], distance, status)

Do not interpret this as “always use many scalar parameters.” A cohesive record can be the right input. The rule is to avoid making a function search unrelated state for the few values it actually needs.

9. Split by reasons to change, not by line count

A kitchen-sink function may validate, calculate, choose policy, format, and print. Those responsibilities change for different reasons. A report format change should not risk its distance calculation.

The opposite extreme also hurts:

def add(left, right):
    return left + right


def turn_into_text(value):
    return str(value)

If these wrappers merely rename obvious operators once, they lengthen the call chain without adding a meaningful contract. Extraction earns its place when it provides reuse, independent policy, a meaningful name, or an independently checkable transformation.

Use this decision list:

  1. Can the job be named as one short promise?
  2. Are its inputs and result narrower than the whole program?
  3. Might it be reused or checked separately?
  4. Does it change for a different reason from its neighbors?

One “yes” can be enough. A line-count limit is not required.

Checkpoint: returns and effects

10. A result can include both an answer and a reason

A plain status may not tell a caller why it was chosen. Return related evidence together when the caller needs both:

def classify_with_reason(distance):
    if distance >= 8:
        return {"status": "pathfinder", "reason": "distance reached 8 km"}
    if distance > 0:
        return {"status": "explorer", "reason": "distance is between 1 and 7 km"}
    return {"status": "ready", "reason": "no distance recorded"}


result = classify_with_reason(9)
assert result["status"] == "pathfinder"
assert result["reason"] == "distance reached 8 km"

The dictionary labels the fields. A two-item tuple can also be suitable when the positions are obvious. Avoid returning a long unexplained tuple that forces every caller to memorize positions.

11. Source preservation is part of the contract

The following formatter reads records without changing them:

def build_lines(records):
    lines = []
    for record in records:
        distance = total_distance(record["distances"])
        status = classify_distance(distance)
        line = format_player_line(record["player"], distance, status)
        lines.append(line)
    return lines

Record a small snapshot before the call:

records = [{"player": "Ari", "distances": [3, 4, 2]}]
snapshot = [{"player": "Ari", "distances": [3, 4, 2]}]

lines = build_lines(records)

assert records == snapshot
assert lines == ["Ari: 9 km — pathfinder"]

Equality is enough for this simple nested fixture. Unit 6 will explain why copying nested mutable data deserves more care.

12. Lab: assemble an expedition report

Build a small pipeline with these contracts:

def valid_record(record):
    """Return whether a record has a non-empty name and a distances list."""
    raise NotImplementedError


def summarize_record(record):
    """Return a summary dictionary for one supported record."""
    raise NotImplementedError


def format_summary(summary):
    """Return one display line for a summary dictionary."""
    raise NotImplementedError


def build_expedition_report(records):
    """Return a complete report string without changing records."""
    raise NotImplementedError

Use this source and keep it unchanged:

expedition = [
    {"name": "Ari", "distances": [3, 4, 2]},
    {"name": "Bo", "distances": [5, 1]},
    {"name": "Cy", "distances": []},
    {"name": "", "distances": [100]},
]

expected_report = """Expedition report — 3 travelers
Ari: 9 km — pathfinder
Bo: 6 km — explorer
Cy: 0 km — ready"""

assert valid_record(expedition[0]) is True
assert valid_record(expedition[-1]) is False
assert summarize_record(expedition[0]) == {
    "name": "Ari",
    "distance": 9,
    "status": "pathfinder",
}
assert format_summary({"name": "Cy", "distance": 0, "status": "ready"}) == (
    "Cy: 0 km — ready"
)

before = [
    {"name": "Ari", "distances": [3, 4, 2]},
    {"name": "Bo", "distances": [5, 1]},
    {"name": "Cy", "distances": []},
    {"name": "", "distances": [100]},
]
assert build_expedition_report(expedition) == expected_report
assert expedition == before
assert build_expedition_report([]) == "Expedition report — 0 travelers"

Work from the inner calculations outward. Do not print inside these four functions. After the assertions pass, display the returned report once.

Hint: make each stage return the next stage’s input

Filter with valid_record, transform each valid record with summarize_record, format each summary, and finally join the headline and lines with "\n".join(...).

Show one complete solution after attempting the lab
def valid_record(record):
    """Return whether a record has a non-empty name and a distances list."""
    return bool(record.get("name")) and isinstance(record.get("distances"), list)


def summarize_record(record):
    """Return a summary dictionary for one supported record."""
    distance = sum(record["distances"])
    if distance >= 8:
        status = "pathfinder"
    elif distance > 0:
        status = "explorer"
    else:
        status = "ready"
    return {"name": record["name"], "distance": distance, "status": status}


def format_summary(summary):
    """Return one display line for a summary dictionary."""
    return (
        f'{summary["name"]}: {summary["distance"]} km — '
        f'{summary["status"]}'
    )


def build_expedition_report(records):
    """Return a complete report string without changing records."""
    summaries = []
    for record in records:
        if valid_record(record):
            summaries.append(summarize_record(record))

    headline = f"Expedition report — {len(summaries)} travelers"
    lines = [headline]
    for summary in summaries:
        lines.append(format_summary(summary))
    return "\n".join(lines)

Checkpoint: composition and preservation

13. Explain the design aloud

Use the completed lab to answer:

  1. Which helper is a query, and where would a display command belong?
  2. What precise value crosses each call boundary?
  3. Which function owns the status policy?
  4. Why is the source-preservation assertion useful?
  5. Which two helpers might change for different reasons?

Then modify the pathfinder threshold in one deliberate place. If you must edit several unrelated functions, the responsibility boundary needs another look.

Key points

TipKey points
  • A useful function owns one coherent responsibility, not an arbitrary number of lines.
  • Contracts state purpose, inputs, returned results, side effects, and important boundaries.
  • Returned data composes naturally; printing and mutation are effects that should be explicit.
  • Pure functions are especially easy to reuse, but command-style functions are appropriate when their documented job is an effect.
  • Intermediate names make a function pipeline observable and easier to explain.
  • Split a large function by reasons to change, while avoiding meaningless wrappers around every tiny expression.

References

Back to top