Decorators

Decorators extend the functionality of functions or methods in Python. They allow you to "wrap" another function, modifying its behavior.

In this tutorial, we'll cover the basics of decorators, provide examples of how to use built-in decorators, and demonstrate how to create your own custom decorators.

## Basics of Decorators

A decorator is a function that takes another function and extends its behavior without explicitly modifying it. This is useful for cross-cutting concerns like logging, access control, and instrumentation.

### Simple Decorator Example

Let's start with 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

def say_hello():
    print("Hello!")

# Apply the decorator
decorated_say_hello = my_decorator(say_hello)
decorated_say_hello()
Something is happening before the function is called.
Hello!
Something is happening after the function is called.
**Explanation:**

- `my_decorator`: The decorator function that takes a function `func` as an argument.
- `wrapper`: The nested function that adds additional behavior before and after calling `func`.
- `decorated_say_hello`: The function `say_hello` wrapped by `my_decorator`, extending its behavior.

### Using Python's `@` Syntax

Instead of wrapping functions manually, Python provides the `@` syntax to apply decorators, making the code cleaner and more readable.

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()
Something is happening before the function is called.
Hello!
Something is happening after the function is called.
In this example, the `@my_decorator` syntax is syntactic sugar for `say_hello = my_decorator(say_hello)`.

## Built-in Decorators

Python provides some built-in decorators like `@staticmethod`, `@classmethod`, and `@property`. Let's look at a few examples.

### `@staticmethod` and `@classmethod`

These decorators are commonly used in classes.

class MyClass:
    @staticmethod
    def static_method():
        print("This is a static method.")

    @classmethod
    def class_method(cls):
        print(f"This is a class method of {cls}.")

# Usage
MyClass.static_method()
MyClass.class_method()
This is a static method.
This is a class method of <class '__main__.MyClass'>.
**Explanation:**

- `@staticmethod`: Declares a method that does not operate on an instance or class variable.
- `@classmethod`: Declares a method that receives the class itself as the first argument, commonly named `cls`.

### `@property`

The `@property` decorator is used to define getters and setters for class attributes.

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative.")
        self._radius = value

# Usage
c = Circle(5)
print(c.radius)
c.radius = 10
print(c.radius)
5
10
**Explanation:**

- `@property`: Decorates the getter method.
- `@<property>.setter`: Decorates the setter method.

## Creating Custom Decorators

You can create your own decorators to encapsulate repetitive tasks or enhance functions with additional behavior.

### Custom Decorator with Arguments

Decorators can also take arguments. Let's create a decorator that logs the execution time of a function.

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"Function '{func.__name__}' took {end_time - start_time:.4f} seconds to execute.")
        return result
    return wrapper

@timer
def slow_function(seconds):
    time.sleep(seconds)
    return "Done sleeping!"

# Usage
print(slow_function(2))
Function 'slow_function' took 2.0027 seconds to execute.
Done sleeping!
**Explanation:**

- `wrapper(*args, **kwargs)`: Ensures the decorator can handle functions with parameters.
- `start_time` and `end_time`: Measure the execution time of the function.
- `@timer`: Applies the `timer` decorator to `slow_function`.

### Chaining Decorators

You can also chain multiple decorators on the same function.

def decorator1(func):
    def wrapper():
        print("Decorator 1")
        func()
    return wrapper

def decorator2(func):
    def wrapper():
        print("Decorator 2")
        func()
    return wrapper

@decorator1
@decorator2
def say_hello():
    print("Hello!")

say_hello()
Decorator 1
Decorator 2
Hello!
**Explanation:**

- `@decorator1` and `@decorator2`: Both decorators are applied to `say_hello`.
- The order of application is bottom to top, so `decorator2` is applied first, followed by `decorator1`.