Generator expressions: lazy evaluation vs list comprehensions

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.

47

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio