Learn About Delegating Generators

PythonPythonBeginner
Practice Now

This tutorial is from open-source community. Access the source code

Introduction

In this lab, you will learn about delegating generators in Python using the yield from statement. This feature, introduced in Python 3.3, simplifies code that depends on generators and coroutines.

Generators are special functions that can pause and resume execution, retaining their state between calls. The yield from statement offers an elegant way to delegate control to another generator, enhancing code readability and maintainability.

Objectives:

  • Understand the purpose of the yield from statement
  • Learn how to use yield from to delegate to other generators
  • Apply this knowledge to simplify coroutine-based code
  • Understand the connection to modern async/await syntax

Files you will work with:

  • cofollow.py - Contains coroutine utility functions
  • server.py - Contains a simple network server implementation

Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL python(("Python")) -.-> python/FunctionsGroup(["Functions"]) python(("Python")) -.-> python/ObjectOrientedProgrammingGroup(["Object-Oriented Programming"]) python(("Python")) -.-> python/AdvancedTopicsGroup(["Advanced Topics"]) python(("Python")) -.-> python/NetworkingGroup(["Networking"]) python/FunctionsGroup -.-> python/function_definition("Function Definition") python/ObjectOrientedProgrammingGroup -.-> python/encapsulation("Encapsulation") python/AdvancedTopicsGroup -.-> python/generators("Generators") python/AdvancedTopicsGroup -.-> python/threading_multiprocessing("Multithreading and Multiprocessing") python/NetworkingGroup -.-> python/socket_programming("Socket Programming") subgraph Lab Skills python/function_definition -.-> lab-132527{{"Learn About Delegating Generators"}} python/encapsulation -.-> lab-132527{{"Learn About Delegating Generators"}} python/generators -.-> lab-132527{{"Learn About Delegating Generators"}} python/threading_multiprocessing -.-> lab-132527{{"Learn About Delegating Generators"}} python/socket_programming -.-> lab-132527{{"Learn About Delegating Generators"}} end

Understanding the yield from Statement

In this step, we're going to explore the yield from statement in Python. This statement is a powerful tool when working with generators, and it simplifies the process of delegating operations to other generators. By the end of this step, you'll understand what yield from is, how it works, and how it can handle value passing between different generators.

What is yield from?

The yield from statement was introduced in Python 3.3. Its main purpose is to simplify the delegation of operations to subgenerators. A subgenerator is just another generator that a main generator can delegate work to.

Normally, when you want a generator to yield values from another generator, you'd have to use a loop. For example, without yield from, you'd write code like this:

def delegating_generator():
    for value in subgenerator():
        yield value

In this code, the delegating_generator uses a for loop to iterate over the values produced by subgenerator and then yields each value one by one.

However, with the yield from statement, the code becomes much simpler:

def delegating_generator():
    yield from subgenerator()

This single line of code achieves the same result as the loop in the previous example. But yield from is not just a shortcut. It also manages the bidirectional communication between the caller and the subgenerator. This means that any values sent to the delegating generator are passed directly to the subgenerator.

Basic Example

Let's create a simple example to see how yield from works in action.

  1. First, we need to open the cofollow.py file in the editor. To do this, we'll use the cd command to navigate to the correct directory. Run the following command in the terminal:
cd /home/labex/project
  1. Next, we'll add two functions to the cofollow.py file. The subgen function is a simple generator that yields the numbers from 0 to 4. The main_gen function uses yield from to delegate the generation of these numbers to subgen and then yields the string 'Done'. Add the following code to the end of the cofollow.py file:
def subgen():
    for i in range(5):
        yield i

def main_gen():
    yield from subgen()
    yield 'Done'
  1. Now, let's test these functions. Open a Python shell and run the following code:
from cofollow import subgen, main_gen

## Test subgen directly
for x in subgen():
    print(x)

## Test main_gen that delegates to subgen
for x in main_gen():
    print(x)

When you run this code, you should see the following output:

0
1
2
3
4

0
1
2
3
4
Done

This output shows that yield from allows main_gen to pass all the values generated by subgen directly to the caller.

Value Passing with yield from

One of the most powerful features of yield from is its ability to handle value passing in both directions. Let's create a more complex example to demonstrate this.

  1. Add the following functions to the cofollow.py file:
