27 min read

> "Any fool can write code that a computer can understand. Good programmers write code that humans can understand."

Learning Objectives

  • Define functions with parameters and return values
  • Explain the difference between parameters and arguments
  • Use default parameters, keyword arguments, and *args
  • Trace function calls through the call stack
  • Apply the principle of single responsibility to function design
  • Write and use docstrings for documentation

Chapter 6: Functions: Writing Reusable Code

"Any fool can write code that a computer can understand. Good programmers write code that humans can understand." — Martin Fowler

Chapter Overview

Let me tell you about a moment that changed how I thought about programming. I was maybe six months into my first job, and I had a script that ran reports. It was 400 lines long — one giant block of code. Variables everywhere. Copy-pasted sections that were almost identical. When something broke, I'd spend an hour just scrolling up and down, trying to figure out which copy of the same logic was misbehaving.

My mentor sat down, looked at the screen, and said: "What does this block do?" I explained it. She said: "Good. That's a function. Give it a name." We spent an afternoon carving that 400-line monster into about 15 functions, each with a clear name and a clear job. The program did the exact same thing — but suddenly I could read it. Suddenly I could think about it. And when something broke the following week, I found the bug in two minutes.

That's what this chapter is about. Functions aren't just a syntax feature — they're the tool that lets you manage complexity. They let you name a chunk of behavior, tuck it away, and use it without thinking about its insides every time. This is abstraction, and it's arguably the single most important idea in all of computer science.

In this chapter, you will learn to: - Define functions with def, call them, and understand what happens when you do - Pass data into functions through parameters and get data back with return - Navigate scope — where variables live and die - Trace execution through the call stack like a debugger - Write documentation that future-you will thank you for - Design functions that are clean, focused, and reusable

🏃 Fast Track: If you've written functions in another language, skim sections 6.1-6.2, read 6.3-6.5 carefully (Python's scope rules have surprises), and jump to section 6.8 for design principles.

🔬 Deep Dive: After this chapter, try refactoring a script you wrote for a previous chapter into functions. The improvement in readability will be immediate and dramatic.


6.1 Why Functions?

Here's a question: have you ever given someone directions to your house? You probably said something like "take I-95 to Exit 12, turn right at the gas station, third house on the left." You didn't explain how the internal combustion engine works, or how traffic signals are timed, or what asphalt is made of. You gave them an abstraction — a high-level set of steps that hides thousands of details they don't need.

Functions are the programmer's version of this. A function is a named block of code that performs a specific task. You define it once, then use it — call it — whenever you need that task done, without thinking about the details inside.

6.1.1 The DRY Principle

Professional developers live by a rule called DRY: Don't Repeat Yourself. The idea is simple: if you find yourself writing the same code (or nearly the same code) in multiple places, something has gone wrong.

Here's why repetition is dangerous. Imagine you have this grade-averaging logic in three different places in your program:

# Calculating average — version 1 (midterm report)
total = 0
for score in midterm_scores:
    total += score
average = total / len(midterm_scores)
print(f"Midterm average: {average:.1f}")

# Calculating average — version 2 (final report)
total = 0
for score in final_scores:
    total += score
average = total / len(final_scores)
print(f"Final average: {average:.1f}")

# Calculating average — version 3 (homework report)
total = 0
for score in homework_scores:
    total += score
average = total / len(homework_scores)
print(f"Homework average: {average:.1f}")

Three copies of the same logic. Now suppose you realize you need to handle the case where the list is empty (division by zero). You have to find and fix three places. Miss one, and you've got a bug hiding in your code. This is how real software accumulates technical debt — and functions are the cure.

6.1.2 Four Reasons to Use Functions

  1. Reuse: Write the logic once, call it anywhere. Fix a bug once, it's fixed everywhere.
  2. Organization: Break a big program into manageable pieces. Each function has a single job.
  3. Readability: calculate_average(scores) tells you what happens without showing you how.
  4. Testability: You can test a function in isolation. You can't easily test line 147 of a 400-line script.

💡 Intuition: Think of functions like tools in a toolbox. A hammer doesn't care whether you're building a bookshelf or a deck — it does its job (drive nails) regardless of the larger project. Similarly, a calculate_average() function doesn't care whether it's processing grades, temperatures, or stock prices. It takes numbers in and gives an average back.

6.1.3 Functions You Already Know

You've been calling functions since Chapter 2 — you just didn't write them yourself:

print("Hello")          # print() is a function
len([1, 2, 3])          # len() is a function
int("42")               # int() is a function
input("Your name: ")    # input() is a function
range(10)               # range() is a function

Every one of these hides complexity behind a simple name. print() does an enormous amount of work under the hood — converting objects to strings, handling output buffering, adding newlines — but you don't think about any of that. You just call it. That's the power of abstraction.

Now it's time to build your own.


6.2 Defining and Calling Functions

Here's the anatomy of a function in Python:

def greet(name):
    """Display a personalized greeting."""
    print(f"Hello, {name}! Welcome to the program.")

Let's break this down:

  • def — The keyword that tells Python "I'm defining a function." Short for define.
  • greet — The function's name. Same naming rules as variables: lowercase, underscores, descriptive.
  • (name) — The parameter list. This function expects one piece of data, which it will call name internally.
  • : — Marks the start of the function body (just like if and for).
  • """Display a personalized greeting.""" — A docstring (more on these in section 6.7).
  • print(f"Hello, {name}!...") — The function body. This is the code that runs when the function is called.

To use the function, you call it:

def greet(name):
    """Display a personalized greeting."""
    print(f"Hello, {name}! Welcome to the program.")

greet("Alice")
greet("Bob")
greet("Dr. Patel")
Hello, Alice! Welcome to the program.
Hello, Bob! Welcome to the program.
Hello, Dr. Patel! Welcome to the program.

