Python & AI Tutorials Logo
Programación Python

36. Generadores y iteración perezosa

En el Capítulo 35, aprendimos cómo funciona la iteración en Python a través de iterables e iteradores. Vimos que los iteradores devuelven valores uno a la vez cuando se solicitan, lo que permite que Python procese secuencias sin cargar todo en memoria de una sola vez. Ahora exploraremos los generadores, la forma más elegante y práctica de Python para crear iteradores.

Los generadores son funciones que pueden pausar y reanudar su ejecución, produciendo valores uno a la vez a medida que se solicitan, en lugar de calcular todos los valores por adelantado y almacenarlos en memoria. Este enfoque —llamado evaluación perezosa (lazy evaluation)— significa que los valores se generan solo cuando se necesitan, lo que convierte este enfoque en una de las características más potentes de Python para escribir código eficiente en memoria.

36.1) Qué son los generadores y por qué son útiles

36.1.1) El problema de crear listas grandes

Empecemos por comprender el problema que resuelven los generadores. Supón que necesitas procesar una secuencia de un millón de números. Este es el enfoque tradicional usando una lista:

python
# Crear una lista de un millón de cuadrados
def get_squares_list(n):
    """Return a list of squares from 0 to n-1."""
    squares = []
    for i in range(n):
        squares.append(i * i)
    return squares
 
# Esto crea una lista con 1,000,000 números en memoria
numbers = get_squares_list(1_000_000)
print(f"First five squares: {numbers[:5]}")  # Output: First five squares: [0, 1, 4, 9, 16]

Este enfoque tiene un problema importante: crea y almacena los un millón de números en memoria a la vez, incluso si solo necesitas procesarlos de uno en uno. Para conjuntos de datos más grandes o cálculos más complejos, esto puede consumir cantidades enormes de memoria o incluso hacer que tu programa se bloquee.

36.1.2) Introducción a los generadores: calcular valores bajo demanda

Un generador es un tipo especial de función que produce valores uno a la vez, solo cuando se solicitan. En lugar de construir y devolver una lista completa, un generador calcula cada valor cuando hace falta y "recuerda" en qué punto se quedó entre llamadas.

Aquí está la misma funcionalidad implementada como un generador:

python
# Crear un generador de cuadrados
def get_squares_generator(n):
    """Generate squares from 0 to n-1, one at a time."""
    for i in range(n):
        yield i * i  # yield pausa la función y devuelve un valor
 
# Esto crea un objeto generador, no una lista
squares_gen = get_squares_generator(1_000_000)
print(squares_gen)  # Output: <generator object get_squares_generator at 0x...>
 
# Obtener valores uno a la vez
print(next(squares_gen))  # Output: 0
print(next(squares_gen))  # Output: 1
print(next(squares_gen))  # Output: 4

El generador no calcula de antemano los un millón de cuadrados. En su lugar, calcula cada cuadrado solo cuando llamas a next() sobre él. Entre llamadas, el generador "pausa" y recuerda su estado (el valor actual de i).

36.1.3) Eficiencia de memoria: la ventaja clave

La diferencia de memoria entre listas y generadores se vuelve drástica con conjuntos de datos grandes. Comparemos:

python
import sys
 
# Enfoque con lista: almacena todos los valores
def squares_list(n):
    return [i * i for i in range(n)]
 
# Enfoque con generador: calcula valores bajo demanda
def squares_generator(n):
    for i in range(n):
        yield i * i
 
# Comparar el uso de memoria para 100,000 números
list_result = squares_list(100_000)
gen_result = squares_generator(100_000)
 
print(f"List size in memory: {sys.getsizeof(list_result):,} bytes")
# Output: List size in memory: 800,984 bytes (actual size may vary)
 
print(f"Generator size in memory: {sys.getsizeof(gen_result)} bytes")
# Output: Generator size in memory: 200 bytes (actual size may vary)

