Python course Β· Module 3: Object-Oriented Programming
Decorators - adapting to the environment
In this lesson12
Welcome to the final lesson in Module 3! Darwin here with an advanced final lesson.
In nature, animals adapt to their environment - chameleons gain the ability to change color, bats develop echolocation, migratory birds develop magnetic navigation. In Python, decorators allow a similar adaptation - they modify the behavior of functions and classes without changing their code!
1# Regular function
2def observe_species(name):
3 print(f"Observation: {name}")
4
5# Function with decorator - automatic logging!
6@log_observation
7def observe_species(name):
8 print(f"Observation: {name}")The function body did not change. One line, @log_observation, above the definition is enough to log every call automatically. You will see how to write such a decorator in a moment.
What are decorators?
A decorator is a function that takes another function (or class) and returns a modified version.
Safari Analogy: It's like equipping a researcher - binoculars don't change the researcher, but they extend their observation capabilities!
1def my_decorator(func):
2 """Decorator - takes a function, returns modified version"""
3 def wrapper():
4 print("Something before the function")
5 func() # Call the original function
6 print("Something after the function")
7 return wrapper
8
9@my_decorator
10def say_hello():
11 print("Hello!")
12
13say_hello()Calling say_hello() prints:
1Something before the function
2Hello!
3Something after the functionThe middle line comes from the original function, the other two from the wrapper that the decorator put in place of say_hello.
The @decorator syntax is shorthand for:
1def say_hello():
2 print("Hello!")
3
4say_hello = my_decorator(say_hello) # Manual decoratingHow do decorators work?
A decorator is a higher-order function - a function that operates on functions!
1def simple_decorator(func):
2 """
3 Decorator - wrapper pattern
4
5 1. Accept a function
6 2. Define wrapper
7 3. Return wrapper
8 """
9 def wrapper():
10 print(f"[BEFORE] Calling {func.__name__}")
11 result = func()
12 print(f"[AFTER] Finished {func.__name__}")
13 return result
14 return wrapper
15
16@simple_decorator
17def greet():
18 print("Hello!")
19 return "Done"
20
21# greet is now wrapper, not the original function!
22greet()The program prints:
1[BEFORE] Calling greet
2Hello!
3[AFTER] Finished greetThe wrapper also returns the original function's result (result), so the value "Done" is not lost on the way.
Decorators with function arguments
For the decorator to work with functions that accept arguments, use *args and **kwargs:
1def log_decorator(func):
2 def wrapper(*args, **kwargs):
3 """Wrapper accepts any arguments"""
4 print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
5 result = func(*args, **kwargs)
6 print(f"Result: {result}")
7 return result
8 return wrapper
9
10@log_decorator
11def add(a, b):
12 return a + b
13
14@log_decorator
15def greet(name, greeting="Hello"):
16 return f"{greeting}, {name}!"
17
18add(5, 3)
19
20greet("Darwin", greeting="Hi")Both calls print:
1Calling add with args=(5, 3), kwargs={}
2Result: 8
3Calling greet with args=('Darwin',), kwargs={'greeting': 'Hi'}
4Result: Hi, Darwin!The same wrapper handled a function with two positional arguments and a function with a keyword argument: *args collects the positional arguments and **kwargs the keyword ones.
functools.wraps - preserving metadata
Problem: the decorator changes the function's __name__ and __doc__!
1def my_decorator(func):
2 def wrapper(*args, **kwargs):
3 return func(*args, **kwargs)
4 return wrapper
5
6@my_decorator
7def important_function():
8 """This function is important"""
9 pass
10
11print(important_function.__name__) # "wrapper" - wrong!
12print(important_function.__doc__) # None - no documentation!Solution: Use @functools.wraps!
1from functools import wraps
2
3def my_decorator(func):
4 @wraps(func) # Preserve original function metadata
5 def wrapper(*args, **kwargs):
6 return func(*args, **kwargs)
7 return wrapper
8
9@my_decorator
10def important_function():
11 """This function is important"""
12 pass
13
14print(important_function.__name__) # "important_function" - correct!
15print(important_function.__doc__) # "This function is important" - preserved!IMPORTANT: Always use @wraps(func) in your decorators!
Decorators with parameters
For a decorator to accept parameters, you need three levels of functions:
1def repeat(times):
2 """Decorator with parameter - repeat function N times"""
3 def decorator(func):
4 @wraps(func)
5 def wrapper(*args, **kwargs):
6 for i in range(times):
7 result = func(*args, **kwargs)
8 return result
9 return wrapper
10 return decorator
11
12@repeat(times=3) # Decorator parameter!
13def say_hello():
14 print("Hello!")
15
16say_hello()A single call to say_hello() now prints Hello! three times, because the wrapper runs the original function in a for loop as many times as you pass in times.
Structure:
repeat(times)- returns the decoratordecorator(func)- the actual decoratorwrapper(*args, **kwargs)- the function wrapper
Built-in decorators
You already know some of Python's built-in decorators!
@property, @classmethod, @staticmethod
You know all three from earlier lessons. They are ordinary decorators built into Python: each one changes how the method below it is called.
1class Species:
2 def __init__(self, name, population):
3 self._name = name
4 self._population = population
5
6 @property # Getter
7 def name(self):
8 return self._name
9
10 @property
11 def population(self):
12 return self._population
13
14 @population.setter # Setter
15 def population(self, value):
16 if value < 0:
17 raise ValueError("Population cannot be negative")
18 self._population = value
19
20 @classmethod # Class method
21 def from_dict(cls, data):
22 return cls(data['name'], data['population'])
23
24 @staticmethod # Static method
25 def is_valid_name(name):
26 return len(name) > 0Practical decorator examples
1. Timing decorator - measuring execution time
This decorator records the time before and after the call and prints the difference in seconds. The measured function does not need any changes:
1import time
2from functools import wraps
3
4def timer(func):
5 """Measure function execution time"""
6 @wraps(func)
7 def wrapper(*args, **kwargs):
8 start = time.time()
9 result = func(*args, **kwargs)
10 end = time.time()
11 print(f"β± {func.__name__} took {end - start:.4f}s")
12 return result
13 return wrapper
14
15@timer
16def analyze_dna_sequence(sequence):
17 """Simulate DNA analysis - time-consuming operation"""
18 time.sleep(2) # Simulation
19 return f"Analyzed {len(sequence)} nucleotides"
20
21result = analyze_dna_sequence("ATCGATCG") # β± analyze_dna_sequence took 2.0015s2. Logger decorator - automatic logging
The logging decorator prints the date and time, the function name and the arguments of every call, then the result or the error. It doesn't swallow the exception but passes it on with raise:
1from functools import wraps
2from datetime import datetime
3
4def log_calls(func):
5 """Log every function call"""
6 @wraps(func)
7 def wrapper(*args, **kwargs):
8 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
9 print(f"[{timestamp}] Call: {func.__name__}")
10 print(f" Arguments: args={args}, kwargs={kwargs}")
11
12 try:
13 result = func(*args, **kwargs)
14 print(f" Success: {result}")
15 return result
16 except Exception as e:
17 print(f" Error: {e}")
18 raise
19 return wrapper
20
21@log_calls
22def observe_species(name, location, count):
23 """Record a species observation"""
24 if count < 0:
25 raise ValueError("Count cannot be negative")
26 return f"Recorded: {count}x {name} in {location}"
27
28observe_species("Lion", "Serengeti", 12)The console shows (with the current date and time):
1[2024-01-15 14:30:45] Call: observe_species
2 Arguments: args=('Lion', 'Serengeti', 12), kwargs={}
3 Success: Recorded: 12x Lion in SerengetiIf the function raised an error, for example for a negative count, you would see an Error line instead of Success, and the exception would travel on to the calling code.
3. Cache decorator - memoization
The cache decorator remembers the result for every set of arguments. On a later call with the same arguments it returns the stored value instead of computing it again:
1from functools import wraps
2
3def cache(func):
4 """Cache results - don't recalculate the same thing twice"""
5 cached_results = {}
6
7 @wraps(func)
8 def wrapper(*args):
9 if args in cached_results:
10 print(f"Cache hit for {args}")
11 return cached_results[args]
12
13 print(f"Computing for {args}")
14 result = func(*args)
15 cached_results[args] = result
16 return result
17
18 return wrapper
19
20@cache
21def fibonacci(n):
22 """Calculate the n-th Fibonacci number"""
23 if n < 2:
24 return n
25 return fibonacci(n - 1) + fibonacci(n - 2)
26
27print(fibonacci(5))The program prints:
1Computing for (5,)
2Computing for (4,)
3Computing for (3,)
4Computing for (2,)
5Computing for (1,)
6Computing for (0,)
7Cache hit for (1,)
8Cache hit for (2,)
9Cache hit for (3,)
105Every value from 0 to 5 is computed only once, and the second calls for 1, 2 and 3 take the result from the cache. Without the decorator the same function would run 15 times, and for a larger n the number of calls explodes.
Note: Python has a built-in @functools.lru_cache!
1from functools import lru_cache
2
3@lru_cache(maxsize=128) # Remembers the last 128 results
4def fibonacci(n):
5 if n < 2:
6 return n
7 return fibonacci(n - 1) + fibonacci(n - 2)4. Access control decorator - authorization
This decorator takes a parameter, the expected code, and checks the first argument of the call. The function receives the remaining arguments only when the code matches:
1from functools import wraps
2
3def require_authorization(authorization_code):
4 """Decorator with parameter - requires authorization"""
5 def decorator(func):
6 @wraps(func)
7 def wrapper(*args, **kwargs):
8 # Check if the first argument is a valid code
9 if len(args) == 0 or args[0] != authorization_code:
10 raise PermissionError(f"Unauthorized access to {func.__name__}")
11
12 # Remove authorization code from arguments
13 return func(*args[1:], **kwargs)
14 return wrapper
15 return decorator
16
17@require_authorization("SAFARI_2024")
18def access_classified_data(species_name):
19 """Access to classified data - requires authorization"""
20 return f"Classified data about {species_name}: [CLASSIFIED]"
21
22# Correct authorization
23print(access_classified_data("SAFARI_2024", "Black Rhinoceros"))
24# "Classified data about Black Rhinoceros: [CLASSIFIED]"
25
26# No authorization - error!
27try:
28 print(access_classified_data("WRONG_CODE", "Black Rhinoceros"))
29except PermissionError as e:
30 print(f"Error: {e}")5. Retry decorator - automatic retrying
A connection to a remote research station sometimes drops. The retry decorator catches the exception, waits a moment and tries again until it runs out of attempts:
1from functools import wraps
2import time
3
4def retry(max_attempts=3, delay=1):
5 """Retry operation on error"""
6 def decorator(func):
7 @wraps(func)
8 def wrapper(*args, **kwargs):
9 for attempt in range(1, max_attempts + 1):
10 try:
11 return func(*args, **kwargs)
12 except Exception as e:
13 print(f"Attempt {attempt}/{max_attempts} failed: {e}")
14 if attempt < max_attempts:
15 print(f"Retrying in {delay}s...")
16 time.sleep(delay)
17 else:
18 print("All attempts exhausted")
19 raise
20 return wrapper
21 return decorator
22
23@retry(max_attempts=3, delay=0.5)
24def unstable_connection(success_rate=0.3):
25 """Simulate an unstable connection"""
26 import random
27 if random.random() > success_rate:
28 raise ConnectionError("Connection interrupted")
29 return "Success!"
30
31# Automatically retries on error
32result = unstable_connection(success_rate=0.8)Stacking multiple decorators
Decorators can be stacked - apply multiple at once!
1@decorator1
2@decorator2
3@decorator3
4def my_function():
5 passThis works the same as wrapping by hand: my_function = decorator1(decorator2(decorator3(my_function))). decorator3 is closest to the function, so it wraps it first.
Order matters - executed bottom to top!
1from functools import wraps
2
3def bold(func):
4 @wraps(func)
5 def wrapper(*args, **kwargs):
6 return f"**{func(*args, **kwargs)}**"
7 return wrapper
8
9def italic(func):
10 @wraps(func)
11 def wrapper(*args, **kwargs):
12 return f"_{func(*args, **kwargs)}_"
13 return wrapper
14
15@bold
16@italic
17def greet(name):
18 return f"Hello, {name}"
19
20print(greet("Darwin")) # "**_Hello, Darwin_**"First italic wrapped the result in underscores, then bold added the asterisks. The order of execution is greet β italic β bold.
Class decorators
Decorators can also modify entire classes!
1def singleton(cls):
2 """Class decorator - Singleton pattern"""
3 instances = {}
4
5 @wraps(cls)
6 def get_instance(*args, **kwargs):
7 if cls not in instances:
8 instances[cls] = cls(*args, **kwargs)
9 return instances[cls]
10
11 return get_instance
12
13@singleton
14class DatabaseConnection:
15 def __init__(self, host):
16 self.host = host
17 print(f"Connecting to {host}...")
18
19# First instance
20db1 = DatabaseConnection("localhost") # Prints: "Connecting to localhost..."
21
22# "Second" instance - actually the same one!
23db2 = DatabaseConnection("localhost") # Prints nothing
24
25print(db1 is db2) # True - same object!Safari example - complete system with decorators
Finally, a species class with five decorators at work: logging and timing of observations, population validation, authorization for deleting, and a cache for calculations.
1from functools import wraps
2from datetime import datetime
3import time
4from typing import Callable, Any
5
6# === DECORATORS ===
7
8def log_observation(func: Callable) -> Callable:
9 """Log all species observations"""
10 @wraps(func)
11 def wrapper(*args, **kwargs):
12 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
13 print(f"[{timestamp}] Observation: {func.__name__}")
14 result = func(*args, **kwargs)
15 print(f" Recorded: {result}")
16 return result
17 return wrapper
18
19def measure_time(func: Callable) -> Callable:
20 """Measure operation execution time"""
21 @wraps(func)
22 def wrapper(*args, **kwargs):
23 start = time.time()
24 result = func(*args, **kwargs)
25 elapsed = time.time() - start
26 print(f" β± Time: {elapsed:.3f}s")
27 return result
28 return wrapper
29
30def validate_population(func: Callable) -> Callable:
31 """Validate population data before saving"""
32 @wraps(func)
33 def wrapper(self, population: int, *args, **kwargs):
34 if not isinstance(population, int):
35 raise TypeError("Population must be an integer")
36 if population < 0:
37 raise ValueError("Population cannot be negative")
38 return func(self, population, *args, **kwargs)
39 return wrapper
40
41def require_auth(authorization_level: str):
42 """Require authorization level"""
43 def decorator(func: Callable) -> Callable:
44 @wraps(func)
45 def wrapper(self, auth_code: str, *args, **kwargs):
46 if auth_code != f"SAFARI_{authorization_level}":
47 raise PermissionError(
48 f"Required level: {authorization_level}"
49 )
50 return func(self, *args, **kwargs)
51 return wrapper
52 return decorator
53
54def cache_result(func: Callable) -> Callable:
55 """Cache computation results"""
56 cache = {}
57
58 @wraps(func)
59 def wrapper(*args):
60 if args in cache:
61 print(f" Cache hit")
62 return cache[args]
63
64 result = func(*args)
65 cache[args] = result
66 return result
67
68 return wrapper
69
70# === CLASS WITH DECORATORS ===
71
72class Species:
73 """
74 Species class with Safari decorators
75
76 Demonstration: logging, timing, validation, authorization, cache
77 """
78
79 _registry = {}
80
81 def __init__(self, scientific_name: str, common_name: str, population: int):
82 self.scientific_name = scientific_name
83 self.common_name = common_name
84 self._population = population
85 self.observations = []
86
87 Species._registry[scientific_name] = self
88
89 @property
90 def population(self) -> int:
91 """Getter - regular property"""
92 return self._population
93
94 @population.setter
95 @validate_population # Validation decorator!
96 def population(self, value: int) -> None:
97 """Setter with automatic validation"""
98 self._population = value
99
100 @log_observation # Automatic logging
101 @measure_time # Time measurement
102 def add_observation(self, location: str, count: int, date: str) -> str:
103 """
104 Add observation - automatically logged and timed
105
106 Decorators: @log_observation, @measure_time
107 """
108 time.sleep(0.1) # Simulate operation
109 obs = {
110 "location": location,
111 "count": count,
112 "date": date
113 }
114 self.observations.append(obs)
115 return f"{count}x {self.common_name} in {location}"
116
117 @cache_result # Cache results
118 def calculate_biodiversity_score(self) -> float:
119 """
120 Calculate biodiversity score - with cache
121
122 Time-consuming operation - cache saves time!
123 """
124 print(f" Computing score for {self.common_name}...")
125 time.sleep(0.5) # Simulate complex computations
126
127 total_obs = sum(obs["count"] for obs in self.observations)
128 unique_locations = len(set(obs["location"] for obs in self.observations))
129
130 return (total_obs * unique_locations) / (self.population + 1)
131
132 @require_auth("ADMIN") # Requires ADMIN authorization
133 def delete_all_observations(self) -> str:
134 """
135 Delete all observations - requires ADMIN authorization
136
137 Decorator: @require_auth("ADMIN")
138 """
139 count = len(self.observations)
140 self.observations.clear()
141 return f"Deleted observations: {count}"
142
143 @classmethod
144 def get_total_population(cls) -> int:
145 """Total population of all species"""
146 return sum(s.population for s in cls._registry.values())
147
148 @staticmethod
149 @cache_result # Even static methods can be cached!
150 def calculate_extinction_risk(population: int, habitat_loss: float) -> str:
151 """
152 Calculate extinction risk - with cache
153
154 Args:
155 population: Number of individuals
156 habitat_loss: Habitat loss (0.0-1.0)
157 """
158 print(f" Computing risk...")
159 time.sleep(0.3)
160
161 risk_score = (1000 - population) * habitat_loss
162
163 if risk_score > 800:
164 return "Critical"
165 elif risk_score > 500:
166 return "High"
167 elif risk_score > 200:
168 return "Medium"
169 else:
170 return "Low"
171
172 def __repr__(self) -> str:
173 return f"Species('{self.common_name}', pop={self.population})"
174
175# === DEMONSTRATION ===
176
177print("=== SAFARI SYSTEM WITH DECORATORS ===\n")
178
179# Creating species
180lion = Species("Panthera leo", "Lion", 120)
181rhino = Species("Diceros bicornis", "Rhinoceros", 45)
182
183# 1. Observations - automatic logging and timing
184print("\n1. OBSERVATIONS (with @log_observation and @measure_time):")
185lion.add_observation("Serengeti", 12, "2024-01-15")
186lion.add_observation("Masai Mara", 8, "2024-01-16")
187
188# 2. Population validation
189print("\n2. VALIDATION (@validate_population):")
190try:
191 lion.population = -10 # Error - negative!
192except ValueError as e:
193 print(f"Validation error: {e}")
194
195try:
196 lion.population = "hundred" # Error - not int!
197except TypeError as e:
198 print(f"Type error: {e}")
199
200lion.population = 125 # OK
201print(f"Population updated: {lion.population}")
202
203# 3. Cache - biodiversity score
204print("\n3. CACHE (@cache_result):")
205print("First call:")
206score1 = lion.calculate_biodiversity_score()
207print(f"Score: {score1:.2f}")
208
209print("\nSecond call (from cache):")
210score2 = lion.calculate_biodiversity_score()
211print(f"Score: {score2:.2f}")
212
213# 4. Authorization
214print("\n4. AUTHORIZATION (@require_auth):")
215try:
216 lion.delete_all_observations("WRONG_CODE")
217except PermissionError as e:
218 print(f"Access denied: {e}")
219
220result = lion.delete_all_observations("SAFARI_ADMIN")
221print(f"{result}")
222
223# 5. Cache in static method
224print("\n5. CACHE IN STATIC METHOD:")
225print("First call:")
226risk1 = Species.calculate_extinction_risk(50, 0.8)
227print(f"Risk: {risk1}")
228
229print("\nSecond call (from cache):")
230risk2 = Species.calculate_extinction_risk(50, 0.8)
231print(f"Risk: {risk2}")
232
233print("\nDifferent parameters (new computation):")
234risk3 = Species.calculate_extinction_risk(500, 0.3)
235print(f"Risk: {risk3}")Summary
In this lesson you learned:
- What decorators are and how they work
- The
@decoratorsyntax - Creating decorators with
*argsand**kwargs - Using
@functools.wrapsto preserve metadata - Decorators with parameters (three levels of functions)
- Built-in decorators (
@property,@classmethod,@staticmethod) - Practical examples: timing, logging, cache, authorization, retry
- Stacking multiple decorators
- Class decorators
- Complete Safari system with decorators
Checkpoint
Before moving on:
- You understand how decorators modify functions
- You can create a simple decorator
- You know when to use
@wraps - You understand decorators with parameters
- You can stack multiple decorators
Safari Analogy: Decorators are evolutionary adaptations - a chameleon with chromatophores, a bat with echolocation - they extend capabilities without changing the fundamental nature!
Congratulations! You've completed Module 3 - Object-Oriented Programming! You've mastered classes, inheritance, encapsulation, magic methods, type hints, and decorators. You're ready for the next challenges in Python Safari!
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. A decorator in Python is:
2. By convention, what is the first parameter of a method marked with @classmethod called?
These are 2 of 6 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Horizontal ordering
Arrange the elements in the correct order:
- Click in order
Click elements in the correct order:
- Code editor
Create a @classmethod from_dict(cls, data) that creates an Animal from a dictionary with the keys 'name' and 'species', e.g. Animal.from_dict({'name': 'Simba', 'species': 'Lion'})
- Code editor
Create a @staticmethod is_dangerous(species) that returns True for the species 'Lion', 'Crocodile' and 'Hippo', and False for any other
- Horizontal ordering
Arrange the header of the class method from_dict (the line under the @classmethod decorator):
- Vertical ordering
Arrange what happens after calling print(lion) on an object with a __str__ method:
- Click in order
Click elements in the correct order:
- Code editor
Create classes: Animal (base), Mammal(Animal), Bird(Animal), Tiger(Mammal), Eagle(Bird)
- Code editor
In the Animal(name, age, health_status) class, store age and health_status in protected or private attributes (self._age or self.__age) and add the properties age and health_status
- Vertical ordering
Arrange the steps for creating a child class:
- Code editor
Create a Zoo class with the methods add_animal(animal), remove_animal(name) (removes the animal with that name), find_by_species(species) and list_all() (both return lists of animals). Animals are Animal(name, species) objects
- Code editor
Combine Animal and Zoo into a program: create zoo = Zoo() and in a while loop read the choice with input(): 1 - add (name, species), 2 - remove (name), 3 - search by species and print the names, 0 - exit
- Vertical ordering
Arrange the method types by their first parameter: first those that receive the object (self), then the class (cls), and last those with neither self nor cls: