Skip to content

Week 5: functions, tuples, and documentation

Every program you have written so far has been one list of statements, read from the top and worked through to the bottom. Branches let that list skip parts of itself, but it was still one list, and it was still read once.

This week the list gets cut into pieces, and the pieces get names. A function is a block of statements with a name, which you can run from somewhere else in the program, as many times as you like, handing it different values each time. That sounds like a convenience, and it is, but the reason this week is the densest one in the course is that four other ideas only make sense once functions exist: values going in as parameters, a value coming back as a return, names that live only inside one function, and a tuple, which is how you get more than one value back at once.

Everything after this week is written in functions. So is Quiz 2, which is a week from now, on a computer.

Save each program in the Week05 folder inside ~/CS149, and commit and push your work when you finish. The exercises are ordered from easiest to hardest, and each one has an assignment on Gradescope so you can check your own work as you go.

Every function needs a docstring, and ruff is checking

Since Week 3 every program has needed a module docstring at the top, with your name and the date in it. From this week on, every function needs one too, and it has to say what goes in and what comes back:

def double(number):
    """Double a number.

    Args:
        number (int): The number to double.

    Returns:
        int: Twice the number.
    """
    return number * 2

That is the Google docstring style, and ruff check will tell you when one is missing, when the Args: section does not match the parameters, and — this is the one worth remembering — when a docstring promises a Returns: that the function never actually returns. Write the docstring before you write the body and most of those messages never happen.

Ex5.1 Children's Songs

Children's songs are built out of repetition, which makes them a good first look at what a function is for. Here is the smallest useful function there is:

>>> def greet(name):
...     print("Hello,", name)
...
>>> greet("Madison")
Hello, Madison
>>> greet("Duke")
Hello, Duke

The def line gives the function a name and lists its parameters, and the indented block under it is the body, which does not run at all until somebody calls it. greet("Madison") is a call, and "Madison" is the argument that arrives in the body as name. Two calls, one body, two different outputs.

Write a program named happy.py that prints five verses of "If You're Happy and You Know It", exactly as below. Every verse has the same four lines with one phrase swapped out, so write a function named verse that takes that phrase as its only parameter and prints one verse followed by a blank line. Then call it five times.

If you're happy and you know it, clap your hands
If you're happy and you know it, clap your hands
If you're happy and you know it, and you really want to show it
If you're happy and you know it, clap your hands

If you're happy and you know it, slap your knees
If you're happy and you know it, slap your knees
If you're happy and you know it, and you really want to show it
If you're happy and you know it, slap your knees

If you're happy and you know it, turn around
If you're happy and you know it, turn around
If you're happy and you know it, and you really want to show it
If you're happy and you know it, turn around

If you're happy and you know it, pat your head
If you're happy and you know it, pat your head
If you're happy and you know it, and you really want to show it
If you're happy and you know it, pat your head

If you're happy and you know it, do all four
If you're happy and you know it, do all four
If you're happy and you know it, and you really want to show it
If you're happy and you know it, do all four

Your program may contain at most five print statements in total, which is the whole point of the exercise: the song is twenty-five lines long and your source code is not.

Where the blank lines come from

There is a blank line after every verse, including the last one, and it belongs inside the function rather than between the calls. A print() with nothing in the parentheses prints a blank line, and it counts toward your five.

Optional challenge: Go Tell Aunt Rhody

Nothing to submit, and no Gradescope assignment — this one is here because it breaks the pattern you just used.

Go tell Aunt Rhody
Go tell Aunt Rhody
Go tell Aunt Rhody
The old gray goose is dead

The one she's been saving
The one she's been saving
The one she's been saving
To make a feather bed

The song runs for six verses: the third is The goslings are mourning / Because their mother's dead, the fourth is The old gander's weeping / Because his wife is dead, the fifth is She died in the mill pond / From standing on her head, and the sixth repeats the first.

Write rhody.py with a function that prints one verse. happy.py needed a function with one parameter; work out for yourself how many this one needs and why, and notice that you did not have to change anything about how functions work to get it.

Ex5.2 Unit Conversions

Printing is not the useful thing a function does. Handing a value back is, because a value that comes back can be stored, printed, compared, or passed straight into another function:

>>> def double(number):
...     return number * 2
...
>>> double(21)
42
>>> answer = double(double(10))
>>> answer
40

