CMPUT-174 · Lesson 7 of 12 · 27 min

Functions and decomposition

A function is a named, reusable piece of code that takes inputs, does a job and usually returns a result. You have already used many built-in functions, such as print, len, round and max, and you have seen small functions such as gcd and letter_grade. This lesson explains how to define functions properly: parameters and arguments, return values, the difference between returning and printing, where names are visible (scope), default and keyword arguments, and docstrings. It then introduces recursion, a function that calls itself, and shows how to use functions to decompose a larger problem into parts that are each easy to write, test and understand. Functions are the main tool of abstraction in everyday programming.

Defining and calling functions

A function definition starts with def, then the function's name, a list of parameters in parentheses and a colon. The indented body runs each time the function is called. A return statement ends the function and sends a value back to the caller; the call expression then has that value. Parameters are the names in the definition; arguments are the actual values supplied in a call. When a function is called, each parameter becomes a name for the corresponding argument for the duration of that call.

Defining a function and calling it with positional and keyword arguments
def bmi(mass_kg, height_m):
    """Return body mass index: mass (kg) divided by the square of height (m)."""
    return mass_kg / height_m ** 2

result = bmi(70, 1.75)                 # 70 and 1.75 are arguments
print(round(result, 1))
print(round(bmi(height_m=1.6, mass_kg=55), 1))   # keyword arguments, any order
Output
22.9
21.5

Arguments can be passed by position, matched to parameters in order, or by keyword, written name=value, which makes calls self-explanatory and lets you give them in any order. Keyword arguments are particularly helpful when a function has several parameters of the same type, where it is easy to swap two by mistake: bmi(1.75, 70) would silently compute nonsense, while bmi(mass_kg=70, height_m=1.75) cannot be misread.

Why define functions at all, when you could just write the code where it is needed? There are four strong reasons. Reuse: write the logic once and call it many times, so a fix or improvement happens in one place. Abstraction: callers need to know what a function does, not how, as lesson 1 described. Testing: a function with clear inputs and outputs can be tested on its own, as lesson 11 shows. Readability: a well-named function call such as convert_to_celsius(reading) explains itself, where the raw formula needs a comment.

Return versus print

Beginners often confuse returning a value with printing it. Printing displays text to a person and gives nothing back to the program. Returning hands a value back to the code that called the function, which can then store it, compare it, pass it to another function or print it. A function that prints its answer instead of returning it cannot be reused in a calculation. A function with no return statement, or with a bare return, returns the special value None.

A function that prints cannot be used in further calculations
def area_print(w, h):
    print(w * h)          # shows the answer, returns None

def area_return(w, h):
    return w * h          # hands the answer back

a = area_print(3, 4)      # prints 12
b = area_return(3, 4)     # prints nothing
print("a is", a, "  b is", b)
print("total area:", area_return(3, 4) + area_return(5, 2))
try:
    print(area_print(3, 4) + 1)
except TypeError as e:
    print("cannot use a printed result in arithmetic:", e)
Output
12
a is None   b is 12
total area: 22
12
cannot use a printed result in arithmetic: unsupported operand type(s) for +: 'NoneType' and 'int'

A good rule is that functions which compute something should return it, and only the outermost part of a program, sometimes a function called main, should print. Separating computation from input and output in this way makes functions easier to test and to reuse in different settings, such as a web page or a robot controller, where there may be no screen to print to at all.

Scope: where names are visible

Names created inside a function, including its parameters, are local: they exist only while the function runs and are invisible outside it. Names created at the top level of a file are global and can be read inside functions. Each call gets its own fresh set of local names, which is what lets the same function be called many times, and even call itself, without the calls interfering with one another.

Local names exist only inside the function; assignment inside a function creates a local name
rate = 0.05                       # global name

def interest(balance):
    earned = balance * rate       # reads the global rate; earned is local
    return earned

print(interest(1000))
try:
    print(earned)                 # earned does not exist outside the function
