Escribir pipelines rápidos
La biblioteca puede hacer que una forma sea rápida; lo que no puede es elegir la forma por ti. Estas son las que compensan.
Lección
Cada ejemplo de la comparativa Dart vs FxDart se mide contra un bucle imperativo escrito a mano, y la mayoría se queda a su altura o muy cerca. Cuando una cadena es más lenta, casi nunca es el algoritmo — ambos lados hacen el mismo trabajo — y casi siempre es una de cuatro cosas: una frontera entre etapas pagada por elemento, una asignación por elemento, un callback que se ejecuta más veces de las necesarias, o un origen que se recorre más de una vez.
1. Termina en un operador terminal
Un terminal como toList ve la cadena
entera y puede tomar una ruta que ningún consumidor elemento a elemento
puede. Sobre un origen List, map(f).toList() y
filter(p).toList() delegan la copia al relleno masivo del
propio SDK, que escribe el resultado sin la comprobación de tipo por
elemento que el código de paquete está obligado a pagar. Tirar de la misma
cadena a mano con un for-in e ir acumulando renuncia por
completo a esa ruta.
2. Filtra antes de mapear
Las etapas se ejecutan en el orden en que las escribes, y cada elemento que
sobrevive a un filter paga todas las etapas posteriores. Poner
la prueba barata primero y la transformación cara después es gratis y a
menudo la mayor mejora disponible.
3. Pide la respuesta, no los ingredientes
groupBy construye una
List por cada clave — asignación proporcional a la
entrada — y si lo único que querías era un total por clave,
esas listas se construyen y se tiran.
foldBy acumula directamente en el mapa
de resultado, y countBy es la versión
con contador, ya con nombre. Recurre a groupBy cuando de verdad
quieras los miembros.
Estos dos son además el caso raro en que el operador es más rápido que el
bucle que habrías escrito. La línea obvia,
counts[k] = (counts[k] ?? 0) + 1, toca la tabla hash
dos veces por elemento — una para leer y otra para volver
a escribir — y en un trabajo de conteo la tabla es prácticamente todo el
coste. Ambos operadores cuentan en una celda mutable alojada en la tabla,
así que esta se escribe una vez por clave distinta en lugar de una
vez por elemento: ~1,5× al contar un millón de filas, y es la razón por la
que Nivel de log más
frecuente le gana a un bucle a mano en vez de quedarse por detrás.
4. Una cadena perezosa se reejecuta en cada pasada
La pereza significa que la cadena es una receta, no un resultado: recórrela
dos veces y el origen se recorre dos veces. Cuando la respuesta se usa más
de una vez, materialízala una sola vez — con
toList, o con
uniqStrict cuando la propia
deduplicación es lo que conservas.
Lo que la pereza te sigue dando
Nada de esto es un argumento contra las cadenas perezosas. Un
take o un head después de un filtro detienen el
origen en cuanto tienen suficiente — el trabajo sencillamente no llega a
ocurrir — y ese es un tipo de ahorro que ninguna pipeline ansiosa puede
igualar. La pereza cuesta un poco por elemento y puede ahorrarlo todo.
zip,
zipWithIndex,
pairwise, o un map a
una tupla — asigna uno por elemento, y un record no se puede reutilizar. Si
la etapa siguiente descarta la mayoría, plantéate si la pipeline puede
llevar índices o un solo valor;
mapWithIndex existe precisamente
para que zipWithIndex().map(...) no tenga que asignar un par
por elemento.
Demo 1 · Terminales y orden del filter
Demo 2 · foldBy en lugar de groupBy, y el precio de una segunda pasada
Pruébalo tú
Ejercicio: el fragmento recorre sus lecturas dos veces y construye una lista que luego tira. Reescríbelo como una única cadena que termine en un solo operador terminal.
toList — el terminal del que cuelgan casi todas las rutas rápidas ·
foldBy — agregar sin los grupos ·
uniqStrict — deduplicar una vez, reutilizar muchas ·
mapWithIndex — el índice sin el record