When solving physics problems you often need to convert one unit into another. Create a module named convert.py containing these four functions. Each one takes a single float parameter and returns a float.

  • kilometers_to_miles
  • miles_to_kilometers
  • meters_per_second_to_miles_per_hour
  • miles_per_hour_to_meters_per_second

Work out the arithmetic yourself. There are 1.60934 kilometers in a mile, 1000 meters in a kilometer, and 3600 seconds in an hour.

Half the work here is the docstrings, and the other half is the Returns: line in each one, which has to name the type that actually comes back.

At the end of the file, add a main block that tries each function out:

if __name__ == "__main__":

    value = 4.9
    print(f"{value:.1f} km = {kilometers_to_miles(value):.1f} mi")

Finish it so that all four functions get called and the program prints exactly this:

4.9 km = 3.0 mi
4.9 mi = 7.9 km
4.9 mps = 11.0 mph
4.9 mph = 2.2 mps

What if __name__ == "__main__": is for

A file like convert.py is two things at once: a module that other programs use, and a program you can run yourself to check that it works. The code under that if runs only when you run the file directly; it is skipped when some other file imports your functions.

That is why your testing code goes there instead of at the bottom of the file, and the autograder checks it: it imports convert and requires that nothing at all gets printed. From this week on, every module you write ends this way.

Two of the four are one line each

You do not need 1.60934 in all four functions, and you should not write it in all four. You already have a function that converts kilometers to miles; a speed in meters per second is a distance in kilometers per hour once you have multiplied by 3600 and divided by 1000. Functions are allowed to call the functions you already wrote, and getting into that habit now is most of what makes next month's programs manageable.

Read your own documentation

Once convert.py runs, open the Shell and try this:

>>> import convert
>>> help(convert.kilometers_to_miles)

help() is the same function you used on abs and round last week, and what it prints for your function is the docstring you wrote. That is all a docstring ever was. If what comes back is unhelpful, the fix is in your file.

Ex5.3 Phone Number

Countries in the North American Numbering Plan use 10-digit phone numbers, usually written with a dash after the third and sixth digits: 2024561414 is displayed as 202-456-1414.

Write a module named phone.py with these two functions.

format_number(number) takes a 10-digit int and returns a str of exactly twelve characters, with no spaces and no newline. Given 1234567890 it returns "123-456-7890".

checksum(number) takes a 10-digit int and returns an int: the sum of the last four digits. Given 1234567890 it returns \(7 + 8 + 9 + 0\), which is 24.

Add a main block that tries both functions on 2024561414 and prints exactly this:

202-456-1414
Checksum: 10

Arithmetic only

Your solution must not convert number to a string with str(), and must not use square brackets. Gradescope checks for both. Extracting digits from an integer is what // and % are for, and you did the two halves of that in Week 3 with cookies.py and digits.py.

Three pieces, three names

Get the area code, the prefix, and the last four digits into three variables of their own before you build the string, and then the f-string that assembles them is short enough to read.

One of the three needs care: try your function on 5400051234, whose prefix is 005. An f-string can be told how many digits to pad a number out to, with f"{prefix:03d}". Try that in the Shell on 5 and on 123 and you will see what the 03 is doing.

format is already taken

The function is called format_number, not format, because format is the name of a built-in function. Python will let you reuse the name, and then the built-in is gone for the rest of your program. Try help(format) in the Shell to see what you would be giving up.

Ex5.4 Date Analysis

So far a function has handed back one value. Often you want several, and Python's answer is the tuple: a fixed group of values, written in parentheses, that travels as one thing.

>>> point = (3, 4)
>>> point
(3, 4)
>>> x, y = point
>>> y
4

That last line is unpacking: three names on the left, three values on the right, matched up in order. A return with commas in it builds a tuple, and unpacking takes it apart again at the other end, so "return several values" and "return one tuple" are the same sentence in Python.

Write a module named date_types.py containing these four functions.

kind_of_day(weekday) takes a day number where 1 is Sunday and 7 is Saturday, and returns "weekend" for Sunday and Saturday, "weekday" for the five in between, and "invalid" for anything outside 1 through 7.

season_of(month) takes a month number, 1 through 12, and returns "Fall" for September through November, "Winter" for December through February, "Spring" for March through May, "Summer" for June through August, and "Invalid" for anything outside 1 through 12.

quarter_of(mo_date) takes a day of the month and returns which quarter of the month it falls in: 1 for days 1 through 7, 2 for 8 through 14, 3 for 15 through 21, and 4 for 22 through 31. For a day below 1 it returns 0, and for a day above 31 it returns 5.

date_analysis(weekday, month, mo_date) takes all three and returns all three answers as a tuple, in that order. Its body is one line.

Add a main block that prints exactly this:

('weekday', 'Summer', 3)
('invalid', 'Winter', 4)
weekday Winter 4

The first two lines come from print(date_analysis(6, 8, 18)) and print(date_analysis(0, 1, 26)). The third line is the same date analysed — Monday, December 25th — but unpacked into three variables first and printed from those. Printing a tuple and printing its contents look different, and seeing both once is worth the extra line.

Write the four functions, not one big one

It is possible to put all three decisions inside date_analysis and skip the helpers. Do not, for two reasons.

The first is that ruff check will fail your program with a message about the function being "too complex", and there is a real rule behind that number: a function you cannot hold in your head is a function you cannot test. The second is that three small functions can be checked one at a time in the Shell, and one big one can only be checked all at once. Gradescope tests each of the three separately, which you could not do either if they did not exist.

One lowercase, one capital

An out-of-range weekday gives lowercase "invalid" and an out-of-range month gives capital "Invalid". That is not a typo in the specification, and it is there to stop you writing one range check and using it for both. The two functions have no idea the other one exists, which is exactly the property that makes them easy to test.

Ex5.5 The Function That Gives Nothing Back

Three times now you have been handed a broken program: one that refused to start, one that ran to completion and printed wrong numbers every time, and one that was right for some inputs and wrong for others. This one is different again, and the difference is the most useful thing on this page.

Save this into your Week05 folder as bake_sale.py, exactly as it appears here.

"""HW5.5 Bake Sale.

Name: (your name here)
Date: (today's date)
"""

total = 0.0


def subtotal(quantity,price):
    """Compute the amount one kind of item brought in.

    Args:
        quantity (int): How many were sold.
        price (float): The price of one.

    Returns:
        float: The amount those sales brought in.
    """
    quantity * price


def add_to_total(amount):
    """Add one subtotal to the running total.

    Args:
        amount (float): The subtotal to add to it.
    """
    total = total + amount


cookies = subtotal(12, 1.25)
print(f"Cookies: ${cookies:.2f}")
add_to_total(cookies)

brownies = subtotal(8, 2.50)
print(f"Brownies: ${brownies:.2f}")
add_to_total(brownies)

lemonade = subtotal(20, 0.75)
print(f"Lemonade: ${lemonade:.2f}")
add_to_total(lemonade)

print("Bake sale total: $", f"{total:.2f}", sep = "")

There are four problems in it: two style violations and two that stop it working. Run it first, and you will get a TypeError mentioning NoneType before a single line of output appears.

Then, before you start guessing, run ruff check bake_sale.py and read all six messages it prints. Two of them are the style violations, and both are rules you have not tripped before. The other four are pointing at the two real bugs, in words that say exactly what is wrong — one of them is docstring should not have a returns section because the function doesn't return anything, and another is local variable "total" referenced before assignment.

Repair it so that it prints exactly this:

Cookies: $15.00
Brownies: $20.00
Lemonade: $15.00
Bake sale total: $50.00

When you are finished, ruff check bake_sale.py should report nothing at all.

What NoneType was telling you

A function that never runs a return still hands something back: the value None, which means "nothing here". So cookies is None, and an f-string cannot format None as money.

This is the single most common mistake in the first week of functions, and it is worth planting deliberately in your own head: a function that computes a value and does not return it has computed nothing.

The second bug is not fixed with global

add_to_total assigns to total, and assigning to a name inside a function creates a variable that belongs to that function and disappears when it ends. It does not touch the total at the top of the file, and because the function reads total before assigning it, Python will not even let it run.

There is a keyword that overrides this, and this course does not use it; your repair must not contain the word global. Use what this week is actually about instead: a function takes values in as parameters and hands a value back with return. add_to_total should take two parameters — the running total and the amount to add — and return the new running total, and each of the three call sites should store what comes back.