except NameError as e:
    print("NameError:", e)

def shadow():
    rate = 0.10                   # a NEW local name; the global is unchanged
    return rate

print(shadow(), rate)
Output
50.0
NameError: name 'earned' is not defined
0.1 0.05

The shadow example shows a subtle rule: assigning to a name inside a function makes it local to that function, even if a global with the same name exists. Python has a global statement to override this, but changing global variables from inside functions makes programs hard to understand and test, because any function might change any global at any time. The better design is to pass values in as parameters and get results out with return.

Default arguments and docstrings

A parameter can have a default value, used when the caller does not supply that argument. Defaults are useful for options that usually take the same value. Parameters with defaults must come after those without. One famous trap: the default value is created once, when the function is defined, not each time it is called. Using a mutable default such as an empty list means every call shares the same list, so items pile up across calls. The standard fix is to use None as the default and create the list inside the function.

Default arguments, and the mutable default trap
def greet(name, greeting="Hello", punctuation="!"):
    return f"{greeting}, {name}{punctuation}"

print(greet("Ada"))
print(greet("Alan", "Welcome"))
print(greet("Grace", punctuation="."))

def add_item_buggy(item, basket=[]):       # one list shared by every call
    basket.append(item)
    return basket

def add_item(item, basket=None):           # a new list each call unless one is given
    if basket is None:
        basket = []
    basket.append(item)
    return basket

print(add_item_buggy("apple"), add_item_buggy("pear"))
print(add_item("apple"), add_item("pear"))
Output
Hello, Ada!
Welcome, Alan!
Hello, Grace.
['apple', 'pear'] ['apple', 'pear']
['apple'] ['pear']

A docstring is a string literal written as the first statement of a function, describing what it does, its parameters, its return value and any errors it raises. Python stores it with the function, and the built-in help function displays it. Writing the docstring before the body is an excellent habit: if you cannot say clearly what a function should do, you are not ready to write it. A widely used convention is a one-line summary, a blank line, then details.

A function with a docstring describing behavior and errors
def clamp(value, low, high):
    """Return value limited to the range low to high.

    If value is below low, return low; if above high, return high;
    otherwise return value unchanged. Raises ValueError if low > high.
    """
    if low > high:
        raise ValueError("low must not exceed high")
    return max(low, min(value, high))

print(clamp(150, 0, 100), clamp(-5, 0, 100), clamp(42, 0, 100))
print(clamp.__doc__.splitlines()[0])
Output
100 0 42
Return value limited to the range low to high.

Decomposition: designing with functions

Decomposition, introduced in lesson 1, becomes concrete with functions. Faced with a problem, write the top-level steps as calls to functions that do not exist yet, with good names. Then write each function, decomposing again if it is still too big. This top-down approach keeps every piece small enough to understand and test. A common guideline is that a function should do one thing and fit on one screen; if you struggle to name it without the word "and", it may be doing two things.

Worked problem

Decomposing a report program

A teacher has a list of (name, mark) pairs and wants a report showing each student's letter grade, the class average and the number of students at each grade. Decompose the problem into functions.

Show solution
  1. Top level: report(records) builds the report from smaller pieces.
  2. letter_grade(mark) converts one mark to a letter, from lesson 5.
  3. class_average(records) returns the mean mark, guarding against an empty class.
  4. grade_counts(records) returns how many students got each letter.
  5. format_report(records) assembles the text; only the top level prints it.

Answer: Five small functions, each testable alone, combined by the top-level report function.

A small program decomposed into focused functions
def letter_grade(mark):
    for cutoff, letter in [(80, "A"), (70, "B"), (60, "C"), (50, "D")]:
        if mark >= cutoff:
            return letter
    return "F"

def class_average(records):
    if not records:
        raise ValueError("no students")
    return sum(mark for _, mark in records) / len(records)

def grade_counts(records):
    counts = {letter: 0 for letter in "ABCDF"}
    for _, mark in records:
        counts[letter_grade(mark)] += 1
    return counts

