Chapter 7

Functions

Every chapter so far has repeated the same few lines of logic in different examples: build an entry, loop over expenses, add up amounts. A function lets you write that logic once, give it a name, and call it as many times as you need. This chapter writes the first real functions this book's tracker is built from.

Defining and calling

def starts a function definition:

def greet():
    print("Hello.")

greet()
Output
Hello.

greet is the function's name. The empty parentheses mean it takes no inputs. greet(), with parentheses, calls it, running the indented block underneath. Without the parentheses, greet just refers to the function itself, without running it.

A function usually takes input through parameters, named inside the parentheses:

def shout(message):
    print(message.upper())

shout("watch out")
Output
WATCH OUT

message is a parameter. "watch out" is the argument, the actual value passed in when you call it. The words are related but distinct: message is the name the function uses internally; "watch out" is what you handed it this time.

return

print() shows a value; return hands one back to whoever called the function, so it can be used in more code:

def double(n):
    return n * 2

result = double(21)
print(result)
Output
42

A function without a return hands back None:

def greet():
    print("Hello.")

result = greet()
print(result)
Output
Hello.
None

greet() printed "Hello." when it ran, then result held None because there was nothing to return. print() and return do different jobs: print() shows something to whoever's watching the terminal; return gives a value back to the code that called the function. This book's real functions almost always return rather than print, so the caller decides what to do with the answer.

Functions for this book's tracker

def add_expense(expenses, amount, category, note):
    expenses.append({"amount": amount, "category": category, "note": note})
    return expenses

expenses = []
add_expense(expenses, 4.50, "coffee", "flat white")
add_expense(expenses, 12.00, "transit", "monthly pass")
print(expenses)
Output
[{'amount': 4.5, 'category': 'coffee', 'note': 'flat white'}, {'amount': 12.0, 'category': 'transit', 'note': 'monthly pass'}]
def total(expenses):
    running = 0
    for entry in expenses:
        running += entry["amount"]
    return running

print(total(expenses))
Output
16.5
def expenses_over(expenses, amount):
    matches = []
    for entry in expenses:
        if entry["amount"] > amount:
            matches.append(entry)
    return matches

big = expenses_over(expenses, 10)
print(len(big))
print(big[0]["category"])
Output
1
transit

Each of these does one clear job, is named for what it does, and can be tested on its own with any list of entries you hand it, not just this particular one. That's worth noticing already; it matters a lot by the end of this chapter.

Default argument values

A parameter can have a default value, used when the caller doesn't supply one:

def add_expense(expenses, amount, category, note=""):
    expenses.append({"amount": amount, "category": category, "note": note})
    return expenses

expenses = []
add_expense(expenses, 4.50, "coffee")
print(expenses)
Output
[{'amount': 4.5, 'category': 'coffee', 'note': ''}]

Leaving out note used the default, "", matching this book's entry shape: "note" is always present, even when it's empty.

Keyword arguments

Any argument can be passed by name instead of position, which is especially useful once a function takes several:

def add_expense(expenses, amount, category, note=""):
    expenses.append({"amount": amount, "category": category, "note": note})
    return expenses

expenses = []
add_expense(expenses, amount=32.10, category="groceries", note="weekly shop")
print(expenses)
Output
[{'amount': 32.1, 'category': 'groceries', 'note': 'weekly shop'}]

Passed by name like this, the order no longer matters, and the call reads clearly even without looking at the function's definition.

Documenting a function

A docstring, a string literal right under the def line, documents what a function does. Python treats it specially: it's stored on the function and help() can show it back to you.

def total(expenses):
    """Return the sum of every entry's amount."""
    running = 0
    for entry in expenses:
        running += entry["amount"]
    return running

print(total.__doc__)
Output
Return the sum of every entry's amount.

Not every function needs one, a short, obviously named function like double(n) rarely does, but a function whose purpose isn't obvious from its name and parameters alone is worth one line explaining what it returns. This book's own functions from here on include a docstring whenever the name alone doesn't say enough.

A function that takes any number of arguments

Every function so far takes a fixed number of parameters. Python also lets a function accept any number of positional arguments, collected into a tuple, using a single * before the parameter name:

def add_amounts(*amounts):
    return sum(amounts)

print(add_amounts(4.50, 12.00))
print(add_amounts(4.50, 12.00, 32.10))
Output
16.5
48.6

*amounts gathers however many arguments were passed, two here, three there, into one tuple named amounts inside the function. The matching tool for keyword arguments is **kwargs, gathering them into a dict instead. Both are common in library code you'll read (you've already called functions that use this under the hood, like print() accepting any number of arguments), but this book's own functions stick to named parameters, which stay easier to read when you're still getting comfortable with functions in general. Treat *args and **kwargs as a "next," not a gap: you'll recognize them on sight now, and write your own once fixed parameter lists start to feel limiting.

The mutable default argument trap

Here's a version of add_expense that looks reasonable and has a real bug:

def add_expense(amount, category, expenses=[]):
    expenses.append({"amount": amount, "category": category, "note": ""})
    return expenses

first_trip = add_expense(4.50, "coffee")
second_trip = add_expense(12.00, "transit")

print(first_trip)
Output
[{'amount': 4.5, 'category': 'coffee', 'note': ''}, {'amount': 12.0, 'category': 'transit', 'note': ''}]

first_trip has two entries, including the transit one it was never handed. The Python documentation is direct about why: "the default value is evaluated only once. This makes a difference when the default is a mutable object such as a list, dictionary, or instances of most classes." That empty [] is built exactly once, when the function is defined, and every call that doesn't supply its own expenses shares that same list. This is the single most common surprise a new Python programmer runs into, and it's worth remembering by name: never use a mutable value, a list or a dict, as a default argument.

The fix is to default to None and build a fresh list inside the function when needed:

def add_expense(amount, category, expenses=None):
    if expenses is None:
        expenses = []
    expenses.append({"amount": amount, "category": category, "note": ""})
    return expenses

first_trip = add_expense(4.50, "coffee")
second_trip = add_expense(12.00, "transit")

print(first_trip)
print(second_trip)
Output
[{'amount': 4.5, 'category': 'coffee', 'note': ''}]
[{'amount': 12.0, 'category': 'transit', 'note': ''}]

Now each call that doesn't supply its own list gets a genuinely fresh one. None itself is immutable, so it's safe to share as a default; the trap is specifically about sharing something changeable.

Why functions help

Three reasons, all visible already in this chapter's examples. Naming: total(expenses) says what it computes, instead of a loop you have to read to understand. Reuse: the same total() works on any list of entries, not just one particular one. And testability: every function above takes its inputs as arguments and hands back a result with return, with no input() or hidden state involved, which means you can check exactly what it does by calling it with known values and looking at what comes back, the way this book's own build checks every example. Chapter 11 leans on this directly: the whole tracker script is built almost entirely from functions exactly like these, and the only part that isn't checkable this way is the handful of lines that actually talk to a person.

Practice

Try each of these before you read the solution under it.

  1. Write average(expenses) that returns the average amount across a list of entries. Use it on a list of three entries you build yourself.
  2. Write category_total(expenses, category) that returns the sum of amounts for entries matching a given category.
  3. Find and fix the mutable-default bug in this function, keeping the same two parameters and behavior otherwise: def with_tax(amount, seen=[]): seen.append(amount * 1.08); return seen
  4. Write largest(*amounts) using *amounts, returning the single largest value passed in. Call it with three different amounts.

Solutions

1. Build on total() from earlier in the chapter.

def total(expenses):
    running = 0
    for entry in expenses:
        running += entry["amount"]
    return running

def average(expenses):
    return total(expenses) / len(expenses)

sample = [
    {"amount": 4.50, "category": "coffee", "note": ""},
    {"amount": 12.00, "category": "transit", "note": ""},
    {"amount": 32.10, "category": "groceries", "note": ""},
]
print(average(sample))
Output
16.2

2.

def category_total(expenses, category):
    running = 0
    for entry in expenses:
        if entry["category"] == category:
            running += entry["amount"]
    return running

sample = [
    {"amount": 4.50, "category": "coffee", "note": ""},
    {"amount": 8.75, "category": "coffee", "note": ""},
    {"amount": 12.00, "category": "transit", "note": ""},
]
print(category_total(sample, "coffee"))
Output
13.25

3. Same fix as the tracker example: default to None, build the list inside.

def with_tax(amount, seen=None):
    if seen is None:
        seen = []
    seen.append(amount * 1.08)
    return seen

first = with_tax(10)
second = with_tax(20)
print(first)
print(second)
Output
[10.8]
[21.6]

4.

def largest(*amounts):
    return max(amounts)

print(largest(4.50, 32.10, 12.00))
Output
32.1

Where this leaves you

You can write a function with parameters, a return value, and default arguments, call it with positional or keyword arguments, and you know the mutable-default trap by name and by the fix. You've also seen why a function with no input() in it is easy to check, which is exactly what makes Chapter 11's closing script possible. Chapter 8 uses import to bring in tools you didn't have to write yourself.