Three calls. Three greetings. One definition. If you later want to change the greeting format — say, add the time of day — you change it in one place and all three calls get the update.

6.2.1 Functions with No Parameters

Not every function needs input data:

def display_separator():
    """Print a visual divider line."""
    print("=" * 50)

def display_menu():
    """Print the main menu options."""
    display_separator()
    print("1. Add a task")
    print("2. List all tasks")
    print("3. Delete a task")
    print("4. Quit")
    display_separator()

display_menu()
==================================================
1. Add a task
2. List all tasks
3. Delete a task
4. Quit
==================================================

Notice that display_menu() calls display_separator() — functions can call other functions. We'll explore this more in section 6.9.

6.2.2 Definition vs. Execution

A critical point that trips up beginners: defining a function does not execute it. The def block is a recipe. Calling the function is cooking the meal.

def say_hello():
    print("Hello!")

# Nothing has been printed yet — we only defined the function.
# The print inside say_hello hasn't run.

say_hello()  # NOW it runs.
Hello!

This is why function definitions typically go at the top of your script, before the code that uses them. Python reads the file top to bottom. If you try to call a function before defining it, you'll get a NameError:

greet("Alice")  # NameError: name 'greet' is not defined

def greet(name):
    print(f"Hello, {name}!")

⚠️ Pitfall: Don't confuse defining and calling. def greet(name): is a definition — it creates the function but doesn't run it. greet("Alice") is a call — it actually executes the code inside.


6.3 Parameters and Arguments

Let's clear up two terms that even experienced developers mix up.

  • Parameter: The variable name in the function definition. It's a placeholder.
  • Argument: The actual value you pass when calling the function.
def calculate_area(length, width):  # length and width are PARAMETERS
    area = length * width
    print(f"Area: {area}")

calculate_area(5, 3)    # 5 and 3 are ARGUMENTS
calculate_area(10, 7)   # 10 and 7 are ARGUMENTS
Area: 15
Area: 70

Think of parameters as labeled slots, and arguments as the values you slot in. When you call calculate_area(5, 3), Python assigns length = 5 and width = 3 inside the function.

6.3.1 Positional Arguments

By default, arguments are matched to parameters by position — the first argument goes to the first parameter, the second to the second, and so on:

def describe_pet(animal, name):
    print(f"I have a {animal} named {name}.")

describe_pet("dog", "Rex")     # animal="dog", name="Rex"
describe_pet("Rex", "dog")     # animal="Rex", name="dog" — oops!
I have a dog named Rex.
I have a Rex named dog.

Order matters with positional arguments. The second call works syntactically, but it's semantically nonsensical.

6.3.2 Keyword Arguments

To make calls clearer and avoid positional confusion, you can use keyword arguments — specifying the parameter name at the call site:

describe_pet(animal="cat", name="Whiskers")
describe_pet(name="Whiskers", animal="cat")  # Order doesn't matter now!
I have a cat named Whiskers.
I have a cat named Whiskers.

Both calls produce the same result because Python matches by name, not position. Keyword arguments make your code more readable, especially for functions with many parameters.

6.3.3 Default Parameter Values

You can give parameters default values. If the caller doesn't provide an argument for that parameter, the default kicks in:

def greet(name, greeting="Hello"):
    """Greet someone with a customizable greeting."""
    print(f"{greeting}, {name}!")

greet("Alice")                # Uses default greeting
greet("Bob", "Good morning")  # Overrides default
greet("Chen", greeting="Hey") # Keyword argument, same effect
Hello, Alice!
Good morning, Bob!
Hey, Chen!

Rule: Parameters with defaults must come after parameters without defaults:

def greet(greeting="Hello", name):  # SyntaxError!
    print(f"{greeting}, {name}!")

This makes sense — if greeting has a default, how would Python know whether greet("Alice") means greeting="Alice" or name="Alice"?

6.3.4 Accepting Variable Arguments with *args

Sometimes you don't know in advance how many arguments a function will receive. Python's *args syntax collects extra positional arguments into a tuple:

def calculate_average(*scores):
    """Calculate the average of any number of scores."""
    if not scores:
        return 0.0
    return sum(scores) / len(scores)

print(calculate_average(85, 90, 78))
print(calculate_average(92, 88, 95, 73, 81))
print(calculate_average(100))
84.33333333333333
85.8
100.0

Inside the function, scores is a regular tuple. The * in the definition means "pack all the positional arguments into this tuple." You can name it anything — *args is convention, not requirement. *grades, *values, or *numbers are all fine.

Elena is building her nonprofit's report. She needs a function that can average any set of scores — sometimes there are 3 program areas, sometimes 12. With *args, she writes the function once:

def average_program_scores(*scores):
    """Average any number of program area scores for Elena's weekly report."""
    if not scores:
        print("Warning: No scores provided.")
        return 0.0
    avg = sum(scores) / len(scores)
    return avg

eastern_county = average_program_scores(87, 91, 78, 85)
western_county = average_program_scores(92, 88)
central_county = average_program_scores(76, 83, 90, 95, 88, 71)
print(f"Eastern: {eastern_county:.1f}")
print(f"Western: {western_county:.1f}")
print(f"Central: {central_county:.1f}")
Eastern: 85.2
Western: 90.0
Central: 83.8

🔄 Check Your Understanding (try to answer without scrolling up)

  1. What's the difference between a parameter and an argument?
  2. What happens if you call greet("Alice") where def greet(name, greeting="Hello"):?
  3. What type does scores become inside a function defined as def f(*scores):?

