Python Decorators

Spread the love

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
  1. Here, two functions (add and subtract) 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:

  1. @multiply: This decorator multiplies the result of the function by 2.
  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:

  1. The result of add is first passed through the @multiply decorator because it’s the nearest decorator to the function.
  2. The result from the @multiply decorator is then passed through the @add_five decorator.

So, if we call add(3, 2), the sequence is:

  1. add returns 5.
  2. multiply takes 5 and returns 10.
  3. add_five takes 10 and returns 15.

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:

  1. 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.
  2. 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 on current_year.
  • The class method from_age allows us to create a Person 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 new Person 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:

  1. Encapsulation: Restricting direct access to the attribute and allowing value retrieval through a method.
  2. 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, and circumference 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.

Leave a Reply