📓 Python Programming - Functions


1. Defining Functions with def Keyword

Functions allow us to group reusable pieces of code.

Syntax:

def function_name(parameters):
    # code block
    return value

➤ Example:

# Simple function
def greet():
    print("Hello, welcome to Python class!")

greet()

2. Parameters, Arguments & Return Statements

  • Parameter: The variable listed inside parentheses in function definition.

  • Argument: The value passed to the function.

  • return sends a result back to the caller.

def add(a, b):
    return a + b

result = add(5, 3)
print("Sum:", result)

3. Returning Multiple Values

You can return multiple values as a tuple.

def calculate(x, y):
    sum_ = x + y
    diff = x - y
    multiply = x * y
    return sum_, diff, multiply

s, d, m = calculate(10, 4)
print("Sum:", s)
print("Difference:", d)
print("Multiplication:", m)

4. Default & Keyword Arguments

Default arguments: Provide default values if no argument is passed.

Keyword arguments: Specify arguments by name during function call.

# Default argument
def greet(name="Guest"):
    print(f"Hello, {name}!")

greet()
greet("Alice")

# Keyword argument
def student(name, age):
    print(f"Name: {name}, Age: {age}")

student(age=21, name="Bob")

5. Anonymous Functions (lambda)

lambda functions are small, one-line anonymous functions.

# Lambda function for square
square = lambda x: x ** 2
print(square(5))

# Lambda with multiple arguments
add = lambda a, b: a + b
print(add(3, 4))

6. Nested Functions & Closures

You can define functions inside other functions.

Closure: Inner function remembers variables from the outer function.

def outer():
    msg = "Python is fun!"

    def inner():
        print(msg)

    return inner

my_func = outer()
my_func()

7. Scopes in Python: Local & Global

  • Local Scope: Variables defined inside a function.

  • Global Scope: Variables defined outside all functions.

➤ Example:

x = 10  # Global variable

def show():
    x = 5  # Local variable
    print("Inside function:", x)

show()
print("Outside function:", x)

➤ Global Keyword Example:

x = 10

def modify():
    global x
    x = 20

modify()
print("After modification:", x)

8. Variable Arguments: args and *kwargs

*args: Allows a function to accept any number of positional arguments.

**kwargs: Allows a function to accept any number of keyword arguments.

# *args example - variable number of positional arguments
def sum_all(*args):
    total = 0
    for num in args:
        total += num
    return total

print(sum_all(1, 2, 3))
print(sum_all(1, 2, 3, 4, 5))

# **kwargs example - variable number of keyword arguments
def student_info(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

student_info(name="Alice", age=21, major="Computer Science")
student_info(name="Bob", age=22, major="Mathematics", gpa=3.8)

# Combining regular args, *args, and **kwargs
def complex_function(required_arg, *args, **kwargs):
    print(f"Required argument: {required_arg}")
    print(f"Additional arguments: {args}")
    print(f"Keyword arguments: {kwargs}")

complex_function("Hello", 1, 2, 3, name="Alice", age=21)

9. Function Annotations & Type Hints

Function annotations help specify the expected types of parameters and return values. They improve code readability and enable better IDE support.

# Function with type hints
def add_numbers(a: int, b: int) -> int:
    """Add two integers and return the sum."""
    return a + b

def greet_user(name: str, age: int) -> str:
    """Create a greeting message for a user."""
    return f"Hello {name}, you are {age} years old!"

# Using the annotated functions
result = add_numbers(5, 3)
print(f"Sum: {result}")

message = greet_user("Alice", 25)
print(message)

# Type hints with complex types
from typing import List, Dict, Optional, Union

def process_numbers(numbers: List[int]) -> Dict[str, float]:
    """Process a list of numbers and return statistics."""
    return {
        "sum": sum(numbers),
        "average": sum(numbers) / len(numbers),
        "max": max(numbers),
        "min": min(numbers)
    }

def find_user(user_id: int) -> Optional[str]:
    """Find a user by ID, return None if not found."""
    users = {1: "Alice", 2: "Bob", 3: "Charlie"}
    return users.get(user_id)

# Test the functions
stats = process_numbers([1, 2, 3, 4, 5])
print(f"Statistics: {stats}")

user = find_user(2)
print(f"Found user: {user}")

user = find_user(99)
print(f"User not found: {user}")
Sum: 8
Hello Alice, you are 25 years old!
Statistics: {'sum': 15, 'average': 3.0, 'max': 5, 'min': 1}
Found user: Bob
User not found: None

10. Decorators

Decorators are a powerful feature that allows you to modify or extend the behavior of functions without changing their code directly.

# Simple decorator example
def timer_decorator(func):
    import time
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"{func.__name__} took {end_time - start_time:.4f} seconds")
        return result
    return wrapper