def accumulator():
    total = 0
    while True:
        value = yield total
        if value is None:
            break
        total += value

def caller():
    acc = accumulator()
    yield from acc
    yield 'Total accumulated'

The accumulator function is a coroutine that keeps track of a running total. It yields the current total and then waits to receive a new value. If it receives None, it stops the loop. The caller function creates an instance of accumulator and uses yield from to delegate all send and receive operations to it.

  1. Test these functions in a Python shell:
from cofollow import caller

c = caller()
print(next(c))  ## Start the coroutine
print(c.send(1))  ## Send value 1, get accumulated value
print(c.send(2))  ## Send value 2, get accumulated value
print(c.send(3))  ## Send value 3, get accumulated value
print(c.send(None))  ## Send None to exit the accumulator

When you run this code, you should see the following output:

0
1
3
6
'Total accumulated'

This output shows that yield from fully delegates all send and receive operations to the subgenerator until it's exhausted.

Now that you understand the basics of yield from, we'll move on to more practical applications in the next step.

โœจ Check Solution and Practice

Using yield from in Coroutines

In this step, we'll explore how to use the yield from statement with coroutines for more practical applications. Coroutines are a powerful concept in Python, and understanding how to use yield from with them can greatly simplify your code.

Coroutines and Message Passing

Coroutines are special functions that can receive values through the yield statement. They're incredibly useful for tasks such as data processing and event handling. In the cofollow.py file, there's a consumer decorator. This decorator helps set up coroutines by automatically advancing them to the first yield point. This means you don't have to manually start the coroutine; the decorator takes care of it for you.

Let's create a coroutine that receives values and validates their types. Here's how you can do it:

  1. First, open the cofollow.py file in the editor. You can use the following command in the terminal to navigate to the correct directory:
cd /home/labex/project
  1. Next, add the following receive function at the end of the cofollow.py file. This function is a coroutine that will receive a message and validate its type.
def receive(expected_type):
    """
    A coroutine that receives a message and validates its type.
    Returns the received message if it matches the expected type.
    """
    msg = yield
    assert isinstance(msg, expected_type), f'Expected type {expected_type}'
    return msg

Here's what this function does:

  • It uses yield without an expression to receive a value. When the coroutine is sent a value, this yield statement will capture it.
  • It checks if the received value is of the expected type using the isinstance function. If the type doesn't match, it raises an AssertionError.
  • If the type check passes, it returns the value.
  1. Now, let's create a coroutine that uses yield from with our receive function. This new coroutine will receive and print integers only.
@consumer
def print_ints():
    """
    A coroutine that receives and prints integers only.
    Uses yield from to delegate to the receive coroutine.
    """
    while True:
        val = yield from receive(int)
        print('Got:', val)
  1. To test this coroutine, open a Python shell and run the following code:
from cofollow import print_ints

p = print_ints()
p.send(42)
p.send(13)
try:
    p.send('13')  ## This should raise an AssertionError
except AssertionError as e:
    print(f"Error: {e}")

You should see the following output:

Got: 42
Got: 13
Error: Expected type <class 'int'>

Understanding How yield from Works with Coroutines

When we use yield from receive(int) in the print_ints coroutine, the following steps occur:

  1. Control is delegated to the receive coroutine. This means that the print_ints coroutine pauses, and the receive coroutine starts executing.
  2. The receive coroutine uses yield to receive a value. It waits for a value to be sent to it.
  3. When a value is sent to print_ints, it's actually received by receive. The yield from statement takes care of passing the value from print_ints to receive.
  4. The receive coroutine validates the type of the received value. If the type is correct, it returns the value.
  5. The returned value becomes the result of the yield from expression in the print_ints coroutine. This means that the val variable in print_ints gets assigned the value returned by receive.

Using yield from makes the code more readable than if we had to handle the yielding and receiving directly. It abstracts away the complexity of passing values between coroutines.

Creating More Advanced Type-Checking Coroutines

Let's expand our utility functions to handle more complex type validation. Here's how you can do it:

  1. Add the following functions to the cofollow.py file:
def receive_dict():
    """Receive and validate a dictionary"""
    result = yield from receive(dict)
    return result

def receive_str():
    """Receive and validate a string"""
    result = yield from receive(str)
    return result

@consumer
def process_data():
    """Process different types of data using the receive utilities"""
    while True:
        print("Waiting for a string...")
        name = yield from receive_str()
        print(f"Got string: {name}")

        print("Waiting for a dictionary...")
        data = yield from receive_dict()
        print(f"Got dictionary with {len(data)} items: {data}")

        print("Processing complete for this round.")
  1. To test the new coroutine, open a Python shell and run the following code:
from cofollow import process_data

proc = process_data()
proc.send("John Doe")
proc.send({"age": 30, "city": "New York"})
proc.send("Jane Smith")
try:
    proc.send(123)  ## This should raise an AssertionError
except AssertionError as e:
    print(f"Error: {e}")

You should see output like this:

Waiting for a string...
Got string: John Doe
Waiting for a dictionary...
Got dictionary with 2 items: {'age': 30, 'city': 'New York'}
Processing complete for this round.
Waiting for a string...
Got string: Jane Smith
Waiting for a dictionary...
Error: Expected type <class 'dict'>

The yield from statement makes the code cleaner and more readable. It allows us to focus on the high-level logic of our program rather than getting bogged down in the details of message passing between coroutines.

โœจ Check Solution and Practice

Wrapping Sockets with Generators

In this step, we're going to learn how to use generators to wrap socket operations. This is a really important concept, especially when it comes to asynchronous programming. Asynchronous programming allows your program to handle multiple tasks at once without waiting for one task to finish before starting another. Using generators to wrap socket operations can make your code more efficient and easier to manage.

Understanding the Problem

The server.py file contains a simple network server implementation using generators. Let's take a look at the current code. This code is the foundation of our server, and understanding it is crucial before we make any changes.

def tcp_server(address, handler):
    sock = socket(AF_INET, SOCK_STREAM)
    sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    sock.bind(address)
    sock.listen(5)
    while True:
        yield 'recv', sock
        client, addr = sock.accept()
        tasks.append(handler(client, addr))

def echo_handler(client, address):
    print('Connection from', address)
    while True:
        yield 'recv', client
        data = client.recv(1000)
        if not data:
            break
        yield 'send', client
        client.send(b'GOT:' + data)
    print('Connection closed')
    client.close()

In this code, we use the yield keyword. The yield keyword is used in Python to create generators. A generator is a special type of iterator that allows you to pause and resume a function's execution. Here, yield is used to indicate when the server is ready to receive a connection or when a client handler is ready to receive or send data. However, the manual yield statements expose the internal workings of the event loop to the user. This means that the user has to know how the event loop works, which can make the code harder to understand and maintain.

Creating a GenSocket Class

Let's create a GenSocket class to wrap socket operations with generators. This will make our code cleaner and more readable. By encapsulating the socket operations in a class, we can hide the details of the event loop from the user and focus on the high-level logic of the server.

  1. Open the server.py file in the editor:
cd /home/labex/project

This command changes the current directory to the project directory where the server.py file is located. Once you're in the correct directory, you can open the file in your preferred text editor.

  1. Add the following GenSocket class at the end of the file, before any existing functions:
class GenSocket:
    """
    A generator-based wrapper for socket operations.
    """
    def __init__(self, sock):
        self.sock = sock

    def accept(self):
        """Accept a connection and return a new GenSocket"""
        yield 'recv', self.sock
        client, addr = self.sock.accept()
        return GenSocket(client), addr

    def recv(self, maxsize):
        """Receive data from the socket"""
        yield 'recv', self.sock
        return self.sock.recv(maxsize)

    def send(self, data):
        """Send data to the socket"""
        yield 'send', self.sock
        return self.sock.send(data)

    def __getattr__(self, name):
        """Forward any other attributes to the underlying socket"""
        return getattr(self.sock, name)

This GenSocket class acts as a wrapper for socket operations. The __init__ method initializes the class with a socket object. The accept, recv, and send methods perform the corresponding socket operations and use yield to indicate when the operation is ready. The __getattr__ method allows the class to forward any other attributes to the underlying socket object.

  1. Now, modify the tcp_server and echo_handler functions to use the GenSocket class:
def tcp_server(address, handler):
    sock = GenSocket(socket(AF_INET, SOCK_STREAM))
    sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    sock.bind(address)
    sock.listen(5)
    while True:
        client, addr = yield from sock.accept()
        tasks.append(handler(client, addr))

def echo_handler(client, address):
    print('Connection from', address)
    while True:
        data = yield from client.recv(1000)
        if not data:
            break
        yield from client.send(b'GOT:' + data)
    print('Connection closed')
    client.close()

Notice how the explicit yield 'recv', sock and yield 'send', client statements have been replaced with cleaner yield from expressions. The yield from keyword is used to delegate the execution to another generator. This makes the code more readable and hides the details of the event loop from the user. Now, the code looks more like normal function calls, and the user doesn't have to worry about the internal workings of the event loop.

  1. Let's add a simple test function to demonstrate how our server would be used:
def run_server():
    """Start the server on port 25000"""
    tasks.append(tcp_server(('localhost', 25000), echo_handler))
    try:
        event_loop()
    except KeyboardInterrupt:
        print("Server stopped")

if __name__ == '__main__':
    print("Starting echo server on port 25000...")
    print("Press Ctrl+C to stop")
    run_server()

This code is more readable and maintainable. The GenSocket class encapsulates the yielding logic, allowing the server code to focus on the high-level flow rather than the details of the event loop. The run_server function starts the server on port 25000 and handles the KeyboardInterrupt exception, which allows the user to stop the server by pressing Ctrl+C.

Understanding the Benefits

The yield from approach provides several benefits:

  1. Cleaner code: The socket operations look more like normal function calls. This makes the code easier to read and understand, especially for beginners.
  2. Abstraction: The details of the event loop are hidden from the user. The user doesn't have to know how the event loop works to use the server code.
  3. Readability: The code better expresses what it's doing rather than how it's doing it. This makes the code more self-explanatory and easier to maintain.
  4. Maintainability: Changes to the event loop won't require changes to the server code. This means that if you need to modify the event loop in the future, you can do so without affecting the server code.

This pattern is a stepping stone to modern async/await syntax, which we'll explore in the next step. The async/await syntax is a more advanced and cleaner way to write asynchronous code in Python, and understanding the yield from pattern will help you transition to it more easily.

โœจ Check Solution and Practice

From Generators to Async/Await

In this final step, we'll explore how the yield from pattern in Python evolved into the modern async/await syntax. Understanding this evolution is crucial as it helps you see the connection between generators and asynchronous programming. Asynchronous programming allows your program to handle multiple tasks without waiting for each one to finish, which is especially useful in network programming and other I/O - bound operations.

The Connection Between Generators and Async/Await

The async/await syntax, introduced in Python 3.5, is built on top of the generator and yield from functionality. Under the hood, async functions are implemented using generators. This means that the concepts you've learned about generators are directly related to how async/await works.

To transition from using generators to the async/await syntax, we need to follow these steps:

  1. Use the @coroutine decorator from the types module. This decorator helps convert generator - based functions into a form that can be used with async/await.
  2. Convert functions that use yield from to use async and await instead. This makes the code more readable and better expresses the asynchronous nature of the operations.
  3. Update the event loop to handle native coroutines. The event loop is responsible for scheduling and running asynchronous tasks.

Updating the GenSocket Class

Now, let's modify our GenSocket class to work with the @coroutine decorator. This will allow our class to be used in an async/await context.

  1. Open the server.py file in the editor. You can do this by running the following command in the terminal:
cd /home/labex/project
  1. At the top of the server.py file, add the import for coroutine. This import is necessary to use the @coroutine decorator.
from types import coroutine
  1. Update the GenSocket class to use the @coroutine decorator. This decorator transforms our generator - based methods into awaitable coroutines, which means they can be used with the await keyword.
class GenSocket:
    """
    A generator-based wrapper for socket operations
    that works with async/await.
    """
    def __init__(self, sock):
        self.sock = sock

    @coroutine
    def accept(self):
        """Accept a connection and return a new GenSocket"""
        yield 'recv', self.sock
        client, addr = self.sock.accept()
        return GenSocket(client), addr

    @coroutine
    def recv(self, maxsize):
        """Receive data from the socket"""
        yield 'recv', self.sock
        return self.sock.recv(maxsize)

    @coroutine
    def send(self, data):
        """Send data to the socket"""
        yield 'send', self.sock
        return self.sock.send(data)

    def __getattr__(self, name):
        """Forward any other attributes to the underlying socket"""
        return getattr(self.sock, name)

Converting to Async/Await Syntax

Next, let's convert our server code to use the async/await syntax. This will make the code more readable and clearly express the asynchronous nature of the operations.

async def tcp_server(address, handler):
    """
    An asynchronous TCP server using async/await.
    """
    sock = GenSocket(socket(AF_INET, SOCK_STREAM))
    sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    sock.bind(address)
    sock.listen(5)
    while True:
        client, addr = await sock.accept()
        tasks.append(handler(client, addr))

async def echo_handler(client, address):
    """
    An asynchronous handler for echo clients.
    """
    print('Connection from', address)
    while True:
        data = await client.recv(1000)
        if not data:
            break
        await client.send(b'GOT:' + data)
    print('Connection closed')
    client.close()

Notice that yield from has been replaced with await, and the functions are now defined with async def instead of def. This change makes the code more intuitive and easier to understand.

Understanding the Transformation

The transition from generators with yield from to the async/await syntax is not just a simple syntactic change. It represents a shift in how we think about asynchronous programming.

  1. Generators with yield from:

    • When using generators with yield from, you explicitly yield control to signal that a task is ready. This means you have to manually manage when a task can continue.
    • You also need to manually manage the scheduling of tasks. This can be complex, especially in larger programs.
    • The focus is on the mechanics of control flow, which can make the code harder to read and maintain.
  2. Async/await syntax:

    • With the async/await syntax, control is implicitly yielded at await points. This makes the code more straightforward as you don't have to worry about explicitly yielding control.
    • The event loop takes care of scheduling tasks, so you don't have to manage it manually.
    • The focus is on the logical flow of the program, which makes the code more readable and maintainable.

This transformation allows for more readable and maintainable asynchronous code, which is especially important for complex applications like network servers.

Modern Asynchronous Programming

In modern Python, we usually use the asyncio module for asynchronous programming instead of a custom event loop. The asyncio module provides built - in support for many useful features:

  • Running multiple coroutines concurrently. This allows your program to handle multiple tasks at the same time.
  • Managing network I/O. It simplifies the process of sending and receiving data over the network.
  • Synchronization primitives. These help you manage access to shared resources in a concurrent environment.
  • Task scheduling and cancellation. You can easily schedule tasks to run at specific times and cancel them if needed.

Here's how our server might look using asyncio:

import asyncio

async def handle_client(reader, writer):
    addr = writer.get_extra_info('peername')
    print(f'Connection from {addr}')

    while True:
        data = await reader.read(1000)
        if not data:
            break

        writer.write(b'GOT:' + data)
        await writer.drain()

    print('Connection closed')
    writer.close()
    await writer.wait_closed()

async def main():
    server = await asyncio.start_server(
        handle_client, 'localhost', 25000
    )

    addr = server.sockets[0].getsockname()
    print(f'Serving on {addr}')

    async with server:
        await server.serve_forever()

if __name__ == '__main__':
    asyncio.run(main())

This code achieves the same functionality as our generator - based server but uses the standard asyncio library, which is more robust and feature - rich.

Conclusion

In this lab, you've learned about several important concepts:

  1. The yield from statement and how it delegates to another generator. This is a fundamental concept in understanding how generators work.
  2. How to use yield from with coroutines for message passing. This allows you to communicate between different parts of your asynchronous program.
  3. Wrapping socket operations with generators for cleaner code. This makes your network - related code more organized and easier to understand.
  4. The transition from generators to the modern async/await syntax. Understanding this transition will help you write more readable and maintainable asynchronous code in Python, whether you're using generators directly or the modern async/await syntax.
โœจ Check Solution and Practice

Summary

In this lab, you have learned about the concept of delegating generators in Python, with a focus on the yield from statement and its various applications. You explored how to use yield from to delegate to another generator, which simplifies code and enhances readability. You also learned about creating coroutines with yield from to receive and validate messages, and using generators to wrap socket operations for cleaner network code.

These concepts are essential for understanding asynchronous programming in Python. The transition from generators to the modern async/await syntax represents a significant advancement in handling asynchronous operations. To further explore these concepts, you can study the asyncio module, examine how popular frameworks use async/await, and develop your own asynchronous libraries. Understanding delegating generators and yield from provides a deeper insight into Python's approach to asynchronous programming.