def format_report(records):
    lines = [f"{name:<8} {mark:>3}  {letter_grade(mark)}" for name, mark in records]
    lines.append(f"average  {class_average(records):.1f}")
    lines.append("counts   " + "  ".join(f"{k}:{v}" for k, v in grade_counts(records).items()))
    return "\n".join(lines)

records = [("Aiyana", 91), ("Ben", 67), ("Chen", 78), ("Dara", 49), ("Elif", 83)]
print(format_report(records))
Output
Aiyana    91  A
Ben       67  C
Chen      78  B
Dara      49  F
Elif      83  A
average  73.6
counts   A:2  B:1  C:1  D:0  F:1

Each function here can be tested separately, and each could change without affecting the others: if the grading scale changes, only letter_grade needs editing. This version of letter_grade uses a list of cutoffs instead of an if-elif chain, a small example of turning logic into data, which often makes code shorter and easier to change.

Recursion

A recursive function is one that calls itself. Recursion fits problems that can be defined in terms of smaller versions of themselves. The factorial of n, written n!, is the product 1 times 2 times ... times n, and it can be defined recursively: 0! is 1, and n! is n times (n minus 1)! for n greater than 0. Every recursive function needs a base case, which is answered directly without recursion, and a recursive case, which reduces the problem toward the base case. Without a reachable base case, recursion never stops.

Watching recursive calls go down to the base case and results come back up
def factorial(n, depth=0):
    """Return n! recursively, printing the calls to show how recursion unfolds."""
    indent = "  " * depth
    print(f"{indent}factorial({n}) called")
    if n == 0:                                  # base case
        result = 1
    else:                                       # recursive case: a smaller problem
        result = n * factorial(n - 1, depth + 1)
    print(f"{indent}factorial({n}) returns {result}")
    return result

factorial(4)
Output
factorial(4) called
  factorial(3) called
    factorial(2) called
      factorial(1) called
        factorial(0) called
        factorial(0) returns 1
      factorial(1) returns 1
    factorial(2) returns 2
  factorial(3) returns 6
factorial(4) returns 24

Each call waits for the call below it to return. Python keeps track of the waiting calls on the call stack, a region of memory where each active call has a frame holding its own local names, including its own n. When factorial(0) returns 1, its frame is removed and factorial(1) resumes, multiplies and returns, and so on back up. This is why local variables are essential: each call's n is separate.

A vertical stack of frames for factorial(3): the bottom frame is the main program, above it factorial(3) waiting with n = 3, then factorial(2) with n = 2, factorial(1) with n = 1, and at the top factorial(0) with n = 0 returning 1. Arrows on the right show return values 1, 1, 2 and 6 passing back down the stack.
The call stack while computing factorial(3)

The stack has a limited size. Python allows about a thousand nested calls by default and raises RecursionError beyond that, which protects the computer from runaway recursion. For problems such as factorial, a loop is simpler and has no depth limit; recursion shines for problems with a naturally recursive structure, such as folders containing folders, family trees, mathematical expressions and many algorithms in CMPUT-204, including fast sorting. Here are two more small recursive functions, with a loop version for comparison.