La lista consume más de 800 KB de memoria, mientras que el generador usa solo 200 bytes, independientemente de cuántos valores vaya a producir finalmente. El generador almacena únicamente el estado de la función (el valor actual de i y dónde reanudar), no la secuencia real de valores.

36.1.4) Cuándo son útiles los generadores

Los generadores destacan en varios escenarios comunes:

Procesamiento de archivos grandes:

python
def read_large_file(filename):
    """Generate lines from a file one at a time."""
    with open(filename, 'r') as file:
        for line in file:
            yield line.strip()
 
# Procesar un archivo de logs enorme sin cargarlo entero en memoria
for line in read_large_file('huge_log.txt'):
    if 'ERROR' in line:
        print(line)

Secuencias infinitas:

python
def fibonacci():
    """Generate Fibonacci numbers indefinitely."""
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b
 
# Generar números de Fibonacci para siempre (o hasta que dejes de pedirlos)
fib = fibonacci()
print([next(fib) for _ in range(10)])
# Output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

36.1.5) Los generadores son iteradores

Como aprendimos en el Capítulo 35, los generadores son en realidad un tipo especial de iterador. Implementan automáticamente el protocolo de iterador (__iter__() y __next__()), por eso funcionan sin problemas con los bucles for:

python
def countdown(n):
    """Generate countdown from n to 1."""
    while n > 0:
        yield n
        n -= 1
 
# Los generadores funcionan directamente en bucles for
for num in countdown(5):
    print(num)
# Output:
# 5
# 4
# 3
# 2
# 1

Cuando usas un generador en un bucle for, Python llama automáticamente a next() sobre él de forma repetida hasta que el generador se agota (lanza StopIteration).

36.2) Crear funciones generadoras con yield

36.2.1) La sentencia yield: pausar y reanudar

La sentencia yield es lo que convierte una función en un generador. Cuando Python encuentra yield, hace algo especial: en lugar de devolver un valor y terminar la función, pausa la función y devuelve el valor. La siguiente vez que llamas a next() sobre el generador, la ejecución se reanuda justo después de la sentencia yield.

Aquí tienes un ejemplo simple que demuestra este comportamiento de pausar y reanudar:

python
def simple_generator():
    """Demonstrate how yield pauses execution."""
    print("Starting generator")
    yield 1
    print("Resuming after first yield")
    yield 2
    print("Resuming after second yield")
    yield 3
    print("Generator finished")
 
gen = simple_generator()
print("Created generator")
# Output:
# Created generator
 
print(f"First value: {next(gen)}")
# Output:
# Starting generator
# First value: 1
 
print(f"Second value: {next(gen)}")
# Output:
# Resuming after first yield
# Second value: 2
 
print(f"Third value: {next(gen)}")
# Output:
# Resuming after second yield
# Third value: 3
 
try:
    next(gen)
except StopIteration:
    print("Generator exhausted - no more values")
# Output:
# Generator finished
# Generator exhausted - no more values

Fíjate en cómo la ejecución de la función se intercala con las llamadas a next(). Cada yield pausa la función, y cada next() la reanuda desde donde se quedó.

36.2.2) Estado del generador: recordar variables locales

Los generadores recuerdan todas sus variables locales entre yield. Esto los hace útiles para mantener estado a lo largo de múltiples llamadas:

python
def counter(start=0):
    """Generate sequential numbers starting from start."""
    current = start
    while True:
        yield current
        current += 1
 
# El generador recuerda 'current' entre yield
count = counter(10)
print(next(count))  # Output: 10
print(next(count))  # Output: 11
print(next(count))  # Output: 12
 
# Cada generador tiene su propio estado independiente
count1 = counter(0)
count2 = counter(100)
print(next(count1))  # Output: 0
print(next(count2))  # Output: 100
print(next(count1))  # Output: 1
print(next(count2))  # Output: 101

La variable current se conserva cada vez que el generador se pausa en un yield y se reanuda en la siguiente llamada a next(). Esto permite que el generador siga contando desde su último valor. Cada instancia de generador mantiene su propio estado independiente.

