5 Functions
Functions are reusable blocks of code that perform specific tasks. They help organize code, avoid repetition, and make programs more maintainable. In Python, functions are defined using the def
keyword.
5.1 Defining functions and using them
Here’s the basic syntax for defining and calling functions:
def greet(name):
= f"Hello, {name}!"
message return message
# Calling the function
= greet("Alice")
result print(result) # Outputs: Hello, Alice!
Let’s understand what happens step by step:
- Function Definition:
def
tells Python we’re creating a new functiongreet
is the name we give our functionname
in parentheses is a parameter - it’s like a variable that will hold whatever value we pass to the function- The colon
:
marks the start of the function’s body - Everything indented belongs to this function
- Function Body:
- Creates a message using the value stored in
name
return
sends this message back to wherever the function was called
- Creates a message using the value stored in
- Using the Function:
- We call the function by writing its name followed by parentheses
- We put “Alice” in the parentheses - this becomes the value of
name
- The function creates the message “Hello, Alice!”
- The message is returned and stored in
result
- Finally, we print
result
to see the message
Let’s break down this example:
def
is a special word that tells Python we’re creating a new functiongreet
is the name we chose for our function - like variables, function names should be descriptive- The parentheses
()
after the name contain the parameters - in this case,name
is a parameter - The colon
:
marks the start of the function’s body - Everything indented under the
def
line is part of the function’s body return
sends a value back to wherever the function was called from
Python uses indentation (usually 4 spaces) to group lines of code together. Jupyter and code editors will insert indentation when you press that Tab key. All the lines indented under def
belong to that function. This is different from many other programming languages that use braces {}
or other symbols.
When you call a function like greet("Alice")
, here’s what happens step by step:
- Python evaluates the argument
"Alice"
(in this case it’s just a simple string) - The parameter
name
is bound to the value"Alice"
- think of this like creating a new variablename = "Alice"
that only exists inside the function - The function body runs:
- First it creates the message
"Hello, Alice!"
- Then it hits the
return
statement
- First it creates the message
- The
return message
sends the string"Hello, Alice!"
back to where we called the function - This returned value is stored in
result
The return
statement is like a special exit door for your function - it both specifies what value to send back AND immediately ends the function’s execution. For example:
def example():
print("First line")
return "Done"
print("This never runs!") # This line is never reached
= example() # Only prints "First line"
result print(result) # Prints "Done"
For example:
def double(x):
return x * 2
= 5
a = double(a + 3) # a + 3 is evaluated first (8), then passed to double()
result print(result) # Outputs: 16
5.2 Multiple return values
Python functions can return multiple values using tuples. The values are automatically packed into a tuple by the function and can be unpacked by the caller:
def get_coordinates():
= 10
x = 20
y return x, y # Automatically packed into a tuple
# Method 1: Unpack into separate variables
= get_coordinates()
x_pos, y_pos print(x_pos) # Outputs: 10
print(y_pos) # Outputs: 20
# Method 2: Keep as tuple
= get_coordinates()
coords print(coords) # Outputs: (10, 20)
This is particularly useful when a function needs to return related values:
def analyze_numbers(numbers):
= min(numbers)
minimum = max(numbers)
maximum = sum(numbers) / len(numbers)
average return minimum, maximum, average
# Unpack all values
= analyze_numbers([1, 2, 3, 4, 5]) min_val, max_val, avg
5.3 Default arguments and keyword arguments
Functions can have default values for parameters, making them optional when calling the function:
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("Bob")) # Uses default greeting: Hello, Bob!
print(greet("Alice", "Hi")) # Override default: Hi, Alice!
You can also use keyword arguments to specify arguments by name, making the code more readable:
def create_profile(name, age, city="Unknown", hobby=None):
return f"Name: {name}, Age: {age}, City: {city}, Hobby: {hobby}"
# Using keyword arguments (order doesn't matter)
= create_profile(
profile =25,
age="Alice",
name="Reading"
hobby )
⚠️ Warning: Be careful with mutable default arguments! They are created once when the function is defined, not each time it’s called:
# BAD: List is mutable and shared between calls
def add_item(item, lst=[]): # Don't do this!
lst.append(item)return lst
print(add_item("apple")) # Returns: ["apple"]
print(add_item("banana")) # Returns: ["apple", "banana"] - Surprise!
print(add_item("cherry")) # Returns: ["apple", "banana", "cherry"] - Oops!
What’s happening here? The empty list []
is created only once when Python first reads the def
statement, not each time the function runs. This means all calls to add_item
are using the same list! It’s like having a shared shopping cart that keeps accumulating items from different shoppers.
The correct way is to use None
as the default and create a new list when needed:
# GOOD: Use None as default and create list in function
def add_item(item, lst=None):
if lst is None:
= [] # Creates a fresh list each time
lst
lst.append(item)return lst
print(add_item("apple")) # Returns: ["apple"]
print(add_item("banana")) # Returns: ["banana"] - Good!
print(add_item("cherry")) # Returns: ["cherry"] - Perfect!
Now each call gets its own fresh list unless you specifically pass one in:
= ["existing"]
my_list "new", my_list) # Returns: ["existing", "new"] add_item(
5.4 Variable scoping
Think of variable scope like different rooms in a house. Each room (scope) has its own set of items (variables), but there are rules about who can see and use which items.
5.4.1 Local Variables - Your Private Room
When you create a variable inside a function, it’s like putting something in your private room. Only code inside that function can see or use it:
def make_greeting():
= "Alice" # This is a local variable
name return f"Hello, {name}!"
print(make_greeting()) # Works fine: "Hello, Alice!"
print(name) # Error! We can't see 'name' out here
This is good! It means different functions can use the same variable names without confusion:
def greet_friend():
= "Bob" # This is a different 'name'
name return f"Hi {name}!"
def greet_teacher():
= "Mrs. Smith" # This is yet another 'name'
name return f"Hello {name}!"
# Each function has its own private 'name' variable
print(greet_friend()) # "Hi Bob!"
print(greet_teacher()) # "Hello Mrs. Smith!"
5.4.2 Global Variables - The Living Room
Variables created outside of any function are called global variables. Think of them like items in the living room - everyone can see them:
= "Welcome!" # A global variable
message
def say_greeting():
print(message) # Functions can see global variables
# Prints: "Welcome!" say_greeting()
However, there’s a catch! While functions can see global variables, they need special permission to change them:
= 0 # Global variable
score
def add_point():
global score # Tell Python we want to change the global variable
= score + 1
score
add_point()print(score) # Prints: 1
Without the global
keyword, Python would create a new local variable instead of changing the global one.
5.4.3 Nested Functions - Rooms within Rooms
You can define functions inside other functions. The inner function can see variables from the outer function:
def make_counter():
= 0 # Variable in outer function
count
def increment():
nonlocal count # Tell Python we want to use outer function's variable
= count + 1
count return count
return increment
# Create a counter
= make_counter()
counter print(counter()) # Prints: 1
print(counter()) # Prints: 2
5.4.4 Best Practices
- Try to avoid global variables when possible. They make it harder to understand where values are coming from.
- Keep functions focused and self-contained. If a function needs data, pass it as parameters.
- Use clear, descriptive names that won’t conflict with other parts of your code.
Here’s a good example putting it all together:
def calculate_total(prices):
"""Calculate total price including 10% tax"""
= sum(prices) # Local variable for sum
subtotal = 0.10 # Local variable for tax rate
tax_rate = subtotal * tax_rate
tax = subtotal + tax
total return total
# Each call is completely independent
print(calculate_total([10, 20])) # Prints: 33.0
print(calculate_total([5, 15])) # Prints: 22.0
5.5 Exceptions
Sometimes things go wrong when running our functions. Let’s look at what happens when we try to convert text to a number:
def convert_to_number(text):
= int(text)
number return number
# This works fine
print(convert_to_number("123")) # Prints: 123
# But this crashes!
print(convert_to_number("cat")) # ValueError: invalid literal for int() with base 10: 'cat'
When something goes wrong, Python raises an exception - a special object that describes the error. Without handling these exceptions, our program crashes!
We can handle these errors gracefully using try
and except
:
def convert_to_number(text):
try:
= int(text)
number return number
except ValueError:
print(f"Sorry, '{text}' is not a valid number")
return None
# Try it with different inputs
print(convert_to_number("123")) # Works fine: 123
print(convert_to_number("cat")) # Handles error gracefully: None
Let’s break this down:
- The
try
block contains code that might raise an exception - If
int(text)
fails (like when text=“cat”), it raises a ValueError - The
except
block catches that error and handles it nicely - Instead of crashing, our function returns None and explains the problem
You can catch multiple types of exceptions:
def divide_numbers(x, y):
try:
= x / y
result return result
except ZeroDivisionError:
print("Error: Cannot divide by zero!")
return None
except TypeError:
print("Error: Both inputs must be numbers!")
return None
print(divide_numbers(10, 2)) # Works: 5.0
print(divide_numbers(10, 0)) # Handles division by zero
print(divide_numbers(10, "two")) # Handles invalid input type
Sometimes we want to handle an exception but continue with the function’s operation. Here’s an example that retries reading user input until it’s valid:
def get_age():
while True:
try:
= input("Please enter your age: ")
age_text = int(age_text)
age if age < 0 or age > 150:
raise ValueError("Age must be between 0 and 150")
return age
except ValueError as e:
print(f"Invalid input: {e}")
print("Please try again...")
# Instead of returning, we continue the loop
# Usage:
# age = get_age()
# This will keep asking until valid input is received
This function:
- Uses a loop to keep trying until successful
- Catches ValueError but doesn’t immediately return
- Gives feedback and continues asking for input
- Only returns once valid input is received
This pattern is common when:
- Reading files that might be temporarily locked
- Making network requests that might timeout
- Handling user input that needs validation
5.5.1 Raising Exceptions
Sometimes your function needs to tell the caller that something went wrong. You can do this by raising your own exceptions:
def withdraw_money(balance, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > balance:
raise ValueError("Insufficient funds")
return balance - amount
# Try using the function
try:
= withdraw_money(100, 50)
new_balance print(f"New balance: ${new_balance}") # Works: New balance: $50
= withdraw_money(100, -10) # Raises: ValueError: Withdrawal amount must be positive
new_balance except ValueError as e:
print(f"Error: {e}")
try:
= withdraw_money(100, 150) # Raises: ValueError: Insufficient funds
new_balance except ValueError as e:
print(f"Error: {e}")
When an exception occurs in Python, execution immediately stops at that point and Python begins searching through the call stack for exception handlers. It starts at the current function and progressively moves outward through each calling function, looking for a try/except block that can handle the specific type of exception.
For example, imagine we have a chain of function calls: outer() calls middle() which calls inner(). If inner() raises an exception, Python first checks if inner() itself has a try/except block to handle it. If not, Python immediately stops executing inner() and checks middle(). If middle() doesn’t handle it either, Python moves to outer(). This continues until either:
- A matching try/except block is found, which handles the exception
- No handler is found, and the program crashes with an error message
Here’s what happens in detail when we call withdraw_money(100, -10
):
The function first checks if amount <= 0
. Since -10 is negative, it raises a ValueError. At this point, Python immediately stops normal execution and starts searching for a handler. It finds the try/except block right in the calling code, jumps to the except ValueError clause, and continues execution there.
The as e
clause in except ValueError as e
gives us access to the exception object itself. This object contains the error message we created (“Withdrawal amount must be positive”) and other details about what went wrong. This information helps us understand and handle the error appropriately.
This is useful when:
- Input validation fails
- A required resource is missing
- Business rules are violated
- You want to provide specific error messages
The caller can then decide how to handle these situations using try/except.
5.5.2 Separating Error Handling from Output
When writing functions, it’s best to:
- Use return values for successful results
- Use exceptions for error conditions
- Avoid printing messages directly from functions
Here’s an example of poor error handling:
def divide(x, y):
if y == 0:
print("Error: Cannot divide by zero!")
return None
return x / y
This is better:
def divide(x, y):
if y == 0:
raise ValueError("Cannot divide by zero")
return x / y
# Handle errors at the top level:
try:
= divide(10, 0)
result print(f"Result: {result}")
except ValueError as e:
print(f"Error: {e}")
Why is this better?
- Functions stay focused on their core task (computing values) rather than handling user interaction
- The calling code can decide how to handle errors (print message, log error, retry, etc.)
- Functions can be reused in different contexts (GUI, web service, notebook) without changing their error handling
- Testing is easier because we can check if functions raise the right exceptions without worrying about printed output
This separation of concerns makes your code more flexible and maintainable.
5.6 Assertions
Sometimes functions have requirements that must be true before they can work correctly. These requirements are called preconditions. Assertions help us check these preconditions and fail fast if they’re not met.
Here’s an example:
def calculate_square_root(number):
# Precondition: number must be non-negative
assert number >= 0, f"Cannot calculate square root of negative number: {number}"
return number ** 0.5
# This works fine
print(calculate_square_root(16.0)) # Prints: 4.0
# This raises an AssertionError
print(calculate_square_root(-16.0)) # AssertionError: Cannot calculate square root of negative number: -16.0
The assert
statement checks if a condition is True. If it’s False, Python immediately raises an AssertionError with your message. This helps catch problems early, before they cause more confusing errors later.
Assertions are different from raising exceptions because:
- They’re meant for programmer errors (impossible conditions), not user errors
- They can be disabled in production code for better performance
- They serve as documentation of your assumptions
Here’s another example checking multiple preconditions:
def create_user(username, age):
assert isinstance(username, str), "Username must be a string"
assert len(username) > 0, "Username cannot be empty"
assert isinstance(age, int), "Age must be an integer"
assert 0 <= age <= 150, "Age must be between 0 and 150"
return {"username": username, "age": age}
The isinstance(object, classinfo)
function checks if an object belongs to a particular type or category (like numbers, strings, or lists). When we create a number like 42
or a string like "hello"
, we’re creating an instance - a specific example - of that type. The function returns True
if the object belongs to that type, and False
otherwise. For example:
# Basic type checking
= "hello"
x print(isinstance(x, str)) # True: x is a string
print(isinstance(x, int)) # False: x is not an integer
# Multiple types can be checked at once
= 42
y print(isinstance(y, (int, float))) # True: y is either an int or float
# Works with custom classes too
class Animal:
pass
= Animal()
dog print(isinstance(dog, Animal)) # True: dog is an Animal instance
Type checking with isinstance()
serves different purposes depending on whether you use it with assertions or exceptions. When used with assertions, it helps catch programming errors early in development - for example, asserting that a function meant to process strings isn’t accidentally called with numbers. These are issues that should never happen in correct code and indicate bugs that need fixing.
On the other hand, when used with exceptions, type checking helps handle situations that might legitimately occur during normal operation. For instance, if your function processes user input, you might use isinstance() in a try/except block to gracefully handle cases where the input isn’t the expected type and guide the user to provide correct input.
The key difference is that assertions should check for programmer errors (preconditions that must always be true in correct code), while exceptions should handle runtime situations that might reasonably occur. This makes isinstance() a versatile tool that fits both scenarios, helping you write more robust and maintainable code.
5.7 Conclusion
Functions are the building blocks of reusable code in Python. They help us organize code into logical units, avoid repetition, and make our programs easier to understand and maintain. Here’s what we’ve covered:
- Functions are defined with
def
and can take parameters and return values - Parameters can have default values and can be passed by position or name
- Functions create their own scope for variables, protecting them from conflicts
- Multiple values can be returned using tuples
- Exceptions help handle errors gracefully using try/except blocks
- Assertions help catch programming errors early
- Type checking helps ensure functions receive the correct kinds of data
Remember that good functions should:
- Do one thing and do it well
- Have clear names that describe what they do
- Handle errors appropriately
- Document their assumptions with assertions
- Be neither too long nor too short
These principles will help you write code that’s easier to understand, test, and maintain.