wandres.dev
COLECCIONES A FONDO · protocolos y algoritmos

lazy: la evaluación perezosa de cadenas de transformaciones

Qué construye realmente la propiedad lazy, cómo fusiona varias pasadas en una sola sin arrays intermedios, por qué no memoriza nada, qué garantías de coste rompe y en qué casos concretos es una pesimización disfrazada de optimización.

⏱ 18 min

Encadenar transformaciones es cómodo y caro: cada eslabón materializa un array que el siguiente consume y tira. La propiedad lazy propone otro trato: no calcules nada hasta que alguien pida un elemento, y entonces calcula solo ese. Suena a ganancia gratuita, y no lo es. La pereza cambia el perfil de coste de una cadena, elimina unas ineficiencias e introduce otras, y usarla bien exige saber exactamente qué desaparece y qué aparece a cambio.

🎯 Al terminar esta lección sabrás
  • Entender qué tipo construye .lazy y por qué la cadena se vuelve una vista.
  • Ver cómo la pereza fusiona varias pasadas en un único recorrido.
  • Reconocer que una vista perezosa no memoriza: recalcula en cada acceso.
  • Decidir con criterio cuándo la pereza ahorra trabajo y cuándo lo añade.

Qué construye realmente .lazy

Acceder a .lazy no ejecuta nada: envuelve la colección en un tipo que recuerda la operación pendiente en lugar de aplicarla. Cada eslabón añade otra capa alrededor de la anterior:

let vista = numeros.lazy.map { $0 * 2 }.filter { $0 > 10 }
// tipo: LazyFilterSequence<LazyMapSequence<[Int], Int>>
// aqui no se ha ejecutado NI UNA sola de las dos closures

for x in vista { print(x) }   // ahora si: un unico recorrido
let arr = Array(vista)        // materializar fuerza el calculo

El tipo resultante es una torre de envoltorios genéricos que describe la cadena entera. Cuando finalmente pides un elemento, la petición desciende por esa torre: el filtro pide al mapa, el mapa pide al array base, y el valor sube transformándose. No hay array intermedio en ningún punto.

flowchart LR
subgraph Eager
  A1[array base] --> M1[map: array nuevo] --> F1[filter: array nuevo] --> R1[resultado]
end
subgraph Lazy
  A2[array base] --> V[vista compuesta: sin memoria] --> R2[elemento bajo demanda]
end
style V fill:#a6e3a1,color:#11111b
style M1 fill:#f38ba8,color:#11111b
style F1 fill:#f38ba8,color:#11111b
📝
No confundir con `lazy var`

La palabra lazy en Swift nombra dos cosas distintas. lazy var es una propiedad almacenada que se inicializa la primera vez que se lee y guarda el resultado para siempre. La propiedad .lazy de una colección es lo contrario: una vista que no guarda absolutamente nada. Comparten nombre y no comparten semántica.

La fusión de pasadas y la salida temprana

La ganancia real de la pereza tiene dos formas. La primera es la fusión: k transformaciones encadenadas se recorren en una sola pasada, sin las k menos una asignaciones intermedias. La segunda, y mucho más importante, es la salida temprana: si el consumidor deja de pedir elementos, el trabajo no realizado nunca se paga.

// EAGER: transforma un millon de elementos y luego se queda con el primero
let a = enormes.map(caro).first { $0.esValido }

// LAZY: transforma solo hasta encontrar el primero que cumple
let b = enormes.lazy.map(caro).first { $0.esValido }

Si el elemento buscado está en la posición diez, la versión perezosa llama a caro diez veces y la ansiosa un millón. Esta asimetría es la justificación central de lazy, y aparece siempre que un consumidor sea parcial: first(where:), prefix, contains, min sobre un prefijo, o simplemente un bucle con break.

// una secuencia infinita solo es utilizable perezosamente
let cuadrados = (1...).lazy.map { $0 * $0 }
print(Array(cuadrados.prefix(5)))   // [1, 4, 9, 16, 25]

Sin lazy, ese map sobre un rango abierto no tarda mucho: no termina nunca. La pereza no es aquí una optimización, es la condición de posibilidad del cálculo, y esa es la señal más clara de que estás ante un uso legítimo.

Conviene además saber qué operaciones tienen versión perezosa y cuáles no. map, filter, compactMap, flatMap, prefix, drop(while:) y reversed sobre bidireccionales devuelven vistas. En cambio sorted, shuffled, reduce, min, max o count son puntos de materialización: necesitan ver todos los elementos para responder, así que consumen la cadena entera en ese instante y devuelven un valor ansioso. Reconocerlos es saber dónde termina la pereza de una expresión.

ℹ️
El tipo delata la cadena

Una expresión perezosa tiene un tipo que describe literalmente su historia: envoltorio de filtro sobre envoltorio de mapa sobre el array base. Si al asignar a una variable el tipo inferido es un array normal, la pereza se rompió en algún punto: alguien materializó. Anotar el tipo a mano es la forma más rápida de auditar dónde.

El precio: sin memoria y sin garantías de coste

Una vista perezosa no memoriza. Si accedes dos veces al mismo elemento, la transformación se ejecuta dos veces:

let v = datos.lazy.map(muyCaro)
let x = v[0]
let y = v[0]     // muyCaro se ejecuta OTRA VEZ

De ahí se sigue el antipatrón más común: recorrer una vista perezosa varias veces, o materializarla implícitamente en sitios distintos, multiplica el trabajo en lugar de dividirlo. Si el resultado se va a consumir más de una vez, hay que materializarlo con Array una sola vez y trabajar sobre él.

