Skip to content

Writing good functions

Goal: write functions that explain themselves, handle special cases cleanly, and fit together into a whole program.

Why this matters

The last lesson taught the mechanics of functions. This one is about using them well. Anyone can split a program into functions, but the split can make a program clearer or make it worse. A few habits separate the two, and they are the same habits professional programmers use every day. By the end you will rebuild the guessing game from Module 3 out of small, named pieces.

Docstrings: a note to the next reader

A string on the first line of a function body is a docstring. It says what the function does, and Python keeps it attached to the function so tools can show it:

docstring.py
def add_tax(price, rate=20):
    """Return the price with tax added, as a percentage rate."""
    return price * (1 + rate / 100)

print(add_tax(100))
help(add_tax)
120.0
Help on function add_tax in module __main__:

add_tax(price, rate=20)
    Return the price with tax added, as a percentage rate.

The docstring does nothing when the function runs. It is there for people, including you in three weeks when you have forgotten what add_tax does. help() prints it, along with the parameters. Every function you have used from Python has one: try help(len) or help(input) in a Python prompt.

Use triple quotes so the docstring can grow to several lines later. Write it as a short instruction: Return the total, Print a banner, Ask until the input is valid. A function whose docstring is hard to write is usually a function that is trying to do too much.

Returning early

When a function has several cases, the natural first attempt is an if inside an else inside an else:

nested_ifs.py
def describe_age(age):
    if age >= 0:
        if age < 13:
            return "child"
        else:
            if age < 20:
                return "teenager"
            else:
                return "adult"
    else:
        return "invalid"

print(describe_age(8))
print(describe_age(15))
print(describe_age(40))
print(describe_age(-3))
child
teenager
adult
invalid

It works, but it is hard to read. Each else pushes the next case further right, and to understand the last return you have to hold three conditions in your head.

Because return ends the function immediately, you can deal with each case and leave. The cases that remain no longer need an else:

early_return.py
def describe_age(age):
    if age < 0:
        return "invalid"
    if age < 13:
        return "child"
    if age < 20:
        return "teenager"
    return "adult"

print(describe_age(8))
print(describe_age(15))
print(describe_age(40))
print(describe_age(-3))
child
teenager
adult
invalid

Same output, half the lines, no nesting. Read it top to bottom: if the age is negative, it is invalid, and we are done. Otherwise, if under 13, child, done. Otherwise, if under 20, teenager, done. Anything left is an adult. The last return has no if at all, because every other possibility has already left the function.

The usual shape is to check the special or invalid cases first and return, then write the main case underneath. Programmers call the early checks guard clauses. The Module 4 average function did this with its empty-list check.

Functions calling functions

Small functions become powerful when you combine them. Here is the ask until valid loop from Module 3 wrapped in a function, then used twice:

compose.py
def ask_for_number(prompt):
    """Keep asking until the user types a whole number, then return it."""
    text = input(prompt)
    while not text.isdigit():
        print("Please type a whole number.")
        text = input(prompt)
    return int(text)

def calculate_area(width, height):
    return width * height

width = ask_for_number("Width: ")
height = ask_for_number("Height: ")
print(f"The area is {calculate_area(width, height)}.")
Width: abc
Please type a whole number.
Width: 4
Height: 5
The area is 20.

ask_for_number() is a function you will want in almost every program that takes input. Now that it exists, asking for a validated number is one line, and the validation logic lives in exactly one place. The last line calls calculate_area() inside an f-string inside print(): three functions in one line, each doing one job.

This is the real reason functions matter. A program made of small named pieces can be read one piece at a time, and each piece can be tested on its own by calling it with a few values and looking at what comes back.

One job per function

How small should a function be? A useful test: if the name needs the word and to be honest, such as ask_for_number_and_calculate_area, it should be two functions. ask_for_number asks. calculate_area calculates. Neither knows or cares about the other, which is why each can be reused elsewhere.

A related rule from the last lesson: functions that calculate should return, not print. calculate_area returns the number, and the caller decides what to do with it. If it printed instead, you could not use the area in a further calculation.

Returning more than one value

Sometimes a function has two answers. Separate them with a comma after return, and catch them with two variables separated by a comma:

two_values.py
def lowest_and_highest(numbers):
    return min(numbers), max(numbers)

low, high = lowest_and_highest([82, 95, 77, 60])
print(f"Lowest: {low}")
print(f"Highest: {high}")
print(f"Range: {high - low}")
Lowest: 60
Highest: 95
Range: 35

low, high = lowest_and_highest(...) is the same unpacking you used with for name, number in book.items(). The values come out in the order they were returned. Python bundles them into a tuple, a small fixed list, and you will meet tuples properly later. For now, return two things, catch two things is all you need.

Project: the guessing game, rebuilt

In Module 3 the guessing game was one block of code about twenty lines long. Here it is again, built from three functions:

guessing_game_v2.py
import random

def ask_for_number(prompt):
    """Keep asking until the user types a whole number, then return it."""
    text = input(prompt)
    while not text.isdigit():
        print("Please type a whole number.")
        text = input(prompt)
    return int(text)

def check_guess(guess, secret):
    """Return a hint comparing the guess with the secret."""
    if guess < secret:
        return "Too low."
    if guess > secret:
        return "Too high."
    return "Correct!"

def play_game():
    """Play one round and return the number of attempts it took."""
    secret = random.randint(1, 100)
    attempts = 0
    print("I am thinking of a number between 1 and 100.")

    while True:
        guess = ask_for_number("Your guess: ")
        attempts += 1
        hint = check_guess(guess, secret)
        print(hint)
        if hint == "Correct!":
            return attempts

attempts = play_game()
print(f"You got it in {attempts} attempts.")
I am thinking of a number between 1 and 100.
Your guess: fifty
Please type a whole number.
Your guess: 50
Too low.
Your guess: 75
Too high.
Your guess: 62
Correct!
You got it in 3 attempts.

Compare the two versions. This one is longer, but look at what each piece does:

  • ask_for_number() is the exact function from the composition section, unchanged. Reuse in action.
  • check_guess() is a pure calculation. Give it two numbers and it returns a hint. It does not print, does not know about attempts, and could be tested at the Python prompt with check_guess(30, 50).
  • play_game() is the only function that knows the rules of the game. It reads like a description of the game because the details are hidden inside the other two.
  • The last two lines are the whole program. Everything above them is definitions.

Notice that play_game() uses return attempts to leave the while True loop. A return inside a loop ends the loop and the function at once, so no break is needed.

Definitions must come before the calls that use them. Python reads the file top to bottom, so if the last two lines were moved to the top, play_game would not exist yet and you would get a NameError.

Try it

Add a fourth function to the game, print_welcome(), that prints the opening message, and call it from play_game(). Then change check_guess() so that a guess more than 20 away returns Way too low. or Way too high. instead. Notice that the second change touches only one function.

Common mistakes

NameError: name 'play_game' is not defined

The call is above the definition in the file. Move the definitions to the top, or the calls to the bottom.

TypeError: unsupported operand type(s) for -: 'tuple' and 'int'

The function returns two values and you caught them with one variable, so that variable holds the pair. Write low, high = my_function().

ValueError: not enough values to unpack (expected 2, got 1)

The opposite: two variables on the left, but the function returned one value. Check its return lines.

The docstring appears in the output

You wrote print("...") instead of a bare string on the first line. A docstring is just a string with no print.

The loop keeps going after return

It cannot. If the loop continues, the return is not being reached. Check its indentation and the if above it.

Exercises

  1. Password check. In Module 2 you wrote a password checker with five rules. Turn it into is_valid_password(password) that returns True or False, using an early return False for each rule. Test it on four passwords.
  2. Vowel counter. Write count_vowels(text) with a docstring that returns how many vowels the text contains. Use it in a loop over a list of words to print each word and its vowel count.
  3. Play again. Extend the rebuilt guessing game. After each round, ask Play again? (y/n). Keep track of the best score, meaning the fewest attempts, and announce it when the player finally quits.
  4. Phone book, rebuilt. Take the phone book menu program from Module 4 and split it into functions: one that prints the menu and returns the choice, and one for each option. The main loop should be short enough to read at a glance.
