Code icon

The App is Under a Quick Maintenance

We apologize for the inconvenience. Please come back later

Menu iconMenu iconPython & SQL Bible
Python & SQL Bible

Chapter 6: Object-Oriented Programming in Python

6.3 Python Special Functions

Let's dive into the special functions in Python, also known as "magic" or "dunder" methods. These methods provide a simple way to make your classes act like built-in types. This means you can use type-specific functions (like len or +) with your objects. You've already seen these in use with the __init__ method for classes. Let's explore more:

1. __str__ and __repr__ Methods

The __str__ and __repr__ methods in Python represent the class objects as a string – they are methods for string representation of a class. The __str__ method in Python represents the class objects as a human-readable string, while the __repr__ method is meant to be an unambiguous representation of the object, and should ideally contain more detail than __str__. If __repr__ is defined, and __str__ is not, the objects will behave as though __str__=__repr__.

class Employee:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __str__(self):
        return f'Employee[name={self.name}, age={self.age}]'

    def __repr__(self):
        return f'{self.__class__.__name__}({self.name!r}, {self.age!r})'

emp = Employee('John Doe', 30)
print(str(emp)) # Employee[name=John Doe, age=30]
print(repr(emp)) # Employee('John Doe', 30)

2. __add__ and __sub__ Methods

These methods are used to overload the + and - operator.

class Complex:
    def __init__(self, real, imag):
        self.real = real
        self.imag = imag

    def __add__(self, other):
        return Complex(self.real + other.real, self.imag + other.imag)

    def __sub__(self, other):
        return Complex(self.real - other.real, self.imag - other.imag)

    def __str__(self):
        return f'{self.real} + {self.imag}i'

c1 = Complex(1, 2)
c2 = Complex(2, 3)
c3 = c1 + c2
c4 = c1 - c2
print(c3) # 3 + 5i
print(c4) # -1 - 1i

3. __len__ Method

The __len__ method returns the length (the number of items) of an object. The method should only be implemented for classes that are collections.

class Stack:
    def __init__(self):
        self.items = []

    def push(self, item):
        self.items.append(item)

    def __len__(self):
        return len(self.items)

s = Stack()
s.push('Hello')
s.push('World')
print(len(s)) # 2

4. __getitem__ and __setitem__ Methods

The __getitem__ method is used to implement self[key] for access. Similarly, __setitem__ is used for assignment to self[key].

class CustomDict:
    def __init__(self, items):
        self.items = items

    def __getitem__(self, key):
        return self.items[key]

    def __setitem__(self, key, value):
        self.items[key] = value

custom_dict = CustomDict({'one': 1, 'two': 2})
print(custom_dict['one'])  # 1
custom_dict['three'] = 3
print(custom_dict['three'])  # 3

5. __eq__ and __ne__ Methods

These methods are used to overload the (==) and (!=) operators respectively.

class Employee:
    def __init__(self, name, id):
        self.name = name
        self.id = id

    def __eq__(self, other):
        return self.id == other.id

    def __ne__(self, other):
        return self.id != other.id

emp1 = Employee('John', 'E101')
emp2 = Employee('Jane', 'E102')
emp3 = Employee('David', 'E101')

print(emp1 == emp2)  # False
print(emp1 == emp3)  # True
print(emp1 != emp3)  # False

6. __del__ Method

The __del__ method is a known as a destructor method in Python. It is called when all references to the object have been deleted i.e when an object is garbage collected.

class Test:
    def __init__(self):
        print('Constructor Executed')

    def __del__(self):
        print('Destructor Executed')

t1 = Test()  # Constructor Executed
t1 = None  # Destructor Executed

As you can see, magic methods are the key to Python's effective use of the object-oriented programming paradigm, allowing you to define behaviors for custom classes that are intuitive to understand and easy to use.

Decorators in Python

Indeed, there is one more Python concept that might be interesting to discuss in this chapter: Decorators in Python, which can be quite handy when you want to change the behavior of a method without changing its source code.

A decorator in Python is a powerful tool that helps developers modify the behavior of a function, method, or class definition without having to rewrite the entire code. It is a higher-order function that takes in another function as an argument and returns a modified version of it.

The decorator modifies the original object, which is passed to it as an argument, and returns an updated version that is bound to the name used in the definition. Decorators are widely used in the Python community and are a key feature of the language that enables developers to write more concise and elegant code.

They are particularly useful when working with large codebases, as they allow developers to make changes to a function's behavior without having to modify its implementation. In addition, decorators can be used to add new functionality to a function, such as logging, caching, or authentication, without having to modify its source code.

Overall, decorators are a powerful tool that can help developers write more efficient and maintainable code in Python.

Example:

Here is a basic example of a Python decorator:

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, you'll see:

Something is happening before the function is called.
Hello!
Something is happening after the function is called.

In the example above, @my_decorator is a decorator. Functions that take other functions as arguments are also called higher-order functions. In this case, my_decorator is a higher-order function.

The @ symbol is just syntactic sugar that allows us to easily apply a decorator to a function. The line @my_decorator is equivalent to say_hello = my_decorator(say_hello).

This might be a lot to take in if you're new to decorators. That's okay. Decorators are a powerful tool in Python, but they can be a bit tricky to understand at first. Just take your time with this concept, play around with a few examples, and you'll get the hang of it.

The concept of decorators opens a whole new world of possibilities in Python. They can be used for logging, enforcing access control and authentication, rate limiting, caching, and much more.

Decorator factories can be used when you want to use a decorator, but need to supply it with arguments. A decorator factory is a function that returns a decorator. Here's how you can create one:

