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:

  1. Local - Inside the current function

  2. Enclosing - Inside enclosing functions

  3. Global - At the module level

  4. 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#

mymath.py:

"""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) + 32

  • fahrenheit_to_celsius(f) - returns (f - 32) * 5/9

  • celsius_to_kelvin(c) - returns c + 273.15

  • kelvin_to_celsius(k) - returns k - 273.15

  • Add 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:

  1. Get all Electronics items (use filter)

  2. Get just the names of all products (use map)

  3. Calculate total price of all products (use reduce)

  4. Get names of products over $500

  5. 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 account

  • withdraw(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:

  1. Use itertools.combinations() to get all 2-item combinations from [1, 2, 3, 4]

  2. Use itertools.permutations() to get all 2-item permutations from ['A', 'B', 'C']

  3. Use itertools.cycle() to create an infinite repeating sequence of ['red', 'green', 'blue'] (take first 10)

  4. 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:

  1. Write a log_decorator that logs function calls with:

    • Function name

    • Arguments

    • Return value

    • Timestamp (use datetime.now())

  2. Apply it to a few functions

  3. 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:

  1. Data Structures - Deep dive into lists, dicts, sets, and comprehensions

  2. Classes and OOP - Object-oriented programming in Python

  3. Error Handling - Try/except blocks and custom exceptions

  4. File I/O - Reading and writing files

  5. 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. ๐Ÿš€