Decorators in Python are more than just an advanced feature; they’re a powerful tool that, once understood, can greatly enhance the way you write and structure your code. Acting as wrappers, they allow programmers to modify and extend the functionality of functions and classes without altering their core. But how do they work? And how can one harness their full potential? In this comprehensive guide, we will delve deep into decorators, exploring their origins, functionalities, and nuances. Whether you’re a beginner looking to grasp the basics or an adept programmer aiming for mastery, this article is tailored to guide you through every facet of Python decorators.
1. Functions
Before delving into decorators, it’s essential to understand some core concepts regarding functions in Python.
1.1 First-Class Objects
In Python, functions are first-class objects, meaning:
- They can be passed as arguments to other functions.
- They can be returned as values from other functions.
- They can be assigned to variables.
- They can be stored in data structures like lists and dictionaries.
Let’s break down each in detail
1. They can be passed as arguments to other functions:
Just as you can pass integers, strings, or lists to a function, you can also pass functions themselves. This allows for creating higher-order functions that accept functions as parameters to operate on.
def greet():
return "Hello"
def shout(func):
return func() + "!!!"
print(shout(greet)) # Outputs: Hello!!!
In this example, the shout
function accepts another function (greet
) as its argument and uses it within its body.
2. They can be returned as values from other functions:
This means that a function can produce a function as its result.
def multiplier(factor):
def multiply(number):
return number * factor
return multiply
double = multiplier(2)
print(double(5)) # Outputs: 10
Here, multiplier
returns the inner multiply
function, which is then assigned to the variable double
.
3. They can be assigned to variables:
A function can be assigned to a variable, and that variable can be used like the original function.
def greet():
return "Hello"
hi = greet
print(hi()) # Outputs: Hello
This isn’t duplicating the function; it’s just another reference to the same function object.
4. They can be stored in data structures like lists and dictionaries:
If you have a set of functions, you can store them in a list, dictionary, or other data structures to be used later.
def add(x, y):
return x + y
def subtract(x, y):
return x - y
operations = {"add": add, "subtract": subtract}
print(operations["add"](3, 4)) # Outputs: 7
- Here, two functions (
add
andsubtract
) are stored in a dictionary and then accessed and used via their dictionary keys.
In essence, treating functions as first-class objects provides a higher level of flexibility and dynamism in Python programming. This foundational concept enables many of Python’s advanced features, including decorators.
1.2 Inner Functions
An inner function is simply a function that is defined inside another function. The inner function’s scope is local to the enclosing (or outer) function, which means it can’t be accessed outside of the outer function.
Basic Example:
def outer_function():
def inner_function():
print("This is the inner function.")
inner_function()
outer_function()
When you run the above code, it will print “This is the inner function.” Even though the inner_function
is called inside the outer_function
, the inner function is not accessible outside its parent function. Thus, trying to call inner_function()
outside of outer_function()
would result in an error.
One of the primary uses of inner functions is in the creation of decorators. The structure of a decorator often involves an outer function that takes a function as an argument and returns an inner function.
1.3 Returning Functions From Functions
Functions can return other functions. Given that functions in Python are first-class citizens, they can be returned just like any other object (e.g., integers, lists, dictionaries). When a function returns another function, it often leads to powerful patterns and techniques, such as closures and decorators.
Basic Example:
def outer_function(message):
def inner_function():
print(message)
return inner_function
greet = outer_function("Hello, world!")
greet() # Outputs: Hello, world!
In the code above, the outer_function
takes a message
parameter and defines an inner_function
that prints this message. The outer function then returns this inner function. When we call outer_function("Hello, world!")
, it returns the inner_function
, which is then assigned to the variable greet
. Calling greet()
subsequently invokes the inner_function
.
Significance:
1. Closures:
When the inner function returned by the outer function references variables from the outer function’s scope, a closure is created. A closure allows the inner function to “remember” those variables even after the outer function has finished executing.
def multiplier(factor):
def multiply_by_factor(number):
return number * factor
return multiply_by_factor
double = multiplier(2)
print(double(5)) # Outputs: 10
Here, multiply_by_factor
(the inner function) references the factor
variable from the enclosing multiplier
function. Even after multiplier
has finished executing, the double
function (which is really multiply_by_factor
) “remembers” the value of factor
.
2. Dynamic Function Generation:
Returning functions can be useful when we want to generate functions dynamically based on certain inputs. This can lead to more concise and readable code in some scenarios.
def power(exponent):
def raise_to_power(base):
return base ** exponent
return raise_to_power
square = power(2)
cube = power(3)
print(square(4)) # Outputs: 16
print(cube(4)) # Outputs: 64
In this example, based on the exponent
passed to the power
function, different functions (square
and cube
) are dynamically generated to raise numbers to the respective powers.
3. Decorators:
As we’ll explore later, the pattern of returning functions from functions is fundamental to Python decorators. Decorators are essentially functions that take a function as input and return a new function that usually extends or modifies the original function’s behavior.
2. What is a Decorators in Python?
Decorators are a powerful and expressive tool in Python, allowing developers to augment or transform the behavior of functions or methods without altering their actual code. At its core, a decorator is just a function that wraps another function (or class), adding some operations before or after the wrapped function runs, thus modifying its behavior.
Basic Concept:
Imagine you have a present (which represents a function) and you want to wrap it with a decorative paper (which represents a decorator) to enhance its appearance. The decorative paper (decorator) doesn’t change the actual content of the present (function) but adds an external enhancement.
Basic Example:
Here’s a simple example to illustrate the concept:
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()
When you run this code, the output will be:
Something is happening before the function is called.
Hello!
Something is happening after the function is called.
Explanation:
1. Defining a Decorator:
In the example above, my_decorator
is our decorator function. It takes a function func
as an argument and defines a new function wrapper
inside it. This wrapper
function calls the original func
and adds some behavior before and after the call.
2. Applying a Decorator:
The line @my_decorator
above def say_hello():
is Python’s decorator syntax. It’s equivalent to:
say_hello = my_decorator(say_hello)
Essentially, you’re reassigning say_hello
to the wrapper
function inside my_decorator
. When you later call say_hello()
, you’re actually calling wrapper()
, which adds the extra behavior and then calls the original say_hello
function.
Decorators in Python provide a flexible way to adhere to the principle of “open/closed” from the SOLID principles. Functions should be open for extension but closed for modification. Decorators allow you to extend the functionality of functions without changing their actual implementation.
3. Syntactic Sugar!
The term “syntactic sugar” refers to syntax within a programming language that is designed to make things easier or more readable. In the context of decorators, Python introduces the “@” symbol as syntactic sugar to apply decorators in a more intuitive manner.
Basic Concept:
Without the “@” symbol, applying a decorator to a function would involve manually passing the function to the decorator and then reassigning the function to the returned value, as such:
def decorator(func):
# some decorator logic
return func
my_function = decorator(my_function)
However, Python simplifies this process with the “@” symbol, allowing for a more readable and concise application:
@decorator
def my_function():
pass
Both of the above examples achieve the same outcome, but the latter is undoubtedly more readable and is the preferred way in Python.
How It Works:
1. Application:
When Python sees the “@” symbol followed by a decorator name above a function definition, it knows to pass the function below the decorator to the decorator function.
Given:
@my_decorator
def my_function():
pass
Python essentially interprets it as:
my_function = my_decorator(my_function)
2. Stacking Decorators:
Multiple decorators can be applied to a single function, which results in what’s known as “stacking”. When stacking decorators, the order matters. Decorators are applied from the innermost outward, which means the closest decorator to the function is applied first.
@decorator1
@decorator2
def my_function():
pass
This is equivalent to:
my_function = decorator1(decorator2(my_function))
3. Behind the Scenes:
The “@” symbol is just shorthand. When the Python interpreter encounters it, it merely executes the longer form described above. However, the “@” syntax greatly improves readability and makes the code look cleaner.
The “@” symbol, as syntactic sugar for decorators in Python, is a testament to the language’s commitment to readability and conciseness. It not only makes the code more intuitive but also adheres to Python’s philosophy that there should be one—and preferably only one—obvious way to do things. In the case of decorators, the “@” symbol has become that obvious and preferred way.
4. Reusing Decorators
One of the primary benefits of decorators is their reusability. Once defined, a decorator can be applied to any number of functions or methods, enabling consistent behavior across different parts of a codebase without duplicating logic.
Basic Concept:
A decorator is designed to augment or modify the behavior of a function (or method). If this modification is something that’s needed in multiple places – be it logging, timing, access checks, or any other cross-cutting concern – using a decorator becomes an elegant solution.
How It Works:
Defining a Reusable Decorator:
Let’s start by defining a simple decorator that logs the execution of any function it decorates.
import functools
def logging_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Executing {func.__name__}...")
result = func(*args, **kwargs)
print(f"{func.__name__} finished execution.")
return result
return wrapper
Applying the Decorator:
Now, you can apply this decorator to any number of functions to add logging behavior.
@logging_decorator
def add(a, b):
return a + b
@logging_decorator
def greet(name):
print(f"Hello, {name}!")
When add
or greet
is called, both will have the logging behavior, even though the actual logic of the functions is entirely different.
Consistency Across the Codebase:
The real power of this approach shines when you consider maintaining a large codebase. If you decide to change how the logging behaves, you only need to modify the logging_decorator
. All functions using this decorator will automatically get the updated behavior without any further changes.
5. Decorating Functions With Arguments
When we speak of decorating functions, it’s not just the parameterless functions we are interested in. Often, the functions we wish to decorate accept arguments. To cater to functions with a variable number of positional and keyword arguments, decorators make use of the *args
and **kwargs
constructs.
Basic Concept:
*args
: A convention used to pass a variable number of non-keyworded arguments to a function. It allows any number of positional arguments to be packed into a tuple.**kwargs
: A convention used to pass a variable number of keyworded arguments to a function. It allows any number of keyword arguments to be packed into a dictionary.
Using these constructs, a decorator can be made generic and flexible enough to wrap around any function, regardless of how many arguments that function accepts.
How It Works:
Suppose you have a web application where multiple functions represent different endpoints, and each endpoint may require user authentication based on the user role.
Defining a Decorator for Authentication:
This decorator will check if a user has the appropriate role to access a particular function.
import functools
# Mock user data for illustration
current_user = {
'username': 'alice',
'role': 'admin'
}
def requires_role(required_role):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if current_user.get('role') != required_role:
raise PermissionError(f"User role '{current_user.get('role')}' does not have '{required_role}' permissions.")
return func(*args, **kwargs)
return wrapper
return decorator
Applying the Decorator:
With our requires_role
decorator, you can now easily specify which roles are required for particular functions.
@requires_role('admin')
def view_admin_dashboard():
return "Admin Dashboard Content"
@requires_role('user')
def view_user_dashboard():
return "User Dashboard Content"
If current_user
has a role ‘admin’, they can access view_admin_dashboard
without issues. However, they’d get a PermissionError
if they tried to access view_user_dashboard
.
Decorator with Arguments:
Notice that requires_role
is a decorator factory. It takes an argument itself (required_role
) and returns the actual decorator (decorator
). This pattern is useful when your decorator needs to accept its own set of arguments, making it even more versatile.
6. Returning Values From Decorated Functions
Decorators, in essence, wrap around functions, allowing them to intervene both before and after the execution of the decorated function. This post-execution intervention capability enables decorators to manipulate the return value of the function.
Basic Concept:
When a decorated function is called, the control is handed over to the decorator. Once the decorator completes the function’s execution, it has access to the function’s return value. This return value can be used as is, modified, replaced, or even ignored, based on the logic inside the decorator.
How It Works:
Defining a Decorator to Modify Return Values:
Let’s say we want to ensure that any string returned by a function is capitalized. We can define a decorator for this:
import functools
def capitalize_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
if isinstance(result, str):
return result.capitalize()
return result
return wrapper
Applying the Decorator:
Let’s apply our decorator to a simple greeting function.
@capitalize_decorator
def greet(name):
return f"hello, {name}"
When you call the greet
function:
print(greet("alice"))
The output will be:
Hello, alice
Decorator Logic Explanation:
The greet
function, when called, would typically return “hello, alice”. However, due to our decorator, before the value is returned, it’s passed through the capitalize_decorator
. This decorator checks if the return value is a string and, if so, capitalizes it.
7. Decorating Classes
While decorators are commonly seen modifying functions, they are versatile enough to be applied to classes as well. Class decorators are useful when you want to modify a class’s behavior, add additional methods or attributes, or even when you want to perform specific checks before creating an instance.
Basic Concept:
A class decorator is essentially a function that receives a class as its argument and returns a modified class or a completely new class. The class being decorated can have its attributes or methods altered, or the decorator can wrap the class in another class to control its instantiation and behavior.
How It Works:
Simple Class Decorator:
Let’s say we want to add a new attribute to every class we decorate:
def class_decorator(cls):
cls.new_attribute = "I am a new attribute!"
return cls
Usage:
@class_decorator
class MyClass:
pass
obj = MyClass()
print(obj.new_attribute) # Outputs: I am a new attribute!
Modifying Methods:
A class decorator can also add new methods or modify existing ones:
def add_method_decorator(cls):
def new_method(self):
return "This is the new method!"
cls.new_method = new_method
return cls
Usage:
@add_method_decorator
class AnotherClass:
pass
obj = AnotherClass()
print(obj.new_method()) # Outputs: This is the new method!
Wrapper Class:
Decorators can return a completely new class to wrap the original one, which can be useful for modifying instantiation:
def singleton_decorator(cls):
_instances = {}
class Wrapper(cls):
def __new__(cls, *args, **kwargs):
if cls not in _instances:
_instances[cls] = super(Wrapper, cls).__new__(cls)
return _instances[cls]
return Wrapper
The above example is a decorator that enforces the Singleton pattern, ensuring only one instance of a class.
Usage:
@singleton_decorator
class SingletonClass:
pass
obj1 = SingletonClass()
obj2 = SingletonClass()
print(obj1 is obj2) # Outputs: True
Class decorators in Python provide a dynamic way to modify or augment class behaviors without altering their actual implementations. They offer a layer of abstraction, enabling developers to make wide-reaching changes from a single location.
8. Nesting Decorators
In Python, decorators can be applied to functions and methods in a stacked or nested manner. This means that you can use multiple decorators for a single function, and each decorator will modify or extend the behavior of the function in a sequence, forming a chain of operations. The decorators are applied from the innermost (nearest to the function) to the outermost.
Basic Concept:
When nesting decorators, the decorator closest to the function is applied first, followed by the one above it, and so forth. The final behavior of the function is a composition of all these decorators.
How It Works:
Let’s illustrate this with an example.
Suppose we have two decorators:
@multiply
: This decorator multiplies the result of the function by 2.@add_five
: This decorator adds 5 to the result of the function.
def multiply(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs) * 2
return wrapper
def add_five(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs) + 5
return wrapper
Now, let’s use both decorators on a function:
@add_five
@multiply
def add(a, b):
return a + b
For the add
function above, the flow is as follows:
- The result of
add
is first passed through the@multiply
decorator because it’s the nearest decorator to the function. - The result from the
@multiply
decorator is then passed through the@add_five
decorator.
So, if we call add(3, 2)
, the sequence is:
add
returns5
.multiply
takes5
and returns10
.add_five
takes10
and returns15
.
Hence, add(3, 2)
will give us 15
.
9. Stateful Decorators
Stateful decorators, as the name suggests, are decorators that can maintain and remember state information across multiple invocations of the decorated functions. This ability to hold state is particularly useful in scenarios like rate limiting, caching, tracking invocation counts, etc.
Basic Concept:
To remember state, decorators usually make use of non-local variables, class instances with attributes, or mutable default arguments (though the latter is less common and can be tricky). This allows the decorator to remember certain details or data between function calls.
How It Works:
Using Non-Local Variables:
One of the simplest ways to hold state within a decorator is by using the nonlocal
keyword, which allows us to flag a variable as a non-local variable.
def count_calls_decorator(func):
count = 0 # initial state
def wrapper(*args, **kwargs):
nonlocal count
count += 1
print(f"Function has been called {count} times")
return func(*args, **kwargs)
return wrapper
@count_calls_decorator
def add(a, b):
return a + b
# Invoke the decorated function
print(add(3, 4)) # Output: Function has been called 1 times
# 7
print(add(1, 2)) # Output: Function has been called 2 times
# 3
print(add(5, 5)) # Output: Function has been called 3 times
# 10
When you run the full code, you’ll see the count being incremented with each call to the add
function, thanks to the stateful count_calls_decorator
.
Using Class Instances:
Another way to maintain state is by using a class-based decorator where the state is stored in the instance attributes.
class CountCallsDecorator:
def __init__(self, func):
self.func = func
self.count = 0 # initial state
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Function has been called {self.count} times")
return self.func(*args, **kwargs)
@CountCallsDecorator
def multiply(a, b):
return a * b
# Invoke the decorated function
print(multiply(3, 4)) # Output: Function has been called 1 times
# 12
print(multiply(2, 2)) # Output: Function has been called 2 times
# 4
print(multiply(5, 5)) # Output: Function has been called 3 times
# 25
Running this code will yield the indicated outputs. The class-based CountCallsDecorator
keeps track of how many times the multiply
function is invoked.
10. Classes as Decorators
Decorators in Python are usually seen as functions that wrap around another function or method, allowing for modifications or augmentations without altering the original function’s code. However, Python’s flexibility allows us to also use classes as decorators. This is especially useful when we want our decorator to maintain state or when we want to provide additional methods that manipulate this state.
How Do Classes Work as Decorators?
For a class to be used as a decorator, it needs to implement the __call__
method. When an instance of a class is “called” like a function, the __call__
method is executed. This feature allows us to utilize class instances in the same way we would use functions, and this includes using them as decorators.
Basic Structure:
class MyClassDecorator:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
# pre-function logic here
result = self.func(*args, **kwargs)
# post-function logic here
return result
Detailed Explanation:
- Initialization (__intit__ method): When you decorate a function using a class, the function is passed to the
__init__
method as an argument. This allows us to store a reference to the original function within the class instance. - Invocation (__call__ method): The magic happens in the
__call__
method. Whenever you invoke the decorated function, you’re essentially calling an instance of the decorator class. The__call__
method gets executed, allowing us to add logic before and after the original function call.
Example:
Let’s see a simple example where a class-based decorator is used to measure the time taken by a function to execute:
import time
class TimerDecorator:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
start_time = time.time()
result = self.func(*args, **kwargs)
end_time = time.time()
print(f"{self.func.__name__} executed in {end_time - start_time} seconds")
return result
@TimerDecorator
def example_function():
time.sleep(2)
print("Function executed!")
example_function()
Output:
Function executed!
example_function executed in 2.00XXXXX seconds
While function-based decorators are more common in Python due to their simplicity and straightforwardness, class-based decorators provide an enhanced level of control and extensibility, especially when state management or additional methods are required. The key is the __call__
method, which gives class instances their function-like behavior, allowing them to be used as decorators.
11. Common Built-in Decorators in Python
Python provides a set of built-in decorators, particularly beneficial when working with classes. These allow developers to modify the behavior of methods within the class, adapting them for specific needs without changing the class’s external interface.
1. @staticmethod
In Python, the @staticmethod
decorator is used to define static methods within a class. A static method doesn’t access or modify class-specific or instance-specific data. Therefore, it doesn’t require a reference to the instance or the class that it’s a part of. This means you can call a static method on a class rather than an instance of the class.
It’s used when you want to perform a task that’s related to the class but doesn’t need to access or modify any of its properties. Static methods are also unable to modify the mutable properties of the class.
Detailed Example
Let’s take the example of a BankAccount
class, where we want to keep a record of all transactions, irrespective of specific accounts.
class BankAccount:
# Shared across all instances of the class
transaction_log = []
def __init__(self, account_holder, balance=0):
self.account_holder = account_holder
self.balance = balance
def deposit(self, amount):
self.balance += amount
BankAccount.transaction_log.append(f"Deposited ${amount} to {self.account_holder}'s account.")
def withdraw(self, amount):
if amount > self.balance:
print(f"Insufficient funds in {self.account_holder}'s account!")
return
self.balance -= amount
BankAccount.transaction_log.append(f"Withdrew ${amount} from {self.account_holder}'s account.")
@staticmethod
def display_transaction_log():
print("Transaction Log:")
for transaction in BankAccount.transaction_log:
print(transaction)
# Creating instances and making transactions
john_account = BankAccount("John Doe", 1000)
jane_account = BankAccount("Jane Doe", 2000)
john_account.deposit(200)
jane_account.withdraw(300)
# Displaying transaction log without needing an instance of BankAccount
BankAccount.display_transaction_log()
Output:
Transaction Log:
Deposited $200 to John Doe's account.
Withdrew $300 from Jane Doe's account.
Here, display_transaction_log
is a static method since it doesn’t need any specific information about a BankAccount
instance; it only uses the class-level transaction_log
. Instead of working on instance-level data (specific to each bank account), it works on class-level data that’s shared by all accounts.
It makes sense for this method to be a static method since displaying the transaction log isn’t an action specific to a single bank account but rather is related to the entire BankAccount
class.
2. @classmethod
In Python, the @classmethod
decorator is utilized to define a method within a class that takes the class itself as its first argument. It’s represented typically by the name cls
(just like self
represents the instance in regular methods). The class method can’t access specific instance-level data unless an instance is passed to it. However, it can access class-level attributes and methods.
Class methods are often used for factory methods, which return class objects (similar to constructors) for different use cases.
Detailed Example
Let’s consider an example using a Person
class, where each person has a name and a birth year. We’ll implement a class method that can create a Person
instance based on the age instead of the birth year.
class Person:
current_year = 2023 # Class-level attribute
def __init__(self, name, birth_year):
self.name = name
self.birth_year = birth_year
@property
def age(self):
return Person.current_year - self.birth_year
@classmethod
def from_age(cls, name, age):
birth_year = cls.current_year - age
return cls(name, birth_year) # returns a new Person instance
def __str__(self):
return f"{self.name} was born in {self.birth_year} and is {self.age} years old."
# Creating instances using the default constructor
person1 = Person("Alice", 1990)
print(person1)
# Creating instances using the class method
person2 = Person.from_age("Bob", 33)
print(person2)
Output:
Alice was born in 1990 and is 33 years old.
Bob was born in 1990 and is 33 years old.
In the example:
current_year
is a class attribute that represents the current year.- The instance method
age
calculates the age of the person based oncurrent_year
. - The class method
from_age
allows us to create aPerson
instance if we know their age, instead of the birth year. It calculates the birth year based on the current year and age, and then creates a newPerson
instance.
This example demonstrates how class methods can provide alternative ways (factories) to create instances of the class. The primary difference between class methods and instance methods is that class methods take a reference to the class (cls
) as their first parameter, while instance methods take a reference to the instance (self
).
3. @property
In Python, the @property
decorator allows us to define methods in a class that are meant to be accessed as attributes without needing to invoke them like typical methods. It offers a way to access the value of an attribute while employing logic contained within a method. This is primarily useful for:
- Encapsulation: Restricting direct access to the attribute and allowing value retrieval through a method.
- Derived or Computed Attributes: If an attribute’s value is derived from other attributes, instead of storing it, we can compute it on the fly.
Furthermore, with @property
, we can also define setter and deleter methods for attributes, providing more control over attribute modifications and deletions.
Detailed Example
Consider a Circle
class where you can set the radius of the circle, and you’d like to compute properties like diameter, area, and circumference.
import math
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""Getter for radius"""
return self._radius
@radius.setter
def radius(self, value):
"""Setter for radius, ensures it's not negative."""
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
@property
def diameter(self):
"""Diameter is derived from the radius."""
return 2 * self._radius
@property
def area(self):
"""Area is derived from the radius."""
return math.pi * (self._radius**2)
@property
def circumference(self):
"""Circumference is derived from the radius."""
return 2 * math.pi * self._radius
circle = Circle(5)
print(f"Radius: {circle.radius}")
print(f"Diameter: {circle.diameter}")
print(f"Area: {circle.area:.2f}")
print(f"Circumference: {circle.circumference:.2f}")
# Trying to set a negative radius
try:
circle.radius = -10
except ValueError as e:
print(f"Error: {e}")
Output:
Radius: 5
Diameter: 10
Area: 78.54
Circumference: 31.42
Error: Radius cannot be negative
Explanation:
- We have a “protected” attribute
_radius
. The leading underscore suggests it shouldn’t be accessed directly. - We have a
radius
property that retrieves_radius
and a setter that ensures the radius can’t be set to a negative value. diameter
,area
, andcircumference
are derived properties. Instead of storing these values, we compute them when accessed. They use the@property
decorator, so they can be accessed just like attributes, without calling them as methods.
This example showcases how @property
allows for attribute access while using methods’ logic, thus enabling encapsulation, validation, and dynamic computation of attribute values.
12. Conclusion
Decorators, with their diverse range of applications, are among Python’s most powerful features. From simple wrappers to stateful and class-based decorators, they offer a plethora of functionalities, making them indispensable for Python programmers aiming for cleaner, DRY (Don’t Repeat Yourself), and more Pythonic code.