Then try changing the body to total = amount and run it again. The crash disappears and the program cheerfully reports $0.00, which is the same bug with the error message removed. That version is worse.

The two style rules are new

One of them is about a comma, and the other is about the spaces around an = inside a function call. Both need a call with more than one argument in it to happen at all, which is why this is the first week you have met either. ruff names the line and the column for both.

Ex5.6 Color Utilities

Images are grids of pixels, and a pixel's color is three numbers: how much red, how much green, and how much blue, each from 0 to 255. Three numbers that always travel together is exactly what a tuple is for.

red = (255, 0, 0)
green = (0, 255, 0)
blue = (0, 0, 255)
black = (0, 0, 0)
white = (255, 255, 255)

Write a module named color_utils.py containing these three functions.

adjust_channel(value, amount) takes one channel value and an amount to add to it, which may be negative, and returns the new value clipped to the range 0 to 255: anything that would land below 0 comes back as 0, and anything above 255 comes back as 255.

adjust_brightness(color, amount) takes a color tuple and an amount, and returns a new color tuple with all three channels adjusted by that amount and clipped the same way.

same_color(color1, color2, tolerance) takes two color tuples and a maximum allowable difference, and returns True if every channel differs by no more than the tolerance, and False otherwise.

Add a main block that prints exactly this:

(10, 10, 255)
(90, 0, 245)
True
False
True

Those five lines come from adjust_brightness((0, 0, 250), 10), adjust_brightness((100, 5, 255), -10), and then same_color on (128, 25, 80) against itself with a tolerance of 0, on (129, 25, 80) against (128, 25, 80) with a tolerance of 0, and the same pair again with a tolerance of 1.

adjust_channel is the point of the exercise

Write adjust_brightness without it and you will write the same three or four lines out three times, once per channel, and get one of the three subtly wrong. A function that exists only to stop you repeating yourself is called a helper function, and this is the clearest example of one you will meet all semester.

Write and test adjust_channel first, on its own, until you are sure about both of its limits. Then adjust_brightness is: unpack the tuple into three names, call the helper three times, and return the results as a tuple.

Two channels, one comparison

same_color returns a bool, and the shortest way to write it is one return with an expression after it rather than an if and an else. You have everything you need: and from last week, and abs from closer.py.

Watch the boundary. A tolerance of 1 means a difference of exactly 1 is still the same color, and a difference of 2 is not. Check both.

Extra Challenges

The six exercises above are the ones with Gradescope assignments, and they are the ones that count. These two have nothing to submit. Both are here because the hard part is deciding what the functions should be, which is the part Quiz 2 will not give you.

Math tricks. Write math_tricks.py with two functions, both returning floats. sphere_volume(radius) gives the volume of a sphere of that radius. normal_pdf(mu, sigma, x) evaluates the probability density function of a normal distribution at the point \(x\), given a mean \(\mu\) and a standard deviation \(\sigma\). Neither formula is given here on purpose: the exercise is translating mathematical notation into Python, and both formulas are one search away. You will need math.pi, math.sqrt, and math.exp; help(math.exp) will tell you what the last one does. Check yourself against sphere_volume(1), which is about 4.19, and normal_pdf(0, 1, 0), which is about 0.399.

Clock time. Write clock.py with two functions that fit together. seconds_to_clock(total) takes a whole number of seconds and returns a tuple of three integers: hours, minutes, and seconds. clock_to_string(hours, minutes, seconds) takes those three numbers and returns a string like "02:45:09", two digits each, zero-padded. Then write a main block in which the second function is called on the unpacked result of the first, so that 9909 seconds prints as 02:45:09. Getting the two to fit together without an intermediate variable is the exercise; getting it to fit together with one is a perfectly good first step.

When you're done

Commit and push your Week05 folder:

git add -A
git commit -m "Add week 5 practice programs"
git push

Then go back through the six and check one thing in each: does every function have a return where its docstring says it has one, and does the value that comes back actually get used by whoever called it? Those two questions catch most of what goes wrong in the first fortnight of writing functions, and both of them are faster to ask than to debug.

Quiz 2 is next week, it covers Weeks 4 and 5, and it is on a computer: you will be handed a problem you have not seen and asked to write, run, and submit a working program, without AI. The honest rehearsal for that is to write one of the Extra Challenges from scratch with a timer running and your notes closed.