wandres.dev
ASYNCSEQUENCE · streams asíncronos

Operadores: transformar sin romper la cadena

Los algoritmos que la biblioteca estándar ofrece sobre secuencias asíncronas, por qué todos son perezosos y estrictamente secuenciales, la explosión de tipos y cómo domarla con tipos asociados primarios, y qué añade `swift-async-algorithms` cuando el tiempo entra en la ecuación.

⏱ 18 min

Sobre Sequence, un map es una promesa modesta: aplicar una función a cada elemento. Sobre AsyncSequence la misma palabra esconde bastante más, porque la transformación puede a su vez suspender, la cadena entera es perezosa y de tracción, y el resultado no es un array sino otra secuencia asíncrona con su propio tipo. Los operadores de la biblioteca estándar son pocos y deliberadamente conservadores: preservan el orden, no introducen concurrencia por su cuenta y no inventan comportamientos temporales. Todo lo que tenga que ver con combinar varias fuentes o con el paso del tiempo vive fuera, en swift-async-algorithms, y esa frontera no es un accidente de empaquetado sino una decisión sobre qué merece estar en el lenguaje base.

🎯 Al terminar esta lección sabrás
  • Usar map, filter, compactMap, prefix y las operaciones terminales sobre secuencias asíncronas.
  • Explicar por qué la cadena es perezosa, secuencial y de tracción, y qué implica para el rendimiento.
  • Domar la explosión de tipos con some AsyncSequence y tipos asociados primarios.
  • Reconocer qué aporta swift-async-algorithms y cuándo compensa depender de él.

El catálogo mínimo

La biblioteca estándar cubre lo esencial y poco más, con nombres calcados de sus equivalentes síncronos.

let titulares = noticias
    .filter { $0.idioma == "es" }
    .map { await traducir($0) }        // la transformacion puede suspender
    .compactMap { $0.titular }
    .prefix(10)

for try await t in titulares { mostrar(t) }

Están también dropFirst, drop(while:), prefix(while:) y flatMap, y del lado de las operaciones terminales —las que consumen la secuencia y devuelven un solo valor— reduce, contains, allSatisfy, first(where:), min y max. La distinción es importante: los operadores intermedios devuelven otra AsyncSequence y no ejecutan nada; los terminales incluyen un await porque tienen que recorrerla entera, o hasta donde la respuesta quede decidida.

Dos rasgos separan estas versiones de sus primas síncronas. El primero es que la clausura puede ser asíncrona: map acepta una transformación que suspenda, algo impensable en Sequence. El segundo es que cada operador tiene una variante que lanza y otra que no, y el compilador elige según lo que le des; de ahí que la familia de tipos incluya nombres como AsyncMapSequence y AsyncThrowingMapSequence.

Hay una ausencia que conviene notar cuanto antes: no existe en la biblioteca estándar una forma directa de convertir una secuencia asíncrona en un array. La razón es honesta: hacerlo significa esperar a que termine, y muchas secuencias asíncronas no terminan nunca. Cuando de verdad quieres materializar, lo escribes tú o usas el inicializador que aporta swift-async-algorithms.

var recogidos: [Titular] = []
for try await t in titulares { recogidos.append(t) }

Perezoso, secuencial y de tracción

Los tres adjetivos describen el comportamiento entero de una cadena de operadores, y el tercero explica los otros dos.

Construir la cadena no ejecuta nada. noticias.filter { ... }.map { ... } es solo un tipo envolviendo a otro; hasta que un bucle pida el primer elemento, ninguna clausura corre. Cuando ese bucle pide, la petición viaja hacia arriba por la cadena: prefix pide a compactMap, que pide a map, que pide a filter, que pide a la fuente. El elemento baja luego transformándose en cada escalón. Un filter que rechaza obliga a su capa inferior a producir otro, y así hasta que algo pasa el corte.

De ese modelo se derivan dos consecuencias prácticas. Una favorable: la terminación temprana se propaga hacia atrás. Un prefix(10) no solo corta la salida; al terminar destruye su iterador, y esa destrucción cierra la cadena hacia arriba, cancelando la suscripción a la fuente cuando la haya. Cortar es barato y detiene trabajo real.

