Organizing Code with Functions

Refactoring Code into Functions


Learning Objectives

  • You can identify the natural functions hidden inside a script.
  • You can extract a function from a script without changing the script’s behavior.
  • You can use tests to verify that a refactor did not break anything.
  • You can describe what “refactoring” means.

The previous chapters introduced the motivation for using functions and how to define functions. In this chapter, we look into refactoring existing code into functions. This is a worked-example chapter, where the point is to see how the ideas from the earlier chapters come together on real code. The syntax is mostly the same as before; the new skill is seeing which pieces of code belong together as functions, and pulling them out without changing what the program does.

Refactoring Is About Changing Code Organization

Refactoring is changing how code is organized without changing what it does. Every time you rename a variable for clarity, extract a piece of repeated logic into a helper function, or split a long function into smaller ones, you are refactoring.

Two things to know about refactoring:

  • It is a normal activity. Programs grow, and the structure that fit yesterday often does not fit today. Refactoring is how the code keeps up.
  • It is separate from adding features. If you refactor and add features in the same pass, you cannot tell whether a new bug came from the new feature or from the refactor. Keep the changes separate; check the program still works after the refactor before you start adding.

In this chapter, we focus on the second point; the refactor changes only the structure of the program and does not add new features.

Five Candidate Functions

Look at the study log version displayed in the chapter Functions Help Organize Code again. Thinking of how to divide the code into functions, five candidate functions stand out:

  1. get_session() — prompts the user for one session. Returns the subject and minutes, or signals that the user is done. Pulls together the input(...) calls inside the while loop.
  2. classify_session(minutes) — takes a number of minutes and returns the response string ("short", "solid", or "long"). Wraps the if/elif/else chain.
  3. total_minutes(minutes_list) — totals a list of minutes. Wraps the accumulator loop.
  4. longest_session(minutes_list) — finds the maximum. Wraps the track-the-best loop.
  5. print_summary(minutes_list) — prints the end-of-run summary. Wraps the entire if len(minutes_list) == 0: ... else: ... block at the bottom.

The first one — get_session — uses input(...), which makes the function impure. The middle three are pure: they take inputs and return outputs without touching anything else. The last one prints, which is impure too.

This split matters for testing. The pure functions can be tested with the assert-based approach from Writing Simple Tests. The impure ones cannot be tested as easily, and you will fall back to running the program and checking the output manually. That is fine.

Candidate functions

0 / 5 points

Which piece of the study log is the best candidate for a pure helper function?

Extracting classify_session First

A good rule for refactoring: start with the easiest extraction. Pure functions are easier than impure ones. Smaller pieces are easier than larger ones. classify_session is both pure and small, so it goes first.

Here is the inline code from the study log program:

if minutes < 15:
print(f" Short session on {subject} -- every bit counts.")
elif minutes <= 90:
print(f" Solid session on {subject}.")
else:
print(f" Long session on {subject} -- remember to take breaks!")

This block does two things mixed together: it classifies the session, and it builds the response string. The cleanest extraction separates them. The classifier returns a label; the calling code combines that label with the subject to build the message.

def classify_session(minutes):
if minutes < 15:
return "short"
elif minutes <= 90:
return "solid"
else:
return "long"

Now back in the main script, the inline if/elif/else is replaced by a call and an f-string:

label = classify_session(minutes)
if label == "short":
print(f" Short session on {subject} -- every bit counts.")
elif label == "solid":
print(f" Solid session on {subject}.")
else:
print(f" Long session on {subject} -- remember to take breaks!")

That looks like it just moved the if/elif/else somewhere else, and in a sense it did. But the function classify_session is now testable in isolation:

assert classify_session(10) == "short"
assert classify_session(15) == "solid"
assert classify_session(90) == "solid"
assert classify_session(91) == "long"

And it is reusable. The same classify_session can be called from the summary section to label each session in a listing, if you ever wanted to add that — without duplicating the threshold logic.