def repeat(num_times):
    def decorator_repeat(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator_repeat

@repeat(num_times=3)
def greet(name):
    print(f"Hello {name}")

greet("World")

In this example, repeat(num_times=3) returns a decorator that will repeat the decorated function three times. This is called a decorator factory.

When you run this code, you'll see:

Hello World
Hello World
Hello World

As you can see, the greet function was called three times.

This is a more advanced use of decorators, but once you understand them, they can be incredibly powerful and help make your code more readable and maintainable. The ability to modify the behavior of a function in such a clean and readable way is one of the things that makes Python such a great language to work with.

6.3 Python Special Functions

Let's dive into the special functions in Python, also known as "magic" or "dunder" methods. These methods provide a simple way to make your classes act like built-in types. This means you can use type-specific functions (like len or +) with your objects. You've already seen these in use with the __init__ method for classes. Let's explore more:

1. __str__ and __repr__ Methods

The __str__ and __repr__ methods in Python represent the class objects as a string – they are methods for string representation of a class. The __str__ method in Python represents the class objects as a human-readable string, while the __repr__ method is meant to be an unambiguous representation of the object, and should ideally contain more detail than __str__. If __repr__ is defined, and __str__ is not, the objects will behave as though __str__=__repr__.

class Employee:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __str__(self):
        return f'Employee[name={self.name}, age={self.age}]'

    def __repr__(self):
        return f'{self.__class__.__name__}({self.name!r}, {self.age!r})'

emp = Employee('John Doe', 30)
print(str(emp)) # Employee[name=John Doe, age=30]
print(repr(emp)) # Employee('John Doe', 30)

2. __add__ and __sub__ Methods

These methods are used to overload the + and - operator.

class Complex:
    def __init__(self, real, imag):
        self.real = real
        self.imag = imag

    def __add__(self, other):
        return Complex(self.real + other.real, self.imag + other.imag)

    def __sub__(self, other):
        return Complex(self.real - other.real, self.imag - other.imag)

    def __str__(self):
        return f'{self.real} + {self.imag}i'

c1 = Complex(1, 2)
c2 = Complex(2, 3)
c3 = c1 + c2
c4 = c1 - c2
print(c3) # 3 + 5i
print(c4) # -1 - 1i

3. __len__ Method

The __len__ method returns the length (the number of items) of an object. The method should only be implemented for classes that are collections.

class Stack:
    def __init__(self):
        self.items = []

    def push(self, item):
        self.items.append(item)

    def __len__(self):
        return len(self.items)

s = Stack()
s.push('Hello')
s.push('World')
print(len(s)) # 2

4. __getitem__ and __setitem__ Methods

The __getitem__ method is used to implement self[key] for access. Similarly, __setitem__ is used for assignment to self[key].

class CustomDict:
    def __init__(self, items):
        self.items = items

    def __getitem__(self, key):
        return self.items[key]

    def __setitem__(self, key, value):
        self.items[key] = value

custom_dict = CustomDict({'one': 1, 'two': 2})
print(custom_dict['one'])  # 1
custom_dict['three'] = 3
print(custom_dict['three'])  # 3

5. __eq__ and __ne__ Methods

These methods are used to overload the (==) and (!=) operators respectively.

class Employee:
    def __init__(self, name, id):
        self.name = name
        self.id = id

    def __eq__(self, other):
        return self.id == other.id

    def __ne__(self, other):
        return self.id != other.id

emp1 = Employee('John', 'E101')
emp2 = Employee('Jane', 'E102')
emp3 = Employee('David', 'E101')

print(emp1 == emp2)  # False
print(emp1 == emp3)  # True
print(emp1 != emp3)  # False

6. __del__ Method

The __del__ method is a known as a destructor method in Python. It is called when all references to the object have been deleted i.e when an object is garbage collected.

class Test:
    def __init__(self):
        print('Constructor Executed')

    def __del__(self):
        print('Destructor Executed')

t1 = Test()  # Constructor Executed
t1 = None  # Destructor Executed

As you can see, magic methods are the key to Python's effective use of the object-oriented programming paradigm, allowing you to define behaviors for custom classes that are intuitive to understand and easy to use.

Decorators in Python

Indeed, there is one more Python concept that might be interesting to discuss in this chapter: Decorators in Python, which can be quite handy when you want to change the behavior of a method without changing its source code.

A decorator in Python is a powerful tool that helps developers modify the behavior of a function, method, or class definition without having to rewrite the entire code. It is a higher-order function that takes in another function as an argument and returns a modified version of it.

The decorator modifies the original object, which is passed to it as an argument, and returns an updated version that is bound to the name used in the definition. Decorators are widely used in the Python community and are a key feature of the language that enables developers to write more concise and elegant code.

They are particularly useful when working with large codebases, as they allow developers to make changes to a function's behavior without having to modify its implementation. In addition, decorators can be used to add new functionality to a function, such as logging, caching, or authentication, without having to modify its source code.

Overall, decorators are a powerful tool that can help developers write more efficient and maintainable code in Python.

Example:

Here is a basic example of a Python decorator:

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, you'll see:

Something is happening before the function is called.
Hello!
Something is happening after the function is called.

In the example above, @my_decorator is a decorator. Functions that take other functions as arguments are also called higher-order functions. In this case, my_decorator is a higher-order function.

The @ symbol is just syntactic sugar that allows us to easily apply a decorator to a function. The line @my_decorator is equivalent to say_hello = my_decorator(say_hello).

This might be a lot to take in if you're new to decorators. That's okay. Decorators are a powerful tool in Python, but they can be a bit tricky to understand at first. Just take your time with this concept, play around with a few examples, and you'll get the hang of it.

The concept of decorators opens a whole new world of possibilities in Python. They can be used for logging, enforcing access control and authentication, rate limiting, caching, and much more.

Decorator factories can be used when you want to use a decorator, but need to supply it with arguments. A decorator factory is a function that returns a decorator. Here's how you can create one:

def repeat(num_times):
    def decorator_repeat(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator_repeat

@repeat(num_times=3)
def greet(name):
    print(f"Hello {name}")

greet("World")

In this example, repeat(num_times=3) returns a decorator that will repeat the decorated function three times. This is called a decorator factory.

When you run this code, you'll see:

Hello World
Hello World
Hello World

As you can see, the greet function was called three times.

This is a more advanced use of decorators, but once you understand them, they can be incredibly powerful and help make your code more readable and maintainable. The ability to modify the behavior of a function in such a clean and readable way is one of the things that makes Python such a great language to work with.

6.3 Python Special Functions

Let's dive into the special functions in Python, also known as "magic" or "dunder" methods. These methods provide a simple way to make your classes act like built-in types. This means you can use type-specific functions (like len or +) with your objects. You've already seen these in use with the __init__ method for classes. Let's explore more:

1. __str__ and __repr__ Methods

The __str__ and __repr__ methods in Python represent the class objects as a string – they are methods for string representation of a class. The __str__ method in Python represents the class objects as a human-readable string, while the __repr__ method is meant to be an unambiguous representation of the object, and should ideally contain more detail than __str__. If __repr__ is defined, and __str__ is not, the objects will behave as though __str__=__repr__.

class Employee:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __str__(self):
        return f'Employee[name={self.name}, age={self.age}]'

    def __repr__(self):
        return f'{self.__class__.__name__}({self.name!r}, {self.age!r})'

emp = Employee('John Doe', 30)
print(str(emp)) # Employee[name=John Doe, age=30]
print(repr(emp)) # Employee('John Doe', 30)

2. __add__ and __sub__ Methods

These methods are used to overload the + and - operator.

class Complex:
    def __init__(self, real, imag):
        self.real = real
        self.imag = imag

    def __add__(self, other):
        return Complex(self.real + other.real, self.imag + other.imag)

    def __sub__(self, other):
        return Complex(self.real - other.real, self.imag - other.imag)

    def __str__(self):
        return f'{self.real} + {self.imag}i'

c1 = Complex(1, 2)
c2 = Complex(2, 3)
c3 = c1 + c2
c4 = c1 - c2
print(c3) # 3 + 5i
print(c4) # -1 - 1i

3. __len__ Method

The __len__ method returns the length (the number of items) of an object. The method should only be implemented for classes that are collections.

class Stack:
    def __init__(self):
        self.items = []

    def push(self, item):
        self.items.append(item)

    def __len__(self):
        return len(self.items)

s = Stack()
s.push('Hello')
s.push('World')
print(len(s)) # 2

4. __getitem__ and __setitem__ Methods

The __getitem__ method is used to implement self[key] for access. Similarly, __setitem__ is used for assignment to self[key].

class CustomDict:
    def __init__(self, items):
        self.items = items

    def __getitem__(self, key):
        return self.items[key]

    def __setitem__(self, key, value):
        self.items[key] = value

custom_dict = CustomDict({'one': 1, 'two': 2})
print(custom_dict['one'])  # 1
custom_dict['three'] = 3
print(custom_dict['three'])  # 3

5. __eq__ and __ne__ Methods

These methods are used to overload the (==) and (!=) operators respectively.

class Employee:
    def __init__(self, name, id):
        self.name = name
        self.id = id

    def __eq__(self, other):
        return self.id == other.id

    def __ne__(self, other):
        return self.id != other.id

emp1 = Employee('John', 'E101')
emp2 = Employee('Jane', 'E102')
emp3 = Employee('David', 'E101')

print(emp1 == emp2)  # False
print(emp1 == emp3)  # True
print(emp1 != emp3)  # False

6. __del__ Method

The __del__ method is a known as a destructor method in Python. It is called when all references to the object have been deleted i.e when an object is garbage collected.

class Test:
    def __init__(self):
        print('Constructor Executed')

    def __del__(self):
        print('Destructor Executed')

t1 = Test()  # Constructor Executed
t1 = None  # Destructor Executed

As you can see, magic methods are the key to Python's effective use of the object-oriented programming paradigm, allowing you to define behaviors for custom classes that are intuitive to understand and easy to use.

Decorators in Python

Indeed, there is one more Python concept that might be interesting to discuss in this chapter: Decorators in Python, which can be quite handy when you want to change the behavior of a method without changing its source code.

A decorator in Python is a powerful tool that helps developers modify the behavior of a function, method, or class definition without having to rewrite the entire code. It is a higher-order function that takes in another function as an argument and returns a modified version of it.

The decorator modifies the original object, which is passed to it as an argument, and returns an updated version that is bound to the name used in the definition. Decorators are widely used in the Python community and are a key feature of the language that enables developers to write more concise and elegant code.

They are particularly useful when working with large codebases, as they allow developers to make changes to a function's behavior without having to modify its implementation. In addition, decorators can be used to add new functionality to a function, such as logging, caching, or authentication, without having to modify its source code.

Overall, decorators are a powerful tool that can help developers write more efficient and maintainable code in Python.

Example:

Here is a basic example of a Python decorator:

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, you'll see:

Something is happening before the function is called.
Hello!
Something is happening after the function is called.

In the example above, @my_decorator is a decorator. Functions that take other functions as arguments are also called higher-order functions. In this case, my_decorator is a higher-order function.

The @ symbol is just syntactic sugar that allows us to easily apply a decorator to a function. The line @my_decorator is equivalent to say_hello = my_decorator(say_hello).

This might be a lot to take in if you're new to decorators. That's okay. Decorators are a powerful tool in Python, but they can be a bit tricky to understand at first. Just take your time with this concept, play around with a few examples, and you'll get the hang of it.

The concept of decorators opens a whole new world of possibilities in Python. They can be used for logging, enforcing access control and authentication, rate limiting, caching, and much more.

Decorator factories can be used when you want to use a decorator, but need to supply it with arguments. A decorator factory is a function that returns a decorator. Here's how you can create one:

def repeat(num_times):
    def decorator_repeat(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator_repeat

@repeat(num_times=3)
def greet(name):
    print(f"Hello {name}")

greet("World")

In this example, repeat(num_times=3) returns a decorator that will repeat the decorated function three times. This is called a decorator factory.

When you run this code, you'll see:

Hello World
Hello World
Hello World

As you can see, the greet function was called three times.

This is a more advanced use of decorators, but once you understand them, they can be incredibly powerful and help make your code more readable and maintainable. The ability to modify the behavior of a function in such a clean and readable way is one of the things that makes Python such a great language to work with.

6.3 Python Special Functions

Let's dive into the special functions in Python, also known as "magic" or "dunder" methods. These methods provide a simple way to make your classes act like built-in types. This means you can use type-specific functions (like len or +) with your objects. You've already seen these in use with the __init__ method for classes. Let's explore more:

1. __str__ and __repr__ Methods

The __str__ and __repr__ methods in Python represent the class objects as a string – they are methods for string representation of a class. The __str__ method in Python represents the class objects as a human-readable string, while the __repr__ method is meant to be an unambiguous representation of the object, and should ideally contain more detail than __str__. If __repr__ is defined, and __str__ is not, the objects will behave as though __str__=__repr__.

class Employee:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __str__(self):
        return f'Employee[name={self.name}, age={self.age}]'

    def __repr__(self):
        return f'{self.__class__.__name__}({self.name!r}, {self.age!r})'

emp = Employee('John Doe', 30)
print(str(emp)) # Employee[name=John Doe, age=30]
print(repr(emp)) # Employee('John Doe', 30)

2. __add__ and __sub__ Methods

These methods are used to overload the + and - operator.

class Complex:
    def __init__(self, real, imag):
        self.real = real
        self.imag = imag

    def __add__(self, other):
        return Complex(self.real + other.real, self.imag + other.imag)

    def __sub__(self, other):
        return Complex(self.real - other.real, self.imag - other.imag)

    def __str__(self):
        return f'{self.real} + {self.imag}i'

c1 = Complex(1, 2)
c2 = Complex(2, 3)
c3 = c1 + c2
c4 = c1 - c2
print(c3) # 3 + 5i
print(c4) # -1 - 1i

3. __len__ Method

The __len__ method returns the length (the number of items) of an object. The method should only be implemented for classes that are collections.

class Stack:
    def __init__(self):
        self.items = []

    def push(self, item):
        self.items.append(item)

    def __len__(self):
        return len(self.items)

s = Stack()
s.push('Hello')
s.push('World')
print(len(s)) # 2

4. __getitem__ and __setitem__ Methods

The __getitem__ method is used to implement self[key] for access. Similarly, __setitem__ is used for assignment to self[key].

class CustomDict:
    def __init__(self, items):
        self.items = items

    def __getitem__(self, key):
        return self.items[key]

    def __setitem__(self, key, value):
        self.items[key] = value

custom_dict = CustomDict({'one': 1, 'two': 2})
print(custom_dict['one'])  # 1
custom_dict['three'] = 3
print(custom_dict['three'])  # 3

5. __eq__ and __ne__ Methods

These methods are used to overload the (==) and (!=) operators respectively.

class Employee:
    def __init__(self, name, id):
        self.name = name
        self.id = id

    def __eq__(self, other):
        return self.id == other.id

    def __ne__(self, other):
        return self.id != other.id

emp1 = Employee('John', 'E101')
emp2 = Employee('Jane', 'E102')
emp3 = Employee('David', 'E101')

print(emp1 == emp2)  # False
print(emp1 == emp3)  # True
print(emp1 != emp3)  # False

6. __del__ Method

The __del__ method is a known as a destructor method in Python. It is called when all references to the object have been deleted i.e when an object is garbage collected.

class Test:
    def __init__(self):
        print('Constructor Executed')

    def __del__(self):
        print('Destructor Executed')

t1 = Test()  # Constructor Executed
t1 = None  # Destructor Executed

As you can see, magic methods are the key to Python's effective use of the object-oriented programming paradigm, allowing you to define behaviors for custom classes that are intuitive to understand and easy to use.

Decorators in Python

Indeed, there is one more Python concept that might be interesting to discuss in this chapter: Decorators in Python, which can be quite handy when you want to change the behavior of a method without changing its source code.

A decorator in Python is a powerful tool that helps developers modify the behavior of a function, method, or class definition without having to rewrite the entire code. It is a higher-order function that takes in another function as an argument and returns a modified version of it.

The decorator modifies the original object, which is passed to it as an argument, and returns an updated version that is bound to the name used in the definition. Decorators are widely used in the Python community and are a key feature of the language that enables developers to write more concise and elegant code.

They are particularly useful when working with large codebases, as they allow developers to make changes to a function's behavior without having to modify its implementation. In addition, decorators can be used to add new functionality to a function, such as logging, caching, or authentication, without having to modify its source code.

Overall, decorators are a powerful tool that can help developers write more efficient and maintainable code in Python.

Example:

Here is a basic example of a Python decorator:

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, you'll see:

Something is happening before the function is called.
Hello!
Something is happening after the function is called.

In the example above, @my_decorator is a decorator. Functions that take other functions as arguments are also called higher-order functions. In this case, my_decorator is a higher-order function.

The @ symbol is just syntactic sugar that allows us to easily apply a decorator to a function. The line @my_decorator is equivalent to say_hello = my_decorator(say_hello).

This might be a lot to take in if you're new to decorators. That's okay. Decorators are a powerful tool in Python, but they can be a bit tricky to understand at first. Just take your time with this concept, play around with a few examples, and you'll get the hang of it.

The concept of decorators opens a whole new world of possibilities in Python. They can be used for logging, enforcing access control and authentication, rate limiting, caching, and much more.

Decorator factories can be used when you want to use a decorator, but need to supply it with arguments. A decorator factory is a function that returns a decorator. Here's how you can create one:

def repeat(num_times):
    def decorator_repeat(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator_repeat

@repeat(num_times=3)
def greet(name):
    print(f"Hello {name}")

greet("World")

In this example, repeat(num_times=3) returns a decorator that will repeat the decorated function three times. This is called a decorator factory.

When you run this code, you'll see:

Hello World
Hello World
Hello World

As you can see, the greet function was called three times.

This is a more advanced use of decorators, but once you understand them, they can be incredibly powerful and help make your code more readable and maintainable. The ability to modify the behavior of a function in such a clean and readable way is one of the things that makes Python such a great language to work with.