4.2. Function Design#

Hide code cell source

import sys
from pathlib import Path

# Find project root by looking for _config.yml
current = Path.cwd()
for parent in [current, *current.parents]:
    if (parent / '_config.yml').exists():
        project_root = parent
        break
else:
    project_root = Path.cwd().parent.parent

# Add project root to path
sys.path.insert(0, str(project_root))

# Import shared teaching helpers and cell magics
from shared import thinkpython, diagram, jupyturtle, structshape
from shared.download import download

Good functions are small, named clearly, documented when needed, and composed with other functions. This section focuses on scope, composition, docstrings, and basic lambda expressions.

4.2.1. Scope#

Variables created inside functions are local to that function:

def my_function():
    local_var = 10  # Only exists inside the function
    print(local_var)

my_function()     # Prints: 10
# print(local_var)  # Error: local_var doesn't exist here

Variables outside functions are global:

global_var = 100  # Accessible everywhere

def show_global():
    print(global_var)  # Can read global variable

show_global()  # Prints: 100

4.2.1.1. Local vs. Global Scope#

When you create a variable (parameters or variables) inside a function, it is local, which means it exists only inside the function.

A variable defined inside a function is by default local. Namely, it can not be accessed from outside of the function.

def greet():
    message = "Hello"
    print(message)

greet()

### try-except block to catch errors ==> next chapter
try:
    print(message)   ### cannot do this because message is local
except NameError as error:
    print(type(error).__name__)
Hello
NameError

So is a parameter defined in a function.

def greet(message):
    print("some message:")  ### para not used in this function

greet("Hello, World!")

### try-except block to catch errors ==> next chapter
try:
    print(message)   ### para message is local
except NameError as error:
    print(type(error).__name__)
some message:
NameError

Now, let’s look at the use of the global keyword. You will see that you can make a variable inside a function accessible from outside of the function.

def greet():
    global message        ### global var: must declare first
    message = "Hello"
    print(message)

greet()

print(message)   ### can do! "message" variable is now global in the function definition.
Hello
Hello

4.2.2. Function Composition#

Function composition is the process of using the output of one function as the input to another. This can involve built-in functions, user-defined functions, or both.

Built-in functions can call other functions internally. For example, sorted() may call a function you pass with key=:

names = ["Bob", "Alexandra", "Chen"]

print(sorted(names))            ### default sort is alphabetical
print(sorted(names, key=len))   ### sort by length of name; len is a function
['Alexandra', 'Bob', 'Chen']
['Bob', 'Chen', 'Alexandra']

For custom functions:

def double(x):
    return x * 2        ### x doubled

def square(x):
    return x ** 2       ### x squared

result = square(double(3))      ### double(3) returns 6, then square(6) returns 36
print(result)
36
### Exercise: Function Composition
### Write three functions:
### - calculate_area(length, width): returns the area
### - format_result(value): returns 'Area is X square units'
### Then write report_rectangle() that uses both to calculate and format.
### Your code starts here:




### Your code stops here.

Hide code cell source

### Solution
def calculate_area(length, width):
    return length * width

def format_result(value):
    return f'Area is {value} square units'

### Function composition: one function calls another
def report_rectangle(length, width):
    area = calculate_area(length, width)
    return format_result(area)

### Test
print(report_rectangle(5, 3))
print(report_rectangle(10, 2))
Area is 15 square units
Area is 20 square units

4.2.3. Pure Functions and Side Effects#

A pure function is one that:

  • Given the same input, always returns the same output — no dependence on external state, randomness, or time.

  • Has no side effects: it doesn’t modify anything outside itself (no mutating global variables, no writing to files, no printing, no changing arguments in place).

A pure function computes a return value from its arguments without changing outside state. A side effect changes something outside the returned value: printing, mutating a list, editing a file, or changing a global variable.

Common ways functions become impure:

  • Mutating a mutable argument (like appending to a list passed in)

  • Reading or writing global variables

  • I/O: printing, reading files, network calls, database queries

  • Using random, datetime.now(), or other non-deterministic sources

Pure functions matter because:

  • Predictable/testable — no hidden state to set up or mock

  • Composable — safe to combine, reorder (where math allows), or memoize

  • Parallelizable — no shared mutable state means no race conditions

  • Easier to reason about — you can understand a pure function just by reading its body and signature

Some Python tools assume purity:

  • map(), filter(), functools.reduce() work best with pure functions

  • functools.lru_cache (memoization) only makes sense if the function is pure — caching an impure function’s results can produce wrong answers

  • Libraries like dataclasses(frozen=True) help enforce immutability, which supports writing pure functions

Pure functions are easier to test because the same inputs always produce the same outputs.

def add_bonus(score, bonus):
    return score + bonus

print(add_bonus(88, 5))
print(add_bonus(88, 5))
93
93

The next function has a side effect because it mutates the list passed to it. Sometimes mutation is useful, but it should be deliberate and visible from the function name or documentation.

def add_bonus_in_place(scores, bonus):
    for i in range(len(scores)):
        scores[i] += bonus          ### assignment operator modifies the list in place

quiz_scores = [80, 85, 90]          ### the outside var list is modified in place
add_bonus_in_place(quiz_scores, 5)
print(quiz_scores)

### object changed
[85, 90, 95]

The function below is impure because it depnds on external state and mutate input.

total = 0

def add_to_total(x):
    global total
    total += x
    return total

add_to_total(5)
5
add_to_total(5)
10
add_to_total(5)
15

As you see, global variables can have side effects: the function’s result depends not only on x, but also on the value left behind by previous calls.

### Exercise: Make the Function Pure
# Rewrite add_bonus_in_place as a pure function named add_bonus_to_scores.
# It should return a new list and leave the original scores unchanged.
scores = [80, 85, 90]
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution
def add_bonus_to_scores(scores, bonus):
    updated = []
    for score in scores:
        updated.append(score + bonus)
    return updated

scores = [80, 85, 90]
updated = add_bonus_to_scores(scores, 5)
print(scores)
print(updated)
[80, 85, 90]
[85, 90, 95]

4.2.4. “One Function, One Job”#

Observe the functions below. You have to trace the flow to understand it. It seems trivial, but for good reasons.

def repeat(text):
    print(text)
    print(text)

def first_two_lines(line):
    repeat(line)

def print_verse(line):
    first_two_lines(line)

# Example usage:
print_verse("Row, row, row your boat")
Row, row, row your boat
Row, row, row your boat

When you run print_verse, it calls first_two_lines, which calls repeat, which calls print. That’s a lot of functions. Of course, we could have done the same thing with fewer functions, but the point of this example is to show how functions can work together.

A good function usually does one job at one level of detail. If a function validates data, computes a result, formats output, and prints a message, it is harder to test and harder to reuse. Split the work into small functions and compose them. This is often summarized as often summarized as “one function, one job.”

For example, you could design a function that does several things:

def process_order(price, quantity):
    if price < 0 or quantity < 0:
        print("Error: invalid input")
        return                          ### like break statement, but exit the "function" early
    total = price * quantity
    if total > 100:
        total *= 0.9  # apply discount
    formatted = f"${total:.2f}"
    print(f"Order total: {formatted}")
    
process_order(10, 5)    # valid input
process_order(-5, 10)   # invalid input
Order total: $50.00
Error: invalid input

Or, we can split it into small, single-purpose functions:

def is_valid_order(price, quantity):
    return price >= 0 and quantity >= 0

def compute_total(price, quantity):
    total = price * quantity
    if total > 100:
        total *= 0.9
    return total

def format_currency(amount):
    return f"${amount:.2f}"

def process_order(price, quantity):
    if not is_valid_order(price, quantity):
        print("Error: invalid input")
        return
    total = compute_total(price, quantity)
    print(f"Order total: {format_currency(total)}")
    
process_order(100, 5)    # valid input
process_order(-5, 10)   # invalid input
Order total: $450.00
Error: invalid input

Why the second version is better?

  • is_valid_order and compute_total are pure functions (same input ==> same output, no side effects). They can be tested directly without capturing printed output.

  • format_currency is reusable anywhere else in the program that needs to display money.

  • process_order becomes a thin orchestrator. It reads almost like a sentence describing the steps, and it’s the only function that touches I/O (print).

Another example of on-single-purpose function that mashes things (averaging, grading, formatting, and printing) all into one function:

def summarize_student(name, scores):
    total = 0
    for s in scores:
        total += s
    average = total / len(scores)

    if average >= 90:
        grade = "A"
    elif average >= 80:
        grade = "B"
    elif average >= 70:
        grade = "C"
    else:
        grade = "Needs review"

    print(f"{name}: {average:.1f} ({grade})")

summarize_student("Maya", [92, 87, 95])
Maya: 91.3 (A)

Why this is not a good function design:

  • It computes, decides, formats, and prints all in one place, so you can’t test the average or the grading logic independently — you’d have to capture stdout just to check if 92, 87, 95 averages correctly.

  • It also returns nothing (None), so the caller has no way to reuse the summary string elsewhere (e.g., writing it to a file, building a report, or displaying it in a different format) — printing is baked in rather than left to the caller.

  • The averaging logic and grading logic can’t be reused for other purposes (e.g., grading a whole class, or computing an average without a grade attached).

In comparison, the design below is easier to change. If the grading rule changes, edit letter_grade. If the average calculation changes, edit mean. The caller can keep using summarize_student.

def mean(values):
    return sum(values) / len(values)

def letter_grade(score):
    if score >= 90:
        return "A"
    if score >= 80:
        return "B"
    if score >= 70:
        return "C"
    return "Needs review"

def summarize_student(name, scores):
    average = mean(scores)
    grade = letter_grade(average)
    return f"{name}: {average:.1f} ({grade})"

print(summarize_student("Maya", [92, 87, 95]))
Maya: 91.3 (A)
### Exercise: Compose Small Functions
# Write three functions:
# 1. celsius_to_fahrenheit(c)
# 2. is_hot(f), which returns True when f is at least 85
# 3. weather_label(c), which returns "hot" or "not hot" for a Celsius temperature
### Your code starts here.



### Your code ends here.

Hide code cell source

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

def is_hot(f):
    return f >= 85

def weather_label(c):
    f = celsius_to_fahrenheit(c)
    if is_hot(f):
        return "hot"
    return "not hot"

print(weather_label(30))
print(weather_label(20))
hot
not hot

4.2.5. Docstrings#

Docstrings (documentation strings) are used to document functions/methods, classes, and modules. They use triple quotes and should be the first statement after defining a function or class.

Always document your functions with docstrings:

### Function with docstring
def greet(name):
    """
    This function does xxx and yyy.         ### 1. what this function is about
    
    Args:                                   ### 2. input parameters
        name: The person's name (string)    
    
    Returns:                                ### 3. what the function returns
        A greeting message (string)
    """
    return f"Hello, {name}!"

message = greet("Homer")       ### call the function

As an example:

def calculate_bmi(weight, height):
    """
    Calculate Body Mass Index (BMI).
    
    Args:
        weight: Weight in kilograms (float)
        height: Height in meters (float)
    
    Returns:
        BMI value (float)
    
    Example:
        >>> calculate_bmi(70, 1.75)
        22.86
    """
    return weight / (height ** 2)

Good docstrings include:

  1. What the function does

  2. Parameters and their types

  3. What the function returns

  4. Usage examples (optional)

### Practical functions with docstrings

def calculate_discount(price, discount_percent):
    """
    Calculate the final price after applying a discount.
    
    Args:
        price: Original price (float or int)
        discount_percent: Discount percentage (0-100)
    
    Returns:
        Final price after discount (float)
    """
    discount_amount = price * (discount_percent / 100)
    final_price = price - discount_amount
    return final_price

def is_valid_email(email):
    """
    Check if email has basic valid format.
    
    Args:
        email: Email address to validate (str)
    
    Returns:
        True if email contains @ and ., False otherwise
    """
    return '@' in email and '.' in email

def celsius_to_fahrenheit(celsius):
    """
    Convert Celsius to Fahrenheit.
    
    Args:
        celsius: Temperature in Celsius
    
    Returns:
        Temperature in Fahrenheit
    """
    return (celsius * 9/5) + 32

### Test the functions
print("Testing calculate_discount:")
original = 100
discount = 20
final = calculate_discount(original, discount)
print(f"  ${original} with {discount}% off = ${final}")

print("\nTesting is_valid_email:")
print(f"  'user@example.com' is valid: {is_valid_email('user@example.com')}")
print(f"  'invalid-email' is valid: {is_valid_email('invalid-email')}")

print("\nTesting celsius_to_fahrenheit:")
print(f"  0°C = {celsius_to_fahrenheit(0)}°F")
print(f"  100°C = {celsius_to_fahrenheit(100)}°F")
print(f"  37°C = {celsius_to_fahrenheit(37):.1f}°F")
Testing calculate_discount:
  $100 with 20% off = $80.0

Testing is_valid_email:
  'user@example.com' is valid: True
  'invalid-email' is valid: False

Testing celsius_to_fahrenheit:
  0°C = 32.0°F
  100°C = 212.0°F
  37°C = 98.6°F
### Exercise: Writing Good Docstrings
### Write a function called validate_password() that:
### - Takes a password string as a parameter
### - Checks if it's at least 8 characters long
### - Returns True if valid, False otherwise
### Include a comprehensive docstring with description, Args, and Returns sections.

### Your code starts here:

Hide code cell source

### Solution
def validate_password(password):
    """
    Validate if a password meets minimum length requirements.
    
    Args:
        password (str): The password to validate
    
    Returns:
        bool: True if password is at least 8 characters, False otherwise
    """
    return len(password) >= 8

### Test the function
print(validate_password("short"))        ### False
print(validate_password("verylongpassword"))  ### True
False
True

4.2.5.1. Docstrings as Contracts#

A docstring should tell a reader what the function expects and what it returns. For student code, a short docstring is enough when it states the purpose, important assumptions, and return value.

def normalized_score(points, total):
    """Return points as a percentage of total.

    Args:
        points: Earned points.
        total: Possible points. Must be greater than zero.

    Returns:
        Percentage score as a float.
    """
    return points / total * 100

print(normalized_score(42, 50))
84.0
### Exercise: Write a Contract Docstring
# Add a docstring to passing_rate. The docstring should state what scores means,
# what cutoff means, and what the function returns.
def passing_rate(scores, cutoff=70):
    passed = []
    for score in scores:
        if score >= cutoff:
            passed.append(score)
    return len(passed) / len(scores)

print(passing_rate([80, 64, 90, 72]))
0.75

Hide code cell source

### Solution
def passing_rate(scores, cutoff=70):
    """Return the proportion of scores greater than or equal to cutoff.

    Args:
        scores: A non-empty list of numeric scores.
        cutoff: Minimum score counted as passing.

    Returns:
        A float between 0 and 1.
    """
    passed = []
    for score in scores:
        if score >= cutoff:
            passed.append(score)
    return len(passed) / len(scores)

print(passing_rate([80, 64, 90, 72]))
0.75

4.2.6. Lambda Functions#

Syntax:

lambda arguments: expression

Lambda functions are small, anonymous functions that can have any number of arguments but can only have one expression. They are useful for short, simple functions that you don’t want to define formally.

Rule of thumb: lambdas are best for short, throwaway functions passed inline to something like sorted, map, or filter — not for anything that needs a docstring, multiple lines, or a name people will call more than once.

4.2.6.1. Lambda Limitations#

  • Can only contain expressions, not statements

  • Cannot contain assignments, print statements, or other statements

  • Best for simple, one-line functions

  • For complex logic, use regular functions (def)

If you find yourself writing a multi-line lambda, switch to a normal def function instead.

### Basic lambda function
square = lambda x: x ** 2
print(square(5))  # Output: 25
25