Verify

  1. A parameter is the variable in the function definition; an argument is the value you pass when calling the function.
  2. Python assigns name = "Alice" and uses the default greeting = "Hello", printing "Hello, Alice!"
  3. scores becomes a tuple containing all the positional arguments passed to the function.

6.4 Return Values

So far, most of our functions have used print() to display results. But in real programs, functions usually need to give data back to the code that called them. That's what return does.

6.4.1 The return Statement

def calculate_average(scores):
    """Calculate and return the average of a list of scores."""
    if not scores:
        return 0.0
    total = sum(scores)
    return total / len(scores)

# The function RETURNS a value — we can store it in a variable
avg = calculate_average([85, 90, 78, 92])
print(f"Average: {avg:.1f}")

# Or use it directly in an expression
if calculate_average([85, 90, 78, 92]) >= 90:
    print("Excellent work!")
else:
    print("Keep going!")
Average: 86.2
Keep going!

When Python hits a return statement, two things happen: 1. The function immediately stops executing (any code after return is skipped). 2. The specified value is sent back to wherever the function was called.

6.4.2 Return vs. Print: The Critical Distinction

This is one of the most common sources of confusion for beginners. Let's nail it down.

Aspect print() return
Purpose Display something to the screen Send a value back to the caller
Who receives it The human reading the console The code that called the function
Can you store the result? No — print() returns None Yes — the return value can be assigned to a variable
Can you use it in calculations? No Yes
Works in automated pipelines? Not useful — nobody is watching the screen Essential — programs pass data between functions

Here's the difference in action:

def add_print(a, b):
    """Prints the sum — but doesn't return it."""
    print(a + b)

def add_return(a, b):
    """Returns the sum — so the caller can use it."""
    return a + b

# Using the print version
result1 = add_print(3, 4)      # Prints: 7
print(f"result1 is: {result1}")  # result1 is: None  (!)

# Using the return version
result2 = add_return(3, 4)      # Prints nothing
print(f"result2 is: {result2}")  # result2 is: 7

# Chaining: only works with return
total = add_return(1, 2) + add_return(3, 4)  # Works: 3 + 7 = 10
# total = add_print(1, 2) + add_print(3, 4)  # TypeError: None + None
7
result1 is: None
result2 is: 7
total is: 10

⚠️ Pitfall: If your function uses print() instead of return, the value is displayed on screen but lost to the program. You can't store it, pass it to another function, or use it in calculations. When in doubt, return the value and let the caller decide whether to print it.

6.4.3 Returning Multiple Values

Python lets you return multiple values as a tuple:

def analyze_scores(scores):
    """Return the min, max, and average of a list of scores."""
    lowest = min(scores)
    highest = max(scores)
    average = sum(scores) / len(scores)
    return lowest, highest, average

# Unpack into separate variables
low, high, avg = analyze_scores([85, 92, 78, 95, 88])
print(f"Low: {low}, High: {high}, Average: {avg:.1f}")
Low: 78, High: 95, Average: 87.6

Technically, return lowest, highest, average creates and returns a tuple (78, 95, 87.6). The line low, high, avg = ... unpacks that tuple into three separate variables.

6.4.4 Functions That Return None

If a function doesn't have a return statement, or has a bare return with no value, it returns None:

def say_hello(name):
    print(f"Hello, {name}!")
    # No return statement — implicitly returns None

result = say_hello("Alice")
print(result)   # None
print(type(result))  # <class 'NoneType'>
Hello, Alice!
None
<class 'NoneType'>

This is fine for functions whose job is to do something (display output, write a file) rather than compute something. But if you're writing a function that computes a result, always use return.

🔗 Connection: Remember None from Chapter 3? It's Python's "nothing" value. When a function doesn't explicitly return something, Python fills in None as the return value. This is why storing the result of print() gives you None — because print() itself is a function that does its job (displays text) but returns None.


6.5 Scope: Where Variables Live

Here's a concept that causes more bugs than almost anything else at this level: scope. Scope determines where a variable is accessible — where it "lives" and where it doesn't.

6.5.1 Local Variables

Variables created inside a function are local to that function. They exist only while the function is running, and they're invisible to code outside the function:

def calculate_tax(price, rate=0.08):
    """Calculate sales tax on a purchase."""
    tax_amount = price * rate     # tax_amount is LOCAL
    total = price + tax_amount    # total is LOCAL
    return total

result = calculate_tax(100.00)
print(f"Total: ${result:.2f}")

# This would cause an error:
# print(tax_amount)  # NameError: name 'tax_amount' is not defined
Total: $108.00

When calculate_tax finishes, tax_amount and total cease to exist. They were created for that function call and destroyed when it returned. This is actually a feature, not a limitation — it means you can use common variable names like total, result, temp, or i inside any function without worrying about clashing with the same name in another function.

6.5.2 Global Variables

Variables created outside any function are global — they're accessible throughout the module:

TAX_RATE = 0.08  # Global variable (convention: UPPER_CASE for constants)

def calculate_tax(price):
    """Calculate tax using the global rate."""
    return price * TAX_RATE  # Reading a global is fine

def calculate_total(price):
    """Calculate total price including tax."""
    tax = calculate_tax(price)
    return price + tax

print(f"Total: ${calculate_total(50.00):.2f}")
Total: $54.00

You can read global variables from inside a function. But if you try to assign to a global variable from inside a function, Python creates a local variable with the same name instead — this is called shadowing, and it's a common source of bugs.

6.5.3 The Shadowing Trap

counter = 0  # Global

def increment():
    counter = counter + 1  # UnboundLocalError!

increment()

This crashes. Why? Because the assignment counter = ... makes Python treat counter as a local variable. But then counter + 1 tries to read the local counter before it's been assigned. Python sees this contradiction and raises UnboundLocalError.