La otra es la que más sorprende: no hay concurrencia alguna. Un map con una transformación de red no lanza diez peticiones a la vez; lanza una, espera, entrega, y solo entonces la siguiente. El orden queda garantizado y el rendimiento, limitado por el elemento más lento. Si lo que quieres es solapar, el operador no es la herramienta: lo es un grupo de tareas.

// Diez peticiones en serie, garantizando el orden
for await r in ids.map({ await api.detalle($0) }) { usar(r) }

// Diez peticiones solapadas, sin garantia de orden de llegada
try await withThrowingTaskGroup(of: Detalle.self) { grupo in
    for id in ids { grupo.addTask { try await api.detalle(id) } }
    for try await d in grupo { usar(d) }
}
💡
La pregunta que decide entre operador y grupo

¿Necesitas los resultados en el orden de entrada, o los necesitas cuanto antes? Si mandan el orden y la secuencialidad, encadena operadores. Si manda el rendimiento agregado y puedes reordenar después, usa un grupo de tareas y controla su anchura. Encadenar map esperando paralelismo es el malentendido más común del nivel.

flowchart RL
L[Bucle for await] -->|pide next| P[prefix 10]
P -->|pide next| C[compactMap]
C -->|pide next| M[map asincrono]
M -->|pide next| F[filter]
F -->|pide next| S[Fuente]
S -.->|entrega elemento| F
F -.->|si pasa el filtro| M
M -.->|transformado| C
C -.->|no nulo| P
P -.->|elemento final| L
style L fill:#89b4fa,color:#11111b
style S fill:#a6e3a1,color:#11111b

La explosión de tipos y su cura

Cada operador devuelve un tipo genérico que envuelve al anterior, así que el tipo real de una cadena de cuatro pasos es una torre ilegible.

// Lo que el compilador ve
AsyncPrefixSequence<AsyncCompactMapSequence<AsyncMapSequence<AsyncFilterSequence<Fuente>, Noticia>, Titular>>

Escribir eso en la firma de una función es impensable, y por eso la respuesta idiomática es ocultarlo con un tipo opaco. Desde Swift 6 los tipos asociados primarios de AsyncSequence permiten además fijar el elemento y el error, que es justo lo que quien llama necesita saber.

func titulares(de fuente: Noticias) -> some AsyncSequence<Titular, Never> {
    fuente.filter { $0.idioma == "es" }
          .compactMap { $0.titular }
}

El opaco tiene el límite que ya conoces: fija un único tipo concreto para todos los caminos de retorno. Si la función elige entre dos cadenas distintas según una condición, el opaco no vale y hay que recurrir a un borrado de tipo escrito a mano, porque la biblioteca estándar no ofrece ningún AnyAsyncSequence oficial. Es un hueco reconocido y una de las razones por las que muchos proyectos acaban escribiendo su propio envoltorio.

Lo que añade swift-async-algorithms

El paquete de Apple cubre tres territorios que la biblioteca estándar dejó fuera a propósito.

Combinar fuentes. merge intercala los elementos de varias secuencias en cuanto llegan; zip los empareja esperando a que todas produzcan; combineLatest emite en cada llegada usando el último valor conocido de las demás; chain las recorre una tras otra. Cada uno responde a una pregunta distinta sobre qué significa juntar dos series con relojes propios.

El tiempo como operando. debounce deja pasar un elemento solo cuando lo sigue un silencio; throttle acota la cadencia máxima; AsyncTimerSequence produce instantes con una frecuencia dada. Son operadores imposibles en el mundo síncrono, porque allí el tiempo entre elementos no existe.

let busquedas = teclado.debounce(for: .milliseconds(300))
for await texto in busquedas { await buscar(texto) }

Reagrupar y afinar. removeDuplicates suprime repeticiones consecutivas; chunked agrupa por tamaño, por tiempo o por predicado; adjacentPairs entrega elementos de dos en dos; buffer intercala una política de búfer en medio de una cadena; AsyncChannel aporta el canal de encuentro con contrapresión real que vimos en la lección anterior.

