Python Functions - Complete Guide

Understanding Python Functions

Functions are one of the fundamental building blocks in Python. They allow you to encapsulate a block of code that can be reused throughout your program. Functions help make your code modular, maintainable, and easier to understand.

In Python, functions can be defined with the def keyword, followed by the function name, a pair of parentheses which may include parameters, and a colon. The function body is indented.

# Basic function definition
def greet():
    print("Hello, World!")

# Calling the function
greet()  # Output: Hello, World!

# Function with parameters
def greet_person(name):
    print(f"Hello, {name}!")

greet_person("Alice")  # Output: Hello, Alice!

# Function with return value
def add_numbers(a, b):
    return a + b

result = add_numbers(5, 3)
print(result)  # Output: 8

Function Parameters and Arguments

Python offers a flexible parameter system for functions, allowing various ways to pass data to your functions.

Required Parameters

# Function with required parameters
def calculate_rectangle_area(length, width):
    return length * width

# Must provide all required arguments
area = calculate_rectangle_area(5, 3)
print(area)  # Output: 15

Default Parameters

# Function with default parameter values
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

# Using the default value
print(greet("Alice"))  # Output: Hello, Alice!

# Overriding the default value
print(greet("Bob", "Hi"))  # Output: Hi, Bob!

Keyword Arguments

# Using keyword arguments
def create_profile(name, age, occupation):
    return f"{name} is {age} years old and works as a {occupation}."

# Calling with positional arguments
print(create_profile("Alice", 30, "Engineer"))

# Calling with keyword arguments (can be in any order)
print(create_profile(age=25, name="Bob", occupation="Designer"))

# Mixing positional and keyword arguments
# Positional arguments must come before keyword arguments
print(create_profile("Charlie", occupation="Doctor", age=35))

Variable-Length Arguments (*args and **kwargs)

# *args: Variable number of positional arguments
def calculate_sum(*numbers):
    return sum(numbers)

print(calculate_sum(1, 2, 3))  # Output: 6
print(calculate_sum(5, 10, 15, 20))  # Output: 50

# **kwargs: Variable number of keyword arguments
def print_person_details(**details):
    for key, value in details.items():
        print(f"{key}: {value}")

print_person_details(name="Alice", age=30, city="New York", job="Engineer")

# Using both *args and **kwargs
def example_function(*args, **kwargs):
    print(f"Args: {args}")
    print(f"Kwargs: {kwargs}")

example_function(1, 2, 3, name="Alice", age=30)

Return Values

Functions can return values using the return statement. A function can return a single value, multiple values, or nothing at all.

# Function returning a single value
def square(x):
    return x ** 2

result = square(5)
print(result)  # Output: 25

# Function returning multiple values
def get_coordinates():
    x = 10
    y = 20
    return x, y  # Returns a tuple

coords = get_coordinates()
print(coords)  # Output: (10, 20)

# Unpacking multiple return values
x, y = get_coordinates()
print(f"X: {x}, Y: {y}")  # Output: X: 10, Y: 20

# Early returns
def get_absolute(number):
    if number < 0:
        return -number
    return number  # This line executes only if number >= 0

# Function with no return (returns None implicitly)
def say_hello():
    print("Hello!")

result = say_hello()
print(result)  # Output: None

Variable Scope and Lifetime