You could fix this with the global keyword:

counter = 0

def increment():
    global counter
    counter = counter + 1

increment()
print(counter)  # 1

But don't. Using global is almost always a sign of poor design. Instead, pass values in as parameters and return new values:

def increment(counter):
    """Return the counter incremented by 1."""
    return counter + 1

count = 0
count = increment(count)
count = increment(count)
print(count)  # 2
2

This is cleaner, more testable, and doesn't create hidden dependencies between your function and some distant global variable.

6.5.4 The LEGB Rule (Simplified)

When Python encounters a variable name, it searches for it in this order:

  1. Local — inside the current function
  2. Enclosing — inside any enclosing functions (for nested functions — we'll cover this in a later chapter)
  3. Global — at the module level
  4. Built-in — Python's built-in names (print, len, int, etc.)

Python stops at the first match. If it finds nothing in any scope, you get a NameError.

x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)  # Finds "local" first (L)

    inner()
    print(x)  # Finds "enclosing" (E, since inner's local x is gone)

outer()
print(x)  # Finds "global" (G)
local
enclosing
global

For now, you mostly need to worry about L and G — local and global. The enclosing scope becomes important when you learn about nested functions and closures in a later chapter.

🐛 Debugging Walkthrough: The Scope Bug

Elena is computing averages for her nonprofit report. Her code isn't working:

```python total = 0 count = 0

def add_score(score): total = total + score # BUG: UnboundLocalError count = count + 1

add_score(85) add_score(90) print(f"Average: {total / count}") ```

The error: UnboundLocalError: cannot access local variable 'total' where it is not associated with a value

Why it happens: The assignments total = total + score and count = count + 1 make Python treat total and count as local variables. But the right side of each assignment tries to read those locals before they have values.

The fix: Don't use global state. Pass data in, return data out:

```python def add_scores(scores): """Calculate total and count from a list of scores.""" total = sum(scores) count = len(scores) return total, count

scores = [85, 90] total, count = add_scores(scores) print(f"Average: {total / count:.1f}") ```

Average: 87.5

Lesson: If a function needs data, pass it as a parameter. If a function produces data, return it. This eliminates an entire category of bugs.


6.6 The Call Stack

When your program calls a function, Python needs to remember where to come back to when the function finishes. It does this using a data structure called the call stack.

6.6.1 How the Call Stack Works

Think of the call stack as a stack of plates. Each function call adds a plate (called a stack frame) on top. When the function returns, its plate is removed. Python always executes the function on top of the stack.

Let's trace through this example:

def calculate_average(scores):
    total = sum(scores)
    count = len(scores)
    return total / count

def determine_grade(average):
    if average >= 90:
        return "A"
    elif average >= 80:
        return "B"
    elif average >= 70:
        return "C"
    elif average >= 60:
        return "D"
    else:
        return "F"

def display_results(name, scores):
    avg = calculate_average(scores)
    grade = determine_grade(avg)
    print(f"{name}: Average = {avg:.1f}, Grade = {grade}")

display_results("Alice", [85, 92, 78, 95])
Alice: Average = 87.5, Grade = B

Here's what the call stack looks like at each step:

Step 1: The program calls display_results("Alice", [85, 92, 78, 95])

| display_results(name="Alice", scores=[85,92,78,95]) |  <-- top
|_____________________________________________________|

Step 2: Inside display_results, the line avg = calculate_average(scores) calls calculate_average:

| calculate_average(scores=[85,92,78,95])              |  <-- top
| display_results(name="Alice", scores=[85,92,78,95])  |
|______________________________________________________|

Step 3: calculate_average finishes, returns 87.5. Its frame is removed. Back in display_results, avg is now 87.5:

| display_results(name="Alice", scores=[85,92,78,95])  |  <-- top
|______________________________________________________|

Step 4: determine_grade(avg) is called — a new frame goes on top:

| determine_grade(average=87.5)                        |  <-- top
| display_results(name="Alice", scores=[85,92,78,95])  |
|______________________________________________________|

Step 5: determine_grade returns "B". Its frame is removed. Back in display_results, grade is now "B":

| display_results(name="Alice", scores=[85,92,78,95])  |  <-- top
|______________________________________________________|

Step 6: display_results prints the result and returns. The stack is empty:

| (empty)                                              |
|______________________________________________________|

6.6.2 Why the Call Stack Matters

Understanding the call stack helps you in three practical ways:

  1. Reading tracebacks: When your program crashes, Python prints a traceback — a snapshot of the call stack at the moment of the error. If you understand the call stack, you can read tracebacks like a detective reading clues.

  2. Debugging: Knowing which function called which function (and with what arguments) is essential for finding bugs.

  3. Understanding recursion: When we get to Chapter 18, the call stack will be central to understanding how recursive functions work.

Here's what a traceback looks like — read it bottom to top:

def divide(a, b):
    return a / b

def calculate_average(scores):
    total = sum(scores)
    return divide(total, len(scores))

def generate_report(scores):
    avg = calculate_average(scores)
    return f"Average: {avg}"

generate_report([])  # Empty list — division by zero!
Traceback (most recent call last):
  File "report.py", line 11, in <module>
    generate_report([])
  File "report.py", line 9, in generate_report
    avg = calculate_average(scores)
  File "report.py", line 6, in calculate_average
    return divide(total, len(scores))
  File "report.py", line 2, in divide
    return a / b
ZeroDivisionError: division by zero

Read from the bottom: the error is in divide, which was called by calculate_average, which was called by generate_report, which was called from the main script. The traceback is a snapshot of the call stack at the moment of the crash.

🔄 Check Your Understanding (try to answer without scrolling up)

  1. What is a stack frame?
  2. When reading a Python traceback, should you read from the top or bottom to find the actual error?
  3. What happens to a function's local variables when the function returns?