36.2.3) Hacer yield en bucles: el patrón más común

El uso más común de generadores es hacer yield de valores dentro de un bucle. Este patrón genera una secuencia de valores:

python
def even_numbers(start, end):
    """Generate even numbers in the given range."""
    current = start if start % 2 == 0 else start + 1
    while current <= end:
        yield current
        current += 2
 
# Usar el generador
evens = even_numbers(1, 20)
print(list(evens))
# Output: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

Cada iteración del bucle produce un valor y luego continúa a la siguiente iteración cuando next() se vuelve a llamar.

36.2.4) Múltiples sentencias yield

Un generador puede tener múltiples sentencias yield en distintos puntos de su código. La ejecución fluye a través de ellas en orden:

python
def process_data(data):
    """Generate processed data with status messages."""
    yield "Starting processing..."
    
    cleaned = [item.strip().lower() for item in data]
    yield f"Cleaned {len(cleaned)} items"
    
    unique = list(set(cleaned))
    yield f"Found {len(unique)} unique items"
    
    for item in sorted(unique):
        yield item
 
# Procesar algunos datos
data = ["  Apple  ", "Banana", "apple", "Cherry", "BANANA"]
processor = process_data(data)
 
for result in processor:
    print(result)
# Output:
# Starting processing...
# Cleaned 5 items
# Found 3 unique items
# apple
# banana
# cherry

Este patrón es útil para generadores que necesitan realizar trabajo de preparación, producir información de estado y después producir los datos reales.

36.3) Expresiones generadoras vs comprensiones de listas

36.3.1) Introducción a las expresiones generadoras

En el Capítulo 34, aprendimos sobre las comprensiones de listas (list comprehensions), una forma concisa de crear listas. Las expresiones generadoras (generator expressions) usan una sintaxis casi idéntica, pero crean generadores en lugar de listas.

Una expresión generadora es esencialmente una forma compacta de escribir una función generadora simple. Compara estos dos enfoques equivalentes:

python
# Función generadora
def squares_function(n):
    for x in range(n):
        yield x * x
 
# Expresión generadora: hace lo mismo
squares_expression = (x * x for x in range(10))
 
# Ambas crean objetos generador
gen1 = squares_function(10)
gen2 = squares_expression
 
print(type(gen1))  # Output: <class 'generator'>
print(type(gen2))  # Output: <class 'generator'>
 
# Ambas producen los mismos valores
print(list(squares_function(10)))  # Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
print(list(squares_expression))  # Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

La sintaxis es casi idéntica a la de las comprensiones de listas. Las diferencias son: usar paréntesis () en lugar de corchetes [], y que mientras las comprensiones de listas crean listas, las expresiones generadoras crean generadores:

python
# Comprensión de lista: crea la lista completa en memoria
squares_list = [x * x for x in range(10)]
print(squares_list)
# Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
 
# Expresión generadora: crea un objeto generador
squares_gen = (x * x for x in range(10))
print(squares_gen)
# Output: <generator object <genexpr> at 0x...>
 
# Convertir a lista para ver los valores
print(list(squares_gen))
# Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Las expresiones generadoras ofrecen la misma sintaxis concisa que las comprensiones de listas, pero con la eficiencia de memoria de los generadores.

36.3.2) Comparación de memoria: cuándo importa

Para secuencias pequeñas, la diferencia de memoria entre comprensiones de listas y expresiones generadoras es despreciable. Pero para secuencias grandes, se vuelve significativa:

python
import sys
 
# Secuencia pequeña: diferencia mínima
small_list = [x for x in range(100)]
small_gen = (x for x in range(100))
 
print(f"Small list: {sys.getsizeof(small_list)} bytes")
# Output: Small list: 920 bytes (actual size may vary)
print(f"Small generator: {sys.getsizeof(small_gen)} bytes")
# Output: Small generator: 192 bytes (actual size may vary)
 
# Secuencia grande: diferencia enorme
large_list = [x for x in range(1_000_000)]
large_gen = (x for x in range(1_000_000))
 
