Generadores en Python: Guía completa de yield, expresiones generadoras y evaluación perezosa
Updated on
Procesar un archivo de logs de 10GB o hacer streaming de millones de registros de base de datos puede poner tu aplicación Python de rodillas. El enfoque tradicional de cargar todos los datos en memoria de una sola vez conduce a cuellos de botella de rendimiento, errores de memoria y usuarios frustrados. Aquí es donde los generadores de Python se vuelven esenciales: te permiten procesar conjuntos de datos masivos con una huella de memoria mínima, generando valores bajo demanda en lugar de almacenar todo por adelantado.
Qué son los generadores de Python y por qué importan
Los generadores son funciones especiales que producen una secuencia de valores a lo largo del tiempo en lugar de calcularlos y devolverlos todos de una vez. A diferencia de las funciones normales que usan return para devolver un único resultado, los generadores usan la palabra clave yield para producir una serie de valores, pausando la ejecución entre cada valor y reanudando cuando se solicita el siguiente.
La ventaja fundamental de los generadores es la evaluación perezosa: los valores se generan solo cuando se necesitan. Esto aporta dos beneficios críticos:
- Eficiencia de memoria: los generadores no almacenan toda la secuencia en memoria. Un generador que produce mil millones de números consume la misma memoria que uno que produce diez números.
- Rendimiento: el procesamiento puede comenzar inmediatamente con el primer valor emitido, sin esperar a que se prepare todo el conjunto de datos.
Aquí tienes una comparación simple que ilustra la diferencia:
# Traditional approach - loads entire list into memory
def get_squares_list(n):
result = []
for i in range(n):
result.append(i * i)
return result
# Generator approach - produces values one at a time
def get_squares_generator(n):
for i in range(n):
yield i * i
# Memory impact comparison
import sys
# List approach
squares_list = get_squares_list(1000000)
print(f"List memory: {sys.getsizeof(squares_list):,} bytes") # ~8,000,000 bytes
# Generator approach
squares_gen = get_squares_generator(1000000)
print(f"Generator memory: {sys.getsizeof(squares_gen):,} bytes") # ~112 bytesLa diferencia de memoria es asombrosa: el generador usa un 99.999% menos memoria que la lista en este ejemplo. Esta diferencia se amplifica drásticamente con conjuntos de datos más grandes.
La palabra clave yield: el corazón de las funciones generadoras
La palabra clave yield es lo que transforma una función normal en una función generadora. Cuando Python encuentra yield, sabe que debe devolver un objeto generador en lugar de ejecutar la función inmediatamente.
def countdown(n):
print(f"Starting countdown from {n}")
while n > 0:
yield n
n -= 1
print("Countdown complete!")
# Creating the generator doesn't execute the function
gen = countdown(3)
print(type(gen)) # <class 'generator'>
# Values are produced on-demand
print(next(gen)) # Starting countdown from 3 -> 3
print(next(gen)) # 2
print(next(gen)) # 1
# next(gen) # Countdown complete! -> Raises StopIterationComportamientos clave que debes entender:
- La ejecución se pausa en cada sentencia
yieldy se reanuda exactamente desde ese punto en la siguiente llamada - Las variables locales mantienen su estado entre llamadas a
yield - Se lanza la excepción StopIteration cuando la función generadora termina (se queda sin valores)
Pueden aparecer múltiples sentencias yield en un solo generador:
def data_pipeline():
# Phase 1: Loading
yield "Loading data..."
# Phase 2: Processing
yield "Processing records..."
# Phase 3: Validation
yield "Validating results..."
# Phase 4: Complete
yield "Pipeline complete!"
for status in data_pipeline():
print(status)Protocolo de generadores: entender iter() y next()
Los generadores implementan el protocolo de iterador mediante dos métodos especiales:
__iter__(): devuelve el propio objeto iterador (el generador)__next__(): devuelve el siguiente valor del generador
Esto hace que los generadores sean perfectos para usarse en bucles for y otros contextos de iteración. Entender este protocolo ayuda a aclarar cómo funcionan por dentro:
def simple_gen():
yield 1
yield 2
yield 3
gen = simple_gen()
# These are equivalent
print(gen.__next__()) # 1
print(next(gen)) # 2
# for loops call __next__() automatically until StopIteration
for value in simple_gen():
print(value) # 1, 2, 3También puedes implementar manualmente el protocolo de iterador para crear un comportamiento tipo generador:
class CountDown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
# Behaves like a generator
for num in CountDown(3):
print(num) # 3, 2, 1Sin embargo, las funciones generadoras son mucho más concisas y legibles que las clases iteradoras implementadas a mano.
Expresiones generadoras vs comprensiones de listas
Las expresiones generadoras ofrecen una sintaxis concisa para crear generadores, similar a las comprensiones de listas pero con paréntesis en lugar de corchetes:
# List comprehension - creates entire list in memory
squares_list = [x * x for x in range(10)]
print(type(squares_list)) # <class 'list'>
print(squares_list) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# Generator expression - creates generator object
squares_gen = (x * x for x in range(10))
print(type(squares_gen)) # <class 'generator'>
print(squares_gen) # <generator object at 0x...>
# Consume the generator
print(list(squares_gen)) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]Comparación de sintaxis:
| Feature | List Comprehension | Generator Expression |
|---|---|---|
| Syntax | [expr for item in iterable] | (expr for item in iterable) |
| Returns | List object | Generator object |
| Memory | Stores all values | Generates on-demand |
| Speed | Faster for small datasets | Faster for large datasets |
| Reusable | Yes (can iterate multiple times) | No (exhausted after one iteration) |
Ejemplo práctico mostrando la diferencia de memoria:
import sys
# List comprehension for 1 million numbers
list_comp = [x for x in range(1000000)]
print(f"List comprehension: {sys.getsizeof(list_comp):,} bytes")
# Generator expression for the same range
gen_exp = (x for x in range(1000000))
print(f"Generator expression: {sys.getsizeof(gen_exp):,} bytes")
# Output:
# List comprehension: 8,000,056 bytes
# Generator expression: 112 bytesLas expresiones generadoras son ideales cuando solo necesitas iterar por los valores una vez y quieres minimizar el uso de memoria.
yield from: delegación a subgeneradores
La sentencia yield from simplifica la delegación a subgeneradores u otros iterables. En lugar de hacer un bucle manual y emitir cada valor, yield from lo gestiona automáticamente:
# Without yield from
def get_numbers_manual():
for i in range(3):
yield i
for i in range(10, 13):
yield i
# With yield from
def get_numbers_delegated():
yield from range(3)
yield from range(10, 13)
print(list(get_numbers_manual())) # [0, 1, 2, 10, 11, 12]
print(list(get_numbers_delegated())) # [0, 1, 2, 10, 11, 12]Esto es especialmente útil para aplanar estructuras anidadas:
def flatten(nested_list):
for item in nested_list:
if isinstance(item, list):
yield from flatten(item) # Recursive delegation
else:
yield item
nested = [1, [2, 3, [4, 5]], 6, [7, [8, 9]]]
print(list(flatten(nested))) # [1, 2, 3, 4, 5, 6, 7, 8, 9]yield from también maneja correctamente excepciones y valores de retorno de subgeneradores, por lo que es esencial para pipelines de generadores complejos.
Avanzado: métodos send() y throw()
Los generadores pueden ser más que simples productores de valores: también pueden recibir valores y manejar excepciones mediante los métodos send() y throw(), lo que permite comunicación bidireccional al estilo coroutine.
Usar send() para enviar valores a los generadores
def running_average():
total = 0
count = 0
average = None
while True:
value = yield average # Yield current average, receive new value
total += value
count += 1
average = total / count
# Create generator
avg = running_average()
next(avg) # Prime the generator (advance to first yield)
# Send values and receive running averages
print(avg.send(10)) # 10.0
print(avg.send(20)) # 15.0
print(avg.send(30)) # 20.0
print(avg.send(40)) # 25.0El método send() tanto envía un valor al generador (que se convierte en el resultado de la expresión yield) como avanza la ejecución hasta el siguiente yield.
Usar throw() para inyectar excepciones
def error_handling_gen():
try:
while True:
value = yield
print(f"Received: {value}")
except ValueError as e:
print(f"Caught ValueError: {e}")
yield "Recovered from error"
except GeneratorExit:
print("Generator is closing")
gen = error_handling_gen()
next(gen) # Prime the generator
gen.send(10) # Received: 10
gen.send(20) # Received: 20
result = gen.throw(ValueError, "Invalid value") # Caught ValueError: Invalid value
print(result) # Recovered from error
gen.close() # Generator is closingEstas funciones avanzadas son particularmente útiles para implementar máquinas de estados, coroutines y patrones asíncronos complejos.
Generadores infinitos: secuencias sin fin
Los generadores destacan al producir secuencias infinitas porque nunca necesitan materializar toda la secuencia en memoria:
# Infinite counter
def count_from(start=0, step=1):
current = start
while True:
yield current
current += step
# Fibonacci sequence
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Cycling through a sequence
def cycle(iterable):
saved = []
for item in iterable:
yield item
saved.append(item)
while saved:
for item in saved:
yield item
# Usage examples
counter = count_from(10, 2)
for _ in range(5):
print(next(counter)) # 10, 12, 14, 16, 18
fib = fibonacci()
print([next(fib) for _ in range(10)]) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
colors = cycle(['red', 'green', 'blue'])
print([next(colors) for _ in range(8)]) # ['red', 'green', 'blue', 'red', 'green', 'blue', 'red', 'green']Los generadores infinitos son especialmente útiles para flujos de eventos, monitorización continua y patrones de iteración con estado.
Encadenar generadores: construir pipelines de procesamiento de datos
Uno de los patrones más potentes con generadores es encadenarlos para crear pipelines de procesamiento de datos eficientes. Cada etapa procesa los datos de forma perezosa y pasa los resultados a la siguiente, sin almacenar resultados intermedios:
# Stage 1: Read lines from a file (generator)
def read_log_file(filename):
with open(filename, 'r') as f:
for line in f:
yield line.strip()
# Stage 2: Filter lines containing 'ERROR'
def filter_errors(lines):
for line in lines:
if 'ERROR' in line:
yield line
# Stage 3: Extract timestamp and message
def parse_error_lines(lines):
for line in lines:
parts = line.split(' - ')
if len(parts) >= 2:
yield {'timestamp': parts[0], 'message': parts[1]}
# Stage 4: Count errors by hour
def group_by_hour(errors):
from collections import defaultdict
hourly_counts = defaultdict(int)
for error in errors:
hour = error['timestamp'][:13] # Extract hour portion
hourly_counts[hour] += 1
return hourly_counts
# Build pipeline
log_lines = read_log_file('app.log')
error_lines = filter_errors(log_lines)
parsed_errors = parse_error_lines(error_lines)
results = group_by_hour(parsed_errors)
print(results)Este pipeline procesa un archivo de logs potencialmente enorme con un uso mínimo de memoria: solo una línea está en memoria en cada momento, hasta la etapa final de agregación.
Otro ejemplo con transformación de datos:
# Pipeline: numbers -> square -> filter evens -> sum
def square_numbers(numbers):
for n in numbers:
yield n * n
def filter_even(numbers):
for n in numbers:
if n % 2 == 0:
yield n
# Chain the pipeline
numbers = range(1, 11) # 1-10
squared = square_numbers(numbers)
evens = filter_even(squared)
result = sum(evens) # Only even squares
print(result) # 220 (4 + 16 + 36 + 64 + 100)Comparación de memoria: benchmark de generador vs lista
Hagamos un benchmark real de memoria y rendimiento para cuantificar los beneficios de los generadores:
import sys
import time
import tracemalloc
def process_with_list(n):
"""Traditional approach using lists"""
tracemalloc.start()
start_time = time.time()
# Create list of squares
squares = [x * x for x in range(n)]
# Filter even squares
even_squares = [x for x in squares if x % 2 == 0]
# Sum results
result = sum(even_squares)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
elapsed = time.time() - start_time
return result, peak / 1024 / 1024, elapsed # Convert to MB
def process_with_generator(n):
"""Generator approach"""
tracemalloc.start()
start_time = time.time()
# Generator pipeline
squares = (x * x for x in range(n))
even_squares = (x for x in squares if x % 2 == 0)
result = sum(even_squares)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
elapsed = time.time() - start_time
return result, peak / 1024 / 1024, elapsed
# Benchmark with 1 million numbers
n = 1000000
list_result, list_memory, list_time = process_with_list(n)
gen_result, gen_memory, gen_time = process_with_generator(n)
print(f"Results match: {list_result == gen_result}")
print(f"\nList approach:")
print(f" Memory: {list_memory:.2f} MB")
print(f" Time: {list_time:.4f} seconds")
print(f"\nGenerator approach:")
print(f" Memory: {gen_memory:.2f} MB")
print(f" Time: {gen_time:.4f} seconds")
print(f"\nMemory savings: {((list_memory - gen_memory) / list_memory * 100):.1f}%")Salida típica:
Results match: True
List approach:
Memory: 36.21 MB
Time: 0.0892 seconds
Generator approach:
Memory: 0.12 MB
Time: 0.0624 seconds
Memory savings: 99.7%El enfoque con generadores usa un 99.7% menos memoria y es un 30% más rápido: una mejora drástica que se amplifica con conjuntos de datos más grandes.
El módulo itertools: utilidades para generadores
El módulo itertools de Python ofrece una colección de herramientas potentes basadas en generadores para una iteración eficiente. Estas utilidades están escritas en C y están altamente optimizadas:
Funciones esenciales de itertools
import itertools
# chain - concatenate multiple iterables
combined = itertools.chain([1, 2], [3, 4], [5, 6])
print(list(combined)) # [1, 2, 3, 4, 5, 6]
# islice - slice an iterable (like list slicing but for generators)
numbers = itertools.count() # Infinite counter: 0, 1, 2, 3...
first_ten = itertools.islice(numbers, 10)
print(list(first_ten)) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# count - infinite counter with start and step
counter = itertools.count(start=10, step=2)
print([next(counter) for _ in range(5)]) # [10, 12, 14, 16, 18]
# cycle - infinite repetition of an iterable
colors = itertools.cycle(['red', 'green', 'blue'])
print([next(colors) for _ in range(7)]) # ['red', 'green', 'blue', 'red', 'green', 'blue', 'red']
# accumulate - cumulative sums or other operations
numbers = [1, 2, 3, 4, 5]
cumulative = itertools.accumulate(numbers)
print(list(cumulative)) # [1, 3, 6, 10, 15]
# accumulate with custom function
import operator
products = itertools.accumulate(numbers, operator.mul)
print(list(products)) # [1, 2, 6, 24, 120]
# groupby - group consecutive elements by key
data = [('A', 1), ('A', 2), ('B', 3), ('B', 4), ('C', 5)]
for key, group in itertools.groupby(data, key=lambda x: x[0]):
print(f"{key}: {list(group)}")
# A: [('A', 1), ('A', 2)]
# B: [('B', 3), ('B', 4)]
# C: [('C', 5)]Combinaciones prácticas con itertools
# Paginating results with islice
def paginate(iterable, page_size):
iterator = iter(iterable)
while True:
page = list(itertools.islice(iterator, page_size))
if not page:
break
yield page
# Usage
data = range(25)
for page_num, page in enumerate(paginate(data, 10), 1):
print(f"Page {page_num}: {page}")
# Page 1: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Page 2: [10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
# Page 3: [20, 21, 22, 23, 24]
# Windowed iteration (sliding window)
def window(iterable, size):
it = iter(iterable)
win = list(itertools.islice(it, size))
if len(win) == size:
yield tuple(win)
for item in it:
win = win[1:] + [item]
yield tuple(win)
print(list(window([1, 2, 3, 4, 5], 3)))
# [(1, 2, 3), (2, 3, 4), (3, 4, 5)]Casos de uso del mundo real
Leer archivos grandes línea por línea
def process_large_csv(filename):
"""Process a multi-GB CSV file efficiently"""
with open(filename, 'r') as f:
# Skip header
next(f)
for line in f:
# Parse and yield record
fields = line.strip().split(',')
yield {
'user_id': fields[0],
'action': fields[1],
'timestamp': fields[2]
}
# Process millions of records with minimal memory
for record in process_large_csv('user_events.csv'):
# Process one record at a time
if record['action'] == 'purchase':
print(f"Purchase by user {record['user_id']}")Procesamiento de datos en streaming
def stream_api_data(url, batch_size=100):
"""Stream paginated API data without loading all results"""
offset = 0
while True:
response = requests.get(url, params={'offset': offset, 'limit': batch_size})
data = response.json()
if not data:
break
for item in data:
yield item
offset += batch_size
# Process unlimited API results
for item in stream_api_data('https://api.example.com/records'):
process_item(item)Iteración sobre resultados de consultas a base de datos
def fetch_users_batch(cursor, batch_size=1000):
"""Fetch database records in batches without loading all into memory"""
while True:
results = cursor.fetchmany(batch_size)
if not results:
break
for row in results:
yield row
# Database query
cursor.execute("SELECT * FROM users WHERE active = 1")
# Process millions of users efficiently
for user in fetch_users_batch(cursor):
send_email(user['email'], generate_report(user))Ejemplo de pipeline ETL
# Extract: Read from source
def extract_from_csv(filename):
with open(filename, 'r') as f:
for line in f:
yield line.strip().split(',')
# Transform: Clean and convert data
def transform_records(records):
for record in records:
yield {
'id': int(record[0]),
'name': record[1].title(),
'email': record[2].lower(),
'age': int(record[3]) if record[3] else None
}
# Load: Write to database
def load_to_database(records, db_connection):
for record in records:
db_connection.execute(
"INSERT INTO users VALUES (?, ?, ?, ?)",
(record['id'], record['name'], record['email'], record['age'])
)
yield record # Pass through for logging
# Build ETL pipeline
raw_data = extract_from_csv('users.csv')
transformed = transform_records(raw_data)
loaded = load_to_database(transformed, db_conn)
# Execute pipeline and count processed records
processed_count = sum(1 for _ in loaded)
print(f"Processed {processed_count} records")Buenas prácticas y errores comunes con generadores
Buenas prácticas
-
Usa expresiones generadoras para casos simples
# Simple transformation - use generator expression squares = (x * x for x in range(1000)) # Complex logic - use generator function def complex_processing(data): for item in data: # Multi-step processing result = step1(item) result = step2(result) if validate(result): yield result -
Encadena generadores para pipelines de datos
# Each stage processes lazily data = read_source() filtered = filter_stage(data) transformed = transform_stage(filtered) results = aggregate_stage(transformed) -
Usa
yield frompara delegacióndef process_all_files(directory): for filename in os.listdir(directory): yield from process_file(filename)
Errores comunes
-
Los generadores se agotan después de una iteración
gen = (x for x in range(3)) print(list(gen)) # [0, 1, 2] print(list(gen)) # [] - exhausted! # Solution: Convert to list or recreate generator data = list(gen) # If data fits in memory # OR gen = (x for x in range(3)) # Recreate -
Los generadores no soportan len() ni indexación
gen = (x for x in range(10)) # len(gen) # TypeError # gen[5] # TypeError # Solution: Convert to list if you need these operations items = list(gen) print(len(items)) print(items[5]) -
Cuidado con el alcance (scope) y closures en generadores
# Wrong - all generators will use final value of i generators = [lambda: i for i in range(3)] print([g() for g in generators]) # [2, 2, 2] # Correct - capture i in default argument generators = [lambda i=i: i for i in range(3)] print([g() for g in generators]) # [0, 1, 2] -
Manejo de excepciones en cadenas de generadores
def stage1(): for i in range(5): if i == 3: raise ValueError("Error in stage1") yield i def stage2(data): try: for item in data: yield item * 2 except ValueError as e: print(f"Caught: {e}") yield -1 # Error marker # Exception is caught in stage2 for result in stage2(stage1()): print(result)
Comparación: generadores vs listas vs iteradores vs map/filter
| Feature | Generators | Lists | Iterators | map/filter |
|---|---|---|---|---|
| Memory usage | Minimal (lazy) | Full dataset | Minimal (lazy) | Minimal (lazy) |
| Creation speed | Instant | Depends on size | Instant | Instant |
| Reusable | No | Yes | No | No |
| Indexable | No | Yes | No | No |
| len() support | No | Yes | No | No |
| Modification | Read-only | Mutable | Read-only | Read-only |
| Infinite sequences | Yes | No | Yes | Yes |
| Syntax | yield or () | [] | iter() | map(), filter() |
| Best for | Large datasets, pipelines | Small datasets, random access | Protocol implementation | Functional transformations |
Ejemplo de comparación:
# All produce same results but with different characteristics
data = range(1000000)
# Generator - memory efficient, not reusable
gen = (x * 2 for x in data)
# List - memory intensive, reusable, indexable
lst = [x * 2 for x in data]
# map - memory efficient, functional style
mapped = map(lambda x: x * 2, data)
# Iterator - explicit protocol implementation
class Doubler:
def __init__(self, data):
self.data = iter(data)
def __iter__(self):
return self
def __next__(self):
return next(self.data) * 2
iterator = Doubler(data)Experimentar con generadores en Jupyter
Al explorar patrones de generadores y características de rendimiento, trabajar en un entorno interactivo de notebooks acelera el aprendizaje. RunCell (opens in a new tab) incorpora asistencia con IA directamente en notebooks Jupyter, lo que lo hace ideal para científicos de datos que experimentan con pipelines de procesamiento basados en generadores.
Con RunCell, puedes:
- Prototipar rápidamente funciones generadoras y probar características de memoria
- Comparar el rendimiento entre generadores y listas con conjuntos de datos reales
- Construir y depurar pipelines complejos de generadores de forma interactiva
- Obtener sugerencias de IA para optimizar flujos ETL basados en generadores
Así podrías explorar generadores en un notebook:
# Cell 1: Define generator pipeline
def read_data():
for i in range(1000000):
yield {'id': i, 'value': i * 2}
def filter_large(records):
for record in records:
if record['value'] > 1000:
yield record
def transform(records):
for record in records:
record['squared'] = record['value'] ** 2
yield record
# Cell 2: Execute pipeline and measure
import time
start = time.time()
pipeline = transform(filter_large(read_data()))
results = list(itertools.islice(pipeline, 100)) # Take first 100
print(f"Time: {time.time() - start:.4f}s")
print(f"Results: {len(results)}")
# Cell 3: Visualize with PyGWalker
import pygwalker as pyg
pyg.walk(results)FAQ
Conclusión
Los generadores de Python representan un cambio fundamental de evaluación ansiosa a evaluación perezosa, lo que permite un procesamiento eficiente en memoria de conjuntos de datos que van desde miles hasta miles de millones de registros. Al comprender yield, las expresiones generadoras, el protocolo de iteradores y funciones avanzadas como send() y yield from, puedes construir pipelines de procesamiento de datos sofisticados que escalan sin esfuerzo.
Ideas clave para recordar:
- Los generadores usan evaluación perezosa para minimizar la huella de memoria; a menudo 99%+ de ahorro frente a listas
- Usa expresiones generadoras para transformaciones simples y funciones generadoras para lógica compleja
- Encadena generadores para construir pipelines de procesamiento de datos eficientes en memoria
- Aprovecha
itertoolspara utilidades potentes de iteración basadas en generadores - Elige generadores para conjuntos de datos grandes e iteración de una sola pasada; elige listas para conjuntos de datos pequeños que requieran acceso aleatorio
Ya sea que estés procesando logs masivos, haciendo streaming de datos de APIs o construyendo pipelines ETL, los generadores proporcionan el rendimiento y la eficiencia de memoria necesarios para el procesamiento de datos a escala de producción. Domina estos patrones y escribirás código Python que maneje conjuntos de datos de cualquier tamaño con elegancia y eficiencia.