Cuando escribes [x**2 for x in range(1_000_000)], Python construye inmediatamente una lista con un millón de enteros en memoria. Cuando escribes (x**2 for x in range(1_000_000)), obtienes un generator expression — un objeto que sabe cómo producir esos valores pero no los ha calculado todavía. Esa distinción, aparentemente cosmética (corchetes vs paréntesis), tiene consecuencias profundas en memoria y rendimiento.
Un generator expression es un objeto de tipo generator, que implementa el protocolo iterador: tiene __iter__ y __next__, y produce cada valor a demanda. Internamente Python compila la expresión a un objeto generador que mantiene su estado entre llamadas a __next__ — posición actual en el iterable fuente, variables locales de la expresión — sin materializar nunca todos los elementos a la vez. Esto es lazy evaluation: el trabajo se pospone hasta que alguien lo pida.
La list comprehension equivalente no es perezosa. Es eager: Python evalúa toda la expresión, asigna la memoria necesaria para la lista completa y la llena antes de que puedas hacer nada con ella. Para un millón de enteros eso son fácilmente 8 MB en el heap (cada int en CPython tiene overhead de objeto). El generator expression análogo ocupa menos de 200 bytes independientemente del tamaño del iterable.
¿Cuándo usar uno u otro? Usa generator expressions cuando solo necesitas pasar una vez por los valores — sumarlos, contarlos, escribirlos a disco, pasarlos a cualquier función que consuma un iterable. Usa list comprehensions cuando necesitas indexar, conocer la longitud de antemano, iterar varias veces o inspeccionar el resultado intermedio.
La trampa que muerde a casi todos: un generador se puede recorrer una sola vez. Después de agotarse, no emite más valores y no puedes “rebobinarlo”. Si tu código necesita los mismos datos en dos lugares, un generador te dará resultados vacíos en el segundo.
import sys
from typing import Iterator
# ---------------------------------------------------------------------------
# Escenario realista: procesar un log grande buscando errores críticos
# ---------------------------------------------------------------------------
def parse_log_lines(filename: str) -> Iterator[dict]:
"""Produce líneas parseadas de forma perezosa — nunca carga el archivo completo."""
with open(filename, encoding="utf-8") as f:
for raw in f:
parts = raw.strip().split("|")
if len(parts) == 3:
yield {"level": parts[0], "ts": parts[1], "msg": parts[2]}
def demo_generator_vs_list(data: list[int]) -> None:
# --- Memory comparison ---------------------------------------------------
list_comp = [x**2 for x in data]
gen_expr = (x**2 for x in data) # aún no ha calculado nada
print(f"list comprehension size : {sys.getsizeof(list_comp):>10} bytes")
print(f"generator expression size: {sys.getsizeof(gen_expr):>10} bytes")
# --- Pasar a funciones que consumen iterables ----------------------------
# sum() solo necesita recorrer una vez → el generador es ideal aquí.
# Nota: NO hay corchetes extra; sum() ya recibe el generador directamente.
total = sum(x**2 for x in data)
print(f"sum of squares: {total}")
# any() y all() también hacen short-circuit sobre generadores
has_large = any(x > 900 for x in data)
print(f"any value > 900: {has_large}")
# --- Composición de generadores ------------------------------------------
# Cada paso es perezoso; la cadena completa no consume memoria proporcional
# al tamaño de 'data' sino solo al estado de la posición actual.
evens = (x for x in data if x % 2 == 0)
even_sq = (x**2 for x in evens) # wrapping otro generador
clipped = (min(x, 500) for x in even_sq) # tercer nivel, aún perezoso
result = list(clipped) # materializar solo cuando realmente necesitamos la lista
print(f"first 5 clipped even squares: {result[:5]}")
def demo_single_pass_trap(data: list[int]) -> None:
"""Demuestra que un generador agotado ya no produce valores."""
gen = (x**2 for x in data)
first_pass = list(gen) # consume el generador completamente
second_pass = list(gen) # gen está agotado → lista vacía
print(f"first pass length: {len(first_pass)}") # len(data)
print(f"second pass length: {len(second_pass)}") # 0 ← la trampa
if __name__ == "__main__":
sample = list(range(1, 51)) # 50 enteros para que la demo sea legible
print("=== memory & consumption ===")
demo_generator_vs_list(sample)
print("\n=== single-pass trap ===")
demo_single_pass_trap(sample)
Qué está pasando realmente
demo_generator_vs_list arranca midiendo con sys.getsizeof. La diferencia que verás es radical: la lista crece linealmente con len(data), el generador siempre reporta el mismo tamaño porque solo guarda su frame interno — el iterable fuente y la posición actual.
La línea sum(x**2 for x in data) es el patrón idiomático más importante. Fíjate en que no hay doble paréntesis: cuando una generator expression es el único argumento de una llamada, los paréntesis de la llamada sirven de contenedor y no necesitas añadir otros. sum((x**2 for x in data)) funciona igual pero es ruido visual. sum([x**2 for x in data]) también funciona, pero construye la lista completa en memoria antes de sumar — trabajo innecesario.
La composición de generadores (evens → even_sq → clipped) es potente porque mantiene el consumo de memoria constante: en ningún momento existe una versión materializada de los tres pasos a la vez. Solo cuando llamas a list(clipped) se recorre la cadena de punta a punta, elemento a elemento.
demo_single_pass_trap ilustra la limitación fundamental. Después de list(gen), el objeto generador tiene su estado interno marcado como “StopIteration permanente”. No hay forma de reiniciarlo — si necesitas volver a iterar, tienes que crear un generador nuevo o, si los datos son costosos de regenerar, materializar la lista desde el principio.
Errores que debes conocer
Error: guardar un generator expression en una variable y asumirlo como lista, pasándolo a dos sitios distintos.
# ❌ Wrong
squares = (x**2 for x in range(10))
print(list(squares)) # [0, 1, 4, 9, ...]
print(list(squares)) # [] ← generador ya agotado
# ✅ Right
# Opción A: materializar cuando sepas que necesitas varios recorridos
squares = [x**2 for x in range(10)]
# Opción B: factory function para crear el generador cada vez que lo necesites
def make_squares(n: int):
return (x**2 for x in range(n))
print(list(make_squares(10)))
print(list(make_squares(10))) # nuevo generador, funciona
Cambiar a list comprehension es la solución directa; la factory function mantiene la pereza si el iterable es grande.
Error: pasar el mismo generador a max() y min() esperando que funcione dos veces.
# ❌ Wrong gen = (x**2 for x in range(-5, 6)) print(max(gen)) # 25 print(min(gen)) # devuelve error ValueError: min() arg is an empty sequence # ✅ Right values = [x**2 for x in range(-5, 6)] # una lista, dos lecturas print(max(values)) print(min(values))
max() agota el generador en la primera línea; min() recibe un iterador vacío y lanza ValueError. La lista permite múltiples recorridos sin coste adicional de regeneración.
N° 47