@timer_decorator
def slow_function():
    import time
    time.sleep(1)
    return "Function completed"

result = slow_function()
print(result)

# Decorator with arguments
def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for i in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say_hello():
    print("Hello!")

say_hello()

# Built-in decorators
class MyClass:
    @staticmethod
    def static_method():
        return "This is a static method"

    @classmethod
    def class_method(cls):
        return f"This is a class method of {cls.__name__}"

    def instance_method(self):
        return "This is an instance method"

# Using the decorated methods
print(MyClass.static_method())
print(MyClass.class_method())
obj = MyClass()
print(obj.instance_method())

11. Higher-Order Functions

Higher-order functions are functions that can take other functions as arguments or return functions as results. Common examples include map(), filter(), and reduce().

# map() - applies a function to all items in an iterable
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x**2, numbers))
print(f"Original: {numbers}")
print(f"Squared: {squared}")

# filter() - filters items based on a condition
even_numbers = list(filter(lambda x: x % 2 == 0, numbers))
print(f"Even numbers: {even_numbers}")

# reduce() - applies a function cumulatively to items
from functools import reduce
sum_all = reduce(lambda x, y: x + y, numbers)
print(f"Sum of all numbers: {sum_all}")

# Custom higher-order function
def apply_operation(numbers, operation):
    """Apply an operation to all numbers in a list."""
    return [operation(num) for num in numbers]

def double(x):
    return x * 2

def cube(x):
    return x ** 3

# Using the higher-order function
original = [1, 2, 3, 4, 5]
doubled = apply_operation(original, double)
cubed = apply_operation(original, cube)

print(f"Original: {original}")
print(f"Doubled: {doubled}")
print(f"Cubed: {cubed}")

# Function that returns a function
def create_multiplier(factor):
    """Create a function that multiplies by a specific factor."""
    def multiplier(x):
        return x * factor
    return multiplier

# Create specific multiplier functions
double_func = create_multiplier(2)
triple_func = create_multiplier(3)

print(f"Double 5: {double_func(5)}")
print(f"Triple 5: {triple_func(5)}")

# Using with map
numbers = [1, 2, 3, 4, 5]
doubled_with_map = list(map(double_func, numbers))
print(f"Doubled with map: {doubled_with_map}")

12. Error Handling in Functions

Proper error handling makes your functions more robust and user-friendly. Use try-except blocks to handle potential errors gracefully.

# Function with error handling
def safe_divide(a, b):
    """Safely divide two numbers with error handling."""
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        print("Error: Cannot divide by zero!")
        return None
    except TypeError:
        print("Error: Both arguments must be numbers!")
        return None

# Test the function
print(safe_divide(10, 2))  # Normal case
print(safe_divide(10, 0))  # Division by zero
print(safe_divide(10, "2"))  # Type error

# Function with custom exceptions
def validate_age(age):
    """Validate age with custom error handling."""
    if not isinstance(age, int):
        raise TypeError("Age must be an integer")
    if age < 0:
        raise ValueError("Age cannot be negative")
    if age > 150:
        raise ValueError("Age seems unrealistic")
    return f"Age {age} is valid"

# Test validation function
try:
    print(validate_age(25))
    print(validate_age(-5))
except (TypeError, ValueError) as e:
    print(f"Validation error: {e}")

# Function with multiple error types
def process_file(filename):
    """Process a file with comprehensive error handling."""
    try:
        with open(filename, 'r') as file:
            content = file.read()
            return len(content)
    except FileNotFoundError:
        print(f"Error: File '{filename}' not found")
        return 0
    except PermissionError:
        print(f"Error: Permission denied to read '{filename}'")
        return 0
    except Exception as e:
        print(f"Unexpected error: {e}")
        return 0

# Test file processing (will show error since file doesn't exist)
result = process_file("nonexistent.txt")
print(f"File length: {result}")

13. Docstrings and Function Documentation

Docstrings are string literals that document what a function does. They should describe the function's purpose, parameters, return values, and any exceptions it might raise.

# Function with comprehensive docstring
def calculate_bmi(weight, height):
    """
    Calculate Body Mass Index (BMI) for a person.

    Parameters:
    -----------
    weight : float
        Weight in kilograms
    height : float
        Height in meters

    Returns:
    --------
    float
        BMI value rounded to 2 decimal places

    Raises:
    -------
    ValueError
        If weight or height are not positive numbers

    Examples:
    ---------
    >>> calculate_bmi(70, 1.75)
    22.86
    >>> calculate_bmi(80, 1.80)
    24.69
    """
    if weight <= 0 or height <= 0:
        raise ValueError("Weight and height must be positive numbers")

    bmi = weight / (height ** 2)
    return round(bmi, 2)

