Python Functions: A Deep Dive
Mastering one of Python's most fundamental building blocks
Table of Contents
1. Function Definition
Basics
Functions in Python are defined using the def
keyword followed by the function name and parentheses.
def greet(name):
"""Return a greeting message."""
return f"Hello, {name}!"
Function naming conventions
- Use snake_case (all lowercase with underscores)
- Should be descriptive but concise
- Verbs for actions (e.g.,
calculate_total
) - Follow PEP 8 guidelines
The pass keyword
Use pass
as a placeholder for empty functions:
def todo_function():
pass # Implement later
Defining vs calling
# Defining a function
def say_hello():
print("Hello!")
# Calling the function
say_hello() # Output: Hello!
Advanced Concepts
First-class functions
Functions are objects that can be assigned to variables, passed as arguments, and returned from other functions.
def square(x):
return x * x
# Assign function to variable
func = square
print(func(5)) # 25
# Pass function as argument
def apply_func(f, x):
return f(x)
print(apply_func(square, 4)) # 16
Docstrings and __doc__
Document functions with docstrings (following PEP 257):
def calculate_area(width, height):
"""Calculate the area of a rectangle.
Args:
width (float): The width of the rectangle
height (float): The height of the rectangle
Returns:
float: The area (width * height)
"""
return width * height
# Access documentation
print(calculate_area.__doc__)
help(calculate_area)
Function annotations
Type hints for parameters and return values (PEP 484):
def repeat(text: str, count: int) -> str:
"""Return text repeated count times."""
return text * count
Namespace and scope
Functions have access to their local scope and enclosing scopes:
x = "global"
def outer():
x = "outer"
def inner():
nonlocal x # Refers to x in outer()
x = "inner"
inner()
print(x) # "inner"
outer()
print(x) # "global"
2. Parameters and Arguments
Parameter Types
Positional arguments
Matched by position in the function call:
def power(base, exponent):
return base ** exponent
print(power(2, 3)) # 8 (2 is base, 3 is exponent)
Keyword arguments
Matched by parameter name:
print(power(exponent=3, base=2)) # 8
Default parameters
Parameters with default values become optional:
def greet(name="World"):
print(f"Hello, {name}!")
greet() # Hello, World!
greet("Alice") # Hello, Alice!
Warning: Mutable default arguments are a common pitfall (see Advanced Concepts below).
Variable-length arguments
Collect arbitrary numbers of arguments:
def sum_all(*args):
"""Sum all positional arguments."""
return sum(args)
print(sum_all(1, 2, 3)) # 6
def print_details(**kwargs):
"""Print all keyword arguments."""
for key, value in kwargs.items():
print(f"{key}: {value}")
print_details(name="Alice", age=25)
Advanced Concepts
Argument unpacking
Use *
and **
to unpack sequences and dictionaries:
def point(x, y):
print(f"Point at ({x}, {y})")
coordinates = (3, 4)
point(*coordinates) # Point at (3, 4)
params = {'x': 1, 'y': 2}
point(**params) # Point at (1, 2)
Mutable default arguments
Default arguments are evaluated once when the function is defined:
# Anti-pattern
def add_item(item, items=[]):
items.append(item)
return items
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] - Surprise!
# Correct approach
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
Keyword-only arguments
Force arguments to be passed by keyword (PEP 3102):
def create_user(name, *, email, phone):
"""Parameters after * must be keyword arguments"""
print(f"Creating {name} with {email} and {phone}")
create_user("Alice", email="a@example.com", phone="1234") # OK
# create_user("Bob", "b@example.com", "5678") # Error
Positional-only arguments
Force arguments to be positional (PEP 570, Python 3.8+):
def power(base, exponent, /):
"""Parameters before / must be positional"""
return base ** exponent
power(2, 3) # OK
# power(base=2, exponent=3) # Error
3. Built-in Functions
Commonly Used Built-ins
Category | Functions | Description |
---|---|---|
I/O | print(), input(), open() |
Basic input/output operations |
Type Conversion | int(), str(), list(), dict() |
Convert between types |
Math | abs(), round(), min(), max(), sum() |
Mathematical operations |
Iteration | len(), range(), enumerate(), zip() |
Working with sequences |
Advanced Built-ins
Functional Programming
# map() - Apply function to each item
numbers = [1, 2, 3]
squared = list(map(lambda x: x**2, numbers)) # [1, 4, 9]
# filter() - Select items where function returns True
evens = list(filter(lambda x: x % 2 == 0, numbers)) # [2]
# reduce() - Cumulatively apply function (from functools)
from functools import reduce
product = reduce(lambda x, y: x * y, numbers) # 6
Dynamic Code Execution
Security Warning: eval()
and exec()
can execute arbitrary code and should be used with extreme caution.
# eval() - Evaluate expression
result = eval("2 + 3 * 4") # 14
# exec() - Execute statements
exec("""
def hello():
print("Hello from exec!")
""")
hello() # Hello from exec!
Type Checking
print(isinstance(42, int)) # True
print(issubclass(bool, int)) # True (bool is a subclass of int)
Namespace Inspection
def show_locals():
x = 10
print(locals()) # {'x': 10}
show_locals()
print(globals().keys()) # Shows global names
4. Lambda Functions
Basics
Lambda functions are small anonymous functions defined with the lambda
keyword.
# Regular function
def square(x):
return x * x
# Equivalent lambda
square = lambda x: x * x
Use cases
Best for short, one-time operations where a full function definition would be verbose:
# Sorting with a key
names = ["Alice", "Bob", "Charlie"]
names.sort(key=lambda name: len(name)) # Sort by length
Limitations
- Single expression only (no statements)
- No annotations or documentation string
- Often less readable than named functions for complex logic
Advanced Usage
With built-in functions
# map() example
numbers = [1, 2, 3]
squared = list(map(lambda x: x**2, numbers)) # [1, 4, 9]
# filter() example
evens = list(filter(lambda x: x % 2 == 0, numbers)) # [2]
# sorted() with custom key
people = [{"name": "Alice", "age": 25}, {"name": "Bob", "age": 30}]
sorted_people = sorted(people, key=lambda p: p["age"])
Closures and late binding
A common gotcha with lambdas in loops:
# Problematic - all lambdas use last i value
functions = []
for i in range(3):
functions.append(lambda: i)
print([f() for f in functions]) # [2, 2, 2]
# Solution - capture current i value
functions = []
for i in range(3):
functions.append(lambda i=i: i) # Default arg captures current i
print([f() for f in functions]) # [0, 1, 2]
When not to use lambdas
Avoid lambdas when:
- The logic is complex enough to need a docstring
- The function is used in multiple places
- You need type hints or other function metadata
- The expression becomes hard to read
5. Decorators
Basic Decorators
Decorators modify or enhance functions without changing their source code.
What is a decorator?
A function that takes another function and extends its behavior.
def my_decorator(func):
def wrapper():
print("Something is happening before the function is called.")
func()
print("Something is happening after the function is called.")
return wrapper
@my_decorator
def say_hello():
print("Hello!")
say_hello()
# Output:
# Something is happening before the function is called.
# Hello!
# Something is happening after the function is called.
Creating a simple decorator
def timer(func):
"""Measure and print execution time of a function."""
import time
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} executed in {end-start:.4f}s")
return result
return wrapper
@timer
def slow_function():
time.sleep(1)
slow_function() # slow_function executed in 1.0001s
Common use cases
- Logging
- Timing
- Authentication/authorization
- Caching/memoization
- Validation
Advanced Decorator Concepts
Decorators with arguments
Requires an extra level of nesting:
def repeat(num_times):
"""Decorator that repeats function call num_times."""
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(num_times=3)
def greet(name):
print(f"Hello {name}")
greet("Alice")
# Output:
# Hello Alice
# Hello Alice
# Hello Alice
Class-based decorators
Implement __call__
to make instances callable:
class CountCalls:
def __init__(self, func):
self.func = func
self.num_calls = 0
def __call__(self, *args, **kwargs):
self.num_calls += 1
print(f"Call {self.num_calls} of {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
print("Hello")
say_hello() # Call 1 of say_hello
say_hello() # Call 2 of say_hello
Preserving metadata with functools.wraps
Decorators obscure the original function's metadata. functools.wraps
fixes this:
from functools import wraps
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
"""Wrapper docstring"""
return func(*args, **kwargs)
return wrapper
@decorator
def example():
"""Example docstring"""
pass
print(example.__name__) # "example" (without wraps: "wrapper")
print(example.__doc__) # "Example docstring"
Stacking decorators
Decorators are applied from bottom to top:
@decorator1
@decorator2
@decorator3
def my_function():
pass
# Equivalent to:
my_function = decorator1(decorator2(decorator3(my_function)))
Built-in decorators
Decorator | Description |
---|---|
@staticmethod |
Method that doesn't receive self/cls |
@classmethod |
Method that receives class as first arg |
@property |
Define getter for an attribute |
@functools.lru_cache |
Memoization decorator |
@dataclasses.dataclass |
Auto-generate special methods for classes |
6. Function Execution & Memory
Stack Frames
Python maintains a call stack with frame objects for each function call:
import inspect
def show_stack():
for frame in inspect.stack():
print(frame.function, frame.lineno)
def outer():
show_stack()
outer()
Recursion
Python has a recursion limit (usually 1000) to prevent stack overflows:
import sys
def factorial(n):
return 1 if n <= 1 else n * factorial(n - 1)
print(factorial(5)) # 120
# Change recursion limit (use with caution!)
sys.setrecursionlimit(2000)
Generator Functions
Functions that use yield
return a generator iterator:
def count_up_to(max):
count = 1
while count <= max:
yield count
count += 1
counter = count_up_to(5)
print(next(counter)) # 1
print(next(counter)) # 2
# Iterate through remaining
for num in counter:
print(num) # 3, 4, 5
7. Error Handling in Functions
Raising Exceptions
def validate_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
return age
try:
validate_age(-5)
except ValueError as e:
print(f"Error: {e}")
Custom Exceptions
class InvalidEmailError(Exception):
"""Raised when an invalid email is provided"""
pass
def validate_email(email):
if "@" not in email:
raise InvalidEmailError(f"Invalid email: {email}")
try:
validate_email("user.example.com")
except InvalidEmailError as e:
print(e)
8. Functional Programming in Python
Pure Functions
Functions with no side effects that always return the same output for the same input:
# Pure function
def square(x):
return x * x
# Not pure (has side effect)
def square_impure(x):
print("Squaring", x)
return x * x
Higher-Order Functions
Functions that take or return other functions:
def make_adder(n):
"""Return a function that adds n to its argument."""
def adder(x):
return x + n
return adder
add5 = make_adder(5)
print(add5(3)) # 8
Partial Function Application
Fix some arguments of a function to create a new function:
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(3)) # 27