Variables in Python have different scopes (regions where they're accessible) and lifetimes (duration they exist in memory).

Local and Global Scope

# Local variables
def function_with_local_var():
    local_var = "I'm local"
    print(local_var)  # Works fine

function_with_local_var()
# print(local_var)  # NameError: local_var is not defined

# Global variables
global_var = "I'm global"

def function_using_global():
    print(global_var)  # Can access global variables

function_using_global()

# Modifying global variables
counter = 0

def increment_counter():
    global counter  # Declare as global to modify
    counter += 1
    print(counter)

increment_counter()  # Output: 1
increment_counter()  # Output: 2

Nested Functions and Closures

# Nested function
def outer_function(x):
    def inner_function(y):
        return x + y  # inner_function can access outer_function's variables
    return inner_function

# Creating a closure (a function that remembers its context)
add_five = outer_function(5)
print(add_five(10))  # Output: 15
print(add_five(20))  # Output: 25

# Using nonlocal for nested function variables
def counter_function():
    count = 0
    
    def increment():
        nonlocal count  # Use nonlocal to modify outer function's variables
        count += 1
        return count
    
    return increment

counter = counter_function()
print(counter())  # Output: 1
print(counter())  # Output: 2
print(counter())  # Output: 3

Recursive Functions

Recursive functions are functions that call themselves. They can be powerful for solving problems that can be broken down into smaller, similar sub-problems.

# Factorial calculation using recursion
def factorial(n):
    if n <= 1:  # Base case
        return 1
    else:  # Recursive case
        return n * factorial(n - 1)

print(factorial(5))  # Output: 120 (5 * 4 * 3 * 2 * 1)

# Fibonacci sequence using recursion
def fibonacci(n):
    if n <= 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

# Print first 10 Fibonacci numbers
for i in range(10):
    print(fibonacci(i), end=" ")  # Output: 0 1 1 2 3 5 8 13 21 34

Recursion Considerations

  • Always have a base case to stop the recursion
  • Python has a default recursion limit (typically 1000) to prevent stack overflow
  • Recursive solutions can be less efficient than iterative ones due to function call overhead
  • Consider using memoization for recursive functions with overlapping subproblems

Lambda Functions

Lambda functions (sometimes called anonymous functions) are small, one-line functions defined using the lambda keyword. They're useful for simple functions that are used only once or as function arguments.

# Basic lambda function
square = lambda x: x ** 2
print(square(5))  # Output: 25

# Multiple parameters
add = lambda x, y: x + y
print(add(3, 4))  # Output: 7

# Using lambda with built-in functions
numbers = [5, 2, 8, 1, 9, 3]

# Sort a list using a custom key
sorted_numbers = sorted(numbers)
print(sorted_numbers)  # Output: [1, 2, 3, 5, 8, 9]

# Filter to get only even numbers
even_numbers = list(filter(lambda x: x % 2 == 0, numbers))
print(even_numbers)  # Output: [2, 8]

# Map to square each number
squared = list(map(lambda x: x ** 2, numbers))
print(squared)  # Output: [25, 4, 64, 1, 81, 9]

Function Decorators

Decorators are a powerful feature that allow you to modify the behavior of functions. They are functions that take another function as an argument, extend its behavior, and return the modified function.

# Basic decorator
def my_decorator(func):
    def wrapper():
        print("Something is happening before the function is called.")
        func()  # Call the original function
        print("Something is happening after the function is called.")
    return wrapper

# Using a decorator
@my_decorator
def say_hello():
    print("Hello!")

# This is equivalent to: say_hello = my_decorator(say_hello)

say_hello()
# Output:
# Something is happening before the function is called.
# Hello!
# Something is happening after the function is called.

# Decorator for functions with arguments
def decorator_with_args(func):
    def wrapper(*args, **kwargs):
        print(f"Arguments passed: {args}, {kwargs}")
        return func(*args, **kwargs)
    return wrapper

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

result = add(5, b=3)
# Output: Arguments passed: (5,), {'b': 3}
print(result)  # Output: 8

Practical Decorator Examples

# Timing decorator
import time

def timer_decorator(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"Function {func.__name__} took {end_time - start_time:.6f} seconds to execute.")
        return result
    return wrapper

@timer_decorator
def slow_function():
    time.sleep(1)  # Simulating a time-consuming task
    return "Done!"

print(slow_function())

# Decorator with parameters
def repeat(n):
    def decorator(func):
        def wrapper(*args, **kwargs):
            results = []
            for _ in range(n):
                results.append(func(*args, **kwargs))
            return results
        return wrapper
    return decorator

@repeat(3)
def say_hi(name):
    return f"Hi, {name}!"

print(say_hi("Alice"))
# Output: ['Hi, Alice!', 'Hi, Alice!', 'Hi, Alice!']

Function Best Practices

Function Design Principles

  • Follow the Single Responsibility Principle: Functions should do one thing and do it well
  • Keep functions small and focused
  • Use descriptive function names (use verbs for functions that perform actions)
  • Limit the number of parameters (consider using classes or dictionaries for many parameters)
  • Minimize side effects (changes to variables outside the function)
  • Include docstrings to document your functions

Documentation with Docstrings

# Using docstrings to document functions
def calculate_area(radius):
    """
    Calculate the area of a circle.
    
    Args:
        radius (float): The radius of the circle.
        
    Returns:
        float: The area of the circle.
        
    Raises:
        ValueError: If radius is negative.
    """
    if radius < 0:
        raise ValueError("Radius cannot be negative")
    return 3.14159 * radius ** 2

# Accessing the docstring
print(calculate_area.__doc__)

# Help function shows the docstring
help(calculate_area)

Type Hints (Python 3.5+)

# Using type hints for better documentation and tooling support
def greet(name: str, times: int = 1) -> str:
    """Greet a person multiple times."""
    return f"Hello, {name}! " * times

# More complex type hints
from typing import List, Dict, Optional, Union

def process_data(
    items: List[int],
    options: Dict[str, str] = {},
    callback: Optional[callable] = None
) -> Union[List[int], Dict[str, int]]:
    """Process a list of integers."""
    result = [item * 2 for item in items]
    
    if callback:
        callback(result)
    
    if "format" in options and options["format"] == "dict":
        return {f"item_{i}": item for i, item in enumerate(result)}
    return result

# Type hints don't enforce types at runtime
# This will still run (but static type checkers will warn)
print(greet(123))  # Works: "Hello, 123!"

Advanced Function Topics

Function as First-Class Objects

# Functions as variables
def square(x):
    return x ** 2

def cube(x):
    return x ** 3

# Assigning a function to a variable
operation = square
print(operation(5))  # Output: 25

# Functions as arguments
def apply_operation(func, value):
    return func(value)

print(apply_operation(square, 5))  # Output: 25
print(apply_operation(cube, 5))    # Output: 125

# Functions returning functions
def get_operation(opcode):
    if opcode == "square":
        return square
    elif opcode == "cube":
        return cube
    else:
        return lambda x: x  # Identity function as default

operation = get_operation("cube")
print(operation(5))  # Output: 125

Function Caching with @lru_cache

# Using memoization to improve recursive functions
from functools import lru_cache

# Without caching (slow for larger values)
def fibonacci_slow(n):
    if n <= 1:
        return n
    return fibonacci_slow(n-1) + fibonacci_slow(n-2)

# With caching (much faster)
@lru_cache(maxsize=None)
def fibonacci_fast(n):
    if n <= 1:
        return n
    return fibonacci_fast(n-1) + fibonacci_fast(n-2)

# Compare execution time for larger value
import time

n = 30

start = time.time()
result_slow = fibonacci_slow(n)
end = time.time()
print(f"Slow: {end - start:.6f} seconds")

start = time.time()
result_fast = fibonacci_fast(n)
end = time.time()
print(f"Fast: {end - start:.6f} seconds")

Partial Functions

# Creating new functions by fixing some parameters
from functools import partial

# Original function
def power(base, exponent):
    return base ** exponent

# Create specialized functions
square = partial(power, exponent=2)
cube = partial(power, exponent=3)

print(square(5))  # Output: 25
print(cube(5))    # Output: 125

# Another example with multiple arguments
def format_string(string, prefix, suffix):
    return f"{prefix}{string}{suffix}"

# Create a HTML paragraph formatter
p_formatter = partial(format_string, prefix="

", suffix="

") print(p_formatter("Hello, World!")) # Output:

Hello, World!

Practice Exercises

Exercise 1: Function Basics

# Write a function called `calculate_average` that takes a list 
# of numbers and returns their average.
# Test it with different lists.

# Your code here

Exercise 2: Recursive Function

# Write a recursive function called `sum_digits` that takes 
# a positive integer and returns the sum of its digits.
# For example, sum_digits(123) should return 6 (1+2+3).

# Your code here

Exercise 3: Decorator

# Create a decorator called `logger` that prints the name of the
# function being called, the arguments it received, and its return value.

# Your code here