Recursive and loop versions, and the recursion limit
def digit_sum(n):
    """Sum of the decimal digits of a non-negative integer, recursively."""
    if n < 10:
        return n                       # base case: one digit
    return n % 10 + digit_sum(n // 10)  # last digit plus the sum of the rest

def digit_sum_loop(n):
    total = 0
    while n > 0:
        total += n % 10
        n //= 10
    return total

def count_down(n):
    if n == 0:
        print("liftoff")
        return
    print(n, end=" ")
    count_down(n - 1)

print(digit_sum(4072), digit_sum_loop(4072))
count_down(5)

import sys
print("default recursion limit:", sys.getrecursionlimit())
try:
    digit_sum(int("9" * 1500))
except RecursionError as e:
    print("RecursionError:", e)
Output
13 13
5 4 3 2 1 liftoff
default recursion limit: 1000
RecursionError: maximum recursion depth exceeded
Loops and recursion compared
QuestionLoopRecursion
How is repetition expressed?The loop statement repeats a blockThe function calls itself on a smaller problem
How does it stop?The loop condition becomes falseA base case returns without calling itself
Where is state kept?Variables updated each passParameters and locals in each stack frame
Limits in PythonNone beyond time and memoryAbout 1,000 nested calls by default
Best forCounting, scanning sequencesNested or self-similar structures: trees, folders, divide and conquer

Common mistakes

  • MistakePrinting a result inside a function instead of returning it.

    InsteadReturn computed values; print only at the top level of the program.

  • MistakeExpecting a variable created inside a function to exist outside it.

    InsteadReturn the value and assign it to a name in the caller.

  • MistakeChanging global variables from inside functions.

    InsteadPass values in as parameters and return results.

  • MistakeUsing a mutable default argument such as basket=[].

    InsteadUse basket=None and create a new list inside the function.

  • MistakeWriting a recursive function without a reachable base case.

    InsteadDefine the base case first and make sure each recursive call moves toward it.

  • MistakeWriting long functions that do several unrelated jobs.

    InsteadGive each function one job, a clear name and a docstring.

Summary and key terms

  • A function is a named, reusable block defined with def; parameters receive the arguments of each call.
  • Arguments can be positional or keyword; keyword arguments make calls clearer and safer.
  • return hands a value back to the caller; print only displays text, and a function without return gives None.
  • Names assigned inside a function are local to each call; avoid modifying globals.
  • Defaults are evaluated once, so never use a mutable default; docstrings document behavior.
  • Top-down decomposition breaks a problem into small, testable functions.
  • Recursion solves a problem through smaller instances of itself, needs a base case and is limited by the call stack.
Function
A named block of code that takes inputs and usually returns a result.
Parameter
A name in a function definition that receives an argument.
Argument
A value supplied to a function when it is called.
Return value
The value a function sends back to its caller.
Scope
The region of a program in which a name is visible.
Local variable
A name created inside a function, existing only during a call.
Docstring
A string at the start of a function that documents what it does.
Recursion
A technique in which a function calls itself on a smaller problem.
Base case
The case a recursive function answers directly, without recursion.
Call stack
The memory structure holding a frame for each active function call.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Write a function celsius_to_fahrenheit(c) that returns the converted value.

    Show answer

    def celsius_to_fahrenheit(c): return c * 9 / 5 + 32

  2. What does a function with no return statement return?

    Show answer

    None.

  3. Why is bmi(mass_kg=70, height_m=1.75) safer than bmi(70, 1.75)?

    Show answer

    Keyword arguments cannot be swapped by mistake and make the call self-explanatory.

  4. After def f(): x = 5, then f(), what happens when the top level runs print(x) with no global x?

    Show answer

    NameError, because x was local to f.

  5. Explain why add_item_buggy("pear") returned ["apple", "pear"].

    Show answer

    The default list was created once and shared by both calls, so the second call added to the same list.

  6. Write a recursive function power(b, n) for whole n >= 0.

    Show answer

    if n == 0: return 1; else: return b * power(b, n - 1)

  7. Trace factorial(3): what calls are made and what does each return?

    Show answer

    factorial(3) calls factorial(2), which calls factorial(1), which calls factorial(0); they return 1, 1, 2 and 6.

  8. Why does digit_sum on a 1,500-digit number raise RecursionError?

    Show answer

    It needs about 1,500 nested calls, more than Python's default limit of about 1,000.

  9. Decompose "print a monthly bank statement" into four functions.

    Show answer

    For example: load_transactions, compute_balance, summarize_by_category and format_statement.

  10. What should a docstring say?

    Show answer

    What the function does, its parameters, what it returns and any errors it raises.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz