5.1 Function Definitions and Returns — def, return, docstring
A function is a reusable block of code that lets you break complex programs into small, clear units. In Python, functions are first-class objects — they can be assigned to variables and passed as arguments.
def Keyword and Basic Syntax
# Basic function definition
def greet(name: str) -> str:
"""Returns a greeting message for the user."""
return f"Hello, {name}!"
message = greet("Alice")
print(message) # Hello, Alice!
# Function with no parameters
def get_separator() -> str:
return "-" * 40
print(get_separator()) # ----------------------------------------
# The function object itself (not called)
print(greet) # <function greet at 0x...>
print(type(greet)) # <class 'function'>
return Statement: Various Return Modes
# Single value return
def square(n: int | float) -> float:
return n ** 2
print(square(5)) # 25
print(square(2.5)) # 6.25
# Multiple return values (actually a tuple)
def min_max(numbers: list[int | float]) -> tuple[float, float]:
return min(numbers), max(numbers)
lo, hi = min_max([3, 1, 4, 1, 5, 9, 2, 6])
print(f"Min: {lo}, Max: {hi}") # Min: 1, Max: 9
# Can also receive as a tuple
result = min_max([10, 20, 30])
print(result) # (10, 30)
print(type(result)) # <class 'tuple'>
# Implicit None return — no return, or bare return
def print_hello():
print("Hello!")
# Implicitly returns None
def early_exit(value: int):
if value < 0:
return # Returns None and exits the function
print(f"Positive: {value}")
result1 = print_hello()
result2 = early_exit(-1)
print(result1, result2) # None None
# Early return pattern
def safe_divide(a: float, b: float) -> float | None:
if b == 0:
return None
return a / b
print(safe_divide(10, 2)) # 5.0
print(safe_divide(10, 0)) # None
Writing Docstrings
Docstrings are documentation strings written as the first statement of a function, class, or module. They are used by help() and IDE autocompletion.
# Single-line docstring — suitable for simple functions
def add(a: int, b: int) -> int:
"""Returns the sum of two integers."""
return a + b
# Multi-line docstring — for complex functions
def process_data(data: list[dict], threshold: float = 0.5) -> list[dict]:
"""
Processes a list of data and returns only items above the threshold.
Parameters
----------
data : list[dict]
List of data to process. Each item must contain a 'value' key.
threshold : float, optional
Filtering threshold (default: 0.5)
Returns
-------
list[dict]
A new list containing only items with value >= threshold
Raises
------
ValueError
When data is empty
Examples
--------
>>> process_data([{"value": 0.3}, {"value": 0.7}], threshold=0.5)
[{'value': 0.7}]
"""
if not data:
raise ValueError("Data is empty")
return [item for item in data if item.get("value", 0) >= threshold]
print(process_data.__doc__)
help(add)
Google Style Docstring
def calculate_bmi(weight_kg: float, height_m: float) -> float:
"""Calculate BMI (Body Mass Index).
Args:
weight_kg: Weight in kilograms
height_m: Height in meters
Returns:
BMI value (weight / height²)
Raises:
ValueError: If weight or height is non-positive
Example:
>>> calculate_bmi(70, 1.75)
22.86
"""
if weight_kg <= 0 or height_m <= 0:
raise ValueError("Weight and height must be positive")
return round(weight_kg / (height_m ** 2), 2)
print(calculate_bmi(70, 1.75)) # 22.86
Type Hints and Function Signatures
from typing import Optional, Union
# Basic type hints
def greet_user(name: str, age: int) -> str:
return f"{name} (age {age})"
# Optional: can be None
def find_user(user_id: int) -> Optional[dict]:
db = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
return db.get(user_id)
# Union: accepts multiple types (Python 3.10+: use X | Y)
def stringify(value: Union[int, float, str]) -> str:
return str(value)
def stringify_new(value: int | float | str) -> str:
return str(value)
# Collection type hints
from typing import Any
def process_items(
items: list[str],
mapping: dict[str, Any],
flags: tuple[bool, ...]
) -> list[str]:
return [item for item in items if item in mapping]
# Callable type hints
from typing import Callable
def apply_twice(func: Callable[[int], int], value: int) -> int:
"""Apply a function twice"""
return func(func(value))
print(apply_twice(lambda x: x * 2, 3)) # 12
# Complex return type
from typing import TypeAlias
Matrix: TypeAlias = list[list[float]]
def identity_matrix(n: int) -> Matrix:
"""Generate an n×n identity matrix"""
return [[1.0 if i == j else 0.0 for j in range(n)] for i in range(n)]
for row in identity_matrix(3):
print(row)
# [1.0, 0.0, 0.0]
# [0.0, 1.0, 0.0]
# [0.0, 0.0, 1.0]
Nested Functions
# Inner function — only valid inside the outer function
def make_greeting(language: str):
"""Generate a greeting function by language"""
def korean_greeting(name: str) -> str:
return f"안녕하세요, {name}님!"
def english_greeting(name: str) -> str:
return f"Hello, {name}!"
def japanese_greeting(name: str) -> str:
return f"こんにちは、{name}さん!"
greetings = {
"ko": korean_greeting,
"en": english_greeting,
"ja": japanese_greeting,
}
return greetings.get(language, english_greeting)
greet_ko = make_greeting("ko")
greet_en = make_greeting("en")
print(greet_ko("Kim Cheolsu")) # 안녕하세요, Kim Cheolsu님!
print(greet_en("Alice")) # Hello, Alice!
# Separate complexity with inner functions
def validate_and_process(data: list[int]) -> dict:
def validate(items: list[int]) -> list[str]:
errors = []
if not items:
errors.append("List is empty")
if any(x < 0 for x in items):
errors.append("Contains negative values")
if len(items) > 1000:
errors.append("Exceeds maximum of 1000 items")
return errors
def compute_stats(items: list[int]) -> dict:
return {
"count": len(items),
"sum": sum(items),
"mean": sum(items) / len(items),
"min": min(items),
"max": max(items),
}
errors = validate(data)
if errors:
return {"success": False, "errors": errors}
return {"success": True, "stats": compute_stats(data)}
result = validate_and_process([3, 7, 2, 9, 1])
print(result)
Real-world Example: Good Function Design
# Single Responsibility Principle
# Bad: one function doing too many things
def process_user_data_bad(user_data: dict) -> None:
if not user_data.get("name"):
raise ValueError("No name")
if not user_data.get("email"):
raise ValueError("No email")
if "@" not in user_data.get("email", ""):
raise ValueError("Invalid email format")
print(f"DB save: {user_data}")
print(f"Email sent to: {user_data['email']}")
print(f"Log: User {user_data['name']} registered")
# Good: each function has one responsibility
def validate_user(user_data: dict) -> list[str]:
"""Validate user data. Returns list of errors."""
errors = []
if not user_data.get("name"):
errors.append("Name is required")
if not user_data.get("email"):
errors.append("Email is required")
elif "@" not in user_data["email"]:
errors.append("Email format is invalid")
return errors
def save_user(user_data: dict) -> int:
"""Save user to DB. Returns created ID."""
print(f"DB save: {user_data['name']}")
return 42 # Simulation
def send_welcome_email(email: str, name: str) -> bool:
"""Send welcome email. Returns success status."""
print(f"Welcome email sent: {name} <{email}>")
return True
def register_user(user_data: dict) -> dict:
"""Orchestrate the user registration flow."""
errors = validate_user(user_data)
if errors:
return {"success": False, "errors": errors}
user_id = save_user(user_data)
send_welcome_email(user_data["email"], user_data["name"])
return {"success": True, "user_id": user_id}
result = register_user({"name": "Alice", "email": "alice@example.com"})
print(result)
Pro Tips
1. Dynamic Access to Function Annotations
def example(x: int, y: float = 1.0) -> str:
"""Example function"""
return str(x + y)
print(example.__annotations__)
# {'x': <class 'int'>, 'y': <class 'float'>, 'return': <class 'str'>}
print(example.__defaults__) # (1.0,)
print(example.__name__) # example
2. Functions Returning Functions (Factory)
def multiplier(factor: int):
"""Returns a function that multiplies by factor"""
def multiply(x: int) -> int:
return x * factor
return multiply
double = multiplier(2)
triple = multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
3. Prefer Pure Functions
# Pure function: same input → same output, no side effects
# Easier to test, fewer bugs
# Impure: depends on global state
count = 0
def increment_bad():
global count
count += 1
return count
# Pure: output determined only by input
def increment(current: int) -> int:
return current + 1
# Date dependency (impure → improved to pure)
from datetime import date
def get_age_bad(birth_year: int) -> int:
return date.today().year - birth_year # Result varies by run date
def get_age(birth_year: int, current_year: int) -> int:
return current_year - birth_year # Easy to test
print(get_age(1990, 2024)) # 34