Classify a focus block

0 / 10 points

Define a function named classify_focus_block(minutes).

The function must:

  • return "quick" for minutes less than 25,
  • return "steady" for minutes from 25 through 50,
  • return "deep" for minutes greater than 50,
  • use return, not print,
  • and include assert tests for 24, 25, 50, and 51.

No user input is needed. Do not print from inside the function. The tests will load your program and call the function directly.

Example calls and return values:

classify_focus_block(24) -> quick
classify_focus_block(25) -> steady
classify_focus_block(50) -> steady
classify_focus_block(51) -> deep

Extracting total_minutes and longest_session

These two are the accumulator and finding the maximum patterns from the chapter on Counting, Accumulating, and Searching. They are also pure, and they have one of the simplest possible shapes for a function: a list goes in and a number comes back.

def total_minutes(minutes_list):
total = 0
for m in minutes_list:
total += m
return total
def longest_session(minutes_list):
if len(minutes_list) == 0:
return None
longest = minutes_list[0]
for m in minutes_list:
if m > longest:
longest = m
return longest

longest_session returns None for an empty list, because there is no maximum of nothing. The caller has to be aware of this and handle the None case, but at least the function does not crash. It would have been possible to also e.g. raise an exception for the empty case, but that would require that the caller catches the exception. Returning None is a simpler contract.

Both functions get tests in the same style as the previous section:

assert total_minutes([]) == 0
assert total_minutes([45]) == 45
assert total_minutes([45, 30, 60]) == 135
assert longest_session([]) is None
assert longest_session([45]) == 45
assert longest_session([45, 30, 60]) == 60

The check is None rather than == None is the Python convention for comparing against None. Both work; is None is the more idiomatic form.

Tuples in Passing

Before tackling get_session, here is a small piece of new syntax. get_session needs to return two things — the subject and the minutes. A function can do that by returning them as a tuple, which is just a sequence of values written in order:

def get_session():
subject = input("Subject (or 'done' to finish): ").strip().lower()
if subject == "done":
return None, None
minutes_text = input("For how many minutes? ").strip()
minutes = int(minutes_text)
return subject, minutes

The line return subject, minutes returns both values. At the call site, you can unpack them into two variables in one line:

subject, minutes = get_session()

This works because the names on the left and the values on the right are matched up by position. The first name gets the first value, the second name gets the second.

Tuples are their own data type — similar to lists, but they cannot be modified after creation. We’ll look at them in more detail later in the course.

Tuple unpacking

0 / 5 points

If get_session() returns ("math", 45), what does subject, minutes = get_session() do?

Extracting get_session

With the tuple syntax in hand, get_session becomes a natural place to apply the input-validation pattern from Exceptions and Input Validation. The user might type a non-number for the minutes, and the function should re-prompt rather than crash:

def get_session():
subject = input("Subject (or 'done' to finish): ").strip().lower()
if subject == "done":
return None, None
while True:
minutes_text = input("For how many minutes? ").strip()
try:
minutes = int(minutes_text)
break
except ValueError:
print("Please enter a whole number of minutes.")
return subject, minutes

The function returns (None, None) when the user is done, and the actual (subject, minutes) tuple otherwise. The inner while/try/except loop handles bad numeric input by asking again. The caller checks for None to decide whether to break out of the outer loop:

while True:
subject, minutes = get_session()
if subject is None:
break
# ... process the session ...

This is one of several reasonable signatures. An alternative would be to return None (just one value, not a tuple) when the user is done, and a tuple otherwise. That makes the check at the call site uglier, because the caller has to inspect what came back before unpacking it. The two-Nones version makes the unpacking always safe.

As mentioned at the beginning of this chapter, testing get_session is harder because it is impure. The function calls input(...), which means a simple assert does not work — Python sits and waits for the user to type something.

Testing functions that interact with the user requires more advanced techniques than this course will teach.