This lambda function is equivalent to:

def square(x):
    return x ** 2

More examples:

### Lambda with multiple arguments
add = lambda x, y: x + y
print(add(3, 4))  # Output: 7
7
is_even = lambda n: n % 2 == 0
is_even(6)  # True
True

Lambda functions are actually useful when serving as arguments to other functions:

students = [("Maya", 92), ("Sam", 78), ("Lee", 85)]

# Sort by score instead of name
sorted(students, key=lambda s: s[1])
# [('Sam', 78), ('Lee', 85), ('Maya', 92)]

# Sort by score, descending
sorted(students, key=lambda s: s[1], reverse=True)
[('Maya', 92), ('Lee', 85), ('Sam', 78)]

Or serving as a dict value:

operations = {
    "add": lambda a, b: a + b,
    "sub": lambda a, b: a - b,
    "mul": lambda a, b: a * b,
}
operations["mul"](3, 4)  # 12
12

If the logic needs a name for clarity, more than one line, or will be reused, use a regular def instead. This connects back to the single-purpose principle.

4.2.7. Closures#

In the Scope section, you saw two places a name can live: inside a function (local) and at the top level of the program (global). A function can also be defined inside another function. This nested function can read the outer function’s variables, which live in the enclosing scope. When Python looks up a name, it searches the scopes in this order: local, enclosing, global, then built-in.

def greet_all(names):
    greeting = "Hello"                  ### local to greet_all; enclosing scope for greet

    def greet(name):                    ### nested function
        print(f"{greeting}, {name}!")   ### reads greeting from the enclosing scope

    for name in names:
        greet(name)

greet_all(["Alice", "Bob"])
Hello, Alice!
Hello, Bob!

4.2.7.1. Functions That Return Functions#

An outer function can also return its inner function. The returned function still remembers the enclosing variables after the outer function has finished. A function that remembers values from the scope where it was created is called a closure. An outer function that builds and returns configured closures is called a function factory.

def make_multiplier(factor):
    def multiply(x):
        return x * factor          ### factor comes from the enclosing call
    return multiply                ### return the function itself, not multiply(...)

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(10))    # 20
print(triple(10))    # 30
20
30

Each call to make_multiplier() creates a new multiply function with its own remembered factor: double remembers 2, and triple remembers 3. Notice that make_multiplier returns multiply without parentheses, so it returns the function object rather than the result of calling it.

When the returned function only computes one expression, a lambda works too:

def make_discount(rate):
    return lambda price: round(price * (1 - rate), 2)   ### closure over rate

student_price = make_discount(0.10)
member_price = make_discount(0.25)

print(student_price(40))    # 36.0
print(member_price(40))     # 30.0
36.0
30.0

4.2.7.2. Changing an Enclosing Variable with nonlocal#

A closure can read an enclosing variable freely, but assigning to that name inside the inner function creates a new local variable instead. To update the enclosing variable, declare it nonlocal, much as global declares a global variable. This lets a closure keep private state between calls, such as a counter:

def make_counter():
    count = 0
    def increment():
        nonlocal count          ### update count in the enclosing scope
        count += 1
        return count
    return increment

click = make_counter()
print(click())    # 1
print(click())    # 2
print(click())    # 3
1
2
3

Unlike the global total example in the Pure Functions section, the counter’s state is private: no other code can change count except by calling click(). Closures are also the foundation of decorators, which you will meet in Chapter 10.

### Exercise: Function Factory
# Write make_greeting(greeting), which returns a function that takes a name
# and returns the string "<greeting>, <name>!".
# Test:
#   hello = make_greeting("Hello")
#   hi = make_greeting("Hi")
#   print(hello("Alice"))   # Hello, Alice!
#   print(hi("Bob"))        # Hi, Bob!
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution
def make_greeting(greeting):
    def greet(name):
        return f"{greeting}, {name}!"
    return greet

hello = make_greeting("Hello")
hi = make_greeting("Hi")
print(hello("Alice"))
print(hi("Bob"))
Hello, Alice!
Hi, Bob!