Python Decorators and Lists: A Comprehensive Guide
Introduction
Python decorators and lists are two fundamental concepts that every intermediate Python developer should master. Lists are the workhorses of Python data structuresโversatile, mutable, and essential for storing collections of items. Decorators, on the other hand, are a more advanced feature that allows you to modify or enhance functions and classes without changing their source code directly.
This guide provides a practical reference for understanding and implementing both concepts, progressing from basic usage to more advanced patterns.
Part 1: Python Decorators
What Are Decorators?
A decorator is a function that takes another function or class as input and extends its behavior without permanently modifying it. Decorators are a form of metaprogrammingโthey allow you to “wrap” a function with additional functionality.
Why use decorators?
- Code reusability: Apply the same logic to multiple functions
- Separation of concerns: Keep cross-cutting concerns (logging, timing, validation) separate from business logic
- Cleaner code: Avoid repetitive wrapper code
- Maintainability: Centralize common functionality in one place
Basic Decorator Syntax and Structure
At their core, decorators are functions that return functions. Here’s the simplest possible decorator:
def my_decorator(func):
def wrapper():
print("Something before the function call")
func()
print("Something after the function call")
return wrapper
@my_decorator
def say_hello():
print("Hello!")
say_hello()
Output:
Something before the function call
Hello!
Something after the function call
The @my_decorator syntax is equivalent to:
say_hello = my_decorator(say_hello)
Decorators with Arguments
Most real-world functions accept arguments. To handle this, use *args and **kwargs:
def my_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
return result
return wrapper
@my_decorator
def add(a, b):
return a + b
result = add(5, 3)
print(f"Result: {result}")
Output:
Calling add with args=(5, 3), kwargs={}
Result: 8
Preserving Function Metadata
When you decorate a function, the wrapper function replaces the original. This means the function’s name, docstring, and other metadata are lost. Use functools.wraps to preserve this information:
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
"""Wrapper function"""
return func(*args, **kwargs)
return wrapper
@my_decorator
def calculate(x, y):
"""Add two numbers together"""
return x + y
print(calculate.__name__) # Output: calculate
print(calculate.__doc__) # Output: Add two numbers together
Practical Use Case 1: Timing Decorator
A common use case is measuring how long a function takes to execute:
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
elapsed = end_time - start_time
print(f"{func.__name__} took {elapsed:.4f} seconds")
return result
return wrapper
@timer
def slow_function():
time.sleep(2)
return "Done!"
slow_function()
Output:
slow_function took 2.0001 seconds
Done!
Practical Use Case 2: Logging Decorator
Decorators are excellent for adding logging without cluttering your function code:
import logging
from functools import wraps
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
logger.info(f"Calling {func.__name__}")
logger.info(f"Arguments: args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
logger.info(f"Result: {result}")
return result
return wrapper
@log_calls
def divide(a, b):
return a / b
divide(10, 2)
Output:
INFO:__main__:Calling divide
INFO:__main__:Arguments: args=(10, 2), kwargs={}
INFO:__main__:Result: 5.0
Practical Use Case 3: Validation Decorator
Decorators can validate input before a function executes:
from functools import wraps
def validate_positive(*arg_names):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Get function parameter names
import inspect
sig = inspect.signature(func)
bound_args = sig.bind(*args, **kwargs)
bound_args.apply_defaults()
# Validate specified arguments
for arg_name in arg_names:
if arg_name in bound_args.arguments:
value = bound_args.arguments[arg_name]
if value <= 0:
raise ValueError(f"{arg_name} must be positive, got {value}")
return func(*args, **kwargs)
return wrapper
return decorator
@validate_positive('x', 'y')
def calculate_area(x, y):
return x * y
print(calculate_area(5, 10)) # Output: 50
# calculate_area(-5, 10) # Raises ValueError
Stacking Decorators
You can apply multiple decorators to a single function. They execute from bottom to top:
def decorator_a(func):
def wrapper(*args, **kwargs):
print("A: Before")
result = func(*args, **kwargs)
print("A: After")
return result
return wrapper
def decorator_b(func):
def wrapper(*args, **kwargs):
print("B: Before")
result = func(*args, **kwargs)
print("B: After")
return result
return wrapper
@decorator_a
@decorator_b
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
Output:
A: Before
B: Before
Hello, Alice!
B: After
A: After
Parameterized Decorators
Sometimes you want to customize decorator behavior. Create a decorator factory:
from functools import wraps
def repeat(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
results = []
for _ in range(times):
result = func(*args, **kwargs)
results.append(result)
return results
return wrapper
return decorator
@repeat(times=3)
def greet(name):
return f"Hello, {name}!"
print(greet("Bob"))
Output:
['Hello, Bob!', 'Hello, Bob!', 'Hello, Bob!']
Class Decorators
Decorators can also be applied to classes:
def add_repr(cls):
def __repr__(self):
attrs = ', '.join(f"{k}={v}" for k, v in self.__dict__.items())
return f"{cls.__name__}({attrs})"
cls.__repr__ = __repr__
return cls
@add_repr
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
person = Person("Alice", 30)
print(person) # Output: Person(name=Alice, age=30)
Part 2: Python Lists
What Are Lists?
A list is an ordered, mutable collection of items in Python. Lists can contain any type of objectโintegers, strings, floats, other lists, or even mixed types. They’re one of the most frequently used data structures in Python.
Key characteristics:
- Ordered: Items maintain their position
- Mutable: You can modify, add, or remove items
- Indexed: Access items by position
- Heterogeneous: Can contain different data types
Creating Lists
Method 1: List Literal
The simplest way to create a list is using square brackets:
# Empty list
empty_list = []
# List with integers
numbers = [1, 2, 3, 4, 5]
# List with strings
fruits = ["apple", "banana", "cherry"]
# Mixed types
mixed = [1, "hello", 3.14, True, None]
# Nested lists
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
Method 2: List Constructor
Use the list() constructor to create a list from an iterable:
# From a string
chars = list("hello")
print(chars) # Output: ['h', 'e', 'l', 'l', 'o']
# From a range
numbers = list(range(5))
print(numbers) # Output: [0, 1, 2, 3, 4]
# From a tuple
tuple_data = (1, 2, 3)
list_data = list(tuple_data)
print(list_data) # Output: [1, 2, 3]
# Empty list
empty = list()
print(empty) # Output: []
Method 3: List Comprehension
List comprehensions provide a concise way to create lists:
# Simple comprehension
squares = [x**2 for x in range(5)]
print(squares) # Output: [0, 1, 4, 9, 16]
# With condition
evens = [x for x in range(10) if x % 2 == 0]
print(evens) # Output: [0, 2, 4, 6, 8]
# Nested comprehension
matrix = [[i*j for j in range(3)] for i in range(3)]
print(matrix) # Output: [[0, 0, 0], [0, 1, 2], [0, 2, 4]]
# String manipulation
words = ["hello", "world", "python"]
uppercase = [word.upper() for word in words]
print(uppercase) # Output: ['HELLO', 'WORLD', 'PYTHON']
Indexing
Lists use zero-based indexing. Access individual elements by their position:
Positive Indexing
fruits = ["apple", "banana", "cherry", "date", "elderberry"]
print(fruits[0]) # Output: apple
print(fruits[2]) # Output: cherry
print(fruits[4]) # Output: elderberry
Negative Indexing
Negative indices count from the end of the list:
fruits = ["apple", "banana", "cherry", "date", "elderberry"]
print(fruits[-1]) # Output: elderberry (last item)
print(fruits[-2]) # Output: date (second to last)
print(fruits[-5]) # Output: apple (first item)
Index Out of Range
Accessing an invalid index raises an IndexError:
fruits = ["apple", "banana", "cherry"]
# print(fruits[10]) # IndexError: list index out of range
Slicing
Slicing extracts a portion of a list using the syntax list[start:stop:step]:
Basic Slicing
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# [start:stop] - includes start, excludes stop
print(numbers[2:5]) # Output: [2, 3, 4]
print(numbers[0:3]) # Output: [0, 1, 2]
# Omit start (defaults to 0)
print(numbers[:4]) # Output: [0, 1, 2, 3]
# Omit stop (defaults to end)
print(numbers[5:]) # Output: [5, 6, 7, 8, 9]
# Omit both (creates a copy)
print(numbers[:]) # Output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Slicing with Step
The step parameter controls the interval:
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Every second element
print(numbers[::2]) # Output: [0, 2, 4, 6, 8]
# Every third element starting from index 1
print(numbers[1::3]) # Output: [1, 4, 7]
# Reverse the list
print(numbers[::-1]) # Output: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
# Reverse every second element
print(numbers[::-2]) # Output: [9, 7, 5, 3, 1]
Negative Indices in Slicing
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Last 3 elements
print(numbers[-3:]) # Output: [7, 8, 9]
# All but last 2 elements
print(numbers[:-2]) # Output: [0, 1, 2, 3, 4, 5, 6, 7]
# Middle elements using negative indices
print(numbers[-7:-2]) # Output: [3, 4, 5, 6, 7]
Essential List Methods
append()
Add a single item to the end of the list:
fruits = ["apple", "banana"]
fruits.append("cherry")
print(fruits) # Output: ['apple', 'banana', 'cherry']
# append() modifies the list in-place and returns None
result = fruits.append("date")
print(result) # Output: None
When to use: Adding one item at a time to a list.
extend()
Add multiple items from an iterable to the end of the list:
fruits = ["apple", "banana"]
fruits.extend(["cherry", "date"])
print(fruits) # Output: ['apple', 'banana', 'cherry', 'date']
# extend() with a string (iterates over characters)
letters = ["a", "b"]
letters.extend("cd")
print(letters) # Output: ['a', 'b', 'c', 'd']
When to use: Adding multiple items from another iterable. More efficient than multiple append() calls.
insert()
Insert an item at a specific position:
fruits = ["apple", "cherry"]
fruits.insert(1, "banana")
print(fruits) # Output: ['apple', 'banana', 'cherry']
# Insert at the beginning
fruits.insert(0, "avocado")
print(fruits) # Output: ['avocado', 'apple', 'banana', 'cherry']
# Insert beyond the list length (adds to the end)
fruits.insert(100, "date")
print(fruits) # Output: ['avocado', 'apple', 'banana', 'cherry', 'date']
When to use: Adding an item at a specific position. Note: This is slower than append() for large lists.
remove()
Remove the first occurrence of a value:
fruits = ["apple", "banana", "cherry", "banana"]
fruits.remove("banana")
print(fruits) # Output: ['apple', 'cherry', 'banana']
# Raises ValueError if item not found
# fruits.remove("grape") # ValueError: list.remove(x): x not in list
When to use: Removing a specific value when you know it exists. Use with cautionโraises an error if the item isn’t found.
pop()
Remove and return an item at a specific index (default: last item):
fruits = ["apple", "banana", "cherry"]
# Remove and return the last item
last = fruits.pop()
print(last) # Output: cherry
print(fruits) # Output: ['apple', 'banana']
# Remove and return item at index 0
first = fruits.pop(0)
print(first) # Output: apple
print(fruits) # Output: ['banana']
# Raises IndexError if index is out of range
# fruits.pop(10) # IndexError: pop index out of range
When to use: Removing an item and using its value. Useful for implementing stacks and queues.
index()
Find the index of the first occurrence of a value:
fruits = ["apple", "banana", "cherry", "banana"]
index = fruits.index("banana")
print(index) # Output: 1
# Raises ValueError if item not found
# fruits.index("grape") # ValueError: 'grape' is not in list
# Search within a range
index = fruits.index("banana", 2) # Start searching from index 2
print(index) # Output: 3
When to use: Finding the position of an item. Remember it returns the first occurrence.
count()
Count occurrences of a value:
numbers = [1, 2, 2, 3, 2, 4, 2, 5]
count = numbers.count(2)
print(count) # Output: 4
# Returns 0 if item not found
print(numbers.count(10)) # Output: 0
When to use: Determining how many times a value appears in a list.
sort()
Sort the list in-place:
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
numbers.sort()
print(numbers) # Output: [1, 1, 2, 3, 4, 5, 6, 9]
# Sort in descending order
numbers.sort(reverse=True)
print(numbers) # Output: [9, 6, 5, 4, 3, 2, 1, 1]
# Sort with custom key
words = ["apple", "pie", "a", "longer"]
words.sort(key=len)
print(words) # Output: ['a', 'pie', 'apple', 'longer']
# Sort strings case-insensitively
words = ["Apple", "banana", "Cherry"]
words.sort(key=str.lower)
print(words) # Output: ['Apple', 'banana', 'Cherry']
When to use: Sorting a list in-place. Returns None, so don’t assign the result to a variable.
reverse()
Reverse the list in-place:
numbers = [1, 2, 3, 4, 5]
numbers.reverse()
print(numbers) # Output: [5, 4, 3, 2, 1]
# reverse() returns None
result = numbers.reverse()
print(result) # Output: None
When to use: Reversing a list in-place. For a non-destructive reverse, use slicing: numbers[::-1].
copy()
Create a shallow copy of the list:
original = [1, 2, 3]
copy_list = original.copy()
copy_list.append(4)
print(original) # Output: [1, 2, 3]
print(copy_list) # Output: [1, 2, 3, 4]
# Alternative: use slicing
copy_list2 = original[:]
When to use: Creating an independent copy of a list. Important for nested listsโthis is a shallow copy.
clear()
Remove all items from the list:
numbers = [1, 2, 3, 4, 5]
numbers.clear()
print(numbers) # Output: []
When to use: Emptying a list while keeping the same list object.
List Methods Comparison Table
| Method | Purpose | Modifies List | Returns |
|---|---|---|---|
append(x) |
Add single item | Yes | None |
extend(iterable) |
Add multiple items | Yes | None |
insert(i, x) |
Insert at position | Yes | None |
remove(x) |
Remove first occurrence | Yes | None |
pop([i]) |
Remove and return item | Yes | Item value |
index(x) |
Find position of item | No | Index |
count(x) |
Count occurrences | No | Count |
sort() |
Sort in-place | Yes | None |
reverse() |
Reverse in-place | Yes | None |
copy() |
Create shallow copy | No | New list |
clear() |
Remove all items | Yes | None |
Common List Operations
Checking Membership
fruits = ["apple", "banana", "cherry"]
print("apple" in fruits) # Output: True
print("grape" in fruits) # Output: False
print("grape" not in fruits) # Output: True
Finding Length
fruits = ["apple", "banana", "cherry"]
print(len(fruits)) # Output: 3
Concatenation
list1 = [1, 2, 3]
list2 = [4, 5, 6]
combined = list1 + list2
print(combined) # Output: [1, 2, 3, 4, 5, 6]
# Original lists unchanged
print(list1) # Output: [1, 2, 3]
Repetition
pattern = [1, 2]
repeated = pattern * 3
print(repeated) # Output: [1, 2, 1, 2, 1, 2]
Iteration
fruits = ["apple", "banana", "cherry"]
# Simple iteration
for fruit in fruits:
print(fruit)
# With index
for i, fruit in enumerate(fruits):
print(f"{i}: {fruit}")
# Output:
# 0: apple
# 1: banana
# 2: cherry
Advanced List Patterns
Unpacking
# Unpack all elements
a, b, c = [1, 2, 3]
print(a, b, c) # Output: 1 2 3
# Unpack with *
first, *middle, last = [1, 2, 3, 4, 5]
print(first) # Output: 1
print(middle) # Output: [2, 3, 4]
print(last) # Output: 5
List Comprehension with Conditions
# Filter and transform
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
even_squares = [x**2 for x in numbers if x % 2 == 0]
print(even_squares) # Output: [4, 16, 36, 64, 100]
# Conditional transformation
values = [1, 2, 3, 4, 5]
result = ["even" if x % 2 == 0 else "odd" for x in values]
print(result) # Output: ['odd', 'even', 'odd', 'even', 'odd']
Flattening Nested Lists
# Flatten a 2D list
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flattened = [item for row in matrix for item in row]
print(flattened) # Output: [1, 2, 3, 4, 5, 6, 7, 8, 9]
Sorting Complex Objects
# Sort dictionaries by a key
students = [
{"name": "Alice", "grade": 85},
{"name": "Bob", "grade": 92},
{"name": "Charlie", "grade": 78}
]
students.sort(key=lambda x: x["grade"], reverse=True)
for student in students:
print(f"{student['name']}: {student['grade']}")
# Output:
# Bob: 92
# Alice: 85
# Charlie: 78
Best Practices
Decorators
- Always use
functools.wraps: Preserve function metadata when creating decorators. - Keep decorators simple: Complex logic should be in the decorated function, not the decorator.
- Document decorator behavior: Clearly explain what the decorator does.
- Use type hints: Make decorator signatures clear with type annotations.
- Consider performance: Decorators add overhead; use them judiciously.
Lists
- Use list comprehensions: They’re more efficient and readable than loops for creating lists.
- Avoid modifying lists while iterating: This can cause unexpected behavior.
- Use appropriate methods: Choose
append()vsextend()based on your needs. - Be aware of shallow copies:
copy()doesn’t copy nested objects. - Consider alternatives: For specific use cases,
collections.dequeorarray.arraymight be more appropriate.
Conclusion
Decorators and lists are powerful tools in Python. Decorators enable elegant code organization and reusability, while lists provide flexible data storage and manipulation. Mastering both will significantly improve your Python programming skills and code quality.
Start with simple decorators for logging and timing, then explore more advanced patterns. For lists, practice different creation methods and methods until they become second nature. The combination of these skills will make you a more effective Python developer.
Comments