In this course, we take a practical approach to testing impure functions. We test the functions by running the program and checking the behavior manually.

Extracting print_summary

The last extraction is the summary block at the bottom. It is impure (it prints) and it uses several variables from the rest of the program. Passing them in as parameters makes the dependency explicit:

def print_summary(minutes_list):
print("=== Summary ===")
if len(minutes_list) == 0:
print("No sessions logged today.")
return
total = total_minutes(minutes_list)
average = total / len(minutes_list)
longest = longest_session(minutes_list)
print(f"Sessions: {len(minutes_list)}")
print(f"Total minutes: {total}")
print(f"Average session: {average:.1f} minutes")
print(f"Longest session: {longest} minutes")

A few things to notice:

  • The function uses total_minutes and longest_session internally. This is one of the rewards of refactoring: once you have a small library of named functions, the next function gets to use them.
  • The early return after the empty-list message replaces the if/else from the earlier version. Once the message is printed, there is nothing left for the function to do.

The Main Script After Refactoring

With all five functions extracted, the main script becomes short:

print("=== Study Log ===")
print()
subjects = []
minutes_list = []
while True:
subject, minutes = get_session()
if subject is None:
break
subjects.append(subject)
minutes_list.append(minutes)
label = classify_session(minutes)
if label == "short":
print(f" Short session on {subject} -- every bit counts.")
elif label == "solid":
print(f" Solid session on {subject}.")
else:
print(f" Long session on {subject} -- remember to take breaks!")
print()
print_summary(minutes_list)

Roughly 18 lines, including blank lines. The total amount of code did not shrink — it grew slightly, in fact, because of the function definitions and the tests. But the main script is now mostly glue. The substantive logic — input handling, classification, accumulation, summary — lives in named functions, each of which can be read or tested in isolation.

Note also how much easier the main script is to read. The high-level flow of the program is now visible at a glance, without having to read through the details of how each piece works.

The per-session response is still inline. It could be packaged as a format_session_response(subject, label) function. The chapter does not extract it because at this scale, it is small enough that inlining is fine; the judgment call about when to extract is itself part of the skill, and there is no rule that says every multi-line block must be a function.

The next exercise sequence gives you a working inline program and asks you to refactor it step by step. The goal is to keep the behavior the same while moving the natural pieces into named functions.

Common Pitfalls

A short list of mistakes that might come up during a refactor:

  • Extracting a function but leaving the old inline code in place. Now there are two copies of the same logic, the script runs both, and you have made the program worse, not better. After extraction, delete the inline version.
  • Pulling out a function but not wiring up the call. Easy to do when the extraction is messy. The script silently behaves differently. Tests catch this.
  • Refactoring and adding features at the same time. When a new bug appears, you do not know whether it came from the new feature or the refactor. Keep changes separate.
  • Tests that pass even after you broke something. Usually because the tests check too little — they pass on the obvious case but do not check the case the bug is in. The fix is to broaden the test set using the edge-case checklist.
  • Forgetting return. A function that should return a value but has no return returns None, silently. The function call evaluates to None, and whatever uses the result misbehaves.

Refactoring pitfalls

0 / 5 points

What should you check after extracting a helper function from working code?

Check Your Understanding

  1. In your own words, what does “refactoring” mean?
  2. Why is it a good idea to keep refactoring and feature changes separate?
  3. Why is get_session harder to test than total_minutes?
  4. Suppose you extract a function and the program stops working. What is a systematic way to find what went wrong?

AI Study Aid

Create a chapter diagram

We're looking into to what extent AI-generated diagrams could help with studying.

Use this study aid to generate an AI-generated visual summary of the material you just studied. Each diagram style emphasizes the content in a different way, so you can choose the focus that feels most useful.

Using the diagram generator is voluntary and does not affect your course progress. We encourage you to try it out and see if it helps, but it's totally up to you! Your answers help us understand how to make better study aids in the future.

Diagrams tried: 0