Lesson 1: Functions and Modules#
Introduction#
Functions are like recipes in a cookbook - they provide reusable instructions for accomplishing specific tasks. Modules are like cookbooks themselves - collections of related recipes (functions) that you can reference whenever needed.
Why Functions Matter:
Reusability: Write once, use many times
Organization: Break complex problems into manageable pieces
Maintainability: Fix bugs in one place
Abstraction: Hide complexity behind simple interfaces
What Youโll Learn:
Creating and calling functions
Parameters: positional, keyword, *args, **kwargs
Return values and multiple returns
Lambda functions
Higher-order functions (map, filter, reduce)
Scope and closures
Decorators (introduction)
Working with modules and packages
Creating your own modules
1. Function Basics#
A function is defined using the def keyword, followed by the function name, parameters in parentheses, and a colon. The function body is indented.
def greet(name):
"""A simple function that greets someone."""
return f"Hello, {name}!"
# Call the function
message = greet("Alice")
print(message)
# You can call it multiple times
print(greet("Bob"))
print(greet("Charlie"))
Docstrings#
The triple-quoted string right after the function definition is called a docstring. It documents what the function does.
def calculate_area(length, width):
"""
Calculate the area of a rectangle.
Parameters:
length (float): The length of the rectangle
width (float): The width of the rectangle
Returns:
float: The area (length * width)
"""
return length * width
# Access the docstring
print(calculate_area.__doc__)
# Use the help function
help(calculate_area)
2. Parameters and Arguments#
Positional Arguments#
Arguments matched to parameters by position.
def describe_person(name, age, city):
return f"{name} is {age} years old and lives in {city}."
# Arguments are matched by position
print(describe_person("Alice", 30, "New York"))
Keyword Arguments#
Arguments matched to parameters by name.
# Keyword arguments - order doesn't matter
print(describe_person(age=25, city="London", name="Bob"))
# You can mix positional and keyword arguments
# But positional must come first
print(describe_person("Charlie", city="Paris", age=35))
Default Parameter Values#
Parameters can have default values, making them optional.
def make_coffee(size="medium", sugar=1, milk=True):
coffee = f"{size.capitalize()} coffee"
if sugar > 0:
coffee += f" with {sugar} sugar(s)"
if milk:
coffee += " and milk"
return coffee
print(make_coffee()) # All defaults
print(make_coffee("large")) # Override size
print(make_coffee(sugar=2, milk=False)) # Override specific params
print(make_coffee("small", 0, False)) # Override all
โ ๏ธ WARNING: Mutable Default Arguments
Never use mutable objects (lists, dicts) as default values!
# DON'T DO THIS!
def add_item_bad(item, items=[]):
items.append(item)
return items
print(add_item_bad("apple")) # ['apple']
print(add_item_bad("banana")) # ['apple', 'banana'] - BUG!
# DO THIS INSTEAD:
def add_item_good(item, items=None):
if items is None:
items = []
items.append(item)
return items
print(add_item_good("apple")) # ['apple']
print(add_item_good("banana")) # ['banana'] - Correct!
3. Variable-Length Arguments#
*args - Arbitrary Positional Arguments#
Use *args to accept any number of positional arguments.
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(10, 20, 30, 40, 50)) # 150
print(sum_all(5)) # 5
# *args is actually a tuple
def show_args(*args):
print(f"Type: {type(args)}")
print(f"Args: {args}")
show_args(1, 2, 3, "hello")
**kwargs - Arbitrary Keyword Arguments#
Use **kwargs to accept any number of keyword arguments.
def create_profile(**kwargs):
"""Create a user profile from keyword arguments."""
profile = {}
for key, value in kwargs.items():
profile[key] = value
return profile
user1 = create_profile(name="Alice", age=30, city="NYC")
print(user1)
user2 = create_profile(name="Bob", email="bob@example.com", role="admin")
print(user2)
# **kwargs is actually a dictionary
def show_kwargs(**kwargs):
print(f"Type: {type(kwargs)}")
for key, value in kwargs.items():
print(f" {key}: {value}")
show_kwargs(language="Python", version=3.11, awesome=True)
Combining All Parameter Types#
Order matters: def func(positional, *args, keyword=default, **kwargs)
def complex_function(required, *args, keyword="default", **kwargs):
print(f"Required: {required}")
print(f"Args: {args}")
print(f"Keyword: {keyword}")
print(f"Kwargs: {kwargs}")
complex_function(
"must have",
"extra1", "extra2",
keyword="custom",
option1="value1",
option2="value2"
)
Keyword-Only Arguments (Python 3+)#
Force arguments to be passed as keywords using *.
def create_user(name, *, email, age=None):
"""Email must be passed as keyword argument."""
user = {"name": name, "email": email}
if age:
user["age"] = age
return user
# This works
print(create_user("Alice", email="alice@example.com"))
# This would cause an error:
# create_user("Bob", "bob@example.com") # TypeError!
4. Return Values#
Single Return Value#
def square(x):
return x * x
result = square(5)
print(result) # 25
# Functions without return statement return None
def no_return():
print("This function doesn't return anything")
result = no_return()
print(f"Result: {result}") # None
Multiple Return Values#
Python can return multiple values as a tuple.
def get_stats(numbers):
"""Return min, max, and average of a list."""
return min(numbers), max(numbers), sum(numbers) / len(numbers)
data = [10, 20, 30, 40, 50]
# Unpack into separate variables
minimum, maximum, average = get_stats(data)
print(f"Min: {minimum}, Max: {maximum}, Avg: {average}")
# Or get as a tuple
stats = get_stats(data)
print(f"Stats tuple: {stats}")
Early Returns#
You can return early from a function for cleaner code.
def divide(a, b):
"""Divide a by b, with error handling."""
if b == 0:
return "Error: Division by zero!"
return a / b
print(divide(10, 2)) # 5.0
print(divide(10, 0)) # Error message
5. Lambda Functions#
Lambda functions are small anonymous functions defined with the lambda keyword.
# Regular function
def add(x, y):
return x + y
# Equivalent lambda function
add_lambda = lambda x, y: x + y
print(add(5, 3)) # 8
print(add_lambda(5, 3)) # 8
# Lambdas are useful for short, simple functions
square = lambda x: x ** 2
is_even = lambda x: x % 2 == 0
print(square(4)) # 16
print(is_even(7)) # False
print(is_even(10)) # True
When to Use Lambda Functions#
Lambdas are best used as arguments to higher-order functions (functions that take functions as arguments).
# Sorting with a custom key
students = [
{"name": "Alice", "grade": 85},
{"name": "Bob", "grade": 92},
{"name": "Charlie", "grade": 78}
]
# Sort by grade
sorted_students = sorted(students, key=lambda student: student["grade"])
print("Sorted by grade:")
for student in sorted_students:
print(f" {student['name']}: {student['grade']}")
# Sort by name
sorted_by_name = sorted(students, key=lambda student: student["name"])
print("\nSorted by name:")
for student in sorted_by_name:
print(f" {student['name']}: {student['grade']}")
6. Higher-Order Functions#
Higher-order functions are functions that take other functions as arguments or return functions.
map()#
Apply a function to every item in an iterable.
numbers = [1, 2, 3, 4, 5]
# Square each number
squared = list(map(lambda x: x ** 2, numbers))
print(f"Original: {numbers}")
print(f"Squared: {squared}")
# Convert strings to integers
str_numbers = ["1", "2", "3", "4", "5"]
int_numbers = list(map(int, str_numbers))
print(f"Strings: {str_numbers}")
print(f"Integers: {int_numbers}")
# Map with multiple iterables
a = [1, 2, 3]
b = [10, 20, 30]
sums = list(map(lambda x, y: x + y, a, b))
print(f"Sums: {sums}") # [11, 22, 33]
filter()#
Filter items based on a condition.
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Get only even numbers
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(f"Even numbers: {evens}")
# Get numbers greater than 5
greater_than_5 = list(filter(lambda x: x > 5, numbers))
print(f"Greater than 5: {greater_than_5}")
# Filter strings by length
words = ["hi", "hello", "hey", "goodbye", "greetings"]
long_words = list(filter(lambda word: len(word) > 5, words))
print(f"Long words: {long_words}")
reduce()#
Reduce a sequence to a single value by repeatedly applying a function.
from functools import reduce
numbers = [1, 2, 3, 4, 5]
# Sum all numbers
total = reduce(lambda x, y: x + y, numbers)
print(f"Sum: {total}") # 15
# Find maximum
maximum = reduce(lambda x, y: x if x > y else y, numbers)
print(f"Max: {maximum}") # 5
# Product of all numbers
product = reduce(lambda x, y: x * y, numbers)
print(f"Product: {product}") # 120
# Concatenate strings
words = ["Python", "is", "awesome"]
sentence = reduce(lambda x, y: x + " " + y, words)
print(f"Sentence: {sentence}")
List Comprehensions vs map/filter#
List comprehensions are often more Pythonic than map/filter.
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Using map and filter
result1 = list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, numbers)))
# Using list comprehension (more readable!)
result2 = [x ** 2 for x in numbers if x % 2 == 0]
print(f"map/filter: {result1}")
print(f"List comp: {result2}")
print(f"Same result: {result1 == result2}")
7. Scope and Namespaces#
LEGB Rule#
Python looks for variables in this order:
Local - Inside the current function
Enclosing - Inside enclosing functions
Global - At the module level
Built-in - Pythonโs built-in names
# Global scope
x = "global"
def outer():
# Enclosing scope
x = "enclosing"
def inner():
# Local scope
x = "local"
print(f"Inner function: {x}")
inner()
print(f"Outer function: {x}")
outer()
print(f"Global scope: {x}")
Global and Nonlocal Keywords#
# Using global keyword
counter = 0
def increment():
global counter # Modify the global variable
counter += 1
increment()
increment()
print(f"Counter: {counter}") # 2
# Using nonlocal keyword
def outer():
count = 0
def inner():
nonlocal count # Modify the enclosing scope's variable
count += 1
return count
print(inner()) # 1
print(inner()) # 2
print(inner()) # 3
outer()
8. Closures#
A closure is a function that remembers values from its enclosing scope, even after that scope has finished executing.
def make_multiplier(n):
"""Create a function that multiplies by n."""
def multiplier(x):
return x * n # n is captured from enclosing scope
return multiplier
# Create specialized functions
times_2 = make_multiplier(2)
times_5 = make_multiplier(5)
print(times_2(10)) # 20
print(times_5(10)) # 50
# Each function "remembers" its n value
print(times_2(3)) # 6
print(times_5(3)) # 15
Practical Closure Example: Counter#
def make_counter(start=0):
"""Create a counter with private state."""
count = start
def increment():
nonlocal count
count += 1
return count
def decrement():
nonlocal count
count -= 1
return count
def get():
return count
# Return multiple functions
return increment, decrement, get
# Create two independent counters
inc1, dec1, get1 = make_counter(10)
inc2, dec2, get2 = make_counter(100)
print(inc1()) # 11
print(inc1()) # 12
print(dec1()) # 11
print(inc2()) # 101
print(get1()) # 11 (independent!)
print(get2()) # 101
9. Decorators (Introduction)#
Decorators are functions that modify other functions. Theyโre a powerful way to add functionality to existing code.
def uppercase_decorator(func):
"""A decorator that converts function output to uppercase."""
def wrapper():
result = func()
return result.upper()
return wrapper
@uppercase_decorator
def greet():
return "hello, world!"
print(greet()) # HELLO, WORLD!
# The @ syntax is equivalent to:
# greet = uppercase_decorator(greet)
Decorator with Arguments#
def repeat(times):
"""Decorator that repeats function execution."""
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def say_hello(name):
print(f"Hello, {name}!")
say_hello("Alice")
Practical Decorator: Timing Functions#
import time
def timer(func):
"""Measure execution time of a function."""
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
@timer
def slow_function():
time.sleep(0.5)
return "Done!"
@timer
def calculate_sum(n):
return sum(range(n))
result = slow_function()
result = calculate_sum(1000000)
10. Working with Modules#
Modules are Python files containing functions, classes, and variables. They help organize code into reusable components.
Importing Modules#
# Import entire module
import math
print(math.pi)
print(math.sqrt(16))
# Import specific items
from math import pi, sqrt, ceil
print(pi)
print(sqrt(25))
print(ceil(4.2))
# Import with alias
import math as m
print(m.floor(4.8))
# Import all (NOT recommended!)
# from math import *
Common Standard Library Modules#
# Random module
import random
print("Random integer:", random.randint(1, 10))
print("Random choice:", random.choice(["apple", "banana", "cherry"]))
print("Random float:", random.random())
# Datetime module
from datetime import datetime, timedelta
now = datetime.now()
print("Current time:", now)
tomorrow = now + timedelta(days=1)
print("Tomorrow:", tomorrow)
# Collections module
from collections import Counter, defaultdict
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
counts = Counter(words)
print("Word counts:", counts)
print("Most common:", counts.most_common(2))
The os and sys Modules#
import os
import sys
# OS module - interact with operating system
print("Current directory:", os.getcwd())
print("Environment PATH:", os.environ.get("PATH", "Not found")[:50] + "...")
# Sys module - system-specific parameters
print("Python version:", sys.version)
print("Platform:", sys.platform)
Module Search Path#
import sys
# Python looks for modules in these directories
print("Module search path:")
for path in sys.path[:5]: # Show first 5 paths
print(f" {path}")
11. Creating Your Own Modules#
Any Python file (.py) is a module! Letโs see how to structure and use your own modules.
Example Module Structure#
"""Custom math utilities."""
def add(a, b):
"""Add two numbers."""
return a + b
def multiply(a, b):
"""Multiply two numbers."""
return a * b
PI = 3.14159
if __name__ == "__main__":
# This code only runs when module is executed directly
print("Testing mymath module")
print(f"5 + 3 = {add(5, 3)}")
print(f"5 * 3 = {multiply(5, 3)}")
Using the module:
import mymath
print(mymath.add(10, 20)) # 30
print(mymath.PI) # 3.14159
The __name__ Variable#
Every module has a __name__ attribute:
When run directly:
__name__ == "__main__"When imported:
__name__is the module name
# This is useful for test code that should only run when file is executed directly
def my_function():
return "Hello from my_function!"
if __name__ == "__main__":
# This code only runs when this file is executed directly
# It won't run when this module is imported
print("Running as main program")
print(my_function())
else:
print(f"Imported as module: {__name__}")
Packages#
A package is a directory containing an __init__.py file and other Python files.
Example package structure:
mypackage/
__init__.py
module1.py
module2.py
subpackage/
__init__.py
module3.py
Usage:
from mypackage import module1
from mypackage.subpackage import module3
Exercises#
Exercise 1: Temperature Converter#
Create a module with functions to convert between Celsius, Fahrenheit, and Kelvin.
Requirements:
celsius_to_fahrenheit(c)- returns (c * 9/5) + 32fahrenheit_to_celsius(f)- returns (f - 32) * 5/9celsius_to_kelvin(c)- returns c + 273.15kelvin_to_celsius(k)- returns k - 273.15Add docstrings to all functions
Test your functions with example values
# Your code here
Exercise 2: Higher-Order Functions#
Given this list of products:
products = [
{"name": "Laptop", "price": 1200, "category": "Electronics"},
{"name": "Phone", "price": 800, "category": "Electronics"},
{"name": "Desk", "price": 300, "category": "Furniture"},
{"name": "Chair", "price": 150, "category": "Furniture"},
{"name": "Monitor", "price": 400, "category": "Electronics"},
]
Use filter(), map(), and reduce() to:
Get all Electronics items (use filter)
Get just the names of all products (use map)
Calculate total price of all products (use reduce)
Get names of products over $500
Calculate average price of Furniture items
from functools import reduce
products = [
{"name": "Laptop", "price": 1200, "category": "Electronics"},
{"name": "Phone", "price": 800, "category": "Electronics"},
{"name": "Desk", "price": 300, "category": "Furniture"},
{"name": "Chair", "price": 150, "category": "Furniture"},
{"name": "Monitor", "price": 400, "category": "Electronics"},
]
# Your code here
Exercise 3: Decorator Challenge#
Create a @validate_positive decorator that:
Checks if all arguments to a function are positive numbers
If any argument is negative or zero, raises a ValueError
Otherwise, calls the function normally
Test it with a function that calculates the area of a rectangle.
# Your code here
Exercise 4: Closure Practice#
Create a make_bank_account(initial_balance) function that returns three functions:
deposit(amount)- adds money to the accountwithdraw(amount)- removes money (only if sufficient balance)get_balance()- returns current balance
The balance should be private (not directly accessible).
# Your code here
Exercise 5: Module Exploration#
Explore the itertools module:
Use
itertools.combinations()to get all 2-item combinations from[1, 2, 3, 4]Use
itertools.permutations()to get all 2-item permutations from['A', 'B', 'C']Use
itertools.cycle()to create an infinite repeating sequence of['red', 'green', 'blue'](take first 10)Use
itertools.groupby()to group this list by length:['a', 'bb', 'ccc', 'dd', 'e', 'fff']
import itertools
# Your code here
Exercise 6: Real-World Application#
Create a simple logging system:
Write a
log_decoratorthat logs function calls with:Function name
Arguments
Return value
Timestamp (use
datetime.now())
Apply it to a few functions
Bonus: Save logs to a file instead of printing
from datetime import datetime
# Your code here
Self-Check Quiz#
1. Whatโs the difference between parameters and arguments?
Answer
Parameters are variables in the function definition. Arguments are actual values passed when calling the function.2. What does *args allow you to do?
Answer
Accept any number of positional arguments as a tuple.3. What does **kwargs allow you to do?
Answer
Accept any number of keyword arguments as a dictionary.4. What is the LEGB rule?
Answer
The order Python searches for variables: Local, Enclosing, Global, Built-in.5. What is a closure?
Answer
A function that remembers values from its enclosing scope, even after that scope has finished executing.6. When should you use lambda functions?
Answer
For simple, one-line functions, especially as arguments to higher-order functions like map/filter/sorted.7. What does the @decorator syntax do?
Answer
It applies a decorator function to the function below it, modifying its behavior.8. Whatโs the purpose of __name__ == "__main__"?
Answer
To run code only when the file is executed directly, not when it's imported as a module.9. Why should you avoid using mutable objects as default parameters?
Answer
The default value is created once when the function is defined, not each time it's called. This causes the same mutable object to be shared across all function calls.10. Whatโs the difference between import math and from math import sqrt?
Answer
`import math` requires `math.sqrt()`, while `from math import sqrt` allows just `sqrt()`. The second imports only specific items.Key Takeaways#
โ Functions organize code into reusable, testable units
โ Parameters can be positional, keyword, with defaults, *args, **kwargs, or keyword-only
โ Lambda functions are concise anonymous functions for simple operations
โ Higher-order functions (map, filter, reduce) process collections functionally
โ Scope follows the LEGB rule: Local โ Enclosing โ Global โ Built-in
โ Closures let functions remember their environment
โ Decorators modify function behavior without changing function code
โ Modules organize code into reusable files
โ
Packages group related modules in directories with __init__.py
โ Standard library provides powerful built-in modules (math, random, itertools, etc.)
Pro Tips#
๐ก Use descriptive function names - calculate_total_price() is better than calc()
๐ก Keep functions small - Each function should do one thing well
๐ก Write docstrings - Future you (and others) will thank you
๐ก Use type hints - def add(x: int, y: int) -> int: improves clarity
๐ก Prefer list comprehensions over map/filter for better readability
๐ก Use *args and **kwargs sparingly - explicit parameters are clearer
๐ก Avoid global variables - Pass values as parameters instead
๐ก Use if __name__ == "__main__" to make modules importable
๐ก Create packages for larger projects to organize code logically
๐ก Explore the standard library - It has solutions for most common tasks
Common Mistakes to Avoid#
โ Mutable default arguments: def func(items=[])
โ
Use: def func(items=None): items = items or []
โ Modifying global variables without the global keyword
โ
Either use global or pass/return values explicitly
โ Using from module import * (pollutes namespace)
โ
Import specific items or use module prefix
โ Forgetting to return a value from a function
โ
Always use return to send data back
โ Complex lambda functions that span multiple lines โ Use regular functions for anything beyond simple expressions
โ Shadowing built-in names: list = [1, 2, 3]
โ
Use descriptive names that donโt conflict with built-ins
Next Steps#
You now understand functions and modules! Next lessons:
Data Structures - Deep dive into lists, dicts, sets, and comprehensions
Classes and OOP - Object-oriented programming in Python
Error Handling - Try/except blocks and custom exceptions
File I/O - Reading and writing files
Advanced Functions - Generators, iterators, and advanced decorators
Practice Projects:
Build a calculator module with basic operations
Create a password validator with decorators
Write a text processing utility using higher-order functions
Design a simple game using functions and closures
Keep practicing! Functions are fundamental to all Python programming. ๐