Solution 1
def is_valid_password(password):
    """Return True if the password passes every rule, otherwise False."""
    if len(password) < 8:
        return False
    if password.isalpha():
        return False
    if password.isdigit():
        return False
    if password == password.lower():
        return False
    if " " in password:
        return False
    return True

print(is_valid_password("short"))
print(is_valid_password("longenough"))
print(is_valid_password("Long3nough"))
print(is_valid_password("Long 3nough"))

Five guard clauses and a final return True. Compared with the if / elif chain in Module 2, each rule is now independent and the function can be used anywhere a yes-or-no answer is needed.

Solution 2
def count_vowels(text):
    """Return how many vowels the text contains, in either case."""
    count = 0
    for character in text.lower():
        if character in "aeiou":
            count += 1
    return count

words = ["Python", "programming", "rhythm", "Aeiou"]
for word in words:
    print(f"{word}: {count_vowels(word)} vowels")
Solution 3
import random

def ask_for_number(prompt):
    """Keep asking until the user types a whole number, then return it."""
    text = input(prompt)
    while not text.isdigit():
        print("Please type a whole number.")
        text = input(prompt)
    return int(text)

def check_guess(guess, secret):
    """Return a hint comparing the guess with the secret."""
    if guess < secret:
        return "Too low."
    if guess > secret:
        return "Too high."
    return "Correct!"

def play_game():
    """Play one round and return the number of attempts it took."""
    secret = random.randint(1, 100)
    attempts = 0
    print("I am thinking of a number between 1 and 100.")

    while True:
        guess = ask_for_number("Your guess: ")
        attempts += 1
        hint = check_guess(guess, secret)
        print(hint)
        if hint == "Correct!":
            return attempts

best = 0
while True:
    attempts = play_game()
    print(f"You got it in {attempts} attempts.")
    if best == 0 or attempts < best:
        best = attempts
        print("That is your best score so far!")

    answer = input("Play again? (y/n) ")
    if answer.lower() != "y":
        break

print(f"Thanks for playing. Your best was {best} attempts.")

play_game() did not change at all. Because it returns the attempts rather than printing a final message, the outer loop can do whatever it likes with the number. best starts at 0 to mean no score yet.

Solution 4
def show_menu():
    """Print the options and return the user's choice."""
    print()
    print("1. Add a number")
    print("2. Look up a number")
    print("3. Show everyone")
    print("4. Quit")
    return input("Choose: ")

def add_entry(phone_book):
    name = input("Name: ")
    number = input("Number: ")
    phone_book[name] = number
    print(f"Saved {name}.")

def look_up(phone_book):
    name = input("Name: ")
    if name in phone_book:
        print(f"{name}: {phone_book[name]}")
    else:
        print(f"{name} is not in the phone book.")

def show_all(phone_book):
    if len(phone_book) == 0:
        print("The phone book is empty.")
    for name, number in phone_book.items():
        print(f"{name}: {number}")

phone_book = {}
while True:
    choice = show_menu()
    if choice == "1":
        add_entry(phone_book)
    elif choice == "2":
        look_up(phone_book)
    elif choice == "3":
        show_all(phone_book)
    elif choice == "4":
        print("Goodbye.")
        break
    else:
        print("Please choose 1, 2, 3, or 4.")

The functions receive phone_book as a parameter and change it directly. That works because a dictionary is mutable: the function gets the same dictionary, not a copy, so entries added inside it are still there afterwards. The same is true for lists. Numbers and strings, being immutable, cannot be changed this way.

Summary

  • A docstring is a string on the first line of a function that says what it does. help() shows it.
  • Handle special cases first with an early return, then write the main case without nesting.
  • Build programs from small functions that each do one job. If a name needs and, split it.
  • return a, b returns two values. Catch them with a, b = function().
  • Definitions must appear above the calls that use them.
  • A function can change a list or dictionary it receives, because those are mutable.

Module 5 complete

You can now organize a program into named, reusable, testable pieces. That skill matters more as programs grow, and from here on every lesson will use functions. Module 6 leaves the keyboard behind and teaches your programs to read and write files, so their work survives after they finish.

Next: Reading files