Python Programming Functions
Core Concept Modular

Python Functions Complete Guide

Learn all Python function concepts - definition, parameters, return values, lambda functions, decorators, scope, and recursion with practical examples.

Modular

Reusable code

Parameters

Flexible inputs

Lambda

Anonymous functions

Decorators

Function wrappers

What are Python Functions?

Functions in Python are reusable blocks of code that perform specific tasks. They help in organizing code, reducing repetition, and making programs more modular and maintainable.

Key Concept

Functions follow the DRY (Don't Repeat Yourself) principle. They take inputs (parameters), perform operations, and return outputs. Functions are defined using the def keyword.

Basic Function Creation and Usage
# Function definition
def greet():
    """Simple function that prints a greeting"""
    print("Hello, World!")

# Function call
greet()  # Output: Hello, World!

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

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

# Function with return value
def add_numbers(a, b):
    """Add two numbers and return the result"""
    return a + b

result = add_numbers(5, 3)
print(f"5 + 3 = {result}")  # Output: 5 + 3 = 8

# Multiple return values (as tuple)
def get_min_max(numbers):
    """Return minimum and maximum from a list"""
    return min(numbers), max(numbers)

nums = [5, 2, 8, 1, 9]
minimum, maximum = get_min_max(nums)
print(f"Min: {minimum}, Max: {maximum}")  # Output: Min: 1, Max: 9

# Function with default parameters
def greet_with_time(name, time_of_day="morning"):
    """Greet with optional time of day"""
    print(f"Good {time_of_day}, {name}!")

greet_with_time("Alice")              # Output: Good morning, Alice!
greet_with_time("Bob", "afternoon")   # Output: Good afternoon, Bob!

# Function with docstring
def calculate_area(length, width):
    """
    Calculate the area of a rectangle.
    
    Parameters:
    length (float): Length of the rectangle
    width (float): Width of the rectangle
    
    Returns:
    float: Area of the rectangle
    """
    return length * width

area = calculate_area(10, 5)
print(f"Area: {area}")  # Output: Area: 50

# Accessing docstring
print(calculate_area.__doc__)

Python Function Types and Categories

Python supports various types of functions with different characteristics and use cases. Understanding these helps in writing better, more efficient code.

Complete Function Types Reference Table

Category Function Type Description Syntax Example Use Case
Basic Functions Simple Function Basic function without parameters def func(): pass Simple reusable tasks
Basic Functions Parameterized Function Function with input parameters def func(a, b): pass Tasks requiring input
Basic Functions Return Function Function that returns a value return value Calculations, data processing
Parameter Types Positional Arguments Arguments passed in order func(1, 2) Required parameters
Parameter Types Keyword Arguments Arguments passed by name func(a=1, b=2) Clarity, optional params
Parameter Types Default Parameters Parameters with default values def func(a=1): pass Optional parameters
Parameter Types *args (Variable-length) Accepts any number of positional args def func(*args): pass Flexible number of inputs
Parameter Types **kwargs (Keyword args) Accepts any number of keyword args def func(**kwargs): pass Flexible keyword inputs
Advanced Functions Lambda Function Anonymous inline function lambda x: x*2 Simple one-liners
Advanced Functions Recursive Function Function that calls itself def func(): func() Tree traversal, factorial
Advanced Functions Generator Function Function with yield statement yield value Memory-efficient iteration
Advanced Functions Higher-order Function Function that takes/returns functions map(func, iterable) Functional programming
Scope & Visibility Local Function Function defined inside another def outer(): def inner(): Encapsulation, closures
Scope & Visibility Closure Function that remembers enclosing scope def outer(): def inner(): Data encapsulation
Scope & Visibility Global Function Function defined at module level def func(): (module level) Module-wide utilities
Decorators Function Decorator Function that modifies another function @decorator Cross-cutting concerns
Decorators Class Decorator Decorator implemented as class class Decorator: Stateful decorators
Decorators Parameterized Decorator Decorator that accepts arguments @decorator(args) Configurable decoration
Special Functions Built-in Functions Python's pre-defined functions len(), print(), type() Common operations
Special Functions Method Function bound to an object obj.method() Object-oriented programming
Important Principles:
  • DRY (Don't Repeat Yourself): Use functions to avoid code duplication
  • Single Responsibility: Each function should do one thing well
  • Pure Functions: Functions without side effects are easier to test and debug
  • Function Composition: Combine simple functions to build complex behavior
  • Immutability: Prefer returning new values over modifying inputs

Function Parameters in Detail

Python offers flexible parameter passing mechanisms. Understanding different parameter types helps write versatile and robust functions.

Function Parameters Examples
# 1. Positional Arguments (Required)
def describe_pet(animal_type, pet_name):
    """Display information about a pet"""
    print(f"I have a {animal_type} named {pet_name}.")

describe_pet("dog", "Rex")      # I have a dog named Rex.
describe_pet("cat", "Whiskers") # I have a cat named Whiskers.

# 2. Keyword Arguments
def describe_person(name, age, city):
    """Describe a person using keyword arguments"""
    print(f"{name} is {age} years old and lives in {city}.")

# All keyword arguments
describe_person(name="Alice", age=30, city="NYC")

# Mix of positional and keyword (positional must come first)
describe_person("Bob", city="London", age=25)

# 3. Default Parameters
def greet(name, greeting="Hello", punctuation="!"):
    """Greet with customizable greeting and punctuation"""
    print(f"{greeting}, {name}{punctuation}")

greet("Alice")                    # Hello, Alice!
greet("Bob", "Hi")                # Hi, Bob!
greet("Charlie", "Hey", ".")      # Hey, Charlie.

# Important: Mutable default arguments
def add_item(item, items=[]):     # WARNING: Default list is shared!
    items.append(item)
    return items

print(add_item("apple"))          # ['apple']
print(add_item("banana"))         # ['apple', 'banana'] (UNEXPECTED!)

# Correct way with None
def add_item_safe(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print(add_item_safe("apple"))     # ['apple']
print(add_item_safe("banana"))    # ['banana'] (CORRECT!)

# 4. *args - Variable-length positional arguments
def sum_all(*args):
    """Sum all provided numbers"""
    total = 0
    for num in args:
        total += num
    return total

print(sum_all(1, 2, 3))           # 6
print(sum_all(1, 2, 3, 4, 5))     # 15
print(sum_all())                  # 0

# *args in function calls
def print_three(a, b, c):
    print(f"a={a}, b={b}, c={c}")

numbers = [1, 2, 3]
print_three(*numbers)             # a=1, b=2, c=3 (unpacking)

# 5. **kwargs - Variable-length keyword arguments
def print_info(**kwargs):
    """Print all key-value pairs"""
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_info(name="Alice", age=30, city="NYC")
# Output:
# name: Alice
# age: 30
# city: NYC

# **kwargs in function calls
def greet_person(name, age):
    print(f"{name} is {age} years old")

person_info = {"name": "Bob", "age": 25}
greet_person(**person_info)       # Bob is 25 years old

# 6. Combined parameter types
def complex_function(a, b, *args, option=True, **kwargs):
    """Function with all parameter types"""
    print(f"a={a}, b={b}")
    print(f"args={args}")
    print(f"option={option}")
    print(f"kwargs={kwargs}")

complex_function(1, 2, 3, 4, 5, option=False, x=10, y=20)
# Output:
# a=1, b=2
# args=(3, 4, 5)
# option=False
# kwargs={'x': 10, 'y': 20}

# 7. Positional-only and keyword-only arguments (Python 3.8+)
def advanced_func(a, b, /, c, d, *, e, f):
    """
    a, b: positional-only (before /)
    c, d: positional or keyword
    e, f: keyword-only (after *)
    """
    return a + b + c + d + e + f

# Valid calls
advanced_func(1, 2, 3, d=4, e=5, f=6)
advanced_func(1, 2, c=3, d=4, e=5, f=6)

# Invalid calls
# advanced_func(a=1, b=2, c=3, d=4, e=5, f=6)  # a,b are positional-only
# advanced_func(1, 2, 3, 4, 5, 6)             # e,f must be keyword

# 8. Type hints (Python 3.5+)
def calculate_total(price: float, quantity: int, tax_rate: float = 0.08) -> float:
    """Calculate total price with tax"""
    subtotal = price * quantity
    tax = subtotal * tax_rate
    return subtotal + tax

total = calculate_total(10.5, 3)
print(f"Total: ${total:.2f}")  # Total: $34.02

Lambda Functions (Anonymous Functions)

Lambda functions are small, anonymous functions defined with the lambda keyword. They are useful for short, simple operations.

Lambda Function Examples
# Basic lambda syntax
# lambda arguments: expression

# 1. Simple lambda functions
square = lambda x: x ** 2
print(square(5))  # 25

add = lambda a, b: a + b
print(add(10, 20))  # 30

# 2. Lambda with conditional expression
is_even = lambda x: True if x % 2 == 0 else False
print(is_even(4))  # True
print(is_even(5))  # False

# Shorter version
is_even = lambda x: x % 2 == 0

# 3. Lambda with multiple conditions
grade = lambda score: 'A' if score >= 90 else 'B' if score >= 80 else 'C' if score >= 70 else 'D' if score >= 60 else 'F'
print(grade(85))  # B
print(grade(95))  # A

# 4. Lambda in higher-order functions
numbers = [1, 2, 3, 4, 5]

# map() with lambda
squared = list(map(lambda x: x ** 2, numbers))
print(f"Squared: {squared}")  # [1, 4, 9, 16, 25]

# filter() with lambda
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(f"Evens: {evens}")  # [2, 4]

# sorted() with lambda
students = [
    {"name": "Alice", "grade": 85},
    {"name": "Bob", "grade": 72},
    {"name": "Charlie", "grade": 90}
]

# Sort by grade
sorted_students = sorted(students, key=lambda x: x["grade"], reverse=True)
print(f"Sorted by grade: {sorted_students}")

# Sort by name length
sorted_by_name_len = sorted(students, key=lambda x: len(x["name"]))
print(f"Sorted by name length: {sorted_by_name_len}")

# 5. Lambda with default arguments
multiply = lambda x, y=2: x * y
print(multiply(5))     # 10 (uses default y=2)
print(multiply(5, 3))  # 15

# 6. Lambda returning multiple values (as tuple)
stats = lambda lst: (min(lst), max(lst), sum(lst)/len(lst))
numbers = [10, 20, 30, 40]
minimum, maximum, average = stats(numbers)
print(f"Min: {minimum}, Max: {maximum}, Avg: {average}")

# 7. Lambda in list comprehensions
operations = [
    lambda x: x + 1,
    lambda x: x * 2,
    lambda x: x ** 2
]

result = [op(5) for op in operations]
print(f"Operations on 5: {result}")  # [6, 10, 25]

# 8. Lambda with *args and **kwargs
sum_all = lambda *args: sum(args)
print(sum_all(1, 2, 3, 4, 5))  # 15

print_info = lambda **kwargs: {k: v for k, v in kwargs.items()}
info = print_info(name="Alice", age=30)
print(f"Info: {info}")  # {'name': 'Alice', 'age': 30}

# 9. Immediately invoked lambda (IIFE - Immediately Invoked Function Expression)
result = (lambda x, y: x + y)(10, 20)
print(f"IIFE result: {result}")  # 30

# 10. Lambda for simple data transformation
data = ["apple", "banana", "cherry"]
uppercase = list(map(lambda s: s.upper(), data))
print(f"Uppercase: {uppercase}")  # ['APPLE', 'BANANA', 'CHERRY']

# 11. Lambda in dictionary sorting
word_counts = {"apple": 5, "banana": 2, "cherry": 8, "date": 3}

# Sort by value (count)
sorted_by_count = dict(sorted(word_counts.items(), key=lambda item: item[1]))
print(f"Sorted by count: {sorted_by_count}")

# Sort by key length
sorted_by_key_len = dict(sorted(word_counts.items(), key=lambda item: len(item[0])))
print(f"Sorted by key length: {sorted_by_key_len}")

# 12. When NOT to use lambda
# Don't use lambda for complex logic - use regular functions instead

# Bad example (too complex for lambda)
# complex_logic = lambda x: "High" if x > 100 else ("Medium" if x > 50 else ("Low" if x > 10 else "Very Low"))

# Good example - use regular function
def categorize_value(x):
    if x > 100:
        return "High"
    elif x > 50:
        return "Medium"
    elif x > 10:
        return "Low"
    else:
        return "Very Low"

print(categorize_value(75))  # Medium

# 13. Lambda with functools.reduce()
from functools import reduce

numbers = [1, 2, 3, 4, 5]
product = reduce(lambda x, y: x * y, numbers)
print(f"Product: {product}")  # 120

# Find maximum
maximum = reduce(lambda x, y: x if x > y else y, numbers)
print(f"Maximum: {maximum}")  # 5

Function Decorators

Decorators are functions that modify the behavior of other functions. They provide a way to add functionality to existing code without modifying it directly.

Decorator Examples
# 1. Basic decorator
def simple_decorator(func):
    """A simple decorator that adds functionality before and after"""
    def wrapper():
        print("Before function execution")
        func()
        print("After function execution")
    return wrapper

@simple_decorator
def say_hello():
    print("Hello!")

say_hello()
# Output:
# Before function execution
# Hello!
# After function execution

# 2. Decorator for functions with parameters
def decorator_with_params(func):
    """Decorator that handles functions with parameters"""
    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

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

result = add(5, 3)
print(f"Result: {result}")
# Output:
# Calling add with args=(5, 3), kwargs={}
# add returned 8
# Result: 8

# 3. Decorator that returns a value
def timing_decorator(func):
    """Decorator that measures execution time"""
    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

@timing_decorator
def slow_function():
    """Simulate a slow function"""
    import time
    time.sleep(1)
    return "Done"

slow_function()  # Output: slow_function took 1.0001 seconds

# 4. Decorator with arguments (decorator factory)
def repeat(n_times):
    """Decorator factory that creates a decorator to repeat function n times"""
    def decorator(func):
        def wrapper(*args, **kwargs):
            for i in range(n_times):
                print(f"Execution {i+1}/{n_times}")
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(n_times=3)
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")
# Output:
# Execution 1/3
# Hello, Alice!
# Execution 2/3
# Hello, Alice!
# Execution 3/3
# Hello, Alice!

# 5. Multiple decorators (applied from bottom to top)
def decorator1(func):
    def wrapper():
        print("Decorator 1: Before")
        func()
        print("Decorator 1: After")
    return wrapper

def decorator2(func):
    def wrapper():
        print("Decorator 2: Before")
        func()
        print("Decorator 2: After")
    return wrapper

@decorator1
@decorator2
def my_function():
    print("Original function")

my_function()
# Output:
# Decorator 1: Before
# Decorator 2: Before
# Original function
# Decorator 2: After
# Decorator 1: After

# 6. Class-based decorator
class CountCalls:
    """Decorator class that counts function calls"""
    def __init__(self, func):
        self.func = func
        self.call_count = 0
    
    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"Call {self.call_count} of {self.func.__name__}")
        return self.func(*args, **kwargs)

@CountCalls
def say_hi():
    print("Hi!")

say_hi()  # Call 1 of say_hi
say_hi()  # Call 2 of say_hi
say_hi()  # Call 3 of say_hi

# 7. Decorator that preserves function metadata
from functools import wraps

def preserve_metadata(func):
    """Decorator that preserves the original function's metadata"""
    @wraps(func)
    def wrapper(*args, **kwargs):
        """Wrapper function docstring"""
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@preserve_metadata
def calculate(x, y):
    """Calculate the sum of x and y"""
    return x + y

print(f"Function name: {calculate.__name__}")      # calculate
print(f"Function docstring: {calculate.__doc__}")  # Calculate the sum of x and y
print(f"Result: {calculate(5, 3)}")                # 8

# 8. Practical decorator examples

# Logging decorator
def log_execution(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[LOG] Executing {func.__name__} with args={args}, kwargs={kwargs}")
        try:
            result = func(*args, **kwargs)
            print(f"[LOG] {func.__name__} returned {result}")
            return result
        except Exception as e:
            print(f"[LOG] {func.__name__} raised {type(e).__name__}: {e}")
            raise
    return wrapper

@log_execution
def divide(a, b):
    return a / b

divide(10, 2)   # Logs execution and result
# divide(10, 0) # Logs exception

# Cache decorator (memoization)
def cache_results(func):
    """Cache function results to avoid recomputation"""
    cache = {}
    
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Create a key from args and kwargs
        key = (args, frozenset(kwargs.items()))
        
        if key not in cache:
            cache[key] = func(*args, **kwargs)
            print(f"Calculated result for {args}")
        else:
            print(f"Using cached result for {args}")
        
        return cache[key]
    return wrapper

@cache_results
def fibonacci(n):
    """Calculate nth Fibonacci number (inefficient without cache)"""
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(f"fibonacci(10) = {fibonacci(10)}")
# Without cache: O(2^n) complexity
# With cache: O(n) complexity

# Authentication decorator
def require_auth(func):
    """Decorator that requires authentication"""
    @wraps(func)
    def wrapper(*args, **kwargs):
        # In real application, check authentication here
        is_authenticated = True  # Simulated
        
        if not is_authenticated:
            raise PermissionError("Authentication required")
        
        return func(*args, **kwargs)
    return wrapper

@require_auth
def view_profile(user_id):
    return f"Viewing profile of user {user_id}"

print(view_profile(123))  # Works if authenticated

Function Scope and Closure

Understanding variable scope is crucial in Python. Functions create their own local scope, and closures allow functions to remember their enclosing scope.

Scope and Closure Examples
# 1. Variable Scope Levels
# LEGB Rule: Local -> Enclosing -> Global -> Built-in

# Global scope
global_var = "I'm global"

def outer_function():
    # Enclosing scope
    enclosing_var = "I'm in enclosing scope"
    
    def inner_function():
        # Local scope
        local_var = "I'm local"
        print(local_var)           # Local
        print(enclosing_var)       # Enclosing
        print(global_var)          # Global
        print(len)                 # Built-in (len function)
    
    inner_function()
    # print(local_var)  # ERROR: local_var not defined in this scope

outer_function()

# 2. Modifying global variables
counter = 0

def increment():
    global counter  # Declare we're using the global variable
    counter += 1

increment()
increment()
print(f"Counter: {counter}")  # 2

# 3. Nonlocal for nested functions
def outer():
    count = 0
    
    def inner():
        nonlocal count  # Refer to count in enclosing scope
        count += 1
        return count
    
    return inner

counter_func = outer()
print(counter_func())  # 1
print(counter_func())  # 2
print(counter_func())  # 3

# 4. Closures - Functions that remember their environment
def multiplier_factory(factor):
    """Create a multiplier function that remembers the factor"""
    def multiplier(number):
        return number * factor
    return multiplier

double = multiplier_factory(2)
triple = multiplier_factory(3)

print(double(5))   # 10
print(triple(5))   # 15

# The closure remembers factor=2 even after multiplier_factory has returned
print(f"Double closure variables: {double.__closure__}")
print(f"Triple closure variables: {triple.__closure__}")

# 5. Practical closure example - Counter with different increments
def create_counter(initial=0, step=1):
    """Create a counter function with configurable initial value and step"""
    count = initial
    
    def counter():
        nonlocal count
        current = count
        count += step
        return current
    
    return counter

# Create different counters
counter1 = create_counter()          # Starts at 0, increments by 1
counter2 = create_counter(10, 2)     # Starts at 10, increments by 2
counter3 = create_counter(100, -5)   # Starts at 100, decrements by 5

print("Counter 1:", counter1(), counter1(), counter1())  # 0, 1, 2
print("Counter 2:", counter2(), counter2(), counter2())  # 10, 12, 14
print("Counter 3:", counter3(), counter3(), counter3())  # 100, 95, 90

# 6. Closure with mutable state
def create_accumulator():
    """Create an accumulator that remembers all values"""
    values = []
    
    def accumulator(new_value):
        values.append(new_value)
        return sum(values), values.copy()
    
    return accumulator

acc = create_accumulator()
print(acc(10))   # (10, [10])
print(acc(20))   # (30, [10, 20])
print(acc(30))   # (60, [10, 20, 30])

# 7. Function attributes (alternative to closures for simple cases)
def counter_with_attr():
    """Counter using function attributes instead of closure"""
    if not hasattr(counter_with_attr, "count"):
        counter_with_attr.count = 0
    
    counter_with_attr.count += 1
    return counter_with_attr.count

print(counter_with_attr())  # 1
print(counter_with_attr())  # 2
print(counter_with_attr())  # 3

# 8. Scope resolution in practice
x = "global"

def test_scope():
    x = "enclosing"
    
    def inner():
        x = "local"
        print(f"Inner: x = {x}")
    
    inner()
    print(f"Test_scope: x = {x}")

test_scope()
print(f"Global: x = {x}")

# 9. Using globals() and locals() functions
def scope_demo():
    local_var = "I'm local"
    print("Local variables:", locals())
    print("Global variables (keys):", list(globals().keys())[:5])

scope_demo()

# 10. Common scope pitfalls
# Pitfall 1: Unintended global variable creation
def create_global():
    global new_global  # Without this line, Python creates a local variable
    new_global = "Oops, I'm global"

create_global()
print(new_global)  # Works, but might be unintended

# Pitfall 2: Late binding in closures
def create_multipliers():
    return [lambda x: i * x for i in range(5)]

multipliers = create_multipliers()
print([m(2) for m in multipliers])  # [8, 8, 8, 8, 8] (NOT [0, 2, 4, 6, 8])

# Why? The closure captures i, not its value at creation time
# Solution: Use default arguments
def create_multipliers_fixed():
    return [lambda x, i=i: i * x for i in range(5)]

multipliers = create_multipliers_fixed()
print([m(2) for m in multipliers])  # [0, 2, 4, 6, 8] (CORRECT!)

Recursive Functions

Recursion occurs when a function calls itself. It's a powerful technique for solving problems that can be broken down into smaller, similar subproblems.

Recursive Function Examples
# 1. Factorial (classic recursion example)
def factorial(n):
    """Calculate factorial recursively"""
    # Base case
    if n == 0 or n == 1:
        return 1
    # Recursive case
    return n * factorial(n - 1)

print(f"5! = {factorial(5)}")    # 120
print(f"0! = {factorial(0)}")    # 1
print(f"7! = {factorial(7)}")    # 5040

# 2. Fibonacci sequence
def fibonacci(n):
    """Calculate nth Fibonacci number recursively"""
    # Base cases
    if n == 0:
        return 0
    elif n == 1:
        return 1
    # Recursive case
    return fibonacci(n - 1) + fibonacci(n - 2)

print(f"Fibonacci(0) = {fibonacci(0)}")  # 0
print(f"Fibonacci(1) = {fibonacci(1)}")  # 1
print(f"Fibonacci(6) = {fibonacci(6)}")  # 8

# 3. Recursive list sum
def recursive_sum(numbers):
    """Sum a list recursively"""
    # Base case: empty list
    if not numbers:
        return 0
    # Recursive case: first element + sum of rest
    return numbers[0] + recursive_sum(numbers[1:])

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

# 4. Recursive list flattening
def flatten_list(nested_list):
    """Flatten a nested list recursively"""
    result = []
    for item in nested_list:
        if isinstance(item, list):
            # Recursive call for nested list
            result.extend(flatten_list(item))
        else:
            result.append(item)
    return result

nested = [1, [2, 3], [4, [5, 6]], 7]
print(f"Flattened: {flatten_list(nested)}")  # [1, 2, 3, 4, 5, 6, 7]

# 5. Recursive binary search
def binary_search(arr, target, low=0, high=None):
    """Binary search using recursion"""
    if high is None:
        high = len(arr) - 1
    
    # Base case: element not found
    if low > high:
        return -1
    
    mid = (low + high) // 2
    
    # Base case: element found
    if arr[mid] == target:
        return mid
    # Recursive cases
    elif arr[mid] > target:
        return binary_search(arr, target, low, mid - 1)
    else:
        return binary_search(arr, target, mid + 1, high)

sorted_numbers = [1, 3, 5, 7, 9, 11, 13, 15]
print(f"Index of 7: {binary_search(sorted_numbers, 7)}")    # 3
print(f"Index of 12: {binary_search(sorted_numbers, 12)}")  # -1 (not found)

# 6. Recursive directory traversal
import os

def list_files(path, indent=0):
    """List all files in directory recursively"""
    try:
        items = os.listdir(path)
    except PermissionError:
        return
    
    for item in items:
        full_path = os.path.join(path, item)
        if os.path.isfile(full_path):
            print(" " * indent + f"📄 {item}")
        elif os.path.isdir(full_path):
            print(" " * indent + f"📁 {item}/")
            list_files(full_path, indent + 2)

# Uncomment to run (adjust path as needed)
# list_files(".")

# 7. Recursive palindrome checker
def is_palindrome(s):
    """Check if string is palindrome recursively"""
    # Base cases
    if len(s) <= 1:
        return True
    # Check first and last characters
    if s[0] != s[-1]:
        return False
    # Recursive call on middle portion
    return is_palindrome(s[1:-1])

print(f"'racecar' is palindrome: {is_palindrome('racecar')}")      # True
print(f"'python' is palindrome: {is_palindrome('python')}")        # False
print(f"'a' is palindrome: {is_palindrome('a')}")                  # True

# 8. Tower of Hanoi
def tower_of_hanoi(n, source, target, auxiliary):
    """Solve Tower of Hanoi puzzle recursively"""
    if n == 1:
        print(f"Move disk 1 from {source} to {target}")
        return
    
    tower_of_hanoi(n - 1, source, auxiliary, target)
    print(f"Move disk {n} from {source} to {target}")
    tower_of_hanoi(n - 1, auxiliary, target, source)

print("\nTower of Hanoi (3 disks):")
tower_of_hanoi(3, 'A', 'C', 'B')

# 9. Recursive power function
def power(base, exponent):
    """Calculate base^exponent recursively"""
    # Base case
    if exponent == 0:
        return 1
    # Recursive case
    return base * power(base, exponent - 1)

print(f"2^5 = {power(2, 5)}")    # 32
print(f"3^4 = {power(3, 4)}")    # 81
print(f"5^0 = {power(5, 0)}")    # 1

# 10. GCD (Greatest Common Divisor) using Euclidean algorithm
def gcd(a, b):
    """Calculate GCD recursively using Euclidean algorithm"""
    # Base case
    if b == 0:
        return a
    # Recursive case
    return gcd(b, a % b)

print(f"GCD(48, 18) = {gcd(48, 18)}")    # 6
print(f"GCD(17, 5) = {gcd(17, 5)}")      # 1

# 11. Tail recursion (Python doesn't optimize it, but concept is important)
def factorial_tail(n, accumulator=1):
    """Factorial using tail recursion"""
    if n == 0:
        return accumulator
    return factorial_tail(n - 1, n * accumulator)

print(f"5! (tail recursive) = {factorial_tail(5)}")  # 120

# 12. Memoization for recursive functions (optimization)
def fibonacci_memo(n, memo=None):
    """Fibonacci with memoization to avoid repeated calculations"""
    if memo is None:
        memo = {}
    
    # Check if already computed
    if n in memo:
        return memo[n]
    
    # Base cases
    if n <= 1:
        return n
    
    # Compute and store in memo
    memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
    return memo[n]

print(f"Fibonacci(40) with memoization = {fibonacci_memo(40)}")  # Fast
# Without memoization, fibonacci(40) would take extremely long

# 13. Recursive tree structure
class TreeNode:
    def __init__(self, value):
        self.value = value
        self.children = []
    
    def add_child(self, node):
        self.children.append(node)
    
    def __repr__(self, level=0):
        ret = "  " * level + str(self.value) + "\n"
        for child in self.children:
            ret += child.__repr__(level + 1)
        return ret

# Create a tree
root = TreeNode("Root")
child1 = TreeNode("Child 1")
child2 = TreeNode("Child 2")
grandchild1 = TreeNode("Grandchild 1")
grandchild2 = TreeNode("Grandchild 2")

root.add_child(child1)
root.add_child(child2)
child1.add_child(grandchild1)
child2.add_child(grandchild2)

print("\nTree structure:")
print(root)

Function Practice Exercises

Try these exercises to test your understanding of Python functions.

Function Practice Script
# Python Functions Practice Exercises

print("=== Exercise 1: Basic Functions ===")
# 1. Create a function that checks if a number is prime
def is_prime(n):
    """Return True if n is prime, False otherwise"""
    if n < 2:
        return False
    for i in range(2, int(n**0.5) + 1):
        if n % i == 0:
            return False
    return True

print(f"is_prime(7): {is_prime(7)}")    # True
print(f"is_prime(10): {is_prime(10)}")  # False
print(f"is_prime(1): {is_prime(1)}")    # False

# 2. Create a function that returns the nth Fibonacci number iteratively
def fibonacci_iterative(n):
    """Return nth Fibonacci number without recursion"""
    if n <= 0:
        return 0
    elif n == 1:
        return 1
    
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

print(f"\nFibonacci(10) iterative: {fibonacci_iterative(10)}")  # 55

print("\n=== Exercise 2: Function Parameters ===")
# 3. Create a function that accepts any number of arguments and returns their average
def calculate_average(*args):
    """Calculate average of any number of arguments"""
    if not args:
        return 0
    return sum(args) / len(args)

print(f"Average of 1, 2, 3, 4, 5: {calculate_average(1, 2, 3, 4, 5):.2f}")  # 3.00
print(f"Average of 10, 20: {calculate_average(10, 20):.2f}")                # 15.00

# 4. Create a function with type hints and default parameters
def format_name(first: str, last: str, title: str = "", middle: str = "") -> str:
    """Format a name with optional title and middle name"""
    parts = []
    if title:
        parts.append(title)
    parts.append(first)
    if middle:
        parts.append(middle)
    parts.append(last)
    return " ".join(parts)

print(f"\nFormatted name: {format_name('John', 'Doe')}")
print(f"Formatted name with title: {format_name('John', 'Doe', title='Dr.')}")
print(f"Formatted full name: {format_name('John', 'Doe', middle='Michael', title='Mr.')}")

print("\n=== Exercise 3: Lambda Functions ===")
# 5. Use lambda with map() to convert temperatures from Celsius to Fahrenheit
celsius_temps = [0, 10, 20, 30, 40]
to_fahrenheit = lambda c: (c * 9/5) + 32
fahrenheit_temps = list(map(to_fahrenheit, celsius_temps))
print(f"Celsius: {celsius_temps}")
print(f"Fahrenheit: {fahrenheit_temps}")

# 6. Use lambda with filter() to find palindromic numbers
numbers = [121, 123, 1331, 456, 555, 78987]
is_palindromic = lambda n: str(n) == str(n)[::-1]
palindromes = list(filter(is_palindromic, numbers))
print(f"\nPalindromic numbers: {palindromes}")  # [121, 1331, 555, 78987]

print("\n=== Exercise 4: Decorators ===")
# 7. Create a decorator that validates function arguments
def validate_args(min_val=None, max_val=None):
    """Decorator factory for validating argument ranges"""
    def decorator(func):
        def wrapper(*args, **kwargs):
            # Check positional arguments
            for arg in args:
                if isinstance(arg, (int, float)):
                    if min_val is not None and arg < min_val:
                        raise ValueError(f"Value {arg} below minimum {min_val}")
                    if max_val is not None and arg > max_val:
                        raise ValueError(f"Value {arg} above maximum {max_val}")
            
            # Check keyword arguments
            for key, value in kwargs.items():
                if isinstance(value, (int, float)):
                    if min_val is not None and value < min_val:
                        raise ValueError(f"{key}={value} below minimum {min_val}")
                    if max_val is not None and value > max_val:
                        raise ValueError(f"{key}={value} above maximum {max_val}")
            
            return func(*args, **kwargs)
        return wrapper
    return decorator

@validate_args(min_val=0, max_val=100)
def calculate_score(score1, score2):
    return (score1 + score2) / 2

try:
    result = calculate_score(85, 95)
    print(f"Average score: {result}")
    # calculate_score(-5, 95)  # Would raise ValueError
except ValueError as e:
    print(f"Error: {e}")

print("\n=== Exercise 5: Recursion ===")
# 8. Implement recursive function to calculate sum of digits
def sum_digits(n):
    """Return sum of digits of n using recursion"""
    if n < 10:
        return n
    return (n % 10) + sum_digits(n // 10)

print(f"Sum of digits in 12345: {sum_digits(12345)}")  # 15 (1+2+3+4+5)
print(f"Sum of digits in 987: {sum_digits(987)}")      # 24 (9+8+7)

# 9. Implement recursive binary tree traversal
def count_tree_nodes(node):
    """Count nodes in a binary tree recursively"""
    if node is None:
        return 0
    return 1 + count_tree_nodes(node.left) + count_tree_nodes(node.right)

# Mock tree structure for demonstration
class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

# Create a sample tree
tree = TreeNode(1,
                TreeNode(2,
                         TreeNode(4),
                         TreeNode(5)),
                TreeNode(3,
                         TreeNode(6),
                         TreeNode(7)))

print(f"Number of nodes in tree: {count_tree_nodes(tree)}")  # 7

print("\n=== Exercise 6: Advanced Functions ===")
# 10. Create a closure that generates sequential IDs
def id_generator(prefix="ID", start=1):
    """Create a function that generates sequential IDs"""
    current = start
    
    def generate():
        nonlocal current
        id_str = f"{prefix}{current:04d}"
        current += 1
        return id_str
    
    return generate

# Create different generators
user_id_gen = id_generator("USER", 100)
order_id_gen = id_generator("ORDER", 1)

print("User IDs:", user_id_gen(), user_id_gen(), user_id_gen())
print("Order IDs:", order_id_gen(), order_id_gen(), order_id_gen())

# 11. Create a higher-order function that applies a function n times
def apply_n_times(func, n):
    """Return a function that applies func n times"""
    def wrapper(x):
        result = x
        for _ in range(n):
            result = func(result)
        return result
    return wrapper

double = lambda x: x * 2
double_twice = apply_n_times(double, 2)
double_thrice = apply_n_times(double, 3)

print(f"\nDouble 5 once: {double(5)}")          # 10
print(f"Double 5 twice: {double_twice(5)}")    # 20 (5*2*2)
print(f"Double 5 thrice: {double_thrice(5)}")  # 40 (5*2*2*2)

# 12. Create a function that returns multiple functions
def calculator_factory():
    """Factory that returns multiple calculator functions"""
    def add(a, b):
        return a + b
    
    def subtract(a, b):
        return a - b
    
    def multiply(a, b):
        return a * b
    
    def divide(a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b
    
    return add, subtract, multiply, divide

add, subtract, multiply, divide = calculator_factory()
print(f"\n10 + 5 = {add(10, 5)}")
print(f"10 - 5 = {subtract(10, 5)}")
print(f"10 * 5 = {multiply(10, 5)}")
print(f"10 / 5 = {divide(10, 5)}")

# 13. Error handling in functions
def safe_divide(numerator, denominator):
    """Divide with proper error handling"""
    try:
        return numerator / denominator
    except ZeroDivisionError:
        return float('inf') if numerator > 0 else float('-inf') if numerator < 0 else float('nan')
    except TypeError:
        raise TypeError("Both arguments must be numbers")

print(f"\nSafe divide 10 / 2: {safe_divide(10, 2)}")    # 5.0
print(f"Safe divide 10 / 0: {safe_divide(10, 0)}")     # inf
print(f"Safe divide 0 / 0: {safe_divide(0, 0)}")       # nan

Key Takeaways

  • Functions are defined using the def keyword and called with parentheses
  • Use docstrings (triple quotes) to document functions
  • Functions can have parameters (inputs) and return values (outputs)
  • Python supports multiple parameter types: positional, keyword, default, *args, **kwargs
  • Lambda functions are anonymous, one-line functions: lambda x: x*2
  • Decorators modify function behavior using @decorator syntax
  • Scope follows LEGB rule: Local → Enclosing → Global → Built-in
  • Closures are functions that remember their enclosing scope
  • Recursion occurs when a function calls itself (needs base case!)
  • Use global to modify global variables, nonlocal for enclosing scope
  • Type hints (Python 3.5+) improve code readability: def func(x: int) -> str:
  • Avoid mutable default arguments (use None instead)
  • Pure functions (no side effects) are easier to test and reason about
  • Follow the Single Responsibility Principle: each function should do one thing well