Python - Generators

21.
Explain the role of the yield statement in cooperative multitasking.

The yield statement in Python plays a crucial role in cooperative multitasking, a programming paradigm where tasks voluntarily yield control to a scheduler, allowing other tasks to run. This enables efficient concurrency without relying on threads or processes. Let's delve into the concept with an example:

import time

def task(name, times):
    for _ in range(times):
        print(f'Task {name} is running')
        yield  # Yield control to the scheduler
        time.sleep(1)

# Cooperative multitasking scheduler
def scheduler(tasks):
    while any(tasks):
        for task in tasks:
            try:
                next(task)
            except StopIteration:
                tasks.remove(task)

# Example: Running two tasks cooperatively
task1 = task('A', 3)
task2 = task('B', 4)

# Scheduler manages the tasks cooperatively
scheduler([task1, task2])
Task A is running
Task B is running
Task A is running
Task B is running
Task A is running
Task B is running
Task B is running

In this example, the task function represents a cooperative task that prints a message and yields control to the scheduler using the yield statement. The scheduler, represented by the scheduler function, iteratively calls the next function on each task, allowing them to run cooperatively.

The tasks run in a cooperative manner, with each task yielding control to the scheduler, which then switches to the next task. This cooperative multitasking approach is useful in scenarios where traditional concurrency mechanisms may be impractical or resource-intensive.


22.
How can you create a generator that generates an infinite sequence of prime numbers?

Creating a generator for an infinite sequence of prime numbers involves implementing the logic to generate primes dynamically. Here's an example:

def is_prime(num):
    """Check if a number is prime."""
    if num < 2:
        return False
    for i in range(2, int(num**0.5) + 1):
        if num % i == 0:
            return False
    return True

def prime_generator():
    """Generate an infinite sequence of prime numbers."""
    num = 2
    while True:
        if is_prime(num):
            yield num
        num += 1

# Example: Generating first 5 prime numbers
prime_gen = prime_generator()
primes = [next(prime_gen) for _ in range(5)]
[2, 3, 5, 7, 11]

In this example, the is_prime function checks if a given number is prime. The prime_generator function is a generator that yields an infinite sequence of prime numbers. It starts from 2 and increments the number, yielding each prime number it encounters.

The example demonstrates using the prime_generator to generate the first 5 prime numbers. You can continue to use the generator to obtain more prime numbers as needed.


23.
Discuss the use of generators in processing large log files.

Generators are particularly useful for processing large log files efficiently. They allow you to iterate over the file line by line, keeping memory usage low. Here's an example:

def process_log_file(file_path):
    """Process a large log file using a generator."""
    with open(file_path, 'r') as log_file:
        for line in log_file:
            # Process each log entry (replace this with your actual processing logic)
            processed_entry = process_log_entry(line)
            yield processed_entry

def process_log_entry(log_entry):
    """Example processing logic for each log entry."""
    # Replace this with your actual processing logic
    return log_entry.strip().upper()

# Example: Processing a log file and printing the first 5 entries
log_file_path = 'large_log_file.txt'
log_entries_generator = process_log_file(log_file_path)
first_5_entries = [next(log_entries_generator) for _ in range(5)]
['LOG ENTRY 1', 'LOG ENTRY 2', 'LOG ENTRY 3', 'LOG ENTRY 4', 'LOG ENTRY 5']

In this example, the process_log_file function is a generator that reads a large log file line by line. For each line, it calls the process_log_entry function, which represents your actual processing logic for each log entry.

The example demonstrates using the generator to process the first 5 log entries, but you can continue to iterate over the generator to process the entire log file efficiently.


24.
What is the purpose of the yield statement in the context of stateful generators?

The yield statement in the context of stateful generators is used to temporarily pause the generator's execution and yield a value. When the generator is later resumed, it continues execution from where it left off, maintaining its internal state. This allows generators to represent sequences with potentially infinite or dynamic lengths without consuming excessive memory.

Let's explore an example of a stateful generator using the Fibonacci sequence:

def fibonacci_generator():
    """Generate the Fibonacci sequence using a stateful generator."""
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

# Example: Generating the first 5 Fibonacci numbers
fibonacci_gen = fibonacci_generator()
first_5_fibonacci_numbers = [next(fibonacci_gen) for _ in range(5)]
[0, 1, 1, 2, 3]

In this example, the fibonacci_generator is a stateful generator that yields Fibonacci numbers indefinitely. The yield a statement pauses the generator, returning the current Fibonacci number (a), and then resumes from the same point in the next iteration.

The first_5_fibonacci_numbers list shows the first 5 numbers generated by the stateful generator.