Advanced Python Features: Generators and Decorators

Advanced Python Features: Generators and Decorators

Python is a very powerful programming language that enables developing simple, comprehensible, and clean code. After advancing a little bit in Python, you will notice a number of advanced techniques that make the language even more powerful. So, every new concept you learn is somehow significant, but there is a notable subprocess – two such processes or concepts are called generators and decorators, which,although at the beginning seem a bit tough, ultimately help one to write more optimized and reusable code components.

One of the main purposes of decorators and generators is to extend the Python functions, with generators being a bit different in that they don't take function inputs instead, their purpose is to enable advocating the management of resources. In this article, we will consider building blocks of generators and decorators in Python, their usage, and why they are needed.

What are Generators?

Generators are considered as a subclass of iterable (for example list, tuple), but instead of having all the values pre allocated, they have them generated on the fly. With this simple mechanism, generators are specifically helpful for the cases when working with very large collections that need plenty of resources "under the hood" to operate smoothly. Dispensers also have one specific point standing out which is, they facilitate scrolling through an information dataset, reviewing one element to the subsequent one while avoiding temporarily duplicating the complete set of elements.

In Python, we can use the keyword 'yield' to create a generator. Unlike the regular function that executes and returns a value, a generator can be said to run to pause and continue at some later stage. This means whenever the function is called to run, it will run only till the next yield statement which enables the functions to be efficient for large data sets. The drawback with this approach is that the values are not too predictive.

How Generators Work

While defining a generator function, you use yield instead of return for the statements. Also, the yield statement gives back a value to the calling function but it also saves the current state of the function. This means that in any subsequent calls, the function execution can resume from where it was stopped.

Let us look at an example to illustrate defining a simple generator function that yields the squares of numbers.

Generator Example

  def square_numbers(nums):
for num in nums:
yield num * num
In this case, the function creates a list of numbers, yields their square to the generator, and then every time the generator is invoked, it returns the next number square until all squares have been returned. Each time the function was called it would generate the next square so the function was never run to completion.

You can go ahead and use a generator in exactly the same way as any other iterable:

Using Generator

  nums = [1, 2, 3, 4, 5]
squares = square_numbers(nums)
for square in squares:
print(square)
Now every time the 'square_numbers()' method is called, it will yield a single square, until all squares have been yielded, the loop will run. This way, there is no need to keep an entire list of squares in memory in other words, it is memory efficient.

Why Use Generators?

The generator's strength mainly stems from its ability to work on a large dataset or computation easily. Since values within them are computed and emitted at runtime, less memory is required in comparison to that of a list or some other data structure. Also, with generators it's possible to implement use cases that generate an infinite sequence, something that is otherwise impossible to do since it would require too much memory to store.

Some of the characteristics of the generators are as follows:

- Memory Efficiency : Only the current value is kept in memory and not the whole collection.

- Lazy Evaluation : The next value is computed only when required, which is excellent for long-running tasks or tasks that require lots of resources.

- Infinite Sequences : Ranges simply could not be represented due to how vast they are but with generators reading in files, or numbers can continuously be generated.

What are Decorators?

Another sophisticated aspect of python is Decorators which enables you to change or extend the function or method behaviors without modifying its implementation directly. To put it in other words, decorators can be defined as functions that receive other functions as arguments and output a new one that will usually be an extension of the behavior of the first one.

In Python, decorators are frequent not only for the purpose of logging but also for access control, memoization, validation, and so on. This avoids the need for code duplication and makes it possible and easy to enhance the previous functionality with new features.

How Decorators Work

A decorator is a function of the first order, that is, one that takes as an input argument a function and yields as output a modified version of the function. The output function in most cases would broaden the scope of the first function being decorated. The decorators are invoked through the use of @ symbol which is placed before the required function definition.


Here's a simple example of a decorator that prints a message before and after a function call:

Decorator Example

  def decorator_function(func):
def wrapper():
print("Before the function is called.")
func()
print("After the function is called.")
return wrapper

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

say_hello()
In this example:

- decorator_function which is a function that decorates the say_hello function.

- The wrapper function is the one which performs additional operations before and after the original function say_hello is executed.

- The @decorator_function is a shorthand notation to mean say_hello = decorator_function(say_hello) .

Now, every time the say_hello function is called, the decorator adds extra print statements before and after the 'say hello' message.

Multiple Decorators

In the world of Python programming, it is possible to use more than one decorator in a single function. In the situation where a function has more than one decoration, the function will be processed in an order beginning with the uppermost and ending with the lowermost, where the latter between the two is the one attached inside the other.

A demonstration of how you can use parse multiple decorators in a code:

Multiple Decorators Example

  def decorator_function_a(func):
def wrapped_function():
print("Decorator A")
func()
return wrapped_function

def decorator_function_b(func):
def wrapped_function():
print("Decorator B")
func()
return wrapped_function

@decorator_function_a
@decorator_function_b
def hello():
print("hi there")

hello()
In this case:

1. The first decoration applied to the function say_hello is the say_hello function.

2. The second decoration applied to decorator_two is decorator_one .

3. The result after invoking the function say_hello can be seen below:

Decorator A
Decorator B
hi there

What is the point of using Decorators?

The use of decorators has various benefits, especially with regard to the level of abstraction of the code and the readability of code. They help to add or even change properties straightforwardly and systematically. Examples of the benefits are:

- Separation of Concerns : Uses of decorators allow for the separation of the functional aspects for example one may create a decorator which is dedicated to logging, another that handles permissions and many more.

- Code Reusability : In that case, one would only need to create a few decorators and be able to apply them to several functions, thus getting rid of code repetition.

Related Articles