# Test the function
try:
    bmi = calculate_bmi(70, 1.75)
    print(f"BMI: {bmi}")

    # Access the docstring
    print("\nFunction documentation:")
    print(calculate_bmi.__doc__)

    # Use help() to see formatted documentation
    help(calculate_bmi)

except ValueError as e:
    print(f"Error: {e}")

# Google-style docstring example
def process_data(data, method='mean', ignore_errors=False):
    """
    Process numerical data using specified method.

    Args:
        data (list): List of numerical values to process
        method (str, optional): Processing method ('mean', 'median', 'sum'). 
                               Defaults to 'mean'.
        ignore_errors (bool, optional): Whether to ignore non-numeric values. 
                                      Defaults to False.

    Returns:
        float: Processed result based on the specified method

    Raises:
        ValueError: If method is not supported or data contains non-numeric values
        TypeError: If data is not a list
    """
    if not isinstance(data, list):
        raise TypeError("Data must be a list")

    if not ignore_errors:
        if not all(isinstance(x, (int, float)) for x in data):
            raise ValueError("All data items must be numeric")
    else:
        data = [x for x in data if isinstance(x, (int, float))]

    if not data:
        raise ValueError("No valid data to process")

    if method == 'mean':
        return sum(data) / len(data)
    elif method == 'median':
        sorted_data = sorted(data)
        n = len(sorted_data)
        if n % 2 == 0:
            return (sorted_data[n//2 - 1] + sorted_data[n//2]) / 2
        else:
            return sorted_data[n//2]
    elif method == 'sum':
        return sum(data)
    else:
        raise ValueError(f"Unsupported method: {method}")

# Test the documented function
test_data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
print(f"Mean: {process_data(test_data, 'mean')}")
print(f"Median: {process_data(test_data, 'median')}")
print(f"Sum: {process_data(test_data, 'sum')}")

# Test with mixed data and error handling
mixed_data = [1, 2, "3", 4, 5]
try:
    result = process_data(mixed_data, ignore_errors=True)
    print(f"Result with ignored errors: {result}")
except Exception as e:
    print(f"Error: {e}")

14. Recursion

Recursion is when a function calls itself to solve a problem. Every recursive function needs a base case (stopping condition) and a recursive case.

# Classic example: Factorial
def factorial(n):
    """Calculate factorial of n using recursion."""
    # Base case
    if n <= 1:
        return 1
    # Recursive case
    else:
        return n * factorial(n - 1)

print(f"5! = {factorial(5)}")
print(f"0! = {factorial(0)}")

# Fibonacci sequence
def fibonacci(n):
    """Calculate nth Fibonacci number using recursion."""
    if n <= 1:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

print("Fibonacci sequence:")
for i in range(10):
    print(f"F({i}) = {fibonacci(i)}")

# More efficient Fibonacci with memoization
def fibonacci_memo(n, memo={}):
    """Calculate nth Fibonacci number with memoization."""
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    else:
        memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
        return memo[n]

print(f"\nOptimized Fibonacci F(30) = {fibonacci_memo(30)}")

# Recursive function for list sum
def recursive_sum(numbers):
    """Calculate sum of a list using recursion."""
    if not numbers:  # Base case: empty list
        return 0
    else:  # Recursive case
        return numbers[0] + recursive_sum(numbers[1:])

numbers = [1, 2, 3, 4, 5]
print(f"Sum of {numbers} = {recursive_sum(numbers)}")

# Directory traversal example (conceptual)
def count_files(directory_structure):
    """Count files in a nested directory structure."""
    count = 0
    for item in directory_structure:
        if isinstance(item, str):  # It's a file
            count += 1
        elif isinstance(item, list):  # It's a subdirectory
            count += count_files(item)
    return count

# Example directory structure
structure = [
    "file1.txt",
    "file2.py",
    ["subdir1", "file3.txt", "file4.py"],
    ["subdir2", "file5.txt", ["subsubdir", "file6.py"]]
]

print(f"Total files: {count_files(structure)}")

# Recursive function with proper error handling
def power(base, exponent):
    """Calculate base^exponent using recursion."""
    if not isinstance(base, (int, float)) or not isinstance(exponent, int):
        raise TypeError("Base must be a number, exponent must be an integer")

    if exponent < 0:
        return 1 / power(base, -exponent)
    elif exponent == 0:
        return 1
    else:
        return base * power(base, exponent - 1)

print(f"2^5 = {power(2, 5)}")
print(f"3^-2 = {power(3, -2)}")
print(f"5^0 = {power(5, 0)}")

15. Generators and Yield

Generators are special functions that return an iterator. They use the yield keyword instead of return and can pause and resume execution, making them memory-efficient for large datasets.

# Simple generator function
def count_up_to(n):
    """Generator that yields numbers from 1 to n."""
    count = 1
    while count <= n:
        yield count
        count += 1

# Using the generator
counter = count_up_to(5)
print("Using generator with next():")
print(next(counter))
print(next(counter))
print(next(counter))

# Using generator in a loop
print("\nUsing generator in a loop:")
for num in count_up_to(5):
    print(num)

# Generator for Fibonacci sequence
def fibonacci_generator():
    """Generate Fibonacci numbers indefinitely."""
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

# Using Fibonacci generator
print("\nFirst 10 Fibonacci numbers:")
fib_gen = fibonacci_generator()
for i in range(10):
    print(next(fib_gen))

# Generator with file processing (memory efficient)
def read_large_file(filename):
    """Generator to read large files line by line."""
    try:
        with open(filename, 'r') as file:
            for line in file:
                yield line.strip()
    except FileNotFoundError:
        yield "File not found"

# Example: processing data in chunks
def process_data_chunks(data, chunk_size):
    """Generator that yields data in chunks."""
    for i in range(0, len(data), chunk_size):
        yield data[i:i + chunk_size]

# Test chunk processing
large_data = list(range(1, 21))  # Numbers 1 to 20
print("\nProcessing data in chunks of 5:")
for chunk in process_data_chunks(large_data, 5):
    print(f"Chunk: {chunk}")

# Generator expressions (like list comprehensions but for generators)
squares_gen = (x**2 for x in range(1, 6))
print("\nGenerator expression for squares:")
for square in squares_gen:
    print(square)

# Generator with conditional logic
def even_numbers(start, end):
    """Generator for even numbers in a range."""
    for num in range(start, end + 1):
        if num % 2 == 0:
            yield num

print("\nEven numbers from 1 to 10:")
for even in even_numbers(1, 10):
    print(even)

# Generator that can receive values (advanced)
def accumulator():
    """Generator that accumulates sent values."""
    total = 0
    while True:
        value = yield total
        if value is not None:
            total += value

# Using the accumulator generator
acc = accumulator()
next(acc)  # Start the generator
print(f"\nAccumulator: {acc.send(10)}")
print(f"Accumulator: {acc.send(20)}")
print(f"Accumulator: {acc.send(5)}")

# Memory comparison: list vs generator
import sys

# List comprehension (stores all values in memory)
large_list = [x for x in range(1000)]
print(f"\nList size: {sys.getsizeof(large_list)} bytes")

# Generator expression (stores only the current state)
large_gen = (x for x in range(1000))
print(f"Generator size: {sys.getsizeof(large_gen)} bytes")

🎯 Quick Recap

✅ Functions are reusable code blocks with parameters and return values.

✅ Can return multiple values and use default/keyword arguments.

✅ Lambda functions create simple anonymous functions.

✅ Nested functions, closures, and variable scope concepts.

✅ args and *kwargs for variable arguments.

✅ Type hints and function annotations improve code clarity.

✅ Decorators modify function behavior without changing code.

✅ Higher-order functions (map, filter, reduce) for functional programming.

✅ Proper error handling and comprehensive documentation.

✅ Recursion for problems that can be broken down into smaller parts.

✅ Generators and yield for memory-efficient iteration.

📝 Practice Exercises

Basic Level:

  1. Write a function to find the maximum of two numbers.

  2. Create a lambda function to calculate the cube of a number.

  3. Write a function that returns the sum, product, and difference of two numbers.

  4. Use a default argument to greet a user if no name is provided.

  5. Demonstrate variable scope by defining a global variable and modifying it inside a function.

Intermediate Level:

  1. Create a function that accepts args and *kwargs to handle variable arguments.

  2. Write a decorator that logs function calls and execution time.

  3. Implement a recursive function to calculate the nth Fibonacci number.

  4. Create a generator function that yields even numbers up to a given limit.

  5. Write a function with proper type hints and comprehensive docstring.

Advanced Level:

  1. Build a higher-order function that applies different operations to a list of numbers.

  2. Create a closure that maintains state between function calls.

  3. Implement a custom decorator with arguments that can modify function behavior.

  4. Write a recursive function to traverse a nested dictionary structure.

  5. Create a generator that processes large datasets in chunks.

Expert Level:

  1. Build a function registry system using decorators.

  2. Implement a memoization decorator for optimizing recursive functions.

  3. Create a context manager function using generator syntax.

  4. Write a function that uses multiple advanced features (type hints, error handling, logging).

  5. Build a simple functional programming toolkit with map, filter, and reduce implementations.