4.2. Function Design#
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.
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 functionsfunctools.lru_cache(memoization) only makes sense if the function is pure — caching an impure function’s results can produce wrong answersLibraries 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.
[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_orderandcompute_totalare pure functions (same input ==> same output, no side effects). They can be tested directly without capturing printed output.format_currencyis reusable anywhere else in the program that needs to display money.process_orderbecomes 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.
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:
What the function does
Parameters and their types
What the function returns
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:
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
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.
Hello, Alice!
Hi, Bob!