📓 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.
-
returnsends 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:
-
Write a function to find the maximum of two numbers.
-
Create a lambda function to calculate the cube of a number.
-
Write a function that returns the sum, product, and difference of two numbers.
-
Use a default argument to greet a user if no name is provided.
-
Demonstrate variable scope by defining a global variable and modifying it inside a function.
Intermediate Level:
-
Create a function that accepts args and *kwargs to handle variable arguments.
-
Write a decorator that logs function calls and execution time.
-
Implement a recursive function to calculate the nth Fibonacci number.
-
Create a generator function that yields even numbers up to a given limit.
-
Write a function with proper type hints and comprehensive docstring.
Advanced Level:
-
Build a higher-order function that applies different operations to a list of numbers.
-
Create a closure that maintains state between function calls.
-
Implement a custom decorator with arguments that can modify function behavior.
-
Write a recursive function to traverse a nested dictionary structure.
-
Create a generator that processes large datasets in chunks.
Expert Level:
-
Build a function registry system using decorators.
-
Implement a memoization decorator for optimizing recursive functions.
-
Create a context manager function using generator syntax.
-
Write a function that uses multiple advanced features (type hints, error handling, logging).
-
Build a simple functional programming toolkit with map, filter, and reduce implementations.