print(f"Large list: {sys.getsizeof(large_list):,} bytes")
# Output: Large list: 8,448,728 bytes (actual size may vary)
print(f"Large generator: {sys.getsizeof(large_gen)} bytes")
# Output: Large generator: 192 bytes (actual size may vary)

El tamaño del generador se mantiene constante sin importar cuántos valores vaya a producir: solo almacena la expresión y el estado actual. La lista, sin embargo, debe almacenar todos los valores en memoria, por eso su tamaño crece proporcionalmente con el número de elementos.

36.3.3) Expresiones generadoras en llamadas a funciones

Las expresiones generadoras son especialmente elegantes cuando se pasan directamente a funciones que consumen iterables. Puedes omitir los paréntesis extra cuando una expresión generadora es el único argumento:

python
# Calcular la suma de cuadrados sin crear una lista
total = sum(x * x for x in range(100))  # Nota: no hacen falta paréntesis extra
print(total)
# Output: 328350
 
# Encontrar el máximo de valores transformados
numbers = [1, 2, 3, 4, 5]
max_square = max(x * x for x in numbers)
print(max_square)
# Output: 25
 
# Comprobar si algún valor cumple una condición
data = [10, 15, 20, 25, 30]
has_large = any(x > 100 for x in data)
print(has_large)
# Output: False

Este patrón es eficiente en memoria y legible. Funciones como sum(), max(), min(), any() y all() procesan el generador un valor a la vez, sin crear nunca una lista intermedia.

36.3.4) Filtrado con expresiones generadoras

Las expresiones generadoras admiten la misma lógica condicional que las comprensiones de listas:

python
# Filtrar números pares
numbers = range(20)
evens = (x for x in numbers if x % 2 == 0)
print(list(evens))
# Output: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
 
# Transformar y filtrar
words = ["hello", "world", "python", "programming"]
long_upper = (word.upper() for word in words if len(word) > 5)
print(list(long_upper))
# Output: ['PYTHON', 'PROGRAMMING']

36.3.5) Cuándo las expresiones generadoras no son suficientes

Las expresiones generadoras son concisas y elegantes, pero tienen limitaciones. Usa funciones generadoras cuando necesites:

Lógica compleja:

python
# Demasiado complejo para una expresión generadora
def process_log_lines(filename):
    """Process log file with complex logic."""
    with open(filename, 'r') as file:
        for line in file:
            line = line.strip()
            if not line or line.startswith('#'):
                continue  # Omitir líneas vacías y comentarios
            
            parts = line.split('|')
            if len(parts) >= 3:
                timestamp, level, message = parts[0], parts[1], parts[2]
                if level in ('ERROR', 'CRITICAL'):
                    yield {
                        'timestamp': timestamp,
                        'level': level,
                        'message': message
                    }

Múltiples yield o estado:

python
# Una expresión generadora no puede mantener estado entre iteraciones
def running_total(numbers):
    """Generate running total of numbers."""
    total = 0
    for num in numbers:
        total += num
        yield total
 
numbers = [1, 2, 3, 4, 5]
print(list(running_total(numbers)))
# Output: [1, 3, 6, 10, 15]

Manejo de errores:

python
# Una expresión generadora no puede manejar excepciones
def safe_divide(numbers, divisor):
    """Generate division results, handling errors."""
    for num in numbers:
        try:
            yield num / divisor
        except ZeroDivisionError:
            yield float('inf')

36.4) Cuándo usar generadores en lugar de listas

36.4.1) Conjuntos de datos grandes: el caso de uso principal

La razón más convincente para usar generadores es cuando trabajas con grandes cantidades de datos. Si estás procesando millones de registros, los generadores pueden marcar la diferencia entre un programa que funciona sin problemas y uno que se bloquea.

Mal enfoque - Cargar el archivo entero en memoria:

python
# NO HAGAS ESTO con archivos grandes
def count_errors_bad(filename):
    """Load entire file into memory - will crash with large files."""
    with open(filename, 'r') as file:
        lines = file.readlines()  # Carga el archivo ENTERO en memoria
    
    error_count = 0
    for line in lines:
        if 'ERROR' in line:
            error_count += 1
    
    return error_count
 
# Si el archivo pesa 10 GB, ¡esto intenta cargar 10 GB en memoria!

Buen enfoque - Usar un generador:

python
def read_log_lines(filename):
    """Generate lines from a log file one at a time."""
    with open(filename, 'r') as file:
        for line in file:
            yield line.strip()
 
def count_errors_good(filename):
    """Count errors without loading entire file into memory."""
    error_count = 0
    for line in read_log_lines(filename):
        if 'ERROR' in line:
            error_count += 1
    
    return error_count
 
# Esto funciona de forma eficiente incluso con archivos de logs de varios gigabytes de tamaño
# porque solo mantiene una línea en memoria a la vez
count = count_errors_good('huge_application.log')
print(f"Found {count} errors")

El enfoque con generador procesa una línea a la vez, así que el uso de memoria se mantiene constante independientemente del tamaño del archivo. Un archivo de 10 GB usa la misma cantidad de memoria que un archivo de 10 KB.

36.4.2) Secuencias infinitas o de longitud desconocida

Los generadores son perfectos para secuencias en las que no conoces la longitud por adelantado o en las que la secuencia es conceptualmente infinita:

python
def user_input_stream():
    """Generate user inputs until they type 'quit'."""
    while True:
        user_input = input("Enter a number (or 'quit'): ")
        if user_input.lower() == 'quit':
            break
        try:
            yield int(user_input)
        except ValueError:
            print("Invalid number, try again")
 
# Procesar entradas del usuario conforme llegan
total = 0
count = 0
for number in user_input_stream():
    total += number
    count += 1
    print(f"Running average: {total / count:.2f}")

No puedes crear una lista de longitud desconocida, pero un generador maneja esto de forma natural.

36.4.3) Transformaciones encadenadas: construir pipelines de datos

Cuando necesitas aplicar múltiples transformaciones a datos, los generadores te permiten encadenar operaciones sin crear listas intermedias:

python
# Transformar números a través de múltiples etapas
def generate_numbers(n):
    """Generate numbers from 1 to n."""
    for i in range(1, n + 1):
        yield i
 
def square_numbers(numbers):
    """Generate squares of input numbers."""
    for num in numbers:
        yield num * num
 
def keep_even(numbers):
    """Generate only even numbers."""
    for num in numbers:
        if num % 2 == 0:
            yield num
 
# Encadenar generadores: no se crean listas intermedias
numbers = generate_numbers(10)
squared = square_numbers(numbers)
even_squares = keep_even(squared)
 
# Procesar resultados
print(list(even_squares))
# Output: [4, 16, 36, 64, 100]

Cada etapa procesa un valor a la vez y lo pasa a la siguiente etapa. Esto es eficiente en memoria y te permite procesar conjuntos de datos más grandes que la RAM disponible.

generate_numbers

square_numbers

keep_even

Resultados

Sin generadores, necesitarías listas intermedias:

python
# Enfoque sin generadores: crea listas intermedias
numbers = list(range(1, 11))           # [1, 2, 3, ..., 10]
squared = [n * n for n in numbers]     # [1, 4, 9, ..., 100]
even_squares = [n for n in squared if n % 2 == 0]  # [4, 16, 36, 64, 100]
 
# Con generadores: sin listas intermedias
numbers = (i for i in range(1, 11))
squared = (n * n for n in numbers)
even_squares = (n for n in squared if n % 2 == 0)
print(list(even_squares))
# Output: [4, 16, 36, 64, 100]

Para un pipeline con tres etapas procesando un millón de elementos, el enfoque con listas crearía tres listas de un millón de elementos cada una. El enfoque con generadores mantiene solo un valor en memoria a la vez.

