Functions · about 40 min

Docstrings and designing good functions

Why this matters

Writing a function that works is the first step. Writing one that others (including you, six months from now) can understand, trust and reuse is the real craft. This lesson covers how Python functions document themselves with docstrings, and the design habits that separate easy-to-maintain code from tangled code: pure functions, controlled side effects, one job per function, and keeping input/output at the edges. These habits also make testing (module 11) dramatically easier.

Docstrings

A docstring is a string literal that is the first statement in a function body:

def area(width, height):
    """Return the area of a width-by-height rectangle."""
    return width * height

It is not a comment. Comments are discarded by the tokenizer, but a docstring is kept: the compiler stores it on the function object as __doc__, and tools read it from there.

print(area.__doc__)
help(area)
Return the area of a width-by-height rectangle.
Help on function area in module __main__:

area(width, height)
    Return the area of a width-by-height rectangle.

help() (which the REPL uses, as do editors when you hover over a name) builds its page from the function's signature and docstring. Every built-in and standard library function is documented this way; help(str.split) is often faster than a web search.

Writing good docstrings

Conventions (from PEP 257, the docstring style guide):

def parse_duration(text):
    """Convert a duration like "1h30m" or "45m" into a number of minutes.

    text: hours followed by "h" and/or minutes followed by "m".
    Returns the total number of minutes as an int.
    Raises ValueError if text is empty or malformed.
    """
    ...

A docstring says what the function does and how to call it, not how it is implemented. Implementation notes belong in comments next to the code they explain.

Pure functions and side effects

A pure function:

  1. returns a result that depends only on its arguments, and
  2. has no side effects: it does not print, read input, write files, change globals, or mutate its arguments.
def total_with_tax(prices, rate):
    """Return the sum of prices plus tax at the given rate."""
    subtotal = 0
    for p in prices:
        subtotal += p
    return subtotal * (1 + rate)

Call it with the same arguments a thousand times and you get the same answer and nothing else changes. That makes it trivial to test (assert total_with_tax([10, 20], 0.5) == 45.0), safe to reuse anywhere, and easy to reason about.

Side effects are not evil; a program with none would be useless, since printing and saving files are side effects. The point is to keep them few and visible. Compare:

discount = 0.1

def price_after_discount(price):
    global discount
    discount += 0.05          # surprise: every call changes the discount
    return price * (1 - discount)

Here the same call returns different values each time, and the effect is invisible at the call site. Bugs like this are hard to find because the cause is far away from the symptom.

Mutating an argument is a side effect that is easy to do by accident; the next module shows exactly how, with lists. If a function mutates its argument on purpose, say so in its docstring and, by convention, return None (as list.sort() does), so that nobody mistakes it for a function that returns a new value.

One job per function

A function should do one thing, which you can name with a verb phrase. Signs a function does too much:

Splitting is cheap in Python. Small, well-named functions read like prose:

def main():
    text = read_input()
    words = split_into_words(text)
    counts = count_words(words)
    print_report(counts)

Keep I/O at the edges

A powerful pattern: put all the reading and printing in a thin outer layer (often a function called main), and keep the logic in pure functions that receive data as arguments and return results.

   stdin/files ---> main() ---> pure functions ---> main() ---> stdout/files
                  (I/O layer)    (all the logic)   (I/O layer)

Before:

def shipping():
    weight = float(input())
    if weight <= 1:
        print("cost: 5")
    else:
        print("cost:", 5 + (weight - 1) * 2)

You cannot test the pricing rule without faking keyboard input and capturing the screen. After:

def shipping_cost(weight):
    """Return the shipping cost: 5 for up to 1 kg, plus 2 per extra kg."""
    if weight <= 1:
        return 5
    return 5 + (weight - 1) * 2


def main():
    weight = float(input())
    print("cost:", shipping_cost(weight))


if __name__ == "__main__":
    main()