Verify

  1. A stack frame is a record that Python creates for each active function call, storing the function's local variables, parameters, and the location to return to when the function finishes.
  2. Read from the bottom — the last line shows the actual error, and the lines above trace the chain of function calls that led to it.
  3. They're destroyed. Local variables exist only for the duration of the function call.

6.7 Docstrings: Documenting Your Functions

A docstring is a string literal that appears as the first statement in a function (or module, or class). Python makes it available through the help() function and the __doc__ attribute.

def calculate_bmi(weight_kg, height_m):
    """Calculate Body Mass Index (BMI) from weight and height.

    Parameters:
        weight_kg (float): Weight in kilograms. Must be positive.
        height_m (float): Height in meters. Must be positive.

    Returns:
        float: The BMI value, calculated as weight / height^2.

    Examples:
        >>> calculate_bmi(70, 1.75)
        22.857142857142858
        >>> calculate_bmi(90, 1.80)
        27.777777777777775
    """
    return weight_kg / (height_m ** 2)

6.7.1 What to Include in a Docstring

For simple functions, a one-liner is fine:

def square(n):
    """Return the square of n."""
    return n ** 2

For more complex functions, include:

  1. One-line summary: What does this function do? (First line, always.)
  2. Parameters: What does it expect? Types and constraints.
  3. Returns: What does it give back? Type and meaning.
  4. Examples: Short usage examples (optional but excellent).
  5. Raises: What exceptions might it throw? (We'll cover this in Chapter 11.)

6.7.2 Using help()

Python's help() function reads docstrings:

def calculate_average(scores):
    """Calculate the arithmetic mean of a list of numeric scores.

    Parameters:
        scores (list): A non-empty list of numbers.

    Returns:
        float: The average of the scores.
    """
    return sum(scores) / len(scores)

help(calculate_average)
Help on function calculate_average in module __main__:

calculate_average(scores)
    Calculate the arithmetic mean of a list of numeric scores.

    Parameters:
        scores (list): A non-empty list of numbers.

    Returns:
        float: The average of the scores.

This is why functions you haven't written yet feel usable — someone wrote a good docstring. When you call help(print), you're reading the docstring that the Python core developers wrote for the print() function.

✅ Best Practice: Write the docstring before you write the function body. This forces you to think about what the function should do — its inputs, outputs, and purpose — before you worry about how to implement it. This technique is called "documentation-driven development" and it catches design problems early.


6.8 Function Design Principles

Knowing the syntax of functions is necessary but not sufficient. Knowing how to design good functions is what separates clean code from a tangled mess.

6.8.1 Single Responsibility Principle

Each function should do one thing. If you can't describe what a function does without using the word "and," it's probably doing too much.

# Bad: does two things (calculates AND prints)
def process_grades(scores):
    avg = sum(scores) / len(scores)
    if avg >= 90:
        grade = "A"
    elif avg >= 80:
        grade = "B"
    elif avg >= 70:
        grade = "C"
    else:
        grade = "F"
    print(f"Average: {avg:.1f}")
    print(f"Grade: {grade}")
    print(f"Highest: {max(scores)}")
    print(f"Lowest: {min(scores)}")

# Good: separate functions, each with one job
def calculate_average(scores):
    """Return the average of a list of scores."""
    return sum(scores) / len(scores)

def determine_grade(average):
    """Return the letter grade for a numeric average."""
    if average >= 90:
        return "A"
    elif average >= 80:
        return "B"
    elif average >= 70:
        return "C"
    else:
        return "F"

def display_report(scores):
    """Display a formatted grade report."""
    avg = calculate_average(scores)
    grade = determine_grade(avg)
    print(f"Average: {avg:.1f}")
    print(f"Grade: {grade}")
    print(f"Highest: {max(scores)}")
    print(f"Lowest: {min(scores)}")

The "good" version has three clear advantages: - You can reuse calculate_average and determine_grade independently. - You can test each function in isolation. - If the grading scale changes, you modify one function.

6.8.2 Naming Conventions

Good function names are verbs or verb phrases that describe what the function does:

Bad Name Better Name Why
data() load_student_data() What data? What does it do with it?
process() calculate_average() "Process" is vague — average is specific
do_stuff() validate_input() What stuff?
x() convert_celsius_to_fahrenheit() Self-documenting

Long, descriptive names are better than short, mysterious ones. You type the name once; you read it a hundred times.

6.8.3 Pure Functions (Preview)

A pure function is one that: 1. Always returns the same output for the same input. 2. Has no side effects — it doesn't modify anything outside itself (no printing, no writing files, no changing global variables).

# Pure function — same input always gives same output
def add(a, b):
    return a + b

# Not pure — depends on external state
total = 0
def add_to_total(n):
    global total
    total += n  # Side effect: modifies global variable
    return total

Pure functions are easier to test, easier to reason about, and easier to reuse. You don't need to make every function pure — functions that print output or write files are inherently impure, and that's fine. But when a function's job is computation, keeping it pure makes your life easier.

🔗 Connection (Theme: Tools you build today power AI): Every AI system — from a chatbot to an image generator — is built from functions. Machine learning models are trained by calling functions that process data, compute errors, and adjust weights. The TensorFlow and PyTorch libraries that power modern AI are massive collections of well-designed functions. The principles you're learning here — clean names, single responsibility, clear inputs and outputs — are exactly the principles that make those systems possible.


6.9 Functions Calling Functions

One of the most powerful aspects of functions is that they compose — you build complex behavior by combining simple functions.

Let's build a grade reporting system piece by piece:

def calculate_average(scores):
    """Return the average of a list of numeric scores."""
    if not scores:
        return 0.0
    return sum(scores) / len(scores)

def determine_grade(average):
    """Return a letter grade based on a numeric average."""
    if average >= 90:
        return "A"
    elif average >= 80:
        return "B"
    elif average >= 70:
        return "C"
    elif average >= 60:
        return "D"
    else:
        return "F"

def format_report(name, scores):
    """Return a formatted string with the student's grade report."""
    avg = calculate_average(scores)
    grade = determine_grade(avg)
    return (
        f"Student: {name}\n"
        f"  Scores: {scores}\n"
        f"  Average: {avg:.1f}\n"
        f"  Grade: {grade}"
    )

def display_class_report(students):
    """Display grade reports for all students.

    Parameters:
        students (dict): Keys are names, values are lists of scores.
    """
    print("=" * 40)
    print("CLASS GRADE REPORT")
    print("=" * 40)
    for name, scores in students.items():
        print(format_report(name, scores))
        print("-" * 40)

# Using the system
class_data = {
    "Alice": [85, 92, 78, 95],
    "Bob": [72, 68, 74, 80],
    "Charlie": [95, 98, 92, 97],
}

display_class_report(class_data)
========================================
CLASS GRADE REPORT
========================================
Student: Alice
  Scores: [85, 92, 78, 95]
  Average: 87.5
  Grade: B
----------------------------------------
Student: Bob
  Scores: [72, 68, 74, 80]
  Average: 73.5
  Grade: C
----------------------------------------
Student: Charlie
  Scores: [95, 98, 92, 97]
  Average: 95.5
  Grade: A
----------------------------------------

Look at the structure: - display_class_report calls format_report for each student. - format_report calls calculate_average and determine_grade. - Each function does one thing and does it well. - Any function can be reused independently.

This is how professional software is built — not with giant monolithic blocks, but with small, composable functions that fit together like building blocks.

🚪 Threshold Concept: Abstraction Through Functions

There's a moment in every programmer's journey when functions "click." Before that moment, you think about programs as sequences of instructions — step 1, step 2, step 3, all the way to step 300. After that moment, you think about programs as compositions of named behaviors.

  • Before: "I write one big block of code that does everything."
  • After: "I build programs from small, reusable, well-named pieces."

This shift changes how you think, not just how you code. Instead of asking "what's the next line of code?", you start asking "what's the right function to build here?" Instead of tracing through 50 lines to understand what happens, you read a function name and trust it.

This is abstraction — the most important idea in computer science. You used it when you called print() without worrying about how the screen renders text. You used it when you called len() without worrying about how Python counts elements. Now you'll build your own abstractions.

If this doesn't click yet, that's okay. Keep writing functions. Keep refactoring. The moment will come — and when it does, you'll wonder how you ever wrote code any other way.


6.10 Common Pitfalls

Let's look at the bugs you're most likely to encounter when working with functions. I've seen every one of these in production code — not just student assignments.

6.10.1 The Mutable Default Argument Trap

This is one of Python's most infamous gotchas:

def add_item(item, items=[]):
    """Add an item to a list. DON'T DO THIS."""
    items.append(item)
    return items

print(add_item("apple"))
print(add_item("banana"))
print(add_item("cherry"))

You'd expect each call to start with a fresh empty list. Instead:

['apple']
['apple', 'banana']
['apple', 'banana', 'cherry']

The default list [] is created once, when the function is defined, and shared across all calls. Each call appends to the same list.

The fix: Use None as the default and create a new list inside the function:

def add_item(item, items=None):
    """Add an item to a list. Safe version."""
    if items is None:
        items = []
    items.append(item)
    return items

print(add_item("apple"))
print(add_item("banana"))
print(add_item("cherry"))
['apple']
['banana']
['cherry']

⚠️ Pitfall: Never use a mutable object (list, dictionary, set) as a default parameter value. Use None instead and create the mutable object inside the function. This is one of the few Python rules that bites even experienced developers.

6.10.2 Confusing Return and Print

We covered this in section 6.4, but it's worth emphasizing: if your function computes a value and you print() it instead of return-ing it, the value is lost to the program.

def calculate_total(price, quantity):
    total = price * quantity
    print(total)  # Displays the value but doesn't return it

# This looks like it works...
calculate_total(10.99, 3)  # Prints: 32.97

# But this fails silently
receipt_total = calculate_total(10.99, 3)  # Prints: 32.97
tax = receipt_total * 0.08  # TypeError: can't multiply None by float

The fix: Return the value. Print it at the call site if needed:

def calculate_total(price, quantity):
    return price * quantity

receipt_total = calculate_total(10.99, 3)
tax = receipt_total * 0.08
print(f"Total: ${receipt_total:.2f}, Tax: ${tax:.2f}")
Total: $32.97, Tax: $2.64

6.10.3 Forgetting to Return

A subtle variation: your function has a return statement, but it's in a conditional branch that doesn't always execute:

def find_max(numbers):
    """Return the largest number in the list."""
    if not numbers:
        return None

    largest = numbers[0]
    for num in numbers:
        if num > largest:
            largest = num
    # Oops — forgot to return largest!

result = find_max([3, 7, 2, 9, 4])
print(result)  # None — the function never returned largest

The fix: Make sure the function returns a value on every code path:

def find_max(numbers):
    """Return the largest number in the list."""
    if not numbers:
        return None

    largest = numbers[0]
    for num in numbers:
        if num > largest:
            largest = num
    return largest  # Don't forget this!

result = find_max([3, 7, 2, 9, 4])
print(result)  # 9
9

6.10.4 Modifying a Parameter and Expecting the Caller to See the Change

With immutable types (int, float, str, tuple), modifications inside a function don't affect the caller's variable:

def double(n):
    n = n * 2  # Creates a new local binding — doesn't change caller's variable

x = 5
double(x)
print(x)  # Still 5 — x wasn't changed
5

This is because n = n * 2 creates a new local variable n pointing to 10, while the caller's x still points to 5. If you need the doubled value, return it:

def double(n):
    return n * 2

x = 5
x = double(x)
print(x)  # 10
10

🔄 Check Your Understanding (try to answer without scrolling up)

  1. Why should you never use a mutable object (like a list) as a default parameter value?
  2. What does a function return if it has no return statement?
  3. If x = 5 and you call def f(n): n = 10 with f(x), what is x after the call?

Verify

  1. Because the default object is created once and shared across all calls, so changes accumulate unexpectedly.
  2. It returns None.
  3. x is still 5. The assignment n = 10 inside the function creates a new local binding without affecting the caller's variable.

6.11 Project Checkpoint: TaskFlow v0.5

It's time to put functions to work. In Chapter 5, you built TaskFlow v0.4 — a menu-driven task list using a while loop. The code probably looks something like one long block with all the logic mixed together.

We're going to refactor it — restructure the code without changing its behavior — into clean functions.

6.11.1 The Refactoring Plan

Here are the functions we'll extract:

Function Job
add_task(tasks) Prompt for a task name and add it to the list
list_tasks(tasks) Display all tasks in a numbered list
delete_task(tasks) Prompt for a task number and remove it
display_menu() Show the menu options
main() Run the main application loop

6.11.2 The Implementation

"""TaskFlow v0.5 — Refactored into functions.

A command-line task manager demonstrating function-based organization.
Builds on v0.4 by restructuring with clean functions.
"""


def display_menu():
    """Display the main menu options."""
    print("\n===== TaskFlow v0.5 =====")
    print("1. Add a task")
    print("2. List all tasks")
    print("3. Delete a task")
    print("4. Quit")
    print("=========================")


def add_task(tasks):
    """Prompt the user for a task name and add it to the list.

    Parameters:
        tasks (list): The current list of task strings.
    """
    task_name = input("Enter task name: ").strip()
    if not task_name:
        print("Task name cannot be empty.")
        return
    tasks.append(task_name)
    print(f'Task "{task_name}" added.')


def list_tasks(tasks):
    """Display all tasks in a numbered list.

    Parameters:
        tasks (list): The current list of task strings.
    """
    if not tasks:
        print("No tasks yet. Add one!")
        return
    print(f"\nYour Tasks ({len(tasks)}):")
    for i, task in enumerate(tasks, 1):
        print(f"  {i}. {task}")


def delete_task(tasks):
    """Prompt the user for a task number and remove it from the list.

    Parameters:
        tasks (list): The current list of task strings.
    """
    if not tasks:
        print("No tasks to delete.")
        return

    list_tasks(tasks)
    choice = input("Enter task number to delete: ").strip()

    if not choice.isdigit():
        print("Please enter a valid number.")
        return

    index = int(choice) - 1
    if 0 <= index < len(tasks):
        removed = tasks.pop(index)
        print(f'Task "{removed}" deleted.')
    else:
        print(f"Invalid task number. Please choose 1-{len(tasks)}.")


def main():
    """Run the TaskFlow application loop."""
    tasks = []
    print("Welcome to TaskFlow v0.5!")

    while True:
        display_menu()
        choice = input("Choose an option (1-4): ").strip()

        if choice == "1":
            add_task(tasks)
        elif choice == "2":
            list_tasks(tasks)
        elif choice == "3":
            delete_task(tasks)
        elif choice == "4":
            print(f"Goodbye! You had {len(tasks)} task(s).")
            break
        else:
            print("Invalid choice. Please enter 1, 2, 3, or 4.")


if __name__ == "__main__":
    main()

6.11.3 What Improved

Compare this to a v0.4 version where everything lives in one giant loop. Notice:

  1. Each function has a single job. add_task adds tasks. list_tasks displays them. delete_task removes them.
  2. main() reads like an outline. You can understand what the program does by reading main() alone — without understanding the implementation of each helper function.
  3. Functions are reusable. list_tasks is called both from the menu option AND from inside delete_task (so the user can see what to delete). We wrote it once.
  4. The if __name__ == "__main__" guard ensures main() runs only when the script is executed directly, not when it's imported as a module. We'll explore this more in Chapter 12.

6.11.4 Try It Yourself

Run the program. Try these scenarios: - Add three tasks, list them, delete the middle one, list again - Try to delete from an empty list - Try entering an empty task name - Try entering an invalid menu choice

Each function handles its own edge cases. That's the beauty of well-designed functions.

🧩 Productive Struggle: Refactor the Messy Version

Here's a version of TaskFlow without functions. Your challenge: identify which chunks of code should become separate functions, what each function should be named, and what parameters it needs.

```python

TaskFlow v0.4 — the messy version

tasks = [] print("Welcome to TaskFlow!")

while True: print("\n1. Add 2. List 3. Delete 4. Quit") choice = input("Choice: ") if choice == "1": name = input("Task name: ").strip() if name: tasks.append(name) print(f"Added: {name}") else: print("Empty name!") elif choice == "2": if tasks: for i, t in enumerate(tasks, 1): print(f" {i}. {t}") else: print("No tasks.") elif choice == "3": if tasks: for i, t in enumerate(tasks, 1): print(f" {i}. {t}") num = input("Number to delete: ") if num.isdigit() and 1 <= int(num) <= len(tasks): removed = tasks.pop(int(num) - 1) print(f"Deleted: {removed}") else: print("Invalid number.") else: print("No tasks to delete.") elif choice == "4": print("Bye!") break else: print("Invalid choice.") ```

Questions to guide your refactoring: 1. How many distinct operations can you identify? (Hint: look for the elif blocks.) 2. Notice that the listing logic appears twice — once in option 2 and once in option 3. What function would eliminate this duplication? 3. Where should the tasks list live? Should it be a global variable or passed as a parameter? 4. What would a main() function look like after all the logic is extracted?


Spaced Review

Before we move on, let's exercise your memory on concepts from earlier chapters. Retrieval practice strengthens learning more effectively than re-reading.

🔄 Spaced Review — From Chapter 3 (Variables & Types)

What does int("42.5") produce — 42, 42.5, or an error? What function would you use to convert "42.5" to a number?

Answer

int("42.5") raises a ValueErrorint() can't convert a string with a decimal point directly. Use float("42.5") to get 42.5, or int(float("42.5")) to get 42.

🔄 Spaced Review — From Chapter 4 (Boolean Logic)

What does this expression evaluate to: True and not False or False? Work through the operator precedence.

Answer

not binds tightest: not False is True. Then and: True and True is True. Then or: True or False is True. The expression evaluates to True. Precedence order: not > and > or.

🔄 Spaced Review — Bridge from Chapter 5 (Loops)

Write a while loop that asks the user for numbers until they type "done", then prints the sum. (This combines loops with the kind of accumulator pattern that you'll now put inside a function.)

Answer

python total = 0 while True: value = input("Enter a number (or 'done'): ") if value.lower() == "done": break total += float(value) print(f"Sum: {total}")


Anchor Example: Text Adventure — describe_room()

Let's bring functions to the Text Adventure. In "Crypts of Pythonia," the player moves between rooms. Instead of copy-pasting room descriptions, we create a describe_room() function:

def describe_room(name, description, exits):
    """Display a room's name, description, and available exits.

    Parameters:
        name (str): The room's display name.
        description (str): A text description of the room.
        exits (list): A list of direction strings (e.g., ["north", "east"]).
    """
    print(f"\n--- {name} ---")
    print(description)
    if exits:
        print(f"Exits: {', '.join(exits)}")
    else:
        print("There are no exits. You are trapped!")


def get_player_action(exits):
    """Prompt the player for an action and return it.

    Parameters:
        exits (list): Valid exit directions.

    Returns:
        str: The player's chosen action (lowercase).
    """
    while True:
        action = input("\nWhat do you do? ").strip().lower()
        if action in exits or action == "quit":
            return action
        print(f"You can't go '{action}'. Try: {', '.join(exits)}")


# Using the functions
describe_room(
    "The Entrance Hall",
    "Torchlight flickers across stone walls. A cold draft blows from the north.",
    ["north", "east"]
)

action = get_player_action(["north", "east"])
print(f"You head {action}...")
--- The Entrance Hall ---
Torchlight flickers across stone walls. A cold draft blows from the north.
Exits: north, east

What do you do? west
You can't go 'west'. Try: north, east

What do you do? north
You head north...

Notice how describe_room() and get_player_action() are completely reusable for any room. Every room in the game uses the same two functions — we just pass in different data. That's abstraction in action.


Recurring Themes

Computational Thinking: Abstraction Is THE Core CS Skill

We've now encountered the concept of abstraction in multiple chapters — it's one of the four pillars of computational thinking from Chapter 1. Functions are abstraction made concrete. Every time you define a function, you're creating an abstraction: you're saying "this chunk of complexity has a name, and you can use it without understanding its internals."

This same pattern scales up to everything in CS: classes abstract data and behavior (Chapter 14), modules abstract collections of functions (Chapter 12), APIs abstract entire systems (Chapter 21). Functions are where it starts.

Reading Code Is as Important as Writing It

When you encounter a function like calculate_average(scores), you should be able to understand what it does from its name and docstring alone — without reading the implementation. This is what it means to "read code": understanding the what from the interface, not the how from the implementation.

Professional developers read far more code than they write. Getting comfortable reading function signatures, docstrings, and parameter names is a skill that pays off every day of your career.

CS Is for Everyone

Here's something beautiful about functions: you don't need to understand how a function works to use it. Elena doesn't need to know how sum() is implemented to use it in her reports. You don't need to understand how print() works to display text. Functions make code accessible — they let you stand on the shoulders of others' work.

This is true at every level. A data scientist uses machine learning libraries written by engineers. A web developer uses frameworks built by systems programmers. Nobody understands everything — and that's okay. Functions are the mechanism that makes this division of labor possible.


Chapter Summary

Functions are the fundamental unit of code organization. They let you name a behavior, define its inputs and outputs, and use it without thinking about its internals. This chapter covered:

Concept Key Idea
def and calling Define once, call anywhere
Parameters vs. arguments Parameters are slots; arguments are values
Default parameters Provide sensible fallbacks
*args Accept any number of arguments
return vs. print return gives data to the program; print shows it to the human
Scope Variables live where they're created; local dies when the function returns
Call stack Python tracks nested function calls with a stack of frames
Docstrings Documentation that travels with the function
Single responsibility One function, one job
Pure functions Same input, same output, no side effects

The threshold concept of this chapter — abstraction through functions — is a shift in thinking that will affect everything you write from here on. You're no longer writing one long script. You're building programs from composable, reusable, well-named pieces.

What's Next

Chapter 7 dives into strings — Python's text processing powerhouse. You'll learn to slice, search, transform, and format text data. The string methods you learn there are among the most frequently used tools in real-world Python code. And now that you understand functions, you'll be able to build string-processing functions that you can reuse across projects.

In TaskFlow v0.6, you'll add search functionality — finding tasks by keyword — which combines string methods with the function-based architecture you built in this chapter.