36.4.4) Cuándo las listas son mejores que los generadores

A pesar de sus ventajas, los generadores no siempre son la elección adecuada. Usa listas cuando necesites:

Múltiples iteraciones:

python
# Lista: se puede iterar varias veces
numbers = [1, 2, 3, 4, 5]
print(sum(numbers))      # Output: 15
print(max(numbers))      # Output: 5 (works fine)
 
# Generador: solo se puede iterar una vez
numbers_gen = (x for x in range(1, 6))
print(sum(numbers_gen))  # Output: 15
print(max(numbers_gen))  # Output: ValueError: max() iterable argument is empty

Si necesitas procesar los mismos datos varias veces, usa una lista.

Acceso aleatorio:

python
# Need to access elements by index - use a list
students = ['Alice', 'Bob', 'Charlie', 'Diana']
print(students[2])  # Output: Charlie
 
# Generators don't support indexing
students_gen = (name for name in students)
# students_gen[2]  # ERROR: 'generator' object is not subscriptable

Información de longitud:

python
# Need to know the length - use a list
data = [1, 2, 3, 4, 5]
print(f"Processing {len(data)} items")
 
# Generators don't have a length
data_gen = (x for x in data)
# len(data_gen)  # ERROR: object of type 'generator' has no len()

Conjuntos de datos pequeños:

python
# For small datasets, lists are fine and more convenient
small_data = [x * 2 for x in range(10)]
 
# The memory savings of a generator aren't significant here
# and the list is more flexible

36.4.5) Guía práctica de decisión

Aquí tienes una guía práctica para elegir entre generadores y listas:

Usa generadores cuando:

  • Procesas archivos o conjuntos de datos grandes
  • Trabajas con streams de datos o entrada del usuario
  • Construyes pipelines de procesamiento de datos
  • La eficiencia de memoria es importante
  • Solo necesitas iterar una vez
  • La secuencia es infinita o muy larga

Usa listas cuando:

  • El conjunto de datos es pequeño (normalmente < 10,000 elementos)
  • Necesitas iterar varias veces
  • Necesitas acceso aleatorio por índice
  • Necesitas conocer la longitud
  • Necesitas pasar los datos a código que espera una lista

36.4.6) Convertir entre generadores y listas

Puedes convertir fácilmente entre generadores y listas cuando lo necesites:

python
# Generador a lista
numbers_gen = (x * 2 for x in range(5))
numbers_list = list(numbers_gen)
print(numbers_list)
# Output: [0, 2, 4, 6, 8]
 
# Lista a generador (usando una expresión generadora)
numbers_list = [1, 2, 3, 4, 5]
numbers_gen = (x for x in numbers_list)

Esta flexibilidad significa que puedes empezar con un generador por eficiencia y convertir a una lista solo cuando necesites características específicas de una lista:

python
# Empezar con un generador para eficiencia de memoria
numbers = (x for x in range(1, 1001))
filtered = (x for x in numbers if x % 7 == 0)
 
# Convertir a lista cuando necesites múltiples iteraciones
multiples_of_seven = list(filtered)
 
# Ahora puedes usar características de listas
print(f"Count: {len(multiples_of_seven)}")
# Output: Count: 142
 
print(f"First: {multiples_of_seven[0]}")
# Output: First: 7
 
print(f"Last: {multiples_of_seven[-1]}")
# Output: Last: 994
 
# Se puede iterar varias veces
total = sum(multiples_of_seven)
average = total / len(multiples_of_seven)
print(f"Average: {average:.1f}")
# Output: Average: 500.5

Los generadores son una de las características más elegantes de Python para escribir código eficiente en memoria. Te permiten procesar grandes conjuntos de datos, construir pipelines de datos y trabajar con secuencias infinitas, todo mientras mantienes tu código limpio y legible. A medida que ganes experiencia, desarrollarás una intuición sobre cuándo los generadores son la herramienta adecuada para el trabajo.

© 2025. Primesoft Co., Ltd.
support@primesoft.ai