Este punto tiene una consecuencia de diseño importante: una vista perezosa no debería salir nunca de una función. Al devolverla, entregas al llamante una promesa de cálculo cuyo coste no aparece en la firma y cuya duplicación no puedes controlar. Si además la transformación tiene efectos secundarios —contar, registrar, mutar un contador capturado— la pereza los reordena y los repite, y el resultado depende de cuántas veces mire el consumidor.

Hay un segundo precio, más sutil: la pereza rompe las garantías de complejidad del protocolo. En una vista con filtro, startIndex deja de ser constante porque hay que recorrer hasta encontrar el primer elemento que pase el predicado, y count se vuelve lineal con una llamada al predicado por elemento.

let v = base.lazy.filter { $0.esValido }
_ = v.startIndex   // recorre hasta el primero que cumple: NO es constante
_ = v.count        // llama al predicado n veces

Esto significa que una vista perezosa con filtro conforma a Collection incumpliendo el espíritu del contrato de coste que viste en la primera lección. La biblioteca lo documenta y lo asume conscientemente, pero tú debes tenerlo presente antes de pasar una de estas vistas a un algoritmo genérico que confíe en índices baratos.

Y hay un tercer precio, el más fácil de olvidar: las closures de una cadena perezosa son de escape, capturan lo que usen y mantienen viva la colección base mientras la vista exista. Una vista guardada en una propiedad retiene sus datos igual que lo hará una rebanada en la próxima lección.

⚠️
La pereza no propaga errores ni concurrencia

Las closures de las operaciones perezosas no pueden lanzar ni ser asíncronas: no existe una variante try de la cadena diferida, porque el error se produciría en un punto de consumo arbitrario y no en la línea que lo escribió. Si tu transformación puede fallar, la cadena tiene que ser ansiosa o convertirse en una secuencia asíncrona.

Cuándo sí y cuándo no

🍊

Sí: consumo parcial

first(where:), prefix, contains o un bucle con break sobre una cadena con transformaciones costosas.

🍋

Sí: fuentes grandes o infinitas

Rangos abiertos, generadores y colecciones enormes que jamás quieres materializar enteras.

🍇

No: colecciones pequeñas

Con decenas de elementos, la indirección de las closures de escape cuesta más que las asignaciones que evita.

🍒

No: consumo múltiple

Si vas a recorrer o indexar el resultado varias veces, materializa una vez y olvídate de la pereza.

Un criterio operativo que funciona casi siempre: escribe la versión ansiosa primero, mide, y solo introduce lazy si el perfil muestra que estás calculando elementos que después descartas. La pereza aplicada por reflejo, sin un consumidor parcial detrás, suele empeorar el rendimiento y siempre empeora la legibilidad, porque convierte tipos simples en torres genéricas que se filtran a las firmas.

⚠️
La pereza no es concurrencia ni asincronía

Una cadena perezosa sigue siendo estrictamente secuencial y síncrona: no paraleliza nada, no cede el hilo y no espera a nadie. Si lo que buscas es solapar trabajo, la herramienta es AsyncSequence o un grupo de tareas, no .lazy. Confundir “no lo he calculado todavía” con “lo estoy calculando en otro sitio” es un error conceptual que produce expectativas de rendimiento imposibles.

La pereza no optimiza el cálculo: reordena quién decide cuánto se calcula

Es tentador leer lazy como un interruptor de rendimiento, y esa lectura te llevará a ponerlo donde no toca y a quitarlo donde hacía falta. Lo que realmente hace la pereza es mover la autoridad sobre la cantidad de trabajo desde el productor hacia el consumidor. En una cadena ansiosa, cada eslabón decide unilateralmente procesar todo lo que recibe: el productor manda, y el consumidor solo puede descartar lo que ya se pagó. En una cadena perezosa, la cadena entera es una descripción inerte del cálculo, y quien decide cuántos elementos existen es quien los pide. Por eso el criterio para usar lazy no es nunca el tamaño del dato ni el coste de la función, sino una pregunta sobre la forma del consumo: ¿va a consumirse todo, o solo una parte que no conozco de antemano? Si va a consumirse todo, la pereza solo puede empatar contigo en el mejor de los casos y perder por indirección en el peor. Si va a consumirse una parte, la pereza no es una micro-optimización: es un cambio de complejidad, de lineal en el tamaño total a lineal en el prefijo que realmente miras. Y aquí está la simetría que conviene guardar: la pereza convierte una colección en una descripción de cómo obtener elementos, exactamente igual que un Slice convierte una colección en una descripción de dónde mirar. Ambas son vistas, ambas evitan copiar, y ambas cobran el mismo peaje: no poseen sus datos, así que dependen de que la base siga viva y siga siendo válida. Esa es la puerta a la próxima lección.

⚔️ Fuerza la pereza a mostrar sus costuras
  1. Encadena un map que imprima cada llamada y consume la cadena con first(where:), con y sin lazy. Cuenta las impresiones.
  2. Accede dos veces al mismo índice de una vista perezosa con map y comprueba que la closure se ejecuta dos veces.
  3. Escribe el tipo completo de una cadena de tres operaciones perezosas y explica qué envuelve a qué.
  4. Mide count sobre una vista con filter y explica por qué no es constante aunque la base sea un Array.
  5. Construye una cadena perezosa sobre un rango abierto y toma un prefijo de diez elementos. Luego intenta materializarla entera y razona qué ocurre.