Now shipping_cost(3) can be checked directly, reused by a web page or a batch job, and changed without touching any I/O. The last two lines mean "call main() only when this file is run as a program, not when it is imported". You will learn exactly why in module 10; learnprog's tests import your file, so this idiom is what lets a test call shipping_cost without main waiting for input.

Naming

Function names in Python use lower_case_with_underscores. Name functions for what they return or do: is_even(n) (returns a bool, reads as a question), word_count(text), send_email(...). Parameter names are part of your interface too: callers may use them as keywords, so def resize(image, width, height) is better than def resize(i, a, b).

Common mistakes

Recap

Going further: skim PEP 257 ("Docstring Conventions") at peps.python.org/pep-0257, then run help(str.split) and help(round) and notice how the standard library structures its docstrings.

Exercises

Exercise 1 · quiz

Spot the pure functions

Which of these functions are pure (result depends only on the arguments, and no side effects)? Select all that apply.

def a(x):
    return x * 2 + 1

def b(items):
    items.append(0)
    return len(items)

def c(name):
    print("hi", name)

def d(text):
    return text.strip().lower()

RATE = 0.2
def e(price):
    global RATE
    RATE += 0.01
    return price * RATE
Answer
  • a

  • d

a and d only compute a return value from their arguments. b mutates the caller's list (a side effect visible after the call). c writes to stdout, which is a side effect too. e changes a global and its result depends on how many times it was called before. Pure functions are the easiest to test and reuse: same input, same output, nothing else touched.

Exercise 2 · write code

Write a docstring

Define celsius_to_fahrenheit(celsius) that returns celsius * 9 / 5 + 32, and give it a docstring.

The docstring must be a triple-quoted string as the first statement of the body, with a one-line summary on its first line that mentions Fahrenheit. For example:

Convert a temperature from Celsius to Fahrenheit.

File: solution.py

Starting point

def celsius_to_fahrenheit(celsius):
    # converts C to F
    return celsius * 9 / 5 + 32
Hint 1

A comment is not a docstring; a docstring is a string literal placed first in the body.

Hint 2

Start the body with """Convert a temperature from Celsius to Fahrenheit.""".

Reference solution
def celsius_to_fahrenheit(celsius):
    """Convert a temperature from Celsius to Fahrenheit.

    celsius: the temperature in degrees Celsius (int or float).
    Returns the temperature in degrees Fahrenheit as a float.
    """
    return celsius * 9 / 5 + 32

Exercise 3 · write code

Separate logic from I/O

The starter mixes reading input, computing and printing in one function, which makes it impossible to test. Restructure it into:

Keep the if __name__ == "__main__": main() lines at the bottom so that importing the file does not run main. (Module 10 explains that idiom; for now, just keep it.)

File: solution.py

Starting point

def report():
    w = float(input())
    h = float(input())
    value = round(w / h ** 2, 1)
    if value < 18.5:
        print("BMI", str(value) + ":", "underweight")
    elif value < 25:
        print("BMI", str(value) + ":", "normal")
    elif value < 30:
        print("BMI", str(value) + ":", "overweight")
    else:
        print("BMI", str(value) + ":", "obese")

if __name__ == "__main__":
    report()

Example

input

70
1.75

expected output

BMI 22.9: normal
Hint 1

Move the arithmetic into bmi and the if/elif chain into category, each ending in return.

Hint 2

main is the only function that calls input and print: value = bmi(w, h) then print("BMI " + str(value) + ": " + category(value)).

Reference solution
def bmi(weight_kg, height_m):
    """Return the body mass index, rounded to 1 decimal place."""
    return round(weight_kg / height_m ** 2, 1)


def category(bmi_value):
    """Return the standard category name for a BMI value."""
    if bmi_value < 18.5:
        return "underweight"
    if bmi_value < 25:
        return "normal"
    if bmi_value < 30:
        return "overweight"
    return "obese"


def main():
    weight = float(input())
    height = float(input())
    value = bmi(weight, height)
    print("BMI " + str(value) + ": " + category(value))


if __name__ == "__main__":
    main()