๐งฉ Lesson 9: Functions โ Creating Your Own Commands
Imagine you're baking cookies. Instead of explaining the recipe every time someone asks, you write it down once. Functions are like recipes for your code โ write once, use many times!
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Define and call functions with
def - Use parameters, return values, and default arguments
- Write professional docstrings for documentation
- Handle flexible arguments with
*argsand**kwargs - Create and apply decorators to enhance functions
- Write unit tests with
assertandunittest - Understand scope, lambda functions, and common patterns
Estimated Time: 60โ80 minutes
Project: Advanced Calculator with decorators and unit tests
In This Lesson
๐ What Are Functions?
A function is a reusable block of code that takes some input, processes it, and gives back a result. You've already been using built-in functions like print(), len(), and input(). Now you'll learn to create your own!
โ๏ธ Creating Your First Function
Functions start with the def keyword (short for "define"). Everything indented beneath it is the function body.
# Basic function structure
def function_name():
# Code goes here
print("I'm a function!")
# Calling (using) the function
function_name()
# Function with parameters (inputs)
def greet(name):
print(f"Hello, {name}!")
print("Welcome to Python!")
# Call with different inputs
greet("Alice")
greet("Bob")
greet("Charlie")
Output:
Hello, Alice!
Welcome to Python!
Hello, Bob!
Welcome to Python!
Hello, Charlie!
Welcome to Python!
๐ Key Terms
Parameter: A variable listed in the function definition (e.g. name).
Argument: The actual value you pass when calling the function (e.g. "Alice").
๐ค Functions That Return Values
Functions can give back results using return. Without it, the function returns None by default.
# Function that returns a value
def add_numbers(a, b):
result = a + b
return result
# Using the returned value
total = add_numbers(5, 3)
print(f"The sum is: {total}") # The sum is: 8
# More examples
def square(number):
return number ** 2
def is_even(number):
return number % 2 == 0
def get_greeting(time_hour):
if time_hour < 12:
return "Good morning!"
elif time_hour < 17:
return "Good afternoon!"
else:
return "Good evening!"
# Using these functions
print(square(7)) # 49
print(is_even(10)) # True
print(get_greeting(14)) # Good afternoon!
๐ Docstrings โ Professional Documentation
Professional functions explain themselves with docstrings โ a triple-quoted string right after the def line. They describe what a function does, what it expects, and what it returns.
def calculate_bmi(weight_kg, height_m):
"""
Calculate Body Mass Index (BMI).
The BMI is a measure of body fat based on height and weight.
Formula: BMI = weight(kg) / height(m)ยฒ
Args:
weight_kg (float): Weight in kilograms. Must be positive.
height_m (float): Height in meters. Must be positive.
Returns:
tuple: (float BMI value, str BMI category)
Raises:
ValueError: If weight or height is negative or zero.
Examples:
>>> bmi, category = calculate_bmi(70, 1.75)
>>> print(f"BMI: {bmi:.1f} - {category}")
BMI: 22.9 - Normal
"""
if weight_kg <= 0 or height_m <= 0:
raise ValueError("Weight and height must be positive values")
bmi = weight_kg / (height_m ** 2)
if bmi < 18.5:
category = "Underweight"
elif bmi < 25:
category = "Normal"
elif bmi < 30:
category = "Overweight"
else:
category = "Obese"
return bmi, category
# Using the function
bmi_value, category = calculate_bmi(70, 1.75)
print(f"BMI: {bmi_value:.1f} ({category})") # BMI: 22.9 (Normal)
# Read the documentation
help(calculate_bmi)
โ Pro Tip
Always write docstrings for functions others will use (or functions you'll forget about in two weeks). The Google style shown above โ with Args:, Returns:, and Raises: sections โ is one of the most popular formats.
โ Default Parameters
Give parameters default values so callers can omit arguments when the default is fine:
# Function with default parameters
def make_coffee(size="medium", sugar=1, milk=False):
print(f"Making {size} coffee")
print(f"Sugar: {sugar} spoon(s)")
print(f"Milk: {'Yes' if milk else 'No'}")
print("-" * 20)
# Different ways to call it
make_coffee() # Uses all defaults
make_coffee("large") # Custom size, other defaults
make_coffee("small", 2) # Custom size and sugar
make_coffee("medium", 0, True) # All custom
make_coffee(milk=True, size="large") # Named arguments (any order!)
Output:
Making medium coffee
Sugar: 1 spoon(s)
Milk: No
--------------------
Making large coffee
Sugar: 1 spoon(s)
Milk: No
--------------------
Making small coffee
Sugar: 2 spoon(s)
Milk: No
--------------------
Making medium coffee
Sugar: 0 spoon(s)
Milk: Yes
--------------------
Making large coffee
Sugar: 1 spoon(s)
Milk: Yes
--------------------
๐ฆ *args and **kwargs โ Flexible Arguments
Sometimes you don't know in advance how many arguments a function will receive. Python has you covered!
*args โ Variable Number of Arguments
# *args collects extra positional arguments into a tuple
def sum_all(*numbers):
"""Sum any number of arguments."""
total = 0
for num in numbers:
total += num
return total
print(sum_all(1, 2, 3)) # 6
print(sum_all(1, 2, 3, 4, 5)) # 15
print(sum_all(10)) # 10
# Combining regular parameters with *args
def greet_many(greeting, *names):
"""Greet multiple people with the same greeting."""
for name in names:
print(f"{greeting}, {name}!")
greet_many("Hello", "Alice", "Bob", "Charlie")
**kwargs โ Keyword Arguments
# **kwargs collects extra keyword arguments into a dictionary
def build_profile(**user_info):
"""Build a user profile from keyword arguments."""
profile = {}
for key, value in user_info.items():
profile[key] = value
return profile
user = build_profile(
name="Alice",
age=30,
city="New York",
occupation="Developer"
)
print(user)
# {'name': 'Alice', 'age': 30, 'city': 'New York', 'occupation': 'Developer'}
Combining Everything
# Regular args, *args, and **kwargs together
def super_function(required, default="default", *args, **kwargs):
print(f"Required: {required}")
print(f"Default: {default}")
print(f"Extra args: {args}")
print(f"Keyword args: {kwargs}")
print("-" * 30)
super_function("must_have")
super_function("must_have", "custom", 1, 2, 3)
super_function("must_have", "custom", 1, 2, name="Alice", age=30)
Practical Example: Configuration Function
def configure_server(host, port=8000, **options):
"""Configure a server with flexible options."""
config = {'host': host, 'port': port}
config.update(options)
print("Server Configuration:")
for key, value in config.items():
print(f" {key}: {value}")
return config
# Simple
configure_server("localhost")
# With many options
configure_server(
"example.com", 443,
ssl=True, debug=False,
max_connections=1000, timeout=30
)
๐ Decorators โ Function Enhancers
Decorators are like gift wrapping for functions โ they add extra functionality without changing the original code! A decorator is a function that takes another function and extends its behavior.
Simple Decorator Example
def timer_decorator(func):
"""Decorator that times how long a function takes to run."""
import time
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
# Using the decorator with @ syntax
@timer_decorator
def slow_function():
"""A function that takes some time."""
import time
time.sleep(1)
return "Done!"
result = slow_function() # Prints: slow_function took 1.0001 seconds
print(result) # Done!
Practical Decorators
# Decorator for logging
def log_calls(func):
"""Log every call to a function."""
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(5, 3) # Logs the call and return value
# Decorator for validation
def validate_positive(func):
"""Ensure all arguments are positive."""
def wrapper(*args, **kwargs):
for arg in args:
if isinstance(arg, (int, float)) and arg < 0:
raise ValueError(f"Negative value not allowed: {arg}")
return func(*args, **kwargs)
return wrapper
@validate_positive
def calculate_area(length, width):
return length * width
print(calculate_area(5, 3)) # 15
# calculate_area(-5, 3) # Raises ValueError
Decorator with Parameters
def repeat(times):
"""Decorator that repeats function execution."""
def decorator(func):
def wrapper(*args, **kwargs):
results = []
for _ in range(times):
result = func(*args, **kwargs)
results.append(result)
return results
return wrapper
return decorator
@repeat(times=3)
def say_hello():
return "Hello!"
print(say_hello()) # ['Hello!', 'Hello!', 'Hello!']
๐งช Unit Testing
Testing ensures your functions work correctly. It's like quality control for your code!
Basic Testing with assert
def multiply(a, b):
"""Multiply two numbers."""
return a * b
# Test the function
assert multiply(3, 4) == 12, "3 * 4 should equal 12"
assert multiply(0, 5) == 0, "0 * 5 should equal 0"
assert multiply(-2, 3) == -6, "-2 * 3 should equal -6"
print("All tests passed!")
# Testing error handling
def divide(a, b):
"""Divide two numbers with error handling."""
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
assert divide(10, 2) == 5
assert divide(7, 2) == 3.5
try:
divide(5, 0)
assert False, "Should have raised an error"
except ValueError as e:
assert str(e) == "Cannot divide by zero"
print("Error handling works correctly!")
Unit Testing with unittest
import unittest
def add(a, b):
return a + b
def is_palindrome(text):
clean = ''.join(text.lower().split())
return clean == clean[::-1]
def get_grade(score):
if score >= 90: return 'A'
elif score >= 80: return 'B'
elif score >= 70: return 'C'
elif score >= 60: return 'D'
else: return 'F'
class TestMathFunctions(unittest.TestCase):
def test_add_positive_numbers(self):
self.assertEqual(add(2, 3), 5)
self.assertEqual(add(10, 20), 30)
def test_add_negative_numbers(self):
self.assertEqual(add(-1, -1), -2)
self.assertEqual(add(-5, 3), -2)
def test_palindrome_true(self):
self.assertTrue(is_palindrome("racecar"))
self.assertTrue(is_palindrome("A man a plan a canal Panama"))
def test_palindrome_false(self):
self.assertFalse(is_palindrome("hello"))
self.assertFalse(is_palindrome("Python"))
def test_grade_boundaries(self):
self.assertEqual(get_grade(95), 'A')
self.assertEqual(get_grade(90), 'A')
self.assertEqual(get_grade(89), 'B')
self.assertEqual(get_grade(80), 'B')
self.assertEqual(get_grade(59), 'F')
if __name__ == '__main__':
unittest.main()
Test-Driven Development (TDD) Example
# Step 1: Write tests FIRST
class TestShoppingCart(unittest.TestCase):
def setUp(self):
self.cart = ShoppingCart()
def test_new_cart_is_empty(self):
self.assertEqual(len(self.cart), 0)
self.assertEqual(self.cart.total(), 0)
def test_add_item(self):
self.cart.add_item("Apple", 1.50, 2)
self.assertEqual(len(self.cart), 1)
self.assertEqual(self.cart.total(), 3.00)
def test_remove_item(self):
self.cart.add_item("Apple", 1.50, 2)
self.cart.remove_item("Apple")
self.assertEqual(len(self.cart), 0)
def test_update_quantity(self):
self.cart.add_item("Apple", 1.50, 2)
self.cart.update_quantity("Apple", 5)
self.assertEqual(self.cart.total(), 7.50)
# Step 2: Write code to PASS the tests
class ShoppingCart:
def __init__(self):
self.items = {}
def __len__(self):
return len(self.items)
def add_item(self, name, price, quantity=1):
if name in self.items:
self.items[name]['quantity'] += quantity
else:
self.items[name] = {'price': price, 'quantity': quantity}
def remove_item(self, name):
if name in self.items:
del self.items[name]
def update_quantity(self, name, quantity):
if name in self.items:
self.items[name]['quantity'] = quantity
def total(self):
return sum(
item['price'] * item['quantity']
for item in self.items.values()
)
๐ก TDD Cycle
Red: Write a failing test. Green: Write just enough code to pass. Refactor: Clean up while tests still pass. Repeat!
๐ Scope โ Where Variables Live
Variables inside functions are like secrets โ they stay inside! This is called local scope. Variables defined outside are global.
# Global scope (outside functions)
player_name = "Alice" # Global variable
def play_game():
# Local scope (inside function)
score = 0 # Local variable
print(f"{player_name} is playing") # Can access global
for i in range(3):
score += 10
return score
final_score = play_game()
print(f"{player_name}'s score: {final_score}")
# This would cause an error:
# print(score) # 'score' only exists inside the function!
# Modifying global variables (use carefully!)
counter = 0
def increment():
global counter # Tell Python we want to modify the global
counter += 1
increment()
increment()
print(counter) # 2
โ ๏ธ Watch Out
Using global makes code harder to debug. Prefer returning values and passing arguments instead. Treat global as a last resort.
โก Lambda Functions โ Mini Functions
Sometimes you need a tiny function for a quick job. Lambda functions are anonymous one-liners:
# Regular function
def double(x):
return x * 2
# Lambda function โ same thing, shorter!
double_lambda = lambda x: x * 2
# Lambdas shine with built-in functions
numbers = [1, 2, 3, 4, 5]
# Using lambda with map
doubled = list(map(lambda x: x * 2, numbers))
print(doubled) # [2, 4, 6, 8, 10]
# Using lambda with filter
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4]
# Using lambda with sorted
students = [
{"name": "Alice", "grade": 85},
{"name": "Bob", "grade": 92},
{"name": "Carol", "grade": 78}
]
students.sort(key=lambda s: s["grade"], reverse=True)
print(students) # Sorted by grade, highest first
๐งฐ Common Patterns
# Pattern 1: Factory functions โ create customized functions
def create_multiplier(factor):
"""Create a custom multiplier function."""
def multiplier(x):
return x * factor
return multiplier
double = create_multiplier(2)
triple = create_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
# Pattern 2: Memoization (caching results)
def memoize(func):
"""Cache function results for repeated calls."""
cache = {}
def wrapper(*args):
if args in cache:
return cache[args]
result = func(*args)
cache[args] = result
return result
return wrapper
@memoize
def fibonacci(n):
"""Calculate Fibonacci number (cached)."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(30)) # Fast thanks to caching!
# Pattern 3: Function composition
def compose(f, g):
"""Compose two functions: f(g(x))."""
return lambda x: f(g(x))
add_one = lambda x: x + 1
double = lambda x: x * 2
add_then_double = compose(double, add_one)
print(add_then_double(3)) # 8: (3 + 1) * 2
# Pattern 4: Partial functions
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(5)) # 125
๐ซ Common Mistakes to Avoid
โ ๏ธ Forgetting to Return
# โ Wrong โ calculates but doesn't return
def add(a, b):
result = a + b # Forgot return!
total = add(5, 3)
print(total) # None
# โ
Right
def add(a, b):
return a + b
โ ๏ธ Mutable Default Arguments
# โ Dangerous โ default list is shared between calls!
def add_item(item, items=[]):
items.append(item)
return items
print(add_item('a')) # ['a']
print(add_item('b')) # ['a', 'b'] โ Unexpected!
# โ
Better โ use None as default
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
๐๏ธ Practice Exercises
๐๏ธ Exercise 1: Advanced Calculator with Testing
Objective: Create a calculator with functions for add, subtract, multiply, and divide. Use *args to handle multiple numbers, add a logging decorator, write docstrings, and include unit tests.
Starter Code:
# TODO: Create a @log_calls decorator
# TODO: Create calculator functions that handle *args
# def add(*numbers):
# def subtract(*numbers):
# def multiply(*numbers):
# def divide(*numbers): # Handle division by zero!
# TODO: Add unit tests
# assert add(1, 2, 3) == 6
# assert multiply(2, 3, 4) == 24
๐ก Hint
For *args, start with the first number and loop through the rest. For divide, check for zeros before dividing. Your decorator should accept *args and **kwargs so it works with any function.
โ Solution
def log_calls(func):
"""Log every calculation."""
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with {args}")
result = func(*args, **kwargs)
print(f" Result: {result}")
return result
return wrapper
@log_calls
def add(*numbers):
"""Add any number of values."""
return sum(numbers)
@log_calls
def subtract(*numbers):
"""Subtract values from the first number."""
result = numbers[0]
for num in numbers[1:]:
result -= num
return result
@log_calls
def multiply(*numbers):
"""Multiply any number of values."""
result = 1
for num in numbers:
result *= num
return result
@log_calls
def divide(*numbers):
"""Divide the first number by the rest."""
result = numbers[0]
for num in numbers[1:]:
if num == 0:
raise ValueError("Cannot divide by zero")
result /= num
return result
# Tests
assert add(1, 2, 3) == 6
assert subtract(10, 3, 2) == 5
assert multiply(2, 3, 4) == 24
assert divide(100, 5, 2) == 10.0
print("All calculator tests passed!")
๐๏ธ Exercise 2: Data Processor with Decorators
Objective: Build a data processing pipeline with functions to clean, validate, and transform data. Use decorators for timing and logging, and **kwargs for flexible options.
Starter Code:
# TODO: Create a @timer decorator
# TODO: Create clean_data(text, **options)
# - options: strip=True, lowercase=True, remove_extra_spaces=True
# TODO: Create validate_email(email) that returns True/False
# TODO: Create transform_data(data, **options)
# Test with:
# raw = " Hello World "
# cleaned = clean_data(raw, lowercase=True)
# print(cleaned) # "hello world"
๐ก Hint
Use kwargs.get("option_name", default_value) to handle optional settings. For the timer, use time.time() before and after calling the function. For email validation, check for @ and . after the @.
โ Solution
import time
def timer(func):
"""Time how long a function takes."""
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def clean_data(text, **options):
"""Clean text data with flexible options."""
if options.get("strip", True):
text = text.strip()
if options.get("remove_extra_spaces", True):
text = ' '.join(text.split())
if options.get("lowercase", False):
text = text.lower()
return text
def validate_email(email):
"""Basic email validation."""
if "@" not in email:
return False
parts = email.split("@")
if len(parts) != 2:
return False
return "." in parts[1] and len(parts[0]) > 0
@timer
def transform_data(data, **options):
"""Transform a list of strings with options."""
result = []
for item in data:
cleaned = clean_data(item, **options)
result.append(cleaned)
return result
# Tests
assert clean_data(" Hello World ") == "Hello World"
assert clean_data(" Hello ", lowercase=True) == "hello"
assert validate_email("user@example.com") == True
assert validate_email("invalid-email") == False
print("All data processor tests passed!")
๐๏ธ Exercise 3: Test-Driven Password Validator
Objective: Using TDD, write tests first for a password validator, then implement the function. Requirements: at least 8 characters, contains uppercase, lowercase, a digit, and a special character.
Starter Code:
# Step 1: Write tests FIRST
# assert validate_password("Abc12345!") == True
# assert validate_password("short1!") == False # too short
# assert validate_password("alllowercase1!") == False # no uppercase
# assert validate_password("ALLUPPERCASE1!") == False # no lowercase
# assert validate_password("NoDigits!!") == False # no digit
# assert validate_password("NoSpecial1") == False # no special char
# Step 2: Write the function to pass all tests
# def validate_password(password):
# pass
๐ก Hint
Use any(c.isupper() for c in password) to check for uppercase letters. Do the same for lowercase (.islower()), digits (.isdigit()), and special characters (check if a character is not alphanumeric with not c.isalnum()).
โ Solution
def validate_password(password):
"""
Validate password strength.
Args:
password (str): Password to validate.
Returns:
bool: True if password meets all requirements.
"""
if len(password) < 8:
return False
if not any(c.isupper() for c in password):
return False
if not any(c.islower() for c in password):
return False
if not any(c.isdigit() for c in password):
return False
if not any(not c.isalnum() for c in password):
return False
return True
# Tests
assert validate_password("Abc12345!") == True
assert validate_password("short1!") == False
assert validate_password("alllowercase1!") == False
assert validate_password("ALLUPPERCASE1!") == False
assert validate_password("NoDigits!!") == False
assert validate_password("NoSpecial1") == False
print("All password validator tests passed!")
๐ฏ Quick Quiz
Question 1: What does a function return if it has no return statement?
Question 2: What does *args collect extra arguments into?
Question 3: What is the main danger of using a mutable default argument like def f(items=[]):?
๐ Summary
๐ Key Takeaways
- Functions package code for reuse and organization
- Docstrings document what functions do and how to use them
*argsand**kwargsprovide flexibility in arguments- Decorators add functionality to existing functions
- Unit testing ensures your functions work correctly
- TDD helps write better, more reliable code
- Lambda functions are great for simple, one-off operations
- Good function design makes code modular, testable, and maintainable
๐ Real-World Applications
Functions power web development (route handlers, middleware, authentication decorators), data science (data transformations, statistical functions, model evaluation), automation (task schedulers, batch processors, report generators), testing (unit tests, integration tests, mocking), and APIs (request handlers, response formatters, validation decorators).
๐ Function Philosophy
"A function should do one thing, do it well, and do it only." This makes your code modular, testable, and maintainable. Combined with good documentation and testing, functions become powerful building blocks for any program!
๐ What's Next?
In the final lesson, we'll put it all together โ combining everything you've learned into a capstone project. You'll see how variables, control flow, data structures, and functions work in harmony to build real programs!
๐ Congratulations!
You've mastered functions โ the most important building block in programming. One more lesson to go!