La dependencia no es gratuita —el paquete sigue sin alcanzar una versión estable y algunas de sus piezas han cambiado de nombre entre versiones— pero reescribir a mano un merge correcto, con su manejo de terminaciones parciales y de errores, es bastante más caro que aceptarla. La regla práctica es sencilla: si tu problema menciona dos fuentes o menciona el reloj, ya estás en su territorio.

🧊

Nada corre hasta que alguien pide

Encadenar operadores solo construye tipos. La primera clausura se ejecuta cuando el bucle solicita el primer elemento.

🚦

Un elemento cada vez

Los operadores preservan el orden y no solapan trabajo. El paralelismo se pide explícitamente con un grupo de tareas.

Operadores con reloj

debounce, throttle y los temporizadores solo tienen sentido cuando los elementos llegan separados en el tiempo.

Del álgebra de colecciones al álgebra de series temporales

Los operadores sobre Sequence forman un álgebra pequeña y bien entendida: transformar, filtrar, plegar. Sus leyes son limpias porque el dominio no tiene tiempo dentro, así que dos elementos consecutivos no guardan más relación que su posición y el resultado de una cadena depende solo de qué había, nunca de cuándo. En cuanto los elementos llegan separados por intervalos, esa álgebra se queda corta y aparece otra, más rica y bastante menos inocente, donde el tiempo entre dos elementos es información de pleno derecho: debounce no es filtrar por una propiedad del elemento sino por una propiedad del silencio que lo sigue, combineLatest fabrica pares que nunca coexistieron en ninguna fuente, merge produce una salida cuyo orden no está determinado por las entradas sino por el azar de la programación de tareas. La consecuencia epistemológica es que un operador temporal deja de ser una función pura de la serie de valores para convertirse en una función de la serie y de su llegada, lo que rompe de golpe la propiedad más cómoda del mundo síncrono: dos ejecuciones con los mismos datos pueden dar resultados distintos si las latencias difieren, y ninguna prueba que ignore el tiempo puede considerarse suficiente. Esto explica por qué Apple dejó estos operadores fuera de la biblioteca estándar y por qué hizo bien: map y filter tienen una semántica que nadie discute, mientras que la respuesta correcta a preguntas como qué debe emitir combineLatest antes de que la segunda fuente hable, o qué hace throttle con el último elemento de una ráfaga, depende del dominio y ha generado décadas de variantes incompatibles en todas las bibliotecas reactivas que la precedieron. Congelar una de esas decisiones dentro del lenguaje sería atarla para siempre. La enseñanza transferible es la que separa a quien usa una tubería asíncrona de quien la diseña: en cuanto tus operadores tocan el tiempo, tu código deja de tener una semántica denotacional cómoda y adquiere una operacional, dependiente del planificador; y la única defensa seria contra esa fragilidad es hacer el tiempo explícito y controlable —relojes inyectables, duraciones como parámetros, pruebas con relojes simulados— en lugar de dejarlo escondido dentro de un operador cuyo nombre suena tan inofensivo como el de un filter.

📝
Lo esencial

Los operadores de la biblioteca estándar son perezosos, ordenados y estrictamente secuenciales: nada corre hasta que el bucle pide, y ningún map paraleliza por su cuenta. La terminación temprana de prefix se propaga hacia la fuente y detiene trabajo real. El tipo resultante es una torre que se oculta con some AsyncSequence y sus tipos asociados primarios. Todo lo que combine fuentes o involucre al reloj vive en swift-async-algorithms.

⚔️ Encadena y mide
  1. Encadena filter, map asíncrono y prefix sobre una fuente lenta y registra el instante en que corre cada clausura.
  2. Demuestra con marcas de tiempo que un map asíncrono sobre diez elementos tarda la suma y no el máximo.
  3. Reescribe esa cadena con un grupo de tareas acotado y compara duración total y orden de los resultados.
  4. Declara una función que devuelva some AsyncSequence con dos caminos de retorno distintos y explica el error del compilador.
  5. Añade swift-async-algorithms, aplica debounce a una fuente de pulsaciones y describe qué elementos desaparecen y por qué.