Las ideas detrás del pipeline — para desarrolladores Dart en activo
Este es el compañero teórico de FxDart 101. Los tutoriales responden a cómo llamo a esta función; este libro responde a por qué la función tiene esa forma, y qué garantiza esa forma.
Está escrito para quien programa en Dart a diario. Eso significa tres cosas.
Sin más requisitos que Dart. Nada de Haskell, nada de teoría de categorías, ninguna matemática más allá de la idea de que una función lleva entradas a salidas. Cuando un concepto tiene definición formal, la tendrás —pero después de haber usado ya aquello que nombra.
Todos los listados se ejecutan. El código marcado con un botón ▶ Run compila con el compilador real de Dart y se ejecuta en esta página. La primera ejecución descarga el runtime del compilador y tarda unos segundos; a partir de ahí es instantánea. Las afirmaciones sobre qué imprime un programa están para comprobarse, no para creerse.
Honestidad antes que propaganda. Algunas de estas ideas se pagan solas en la primera hora. Otras son elegantes y, en Dart, no compensan la fricción —Dart no puede expresar varias de ellas en absoluto, y donde ocurre eso este libro lo dice y muestra qué hace FxDart en su lugar.
Usa las flechas, las teclas ← y →, o Contents para saltar a un capítulo. Cada capítulo termina con ejercicios; las soluciones están en el pliego siguiente, así que puedes pensar antes de pasar la página.
Notación.
A,Bson tipos corrientes (int,User).M<A>es un valor de tipoAsituado dentro de alguna estructuraM—List<A>,Future<A>,Either<E, A>. Una función escritaA → M<B>toma un valor plano y devuelve uno que está dentro de la estructura. Esa única forma es de lo que trata la mayor parte de la Parte I.
En este capítulo
- las tres mónadas que ya usas en Dart, y qué las convierte en una sola forma
- las dos operaciones —
ofyflatMap— y por qué aplanar es el punto- las tres leyes, como código que puedes ejecutar, y qué se rompe cuando un tipo las ignora
- por qué Dart no puede declarar una interfaz
Monad, y qué hace FxDart en su lugar
La famosa definición — una mónada es un monoide en la categoría de los endofunctores — es cierta, y es la peor primera frase posible. Describe el caso general a alguien que todavía no ha visto una sola instancia. Así que aquí van tres instancias primero. Has escrito las tres.
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String text) => either((r) { final n = r.ensureNotNull( int.tryParse(text), () => 'not a number: $text'); r.ensure(n > 1023, () => 'privileged port: $n'); return n;}); Future<int> fetchTimeout(int port) async => port + 100; void main() async { // List<A>: many values in one structure. print([1, 2, 3].expand((x) => [x, x * 10]).toList()); // Either<String, int>: a value, or a failure instead of it. print(parsePort('8080')); print(parsePort('80')); // Future<A>: a value that is not here yet. print(await Future.value(8080).then(fetchTimeout));}Tres tipos sin relación entre sí. List guarda muchos valores, Either guarda un valor o un fallo, Future guarda un valor que aún no ha llegado. Lo que comparten no es lo que guardan: es lo que puedes hacer con ellos.
Figura 1-1. Contenidos distintos, cableado idéntico: todas ellas pueden recibir un valor plano, y todas ellas pueden encadenar una función que devuelve otra caja del mismo tipo.
Cada uno de estos tipos te da dos operaciones:
| meter un valor | encadenar un paso que devuelve otra caja | |
|---|---|---|
List<A> | [a] | expand |
Future<A> | Future.value(a) | then |
Either<E, A> | Either.right(a) | flatMap |
Fx<A> (FxDart) | fx([a]) | flatMap |
Un tipo con esas dos operaciones, que cumple tres leyes a las que llegaremos, es una mónada. Esa es toda la definición. La palabra intimida porque llegó desde la teoría de categorías con su vocabulario a cuestas, no porque la idea de debajo sea grande.
Escribe M<A> para un valor de tipo A dentro de una estructura M. Una mónada es un constructor de tipos M más:
pure, return o unit): A → M<A>. Toma un valor corriente, obtén la caja más aburrida posible que lo contenga. Aburrida es un requisito técnico: Either.right(3) no añade ningún fallo, Future.value(3) no añade ninguna espera, [3] no añade elementos de más.bind o >>=): M<A> × (A → M<B>) → M<B>. Toma una caja, y una función que convierte el valor de dentro en otra caja, y recupera una sola caja — no una caja de cajas.La segunda mitad de esa última frase es el punto entero, y se ve mejor quitándola. map por sí solo no basta:
void main() { // The step returns a List, so map gives a List of Lists. final nested = [1, 2, 3].map((x) => [x, x * 10]).toList(); print(nested); print(nested.runtimeType); // flatMap (Dart spells it `expand`) joins the inner lists // into the outer one. final flat = [1, 2, 3].expand((x) => [x, x * 10]).toList(); print(flat); print(flat.runtimeType);}Figura 1-2. Ambas operaciones aplican la misma función. map conserva la caja que devolvió la función, envolviéndola en la caja de la que partió; flatMap une las dos capas en una.
¿Por qué importa tanto? Porque un paso que puede fallar, o esperar, o producir muchas respuestas, es exactamente una función de tipo A → M<B>. Los programas reales son secuencias de esos pasos. Con solo map, cada paso añade una capa: tres pasos seguidos te dan Either<E, Either<E, Either<E, A>>>, y no se puede hacer nada con ese valor sin desenvolverlo tres veces. flatMap mantiene la profundidad en uno, para siempre, encadenes los pasos que encadenes. Las mónadas son cómo compones funciones que devuelven contextos.
Terminología. Un tipo con solo
map(que cumple sus propias dos leyes) es un functor — capítulo 5. Toda mónada es un functor: puedes definirmap(f)comoflatMap((a) => of(f(a))). Lo contrario no es cierto, y por eso la torre tiene más de un piso.
Dart esconde flatMap detrás de sintaxis que usas a diario. await es flatMap para Future: saca el valor de un future, ejecuta el resto de la función sobre él, y el resultado es un solo future — nunca un Future<Future<T>>. Un bucle for-in que va añadiendo a una lista es flatMap para List. El bloque either { } del capítulo 14 es flatMap para Either.
Observa el mismo cálculo escrito de las dos maneras — primero como cadena explícita, luego dentro del ámbito either de FxDart:
import 'package:fxdart/fxdart.dart'; Either<String, int> parseAge(String text) => either((r) { final n = r.ensureNotNull( int.tryParse(text), () => 'not a number: $text'); r.ensure(n >= 0, () => 'negative age: $n'); return n;}); Either<String, String> lookup(String id) => id == 'u1' ? Either.right('Ada') : Either.left('no such user: $id'); // Explicit chaining: every dependent step nests// one level deeper.Either<String, String> greetChained(String id, String ageText) => lookup(id).flatMap((name) => parseAge(ageText).flatMap((age) => Either.right('$name is $age'))); // The same steps in a Raise scope: straight-line code,// with the same short-circuiting.Either<String, String> greetScoped(String id, String ageText) => either((r) { final name = r.bind(lookup(id)); final age = r.bind(parseAge(ageText)); return '$name is $age';}); void main() { print(greetChained('u1', '36')); print(greetScoped('u1', '36')); print(greetScoped('u9', '36')); print(greetScoped('u1', 'old'));}Las dos versiones hacen lo mismo, incluido detenerse en el primer fallo y no ejecutar nunca el segundo paso cuando el primero falla. La diferencia es que la versión encadenada se desplaza un nivel de indentación a la derecha por cada paso — la forma que todo lenguaje con mónadas acaba inventando sintaxis para ocultar. Haskell llama a la suya notación do, Scala la llama for-comprehension, Dart llama al caso especial async/await. El bloque either de FxDart es la misma idea alcanzada por otro mecanismo, que es el tema del capítulo 15.
Las dos operaciones no bastan. Un tipo podría definir of y flatMap y aun así comportarse de forma sorprendente — de modo que una mónada debe cumplir además tres leyes. Se leen como enunciados pedantes de lo obvio, que es justo lo que las hace valiosas: son las garantías que ya das por supuestas cuando refactorizas.
of(a).flatMap(f) = f(a). Encajar un valor y encadenar un paso inmediatamente es lo mismo que llamar al paso.m.flatMap(of) = m. Sacar el valor de una caja y volver a meterlo tal cual no cambia nada.m.flatMap(f).flatMap(g) = m.flatMap((a) => f(a).flatMap(g)). Cómo agrupes una cadena de pasos no afecta al resultado.Figura 1-3. Todas las leyes dicen lo mismo: dos rutas distintas por el diagrama tienen que llegar al mismo valor. Las leyes son lo que te permite tomar cualquiera de las dos.
Aquí están como aserciones que puedes ejecutar contra el Either de FxDart:
import 'package:fxdart/fxdart.dart'; Either<String, int> half(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd: $n'); Either<String, int> minusOne(int n) => Either.right(n - 1); void main() { final m = Either<String, int>.right(20); print( Either<String, int>.right(20).flatMap(half) == half(20)); print(m.flatMap((a) => Either<String, int>.right(a)) == m); print(m.flatMap(half).flatMap(minusOne) == m.flatMap((a) => half(a).flatMap(minusOne))); // The laws hold on the failure side too — that is what makes // short-circuiting composable rather than a special case. final bad = Either<String, int>.left('boom'); print(bad.flatMap(half).flatMap(minusOne) == bad.flatMap((a) => half(a).flatMap(minusOne)));}Las leyes no son adorno. Rompe una y un refactor corriente cambia el comportamiento en silencio. Aquí hay una caja que cuenta los pasos dados — un diseño plausible, y sin ley:
class Logged<A> { const Logged(this.value, this.steps); final A value; final int steps; static Logged<A> of<A>(A value) => Logged(value, 0); // The `+ 1` is the bug: chaining charges for // the chaining itself. Logged<B> flatMap<B>(Logged<B> Function(A) f) { final next = f(value); return Logged(next.value, steps + next.steps + 1); } @override String toString() => 'Logged($value, steps: $steps)';} Logged<int> double_(int n) => Logged(n * 2, 1); void main() { // Left identity: of(a).flatMap(f) should equal f(a). // It does not. print(Logged.of(21).flatMap(double_)); print(double_(21)); // Right identity: chaining a step that does nothing // should be invisible. final m = double_(21); print(m); print(m.flatMap(Logged.of));}La asociatividad sobrevive aquí por casualidad — reagrupa la cadena y la cuenta no cambia — pero ambas leyes de identidad fallan, y eso ya es fatal. Extraer un paso trivial a su propio flatMap, o eliminarlo por inlining, es un refactor que cualquier revisor dejaría pasar, y en este tipo cambia la respuesta.
El arreglo no es añadir un caso especial; es hacer de steps un monoide — un tipo con una combinación asociativa y un elemento identidad (capítulo 8) — y dejar que of produzca la identidad. Quita el + 1 y Logged se convierte en la mónada Writer, con ley y útil. Ese es el patrón detrás de la mayoría de violaciones: una operación que parece inocua pero no tiene elemento identidad.
🎓 La definición formal, para que conste. En teoría de categorías una mónada sobre una categoría C es un endofunctor
T : C → Ccon dos transformaciones naturales,η : Id ⇒ T(eso esof) yμ : T² ⇒ T(eso esflatten, de dondeflatMap(f) = μ ∘ T(f)), que satisfacen las condiciones de coherencia de unidad y asociatividad — las tres leyes de arriba, dibujadas como diagramas conmutativos. «Un monoide en la categoría de los endofunctores» dice lo mismo otra vez:μes la multiplicación,ηla unidad. Nada de este párrafo te ayudará a escribir Dart, y por eso está en una caja, y por eso el capítulo 20 es donde le corresponde.
Ahora la parte honesta. Dart no puede expresar la interfaz que este capítulo acaba de describir. Escribirla requiere un parámetro de tipo que a su vez sea genérico — un tipo de orden superior — y Dart no tiene ninguno:
// Does not compile. `M` is a type, and a type cannot take// arguments here.abstract class Monad<M> { M<A> of<A>(A value); M<B> flatMap<A, B>(M<A> box, M<B> Function(A) f);}
Arrow, la librería de Kotlin de la que están portados los errores tipados de FxDart, lo sortea con plugins de compilador y context receivers. Scala tiene el sistema de kinds de forma nativa. Dart no tiene ninguno de los dos, y ninguna astucia lo recupera — los intentos acaban en casts a dynamic que renuncian exactamente a la seguridad de tipos por la que existía la abstracción.
Así que FxDart hace lo único honesto: implementa la forma, tipo a tipo, y nunca finge abstraer sobre ella.
Either<L, R> tiene flatMap, y Either.right es su of. Las leyes se cumplen; ejecutaste la comprobación dos páginas atrás.either((r) { … }) es el sustituto ergonómico de la notación do. No es azúcar sintáctico — r.bind cortocircuita elevando hacia un ámbito (capítulo 15), un truco de continuaciones delimitadas y no una reescritura monádica. El mismo código en línea recta, distinto mecanismo, y una distinción que importa cuando preguntas por qué no hay una instancia de mónada para Raise.Fx<A> es una cadena perezosa de Iterable, e Iterable es la mónada lista: flatMap es su bind, fx([a]) su of. La pereza no perturba las leyes — el capítulo 11 muestra por qué el orden de evaluación les es invisible.FxAsyncIterable<A> es la misma forma sobre fuentes asíncronas, con la propiedad extra de que concurrent(n) cambia cuándo se calculan los elementos sin cambiar cuáles — una afirmación de razonamiento ecuacional que las leyes respaldan.Lo que pierdes por no tener una interfaz Monad es el código genérico que funciona para todas las mónadas a la vez: un traverse, un sequence, un juego de combinadores reutilizado entre Either, Fx y Future. FxDart escribe en su lugar las versiones concretas. Eso es más código en la librería y menos abstracción en tu programa — un intercambio que eligió el lenguaje, no la librería.
No necesitas la palabra «mónada» para usar await. La palabra empieza a pagar cuando adviertes el mismo problema en tres sitios — callbacks anidados, una pirámide de comprobaciones de null, una cadena de Either — y te das cuenta de que es un problema con una sola forma de solución. Paga otra vez cuando una librería te da un tipo con flatMap y puedes predecir, sin leer el código, qué hará encadenarlo.
Y paga cuando eliges entre diseños: si tu tipo tiene of y flatMap y las leyes se cumplen, quien lo use podrá refactorizar cadenas con libertad. Si los tiene y las leyes no se cumplen, has construido una trampa. El capítulo 5 baja un piso hasta el functor y el capítulo 6 hasta el applicative, donde vive buena parte del código práctico de validación.
Set<A> tiene expand y {a}. Comprueba las tres leyes con un paso cuyos resultados colisionen — por ejemplo (x) => {x % 3} sobre {1, 2, 3, 4}. ¿Es Set una mónada? ¿De qué depende tu respuesta?map para Either usando solo flatMap y Either.right, y comprueba después que coincide con el map incorporado tanto sobre un Right como sobre un Left.Future tiene then. ¿Es Future.value(a).then(f) realmente igual a f(a) — igual como valores, o solo en lo que acaban produciendo? ¿Qué te dice eso sobre qué igualdad enuncian las leyes?Logged para que las tres leyes se cumplan, encadena después dos pasos en ambas agrupaciones y demuestra que las cuentas coinciden.Set descarta orden y duplicados a ambos lados de cada ley por igual. {1,2,3,4}.expand((x) => {x % 3}) da {1, 2, 0} los agrupes como los agrupes. La salvedad es el punto: una ley se enuncia sobre una igualdad, y un tipo puede cumplirla bajo una noción de igualdad y no bajo otra — List bajo igualdad de conjuntos la cumple; Set bajo «mismo orden de inserción» no.Either<L, B> mapViaFlatMap<L, A, B>(Either<L, A> e, B Function(A) f) => e.flatMap((a) => Either.right(f(a)));. Sobre un Left ninguna de las dos versiones llama a f, que es la huella de la identidad por la izquierda en el lado del fallo.== sobre futures compara identidad, así que la ley se enuncia sobre igualdad observacional: los dos programas producen el mismo valor y los mismos efectos. Esa es la igualdad de la que hablan en realidad todas las leyes monádicas; las comprobaciones con Either de antes en el capítulo solo pudieron usar == porque Either define igualdad estructural.+ 1 de flatMap y deja que double_ declare su propio coste: Logged(next.value, steps + next.steps). Ahora of aporta el elemento identidad de +, encadenar no aporta nada por su cuenta, y las tres leyes se cumplen — Logged.of(1).flatMap(f).flatMap(g) y Logged.of(1).flatMap((x) => f(x).flatMap(g)) informan ambos de 2.En este capítulo
- la transparencia referencial como prueba mecánica que puedes aplicar a cualquier expresión
- las cuatro capacidades que compra la pureza — memoizar, reordenar, paralelizar, probar
- dónde se esconden los efectos en el Dart corriente, incluidos los que no parecen efectos
- la costura: un núcleo puro con los efectos empujados al borde, y qué te da FxDart en esa costura
Una función es pura cuando una llamada a ella puede sustituirse por su resultado en todas partes sin cambiar lo que hace el programa. Esa propiedad tiene nombre — transparencia referencial — y es una prueba mecánica, no una preferencia de estilo.
int double_(int n) => n * 2; var log = <String>[];int doubleAndLog(int n) { log.add('doubled $n'); return n * 2;} void main() { // Substitution holds: double_(21) and 42 are the same thing. print([double_(21), double_(21)]); print([42, 42]); // Substitution fails: the two programs differ in `log`. print([doubleAndLog(21), doubleAndLog(21)]); print(log);}Las dos funciones devuelven el mismo número. Solo una de ellas te deja reescribir el programa a su alrededor. Esa diferencia — no la presencia de la palabra void, no que un linter se queje — es lo que significa «pura».
Figura 2-1. La pureza es el permiso para redibujar la imagen de la izquierda como la de la derecha. Cada refactor que haces a mano invoca ese permiso.
Cuatro capacidades, y ya dependes de las cuatro:
| Capacidad | Por qué hace falta pureza |
|---|---|
| Memoizar | Cachear un resultado supone que la segunda llamada habría hecho lo mismo |
| Reordenar | Mover una línea supone que nadie más observa cuándo se ejecutó |
| Paralelizar | Ejecutar dos llamadas a la vez supone que ninguna puede ver a la otra |
| Probar | Aseverar sobre un valor de retorno supone que el valor es toda la historia |
El memoize de FxDart es el ejemplo más nítido: es correcto para una función pura y un bug silencioso para una impura.
import 'package:fxdart/fxdart.dart'; int calls = 0;int slowSquare(int n) { calls++; return n * n;} void main() { final fast = memoize(slowSquare); print([fast(9), fast(9), fast(9)]); print('underlying calls: $calls');}Tres llamadas, una evaluación. Nada dentro de memoize comprueba que slowSquare sea pura — lo supone. Esa es la forma de casi toda la maquinaria funcional: la librería aporta el mecanismo, la ley aporta la licencia, y quien tiene que cumplir el trato eres tú.
Un efecto es cualquier cosa que quien llama pueda observar además del valor devuelto, o cualquier cosa de la que dependa el resultado además de los argumentos. Dart esconde varios a plena vista:
List capturada.DateTime.now(), Platform.isIOS. Mismos argumentos, respuestas distintas.createSeededRandom: una semilla convierte un efecto de vuelta en un argumento.print y la E/S — la salida es observable por definición.List es por referencia, así que devolver una lista nueva es observablemente distinto de devolver una compartida bajo identical.import 'package:fxdart/fxdart.dart'; void main() { // A seed makes randomness reproducible: same input, same // output, so a shuffle becomes testable. final a = shuffle([1, 2, 3, 4, 5], 7); final b = shuffle([1, 2, 3, 4, 5], 7); print(a); print('reproducible: ${a.toString() == b.toString()}');}🎓 «Puro» habla de lo que observa el lenguaje, no el universo. Una función pura sigue quemando CPU, reservando memoria y calentando la habitación. La pureza se define relativa a lo que el programa puede observar: dos expresiones son intercambiables si ningún código Dart puede distinguirlas. El tiempo y la memoria quedan fuera de esa lente — que es justo por lo que el capítulo 14 tiene que medirlos aparte, y por lo que «puro» nunca significa «gratis».
Nadie publica un programa sin efectos; el objetivo es saber dónde están. La disposición estándar es un núcleo puro con una cáscara con efectos: parsear, decidir y calcular en funciones puras; leer y escribir en los bordes.
Las tuberías hacen visible la costura, porque una tubería perezosa es una descripción del trabajo y no el trabajo. Compara dónde queda el efecto:
import 'package:fxdart/fxdart.dart'; class Order { const Order(this.id, this.total, this.status); final String id; final int total; final String status;} const orders = [ Order('a', 120, 'paid'), Order('b', 40, 'refunded'), Order('c', 260, 'paid'),]; // Pure core: data in, data out. No printing, no clock, no IO.List<String> receipts(Iterable<Order> all) => fx(all) .filter((o) => o.status == 'paid') .sortByDesc((o) => o.total) .map((o) => '${o.id}: ${o.total}') .toList(); void main() { // Effectful shell: the one place that touches the world. receipts(orders).forEach(print);}receipts es comprobable solo por igualdad, y peek te da una costura declarada para las veces en que necesitas observar una tubería sin romper esa propiedad — es un efecto etiquetado en lugar de uno oculto:
import 'package:fxdart/fxdart.dart'; void main() { final seen = <int>[]; final result = fx(range(1, 6)) // the effect is named, and it is the only one .peek(seen.add) .filter((n) => n.isEven) .toList(); print(result); print(seen);}La pureza no es una virtud que se acumula; es una palanca que se gasta. Paga cuando necesitas cachear, reintentar, reordenar, ejecutar en paralelo o escribir una prueba que no necesite un fixture — es decir, exactamente en las situaciones de las que tratan las Partes III y IV. concurrent(n) (capítulo 13) solo es seguro porque los callbacks que ejecuta fuera de orden no pueden verse entre sí.
Cuesta cuando el efecto es el objetivo. Un logger, un script de migración, un manejador de eventos de interfaz: envolver eso en ceremonia no compra nada. El capítulo 22 defiende ese caso con calma.
List.of(items) pura? Considera tanto == como identical como la forma en que quien llama podría observar el resultado.final?memoize sobre una función de tipo int Function(int) es seguro. ¿Qué sale mal si el tipo del argumento es una List<int> mutable?receipts de arriba y añade un requisito: registrar cada pedido que quedó filtrado. Hazlo sin volver impura a receipts.==, impura según identical. Dos llamadas con el mismo argumento devuelven listas iguales pero nunca el mismo objeto, así que un programa que compare con identical puede distinguir las llamadas. Por eso «pura» se enuncia siempre relativa a una observación — la misma sutileza aparece en el ejercicio del capítulo 1 sobre la igualdad de Future.class Rate { const Rate(this.pct); final int pct; int apply(int n) => n * pct ~/ 100; }. Es referencialmente transparente porque pct no puede cambiar; la instancia es parte de la entrada, solo que escrita como receptor en vez de como argumento. Quita final y la misma llamada puede devolver dos respuestas, así que la sustitución falla.memoize indexa por el argumento, y el contenido de una lista mutable puede cambiar después de haberse usado como clave — quien llama muta la lista, vuelve a llamar y recibe la respuesta del contenido antiguo. La caché no está mal; la suposición sí lo estaba.fork o una división al estilo partition hacen la función total en lo que informa, y quien llama (la cáscara) decide qué imprimir. Si solo necesitas observar, usa .peek(rejected.add) en la rama rechazada: sigue siendo un efecto declarado en una costura con nombre, y sigue sin haber E/S dentro del núcleo.En este capítulo
- productos y sumas: las dos formas de combinar tipos, y cómo contar sus valores
- por qué
sealed+switches la característica que hace que los tipos suma valgan la pena- el refactor: sustituir un saco de campos nulables por un tipo que no puede mentir
- dónde encajan los records, y dónde un tipo es la herramienta equivocada
Un tipo es un conjunto de valores, y se puede contar. bool tiene 2. Null tiene 1. Un enum con tres constantes tiene 3. Una vez sabes contar, las dos formas de combinar tipos reciben su nombre:
(bool, bool) tiene 2 × 2 = 4 valores. Los campos de una clase son un producto.bool | Null tiene 2 + 1 = 3 valores. En Dart, una jerarquía sealed es una suma, y — de manera informal — también lo es T?.Los errores de diseño son casi siempre el mismo error: el tipo tiene más valores que el dominio. Aquí está la forma clásica.
// Four fields; 2 × 2 × 2 × 2 = 16 representable combinations…class Request { Request( {this.loading = false, this.data, this.error, this.cancelled = false}); final bool loading; final String? data; final String? error; final bool cancelled;} void main() { // …but this one is nonsense, and it compiles. final broken = Request(loading: true, data: 'ok', error: 'boom'); print([broken.loading, broken.data, broken.error]);}Cuatro estados tienen sentido — cargando, cargado, fallido, cancelado — y el tipo admite dieciséis. En los doce sobrantes es donde viven los bugs, y cada if (r.error != null && !r.loading) del código es un parche escrito a mano sobre uno de ellos.
Figura 3-1. El tipo de la izquierda es un producto de cuatro banderas; el dominio es una suma de cuatro casos. Cada celda fuera de la diagonal es un estado que tu código debe manejar o confiar en que nunca ocurra.
sealed class Request { const Request();} class Loading extends Request { const Loading();} class Loaded extends Request { const Loaded(this.data); final String data;} class Failed extends Request { const Failed(this.message); final String message;} class Cancelled extends Request { const Cancelled();} String render(Request r) => switch (r) { Loading() => 'spinner', Loaded(:final data) => 'showing $data', Failed(:final message) => 'error: $message', Cancelled() => 'cancelled',}; void main() { const all = [ Loading(), Loaded('42 rows'), Failed('timeout'), Cancelled() ]; all.map(render).forEach(print);}Cuatro casos, exactamente cuatro estados, ningún campo nulable y ninguna rama default. Ese último detalle es el punto entero: sealed hace que el switch sea exhaustivo, así que añadir un quinto caso convierte cada lugar que maneja el tipo en un error de compilación que enumera con precisión lo que aún no has pensado. Un tipo suma sin comprobación de exhaustividad no es más que una jerarquía de clases con pasos de más; Dart 3 aportó la mitad que faltaba.
Esta es la misma maquinaria que usa Either — es una suma sellada de Left y Right (capítulo 16), y por eso un switch sobre un Either tampoco necesita rama de reserva.
Los records te dan un producto anónimo donde una clase sería ceremonia:
import 'package:fxdart/fxdart.dart'; void main() { // `attach` pairs each value with something derived from it: // a product, produced lazily, with no class to declare. final priced = fx(['apple', 'fig', 'banana']) .attach((name) => name.length) .toList(); print(priced); final total = fx(priced).sumBy((row) => row.$2); print('total letters: $total');}Un record es la herramienta correcta cuando el emparejamiento es local — un paso intermedio de una tubería, un retorno de dos valores. Es la herramienta equivocada en cuanto el emparejamiento tiene un nombre en el dominio y reglas asociadas, porque un record no puede llevar una invariante. (String, int) no puede prometer que el int sea no negativo; class Money { Money(this.cents) : assert(cents >= 0); } sí.
🎓 Por qué tipos de datos «algebraicos». Los productos multiplican sus tamaños y las sumas los suman, y el álgebra sigue:
Either<A, B>tiene |A| + |B| valores,A?tiene |A| + 1, y las funcionesA → Btienen |B|^|A| — por eso en la literatura la flecha se escribe como exponenciación. El isomorfismo(A, B) → C ≅ A → (B → C)— currificación, capítulo 4 — es el enunciado a nivel de tipos de que(c^b)^a = c^(b×a). Los nombres no son adorno; la aritmética es real, y predice qué refactorizaciones preservan el significado.
sealed para cada uno, cargando exactamente los datos que ese caso necesita — Loaded tiene data y no message, y nada nulable.if (x != null && !y) que existía para descartar una combinación imposible desaparece, sustituido por una rama de switch que el compilador vigila.La recompensa no es elegancia, es que el siguiente cambio queda comprobado. Añadir Retrying a Request produce una lista de errores de compilación, que es una lista de tareas escrita por el compilador e imposible de olvidar.
Úsalo donde una combinación equivocada sería un defecto real y donde los casos vayan a crecer: estados de petición/respuesta, resultados de parseo, mensajes de protocolo, cualquier cosa con un «o» en su especificación.
Sáltatelo para datos genuinamente abiertos, para una estructura que solo son tres números independientes y — importante — en la frontera con JSON, donde el mundo te entrega un saco de nulables de todos modos. Ahí el tipo suma es aquello hacia lo que parseas: un único sitio convierte el mapa informe en un valor que no puede mentir, y todo lo que viene después recibe la garantía. Ese parseo es el tema de la Parte IV.
(bool, String?) si String tiene n valores? ¿Y Either<bool, bool>?Request del principio del capítulo y anota los doce estados sin sentido. ¿En cuáles reventaría hoy tu código, y en cuáles renderizaría algo incorrecto en silencio?Either<String, int> como (String?, int?) pueden representar «un fallo o un número». Da una razón concreta para preferir el primero.(bool, String?) es un producto de 2 por (n + 1), o sea 2n + 2 valores. Either<bool, bool> es una suma: 2 + 2 = 4 — la misma cuenta que (bool, bool), pero son tipos distintos, y confundirlos es precisamente el error de modelado del que trata este capítulo.String reason: sealed class Light con Red, Amber, Green, FlashingAmber(reason). El tipo admite 3 + r estados, donde r es el número de cadenas de motivo — lo cual es honesto, porque un ámbar intermitente lleva de verdad más información que un rojo.loading con data, loading con error, data con error, cancelled con cualquier otra cosa, y el estado vacío en que los cuatro son null/false. El vacío suele ser el reventón (no hay nada que renderizar); las combinaciones suelen ser el bug silencioso, porque gana el primer if de la función de render y el resto del estado se descarta sin leer.Either es una suma, así que el compilador puede demostrar que hay exactamente un lado presente y el switch cubre ambos sin reserva. (String?, int?) es un producto de dos opcionales: cuatro estados, dos de los cuales — ambos null, ambos no null — son un sinsentido que tienes que manejar a mano en cada punto de uso.En este capítulo
- la composición como la operación que convierte dos funciones en una
- la aplicación parcial y la currificación, y la diferencia entre ambas
- por qué un
pipecurrificado fiel no se puede tipar en Dart- qué trae FxDart en su lugar, y el precio de esa elección
Dos funciones encajan cuando la salida de una es la entrada de la otra, y componerlas produce una tercera función que no menciona ningún valor intermedio:
import 'package:fxdart/fxdart.dart'; String trim(String s) => s.trim();String upper(String s) => s.toUpperCase(); // Dart has no composition operator, so composition is a// three-line helper. Its shortness is the point: the concept// is small, only the notation is missing.C Function(A) compose2<A, B, C>( B Function(A) f, C Function(B) g) => (a) => g(f(a)); void main() { // By hand. String shout(String s) => upper(trim(s)); print(shout(' hello ')); // As a value: the composition is itself passable. final shout2 = compose2(trim, upper); print(shout2(' hello ')); print([' a ', ' b'].map(shout2).toList()); // pipe1 is the same idea with the value supplied first. print(pipe1(' hi ', shout2));}La composición es asociativa — (f ∘ g) ∘ h es igual a f ∘ (g ∘ h) — y la función identidad es su unidad. Eso es un monoide (capítulo 8), y es la razón de que puedas agrupar las etapas de una tubería como quieras sin cambiar el resultado. Es también la razón de que «extraer un ayudante» sea siempre seguro: sacar tres pasos encadenados a una función con nombre es exactamente la reagrupación que permite la ley.
Se usan como sinónimos y no son lo mismo.
add(2, _) se convierte en una función de un argumento.int Function(int, int) se convierte en int Function(int) Function(int). La aplicación parcial es entonces solo llamar a la primera capa.import 'package:fxdart/fxdart.dart'; int addTwo(int a, int b) => a + b; void main() { // Currying: one call per argument. final curriedAdd = addTwo.curried; final add10 = curriedAdd(10); print([add10(5), add10(32)]); // Partial application without currying: a closure does it too. int Function(int) addAlso(int a) => (b) => a + b; print(addAlso(10)(32)); // Uncurrying goes back. print(curriedAdd.uncurried(40, 2));}Figura 4-1. La composición une dos máquinas de extremo a extremo y esconde la unión. La currificación recoloca una máquina de dos entradas como dos máquinas de una entrada cada una.
pipe de FxTS no se pudo portarFxTS está construido sobre un pipe currificado: cada operador es una función que toma su callback y devuelve una función a la espera de los datos, y pipe hace pasar un valor por una lista de ellas. TypeScript lo tipa con unas 20 sobrecargas escritas a mano, una por aridad, y tipos de tupla variádicos para relacionarlas.
Dart no tiene sobrecargas ni genéricos variádicos. Un pipe que acepte cualquier número de etapas tiene que recurrir a dynamic:
// FxDart ships this for FxTS parity — and every stage boundary// is an unchecked cast.final result = pipe( [1, 2, 3, 4], (dynamic xs) => map((dynamic n) => (n as int) * 2, xs as Iterable), (dynamic xs) => toList(xs as Iterable<int>),);
Cada frontera de etapa es un cast sin comprobar. El error de tipos que querías que atrapara el compilador — una etapa String en una tubería de int — llega ahora en tiempo de ejecución, en mitad de un iterador perezoso, con una traza que apunta a las tripas de la librería.
Así que FxDart eligió otra forma para la misma idea:
import 'package:fxdart/fxdart.dart'; void main() { final result = fx([1, 2, 3, 4, 5, 6]) .map((n) => n * 2) .filter((n) => n > 4) .take(3) .toList(); print(result);}La cadena es una composición tipada: cada método devuelve Fx<R> con el nuevo tipo de elemento, así que el compilador sigue el valor hasta el final y tu editor puede autocompletarlo. Lo que renuncia es a poder sostener una etapa como valor de primera clase y pasarla por ahí — en FxTS, map(f) por sí solo es un valor; en FxDart es una llamada a método que necesita un receptor. WHY_CURRIED.md en el repositorio recoge ese intercambio al completo.
🎓 La currificación es un isomorfismo, no una convención.
(A, B) → CyA → (B → C)llevan exactamente la misma información — puedes convertir en cualquier dirección sin pérdida, que es lo que demuestran.curried/.uncurrieden tiempo de ejecución. Los lenguajes que currifican por defecto (Haskell, OCaml) eligieron un lado del isomorfismo como primitivo; Dart eligió el otro. Nada expresable en uno deja de serlo en el otro — solo difiere la ergonomía, y la ergonomía es justo por lo que importa la elección.
Una función que toma o devuelve funciones es de orden superior, y el vocabulario de las tuberías no es otra cosa que funciones de orden superior: map, filter, fold, sortBy toman comportamiento como argumento. Dos más de FxDart que conviene conocer por su nombre:
import 'package:fxdart/fxdart.dart'; bool small(int n) => n < 10;bool odd(int n) => n.isOdd; void main() { // juxt: one input, several functions, all their results. final stats = juxt([ (Iterable<int> xs) => xs.length, (Iterable<int> xs) => xs.reduce((a, b) => a + b), ]); print(stats([3, 1, 4, 1, 5])); // Predicates are values too, so they combine. final both = (int n) => small(n) && odd(n); print(fx([3, 12, 7, 20]).filter(both).toList()); print(fx([3, 12, 7, 20]).filter(negate(small)).toList());}Tratar las funciones como valores paga cuando el comportamiento varía pero la estructura no — una tubería, cuatro políticas pasadas por argumento; un validador compuesto de reglas pequeñas con nombre. Paga también a la hora de probar: un parámetro de función es la costura más barata que existe, y no necesita ningún framework de mocks.
Deja de pagar cuando la composición se hace más larga que aquello que sustituyó. Una cadena de seis combinadores sin puntos que quien lee tiene que aplicar mentalmente a un valor es peor que un bucle for con un buen nombre. La falta de un operador de composición en Dart hace que ese umbral llegue antes que en Haskell, y fingir lo contrario es cómo el código funcional se gana su fama.
compose2(f, g) aplica f primero. El operador . de Haskell aplica primero la función de la derecha. ¿Qué orden usa fx(...).map(f).map(g), y por qué es la única elección sensata para una cadena de métodos?compose3 para tres funciones de un argumento usando compose2 dos veces. Argumenta después que las dos formas de agrupar las llamadas dan la misma función.addTwo.curried(10) devuelve una función. ¿Cuál es su tipo, escrito por completo? ¿Por qué Dart no puede inferir un getter curried para una función de aridad arbitraria?fx(xs).filter(small).filter(odd) como un único filter. ¿Es siempre un refactor seguro? ¿De qué propiedad de filter depende?map(f) y luego map(g), siguiendo el orden de lectura. Una cadena de métodos no puede hacer otra cosa — el receptor está a la izquierda, así que lo primero escrito es lo primero aplicado. El . de Haskell se lee de derecha a izquierda porque refleja el f ∘ g matemático; ambos son coherentes, y mezclarlos en un mismo código es el peligro real.D Function(A) compose3<A, B, C, D>(...) construido como compose2(compose2(f, g), h) o compose2(f, compose2(g, h)). La misma función porque la composición es asociativa — la misma ley que te deja reagrupar etapas de una tubería, y la misma forma que la asociatividad monádica del capítulo 1.int Function(int). Dart no puede expresar «una función de cualquier aridad» como parámetro de tipo, así que curried está escrito una vez por aridad — extensiones Curry2 a Curry5 sobre R Function(A, B), R Function(A, B, C), etcétera. Es el mismo muro que los genéricos variádicos ausentes en pipe, y el mismo muro que los tipos de orden superior del capítulo 10: el sistema de tipos de Dart es deliberadamente de primer orden.fx(xs).filter((n) => small(n) && odd(n)). Es seguro cuando los predicados son puros — la versión fusionada llama a small y a odd sobre el mismo elemento en el mismo orden, y cortocircuita igual. Si un predicado tiene un efecto colateral (contar cuántos elementos vio, digamos), las dos versiones difieren: la forma encadenada ejecuta odd solo sobre los supervivientes, y la fusionada también, pero un efecto ordenado entre los dos filtros se movería. La pureza es lo que convierte la fusión en un refactor y no en una reescritura.En este capítulo
- el functor: una operación,
map, con dos leyes- qué prohíben las leyes, mostrado con un tipo que las rompe
- por qué la ley de composición es la que autoriza la fusión de etapas en una tubería
- functores que no son contenedores, incluido el que se esconde en
Function
Un functor es un tipo F con una sola operación:
map : F<A> × (A → B) → F<B>
Toma una estructura que contiene A y una función plana A → B, obtén la misma estructura conteniendo B. «La misma estructura» es lo que hace el trabajo de verdad en esa frase, y las dos leyes son lo que lo precisa.
Dart está lleno de functores y los llama de distintas maneras:
import 'package:fxdart/fxdart.dart'; void main() { print([1, 2, 3].map((n) => n * 2).toList()); // List print(Either<String, int>.right(20).map((n) => n * 2)); print(Either<String, int>.left('nope').map((n) => n * 2)); print(fx([1, 2, 3]).map((n) => n * 2).toList()); // Fx}Fíjate en la tercera línea. Mapear un Left no hace nada, y eso no es un caso especial atornillado — es forzoso. map no puede cambiar la estructura, y en Either la elección de lado es la estructura. Un map que convirtiera un Left en un Right sería otra función llevando ese nombre.
m.map((x) => x) == m. Mapear la función identidad no cambia absolutamente nada — ni los valores, ni la forma, ni nada observable.m.map(f).map(g) == m.map((x) => g(f(x))). Dos pasadas con dos funciones equivalen a una pasada con su composición.import 'package:fxdart/fxdart.dart'; int addOne(int n) => n + 1;int triple(int n) => n * 3; void main() { final m = Either<String, int>.right(7); // identity print(m.map((x) => x) == m); // composition print(m.map(addOne).map(triple) == m.map((x) => triple(addOne(x)))); // both hold on the other side too final bad = Either<String, int>.left('boom'); print(bad.map((x) => x) == bad);}Figura 5-1. La identidad dice que el bucle no hace nada. La composición dice que las dos rutas por el cuadrado aterrizan en el mismo valor — que es por lo que una tubería puede recortarse en cualquier punto entre etapas.
Descartan un map que haga cualquier cosa además de aplicar la función. Aquí hay un tipo plausible que falla:
// A box that remembers how many times it was mapped.class Counted<A> { const Counted(this.value, this.maps); final A value; final int maps; Counted<B> map<B>(B Function(A) f) => Counted(f(value), maps + 1); @override bool operator ==(Object other) => other is Counted && other.value == value && other.maps == maps; @override int get hashCode => Object.hash(value, maps); @override String toString() => 'Counted($value, maps: $maps)';} void main() { final m = Counted(7, 0); // Identity fails: mapping "nothing" is observable. print(m.map((x) => x) == m); // Composition fails: two passes cost two, one pass costs one. print(m.map((x) => x + 1).map((x) => x * 3)); print(m.map((x) => (x + 1) * 3));}El tipo no está mal — contar los mapeos podría ser exactamente lo que quieres. Lo que no es, es un functor, y la consecuencia práctica es precisa: quien lo lea ya no puede fusionar ni dividir sus llamadas a map, porque hacerlo cambia el resultado. Las leyes son permisos, y este tipo retiene uno.
Lee la ley de composición de derecha a izquierda y deja de ser filosofía:
m.map(f).map(g) — dos recorridos — es igual a m.map(g ∘ f), un recorrido. Una librería puede por tanto reescribir el primero como el segundo cuando le apetezca, y tú nunca te enteras.
Eso no es hipotético en FxDart. Las tuberías perezosas fusionan etapas para que un valor recorra la cadena entera una vez en lugar de materializarse entre pasos, y la licencia para esa reescritura es la ley del functor:
import 'package:fxdart/fxdart.dart'; void main() { final seen = <String>[]; final result = fx([1, 2, 3]) .map((n) => n + 1) .peek((n) => seen.add('after +1: $n')) .map((n) => n * 3) .peek((n) => seen.add('after *3: $n')) .toList(); print(result); // Interleaved, not staged: element by element through the // whole chain — the one-pass reading of the composition law. seen.forEach(print);}Dos map y ninguna lista intermedia. En un lenguaje ansioso pagarías una lista por etapa; aquí la ley dice que no hace falta, y la implementación toma la ley por su palabra. El capítulo 11 hace explícita esta historia de evaluación.
🎓 Functor, formalmente. Un functor es una aplicación entre categorías que lleva objetos a objetos y flechas a flechas preservando la identidad y la composición — que es exactamente las dos leyes, enunciadas una vez para el caso general. En programación solo usamos endofunctores sobre la categoría de los tipos:
Flleva el tipoAal tipoF<A>, ymapeleva una flechaA → Ba una flechaF<A> → F<B>. El capítulo 20 dibuja el diagrama; nada de lo anterior depende de él.
«Un functor contiene valores» es una mentira útil. Lo que un functor tiene en realidad es una posición sobre la que la función puede actuar, y algunas de esas posiciones no contienen nada en absoluto.
Future<A> — el valor todavía no está aquí; then es su map.map cambia lo que producirá un parseo futuro.Function(X) → A — el functor lector. Mapear sobre una función compone sobre su resultado:void main() { int Function(String) length = (s) => s.length; // map for functions IS composition: apply, then transform. int Function(String) doubledLength = (s) => length(s) * 2; print([length('functor'), doubledLength('functor')]);}Ese último merece un momento: la composición y map son la misma operación vista desde dos ángulos, y por eso la asociatividad del capítulo 4 y la ley de composición de este capítulo suenan a la misma frase dicha dos veces. Lo son.
La palabra rinde como herramienta de predicción. Encuentra un tipo desconocido con un map y ya sabes tres cosas: no cambiará la forma, mapear la identidad no hace nada, y puedes dividir o fusionar las llamadas con libertad. Eso es mucho conocimiento para una sola palabra.
También te dice cuándo un tipo miente. Un map cuya documentación menciona reintentos, cambios de orden o caché no es el map de un functor, y deberías leer el código antes de refactorizar a su alrededor.
Either.map cumple la ley de identidad. ¿Cuántos casos hay, y por qué ese número es la demostración entera?Set tiene un map. ¿Cumple la ley de composición cuando f lleva dos elementos distintos al mismo valor? Prueba {1, 2} con f = (x) => 0 y g = (x) => x + 1.map cumpliendo ambas leyes, ¿es map único? Es decir, ¿podría haber dos map legales distintos para el mismo tipo — y difiere la respuesta entre List y Either?peek de FxDart devuelve el mismo tipo de elemento. ¿Es peek un map? ¿Qué ley rompe, y el vocabulario de qué capítulo explica por qué a nadie le importa?Left(e).map(id) devuelve Left(e) por definición, y Right(a).map(id) devuelve Right(id(a)) = Right(a). Either es una suma con exactamente dos constructores, así que cubrir ambos es cubrir todos los valores — la misma exhaustividad que el capítulo 3 obtuvo de sealed.{1, 2}.map(f) es {0} y mapear g da {1}; la fusión g ∘ f da {1} también. La deduplicación ocurre a la salida en ambas rutas. Lo que Set rompe no es la composición sino la intuición de que un functor preserva el tamaño — nada en las leyes promete eso.List, no: un map que además invirtiera la lista cumple la identidad (¿invirtiendo dos veces? no — invertir una vez rompe la identidad, porque xs.map(id) sería xs.reversed). La respuesta interesante es que las leyes fijan map para cualquier tipo cuya forma quede determinada por las posiciones de su contenido, lo cual cubre tanto a List como a Either. En la práctica, para estos tipos el map legal es único, y esa unicidad es por lo que el nombre merece confianza.peek no es un map — es map con un efecto atado, así que rompe la ley de identidad en cuanto el callback hace algo observable (peek((_) {}) no hace nada, peek(print) sí). El vocabulario del capítulo 2 es la explicación: peek existe precisamente para hacer un efecto declarado, y un efecto declarado no es una violación sino una excepción documentada.En este capítulo
- la diferencia entre pasos dependientes e independientes, en los tipos
- el applicative: combinar varias estructuras sin que ninguna vea a la otra
- por qué acumular todos los errores es imposible para una mónada y natural aquí
map2,zipOrAccumulatey el ámbitoaccumulatede FxDart
El flatMap del capítulo 1 compone pasos donde el segundo depende del primero: no puedes buscar el pedido del usuario hasta tener al usuario. Esa dependencia está escrita en el tipo — A → M<B> saca el valor de la primera caja.
Pero muchísimo código real no tiene esa dependencia. Validar un formulario: la comprobación del nombre no necesita la edad, y la de la edad no necesita el nombre. Son independientes, y el tipo de la operación que los combina lo dice:
map2 : F<A> × F<B> × ((A, B) → C) → F<C>
Ninguna flecha desde A hacia la segunda estructura. Ambas están ya ahí; la función solo combina los resultados. Un tipo con map2 (más una forma de elevar un valor plano, exactamente el of del capítulo 1) es un functor applicative.
Figura 6-1. flatMap no puede empezar el segundo paso hasta que el primero produce un valor. map2 tiene los dos desde el principio — que es lo que hace siquiera posible ejecutarlos a la vez, o informar de ambos fallos.
map2import 'package:fxdart/fxdart.dart'; class User { const User(this.name, this.age); final String name; final int age; @override String toString() => 'User($name, $age)';} Either<String, String> vName(String s) => s.isEmpty ? Either.left('name is empty') : Either.right(s); Either<String, int> vAge(String s) { final n = int.tryParse(s); if (n == null) return Either.left('age is not a number'); if (n < 0) return Either.left('age is negative'); return Either.right(n);} void main() { print(vName('Ada').map2(vAge('36'), User.new)); print(vName('').map2(vAge('36'), User.new)); // Both wrong — but only the leftmost failure is reported. print(vName('').map2(vAge('nope'), User.new));}La última línea es el problema por el que existe este capítulo. La persona rellenó mal dos campos; el formulario le habló de uno. Nada en la estructura forzó eso — ambos Either estaban calculados. Es el informe el que falla rápido, y map2 informa solo del que está más a la izquierda.
Intenta escribir la versión acumuladora solo con flatMap y das contra un muro que no es falta de empeño:
name.flatMap((n) => age.flatMap((a) => Either.right(User(n, a))));
Si name es un Left, el flatMap exterior cortocircuita — y la función que habría mirado age nunca se ejecuta, porque está dentro del callback. El tipo de flatMap dice que el segundo paso es una función del primer valor, así que cuando no hay primer valor no hay segundo paso. Cortocircuitar no es aquí una decisión de política; es lo que significa el tipo.
El applicative es estrictamente más débil, y la debilidad es la característica. map2 sostiene ambas estructuras como datos antes de combinarlas, así que una implementación es libre de mirar las dos y concatenar sus fallos.
Dart no tiene un tipo Validated; FxDart sigue a Arrow 2.x y ofrece en su lugar un ámbito acumulador. Dentro de either, pide uno:
import 'package:fxdart/fxdart.dart'; class User { const User(this.name, this.age); final String name; final int age; @override String toString() => 'User($name, $age)';} Either<Nel<String>, User> parse(String name, String age) => either((r) => r.zipOrAccumulate2( (br) { if (name.isEmpty) br.raise('name is empty'); return name; }, (br) { final n = int.tryParse(age); if (n == null) br.raise('age is not a number'); if (n! < 0) br.raise('age is negative'); return n; }, User.new, )); void main() { print(parse('Ada', '36')); print(parse('', '36')); print(parse('', 'nope')); // both failures, in branch order}Todas las ramas se ejecutan; los fallos se concatenan en una NonEmptyList (el capítulo 8 explica por qué ese tipo y no una List corriente). Para más de cinco ramas, o para reglas que dependen de otras anteriores, baja al ámbito completo:
import 'package:fxdart/fxdart.dart'; Either<Nel<String>, String> checkout( String item, String qty, String coupon,) => either((r) => r.accumulate((acc) { final i = acc.accumulating((br) { if (item.isEmpty) br.raise('item required'); return item; }); final q = acc.accumulating((br) { final n = int.tryParse(qty); if (n == null) br.raise('qty is not a number'); return n ?? 0; }); // Dependent rule: only meaningful once qty parsed. final c = acc.dependent((br) { if (coupon.isNotEmpty && q.value > 10) { br.raise('coupon not valid in bulk'); } return coupon; }); return '${q.value} x ${i.value} ${c.value}'.trim(); })); void main() { print(checkout('mug', '2', '')); print(checkout('', 'x', 'SAVE5')); print(checkout('mug', '99', 'SAVE5'));}accumulating ejecuta ramas independientes y registra sus errores; dependent se ejecuta solo cuando aún no ha fallado nada, porque una regla que lee el valor de otra rama no puede ejecutarse cuando ese valor no existe. Esa división — independiente frente a dependiente — es la distinción de este capítulo, convertida en API.
🎓 Las leyes, y la definición de verdad. Un applicative se suele dar como
pure : A → F<A>másap : F<A → B> × F<A> → F<B>(una función dentro de la estructura, aplicada a un valor dentro de la estructura).map2yapson interdefinibles, ymap2se lee mejor en un lenguaje sin currificación por defecto, y por eso FxDart expone esa cara. Las cuatro leyes — identidad, composición, homomorfismo, intercambio — dicen lo que esperarías:pureno añade nada, y la aplicación es asociativa igual que lo es la composición. Toda mónada es un applicative (map2víaflatMap); el recíproco falla, y la validación de este capítulo es el contraejemplo estándar.
| Necesitas | Usa | Porque |
|---|---|---|
| El paso 2 necesita el valor del paso 1 | flatMap / ámbito either | La dependencia es real |
| Los pasos son independientes, basta el primer fallo | map2 | Lo más barato, y cortocircuita |
| Los pasos son independientes, informa de todos los fallos | zipOrAccumulate / accumulate | Solo la forma applicative puede |
| Los pasos son independientes y lentos | Applicative + concurrencia | La independencia es lo que hace legal el solape |
La última fila es la que se pasa por alto. concurrent(n) (capítulo 13) se aplica exactamente cuando los pasos no dependen entre sí — la misma condición que hace posible acumular errores. La independencia te compra ambas cosas, y flatMap la gasta.
Validación de formularios y de payloads, evidentemente. También: carga de configuración (informar de todas las claves ausentes a la vez, no de la primera), importación de CSV (todas las filas malas, no la fila 7), y dondequiera que una persona vaya a leer los errores y arreglarlos de una pasada. La prueba es simple — ¿preferiría quien lo usa ver todos los problemas a la vez? Si sí, quieres el applicative.
Sáltatelo cuando los fallos sean genuinamente secuenciales (no puedes comprobar el pedido hasta que el usuario existe) o cuando haya exactamente una cosa que pueda salir mal. accumulate alrededor de una sola regla es ceremonia sin recompensa.
Future tiene en la librería estándar un combinador con forma de map2. Nómbralo, y explica por qué puede ejecutar ambos futures a la vez mientras que f1.then((_) => f2) no.map2 para Either usando solo flatMap y map. Explica después por qué la versión que escribiste no puede acumular errores, en una frase sobre tipos.checkout, cambia dependent por accumulating en la regla del cupón y predice qué imprime checkout('', 'x', 'SAVE5'). ¿Por qué es dependent el valor por defecto más seguro para reglas que leen a sus hermanas?Set un applicative? ¿Qué significaría map2, y encaja con tu intuición sobre «combinar dos conjuntos»?Future.wait([a, b]) — recibe ambos futures ya construidos, así que los dos están corriendo antes de llamarlo. a.then((_) => b) construye b dentro de un callback, así que b ni siquiera puede existir hasta que a termine. La diferencia es exactamente map2 frente a flatMap, y se ve en el reloj de pared.a.flatMap((x) => b.map((y) => f(x, y))). No puede acumular porque b.map está dentro de una función de x: cuando a es un Left, esa función nunca se aplica, así que el fallo de b nunca se examina. El tipo A → Either<E, C> es lo que vuelve inaccesible el segundo valor.accumulating, la rama del cupón se ejecuta aunque qty haya fallado, y leer q.value dentro de ella detona — elevando los errores acumulados desde dentro de una rama en vez de al final. dependent existe para hacer eso imposible: se salta el bloque por completo cuando ya hay errores, que es el valor por defecto correcto para cualquier regla que lea el .value de una hermana.map2 sobre conjuntos es el producto cartesiano con los resultados deduplicados — {1,2} y {10,20} con + dan {11, 21, 12, 22}. Encaja con la lectura no determinista (cada conjunto es «uno de estos valores»), que es la misma lectura que hace de List una mónada en el capítulo 1. No encaja con la intuición de estilo zip — y elegir entre esas dos lecturas es exactamente por lo que Haskell tiene [] y ZipList como applicatives separados.En este capítulo
- la composición de Kleisli: por qué las funciones
A → M<B>necesitan su propio∘- la pirámide, y la sintaxis que todo lenguaje inventa para aplanarla
async/awaitleído como notación do para exactamente una mónada- por qué «una mónada cada vez» es el límite real, y qué cuesta en Dart
El capítulo 4 compuso A → B con B → C y obtuvo A → C. Prueba lo mismo con pasos que pueden fallar:
parseId : String → Either<E, int>loadUser : int → Either<E, User>No encajan. La salida de parseId es Either<E, int>, y loadUser quiere un int pelado. La composición corriente queda descartada, y esto no es un caso límite — todo paso con efectos tiene esta forma.
flatMap es el arreglo, y darle un operador de composición hace visible el patrón:
import 'package:fxdart/fxdart.dart'; // Kleisli composition: compose two "returns a box" functions.Either<E, C> Function(A) kleisli<E, A, B, C>( Either<E, B> Function(A) f, Either<E, C> Function(B) g,) => (a) => f(a).flatMap(g); Either<String, int> parseId(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad id: $s') : Either.right(n);} Either<String, String> loadUser(int id) => id == 1 ? Either.right('Ada') : Either.left('no user $id'); void main() { final lookup = kleisli(parseId, loadUser); print(lookup('1')); print(lookup('2')); print(lookup('x'));}kleisli compone A → M<B> con B → M<C> en A → M<C>. Esas flechas forman su propia categoría — la categoría de Kleisli de la mónada — y las tres leyes del capítulo 1 son exactamente lo que una categoría necesita: of es la flecha identidad (identidad por izquierda y por derecha), y flatMap es composición asociativa.
Ese es todo el contenido de «una mónada es una forma de componer funciones con efectos»: flatMap restaura la composición después de que los efectos la rompan.
Figura 7-1. Las funciones planas encajan entre sí. Las que tienen efectos no — la salida lleva una envoltura que la siguiente entrada no acepta. flatMap es el adaptador, y las leyes dicen que el adaptador es invisible.
Compón tres o cuatro pasos dependientes a mano y el código se va a la derecha:
parseId(raw).flatMap((id) => loadUser(id).flatMap((user) => loadOrders(user).flatMap((orders) => Either.right(summarise(user, orders)))));
Todo lenguaje con mónadas acaba criando sintaxis para aplanar esto. El mismo cálculo, cuatro superficies:
| Lenguaje | Sintaxis | Qué emite el compilador |
|---|---|---|
| Haskell | do { id <- parseId raw; … } | una cadena de >>= |
| Scala | for { id <- parseId(raw) } yield … | una cadena de flatMap/map |
| Kotlin (Arrow) | either { val id = parseId(raw).bind() } | un ámbito con una salida no local |
| Dart | either((r) { final id = r.bind(parseId(raw)); … }) | un ámbito con una salida no local |
Los dos primeros son azúcar sintáctico: el compilador reescribe el bloque en llamadas a métodos, y funciona para cualquier mónada que el verificador de tipos pueda nombrar. Los dos últimos no lo son — no hay reescritura, solo un objeto de ámbito cuyo bind puede abandonar el bloque. El capítulo 15 trata de ese mecanismo y de por qué Dart lo obligó.
El resultado se lee igual en cualquier caso:
import 'package:fxdart/fxdart.dart'; Either<String, int> parseId(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad id: $s') : Either.right(n);} Either<String, String> loadUser(int id) => id == 1 ? Either.right('Ada') : Either.left('no user $id'); Either<String, List<String>> loadOrders(String user) => user == 'Ada' ? Either.right(['mug', 'book']) : Either.left('none'); Either<String, String> summary(String raw) => either((r) { final id = r.bind(parseId(raw)); final user = r.bind(loadUser(id)); final orders = r.bind(loadOrders(user)); return '$user bought ${orders.length} things'; }); void main() { print(summary('1')); print(summary('2')); print(summary('nope'));}Código en línea recta, tres pasos dependientes, un tipo de fallo y ninguna pirámide.
async/await es notación do para una sola mónadaDart ya trae esta idea — para Future, y solo para Future:
Future<int> parseId(String s) async => int.parse(s);Future<String> loadUser(int id) async => id == 1 ? 'Ada' : 'nobody'; Future<String> summary(String raw) async { final id = await parseId(raw); // r.bind, spelled `await` final user = await loadUser(id); return 'user: $user';} void main() async { print(await summary('1')); print(await summary('7'));}Línea por línea, esto es el bloque either de arriba con await donde estaba r.bind. async marca el ámbito; await desenvuelve una capa; el compilador reescribe el cuerpo en continuaciones, que es flatMap con otro nombre. La prueba de que es monádico y no magia: await sobre un Future<Future<T>> te da Future<T> — aplanando, exactamente como exigía el capítulo 1.
Lo que Dart no hizo fue generalizarlo. await funciona sobre Future (y sobre cualquier cosa con un then, por suerte estructural), y no hay await para Either, ni await para Iterable, ni forma de escribir el tuyo. Todos los lenguajes de la tabla anterior tomaron la misma decisión al principio y luego generalizaron; el async de Dart es donde esa generalización se detuvo.
🎓 Las mónadas no se apilan. Dado
Future<Either<E, A>>tienes dos mónadas y ningúnflatMapúnico para el par. Scala recurre a los transformadores de mónadas (EitherT[Future, E, A]), una envoltura por combinación, con una torre de elevaciones. Kotlin y Dart evitan la torre haciendo que el ámbito cumpla doble función:eitherAsyncte da un ámbitoRaisedentro de un cuerpoasync, así queawaitse ocupa del tiempo yr.binddel fallo, sin un tercer tipo. No es más potente que los transformadores — es menos general y mucho más fácil de leer, y el capítulo 21 registra quién pagó qué por ese intercambio.
import 'package:fxdart/fxdart.dart'; Future<Either<String, int>> fetchPort(String key) async => key == 'http' ? Either.right(8080) : Either.left('unknown: $key'); Future<Either<String, String>> describe(String key) => eitherAsync((r) async { // `await` sequences time; `r.bind` sequences failure. final port = r.bind(await fetchPort(key)); return 'listening on $port'; }); void main() async { print(await describe('http')); print(await describe('gopher'));}Dos efectos, un bloque en línea recta, ningún EitherT. El coste es que esto solo funciona para las combinaciones que FxDart escribió a mano — eitherAsync, nullable, catching. No hay un mecanismo genérico que puedas extender, porque expresar «cualquier mónada» requiere una característica de tipos que Dart no tiene. Eso es el capítulo 10.
Recurre al ámbito siempre que tres o más pasos dependientes puedan fallar con el mismo tipo de error — parsear, cargar, autorizar, calcular. Esa es la forma donde aparece la pirámide, y la forma donde una cadena artesanal de if (x == null) return null pierde en silencio el motivo del fallo.
No recurras a él cuando los pasos sean independientes (capítulo 6: pierdes la acumulación y la concurrencia), cuando haya exactamente un paso (un Either.map simple dice más), o cuando el fallo sea genuinamente excepcional y quien llama no pueda hacer nada al respecto (capítulo 18).
kleisli para Future — compón A → Future<B> con B → Future<C>. ¿De qué método existente de Dart es una envoltura fina?Either es Either.right. Demuestra que kleisli(Either.right, f) y kleisli(f, Either.right) se comportan ambas como f, y nombra las dos leyes monádicas que acabas de usar.summary usando solo flatMap, y cuenta después las líneas y la indentación máxima de cada versión. ¿A partir de cuántos pasos empieza a ganar la versión con ámbito?await aplana Future<Future<T>>. ¿Qué te dice eso sobre la firma de Future.then, comparada con el map del capítulo 5?Future<C> Function(A) k<A, B, C>(Future<B> Function(A) f, Future<C> Function(B) g) => (a) => f(a).then(g);. Envuelve a then, que es el flatMap de Future — el mismo método que hace además de map, que es el tema del ejercicio 4.kleisli(Either.right, f) aplicado a a es Either.right(a).flatMap(f), que es f(a) por identidad por la izquierda. kleisli(f, Either.right) aplicado a a es f(a).flatMap(Either.right), que es f(a) por identidad por la derecha. Esas dos leyes son precisamente el enunciado de que of es una flecha identidad en la categoría de Kleisli.flatMap tiene aproximadamente el mismo número de líneas pero anida tres niveles y termina en una ristra de paréntesis de cierre; la versión con ámbito se mantiene plana. El cruce está en dos pasos — en tres no hay color, y en cuatro la versión pirámide empieza a acumular bugs en los paréntesis.then está sobrecargado de una forma en que map no lo está: acepta tanto B Function(A) como Future<B> Function(A), y aplana en el segundo caso. Así que then es map y flatMap fundidos en un solo método, lo cual es cómodo y es también por lo que Future a solas nunca te enseña la diferencia entre los dos pisos de la torre.En este capítulo
- dos leyes — asociatividad e identidad — y qué compra cada una por separado
- por qué
reducelanza sobre una colección vacía yfoldno- la propiedad que hace que la reducción paralela y por trozos den la misma respuesta
NonEmptyListcomo semigrupo, y por qué los errores de FxDart se acumulan en una
Un semigrupo es un tipo con una operación binaria asociativa:
combine(a, combine(b, c)) == combine(combine(a, b), c)
Un monoide es un semigrupo con un elemento identidad:
combine(empty, a) == a == combine(a, empty)
Eso es todo. int con + y 0; int con * y 1; String con + y ''; List con + y []; bool con && y true. Has usado todos ellos hoy.
void main() { // associativity: grouping does not matter print((1 + 2) + 3 == 1 + (2 + 3)); print(('a' + 'b') + 'c' == 'a' + ('b' + 'c')); // identity: the neutral element changes nothing print(0 + 7 == 7 && 7 + 0 == 7); print(''.length + 'abc'.length == 3); // subtraction is neither associative nor unital print((10 - 3) - 2 == 10 - (3 - 2));}La última línea es la razón de ser de la definición: «combinar dos cosas» no basta. La resta combina dos int y es inútil para los trabajos de abajo.
La identidad te da el caso vacío. Por eso Dart tiene dos métodos de plegado y se comportan de forma distinta sobre una colección vacía:
import 'package:fxdart/fxdart.dart'; void main() { // fold carries the identity element as a seed — total, always. print(fx(<int>[]).fold<int>(0, (a, b) => a + b)); print(fx([1, 2, 3]).fold<int>(0, (a, b) => a + b)); // reduce has no seed, so the empty case has no answer to give. try { print(fx(<int>[]).reduce((a, b) => a + b)); } catch (e) { print('reduce on empty: ${e.runtimeType}'); }}reduce solo requiere un semigrupo y por tanto es parcial. fold requiere un monoide — tú aportas empty como semilla — y es total. La excepción con la que has chocado cien veces es un elemento identidad ausente, apareciendo en tiempo de ejecución.
La asociatividad te da libertad de agrupación, y eso vale más de lo que parece. Significa que la misma operación puede ejecutarse:
Las cuatro dan la misma respuesta, y solo la asociatividad lo garantiza.
Figura 8-1. La asociatividad dice que toda forma de poner paréntesis en la misma secuencia aterriza en el mismo valor. Esa es la licencia para trocear, para reducir en paralelo y para retomar un total parcial.
import 'package:fxdart/fxdart.dart'; void main() { final data = List.generate(12, (i) => i + 1); // Sequential. final straight = fx(data).fold(0, (a, b) => a + b); // Chunked, then the chunk results combined — legal because + // is associative and 0 is its identity. final chunked = fx(data) .chunk(5) .map((c) => fx(c).fold(0, (a, b) => a + b)) .fold(0, (a, b) => a + b); print([straight, chunked, straight == chunked]); // Order does NOT come free: subtraction disagrees with itself. final subStraight = fx(data).fold(0, (a, b) => a - b); final subChunked = fx(data) .chunk(5) .map((c) => fx(c).fold(0, (a, b) => a - b)) .fold(0, (a, b) => a - b); print([subStraight, subChunked, subStraight == subChunked]);}La asociatividad dice que la agrupación no importa. La conmutatividad — a + b == b + a — dice que el orden no importa, y la mayoría de los monoides útiles no la tienen. La concatenación de cadenas, el append de listas y la composición de funciones son todos asociativos y ninguno es conmutativo.
La distinción tiene dientes en el capítulo asíncrono de FxDart: concurrent(n) evalúa los elementos fuera de orden pero los emite en el orden de la fuente, precisamente para que un fold posterior solo necesite asociatividad y no conmutatividad. Una librería que entregara los resultados en orden de finalización estaría exigiendo en silencio la ley más fuerte a tu código.
NonEmptyList, y por qué los errores son un semigrupoEl capítulo 6 acumulaba errores de validación. Pregunta en qué tipo se acumulan hacia y el álgebra responde antes que tú: necesitas algo que puedas combinar asociativamente (dos ramas fallidas se concatenan), y el resultado de combinar fallos nunca está vacío — así que la identidad no es meramente innecesaria, sería una mentira.
Eso es un semigrupo sin monoide, y FxDart lo llama NonEmptyList:
import 'package:fxdart/fxdart.dart'; void main() { final a = NonEmptyList.of('name is empty'); final b = NonEmptyList.of( 'age is negative', ['age is not a number']); // Combining failures is list concatenation: associative, // and the result cannot be empty. final all = NonEmptyList.of(a.first, [...a.skip(1), ...b]); print(all.toList()); print('length: ${all.length}'); // Nel is an extension type over List, so it costs nothing at // runtime — and `orNull` is the only way in from a plain list. print(NonEmptyList.orNull(<String>[]));}Either<Nel<E>, A> se lee por tanto como una afirmación precisa: si esto falló, hay al menos un motivo, y los motivos se combinan. Una List<E> habría admitido el estado sin sentido «falló con cero errores» — el argumento del capítulo 3, aplicado al canal de errores.
🎓 Los monoides componen, y por eso están en todas partes. Si
AyBson monoides, también lo es(A, B), combinando componente a componente con(emptyA, emptyB)como identidad — así que «suma, cuenta y máximo en una pasada» es un único fold sobre un monoide producto, y una media es ese fold más una división. Las funciones hacia un monoide forman un monoide ((f + g)(x) = f(x) + g(x)), y las endofunciones forman un monoide bajo composición conidentitycomo unidad — que es la frase escondida dentro de «una mónada es un monoide en la categoría de los endofunctores»:flattenes la combinación,ofes la identidad, y las tres leyes monádicas del capítulo 1 son estas dos leyes disfrazadas.
Cada vez que escribes un fold estás eligiendo un monoide, y nombrarlo en voz alta te dice si el código está bien: ¿tiene identidad (qué debería devolver el caso vacío?), y es asociativo (se puede repartir el trabajo?).
Paga más fuerte a escala — procesamiento por trozos, agregación paralela, totales incrementales en una base de datos — y en el diseño de API, donde «dame una semilla y una combinación» es la interfaz que deja a una librería agrupar tu trabajo sin preguntar.
No paga como vocabulario en un código que hace un reduce sobre diez elementos. Ahí di «suma».
max un semigrupo sobre int? ¿Un monoide? ¿Cuál tendría que ser el elemento identidad, y lo tiene Dart?empty no sea el valor «obviamente vacío» — es decir, uno donde quien lea lo adivinaría mal.fx(xs).fold(0, (a, b) => a + b.length) suma longitudes de cadenas. ¿Es asociativa la función que le pasaste a fold? ¿Por qué eso no es un problema?Either como Future.wait combinan resultados independientes. ¿Qué monoide usa Future.wait, y qué hace con los fallos?max es asociativo y conmutativo; su identidad es menos infinito, que para int no existe en Dart — así que max es un semigrupo sobre int y un monoide solo sobre double (double.negativeInfinity) o sobre int? con null como identidad. Esa es la razón honesta de que reduce encaje de forma natural con max y fold necesite una semilla incómoda.bool bajo && tiene identidad true, no false; int bajo * tiene identidad 1, no 0; y el monoide «el primero no nulo» tiene identidad null. La lección es que empty lo determina la operación, nunca el tipo — adivinarlo por el tipo es cómo un fold acaba multiplicándolo todo por cero.int difiere del tipo del elemento String. fold en Dart es el catamorfismo más general (B, A) → B, y solo cuando B == A surge la pregunta del monoide. No es un problema porque el fold secuencial nunca reagrupa; se convierte en un problema en el momento en que quieres trocearlo, punto en el cual debes factorizar la operación en un monoide genuino (String → int, y luego sumar).Future.wait usa el monoide de listas sobre los resultados — concatenándolos en el orden de los argumentos, con [] como identidad (esperar a nada da una lista vacía). Los fallos no se acumulan: por defecto gana el primer error y los demás se descartan, que es el comportamiento de fallo rápido que el capítulo 6 contrastaba con zipOrAccumulate. eagerError: false cambia cuándo informa, no de cuántos informa.En este capítulo
- el intercambio:
List<Either<E, A>>→Either<E, List<A>>, y por qué lo necesitas una y otra veztraverse= map + sequence, y qué aporta el applicative- versiones de fallo rápido y fallo lento, y el coste honesto de cada una
- la gemela asíncrona, y dónde
traversese encuentra conconcurrent(n)
Valida diez filas y tienes List<Either<E, Row>>. Nada de lo que viene después quiere eso: quien llama quiere o bien todas las filas, o bien los motivos por los que no puede tenerlas. Escrito a mano son las mismas quince líneas siempre — un acumulador, un bucle, un retorno temprano.
La operación tiene nombre, sequence, y su generalización — mapear primero, después secuenciar — es traverse:
sequence : List<F<A>> → F<List<A>>traverse : List<A> × (A → F<B>) → F<List<B>>
Léelo como intercambiar las dos estructuras. La lista sigue siendo lista, el efecto sigue siendo efecto; lo que cambia es cuál queda por fuera.
Figura 9-1. Cada elemento lleva su propio efectito; tras el intercambio, un solo efecto lleva la lista entera. Los valores no cambian — solo el anidamiento.
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String s) { final n = int.tryParse(s); if (n == null) return Either.left('not a number: $s'); if (n < 1024) return Either.left('privileged: $n'); return Either.right(n);} void main() { // traverse: map each element to an Either, then swap. print(fx(['8080', '9000']).map(parsePort).sequence()); print(fx(['8080', 'x', '80']).map(parsePort).sequence());}Un solo valor de salida, y es el valor que quiere el resto del programa: Right con todos los puertos, o Left con el primer motivo por el que no hay lista en absoluto.
map por sí solo no puede hacer esto. Mapear sobre la lista te deja los efectos dentro, y nada en map puede sacar uno fuera. Para construir F<List<A>> tienes que combinar los efectos de los elementos entre sí — eso es map2 del capítulo 6, aplicado repetidamente:
sequence([a, b, c]) = map2(a, map2(b, map2(c, of([]), cons), cons), cons)
Lo cual explica de inmediato los dos comportamientos que puedes obtener. La operación que combina es la del applicative, así que el applicative con el que recorres decide la política de fallo:
Either → párate en el primer Left.Left.El mismo recorrido, distinta álgebra, distinto informe. FxDart expone ambos:
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String s) { final n = int.tryParse(s); if (n == null) return Either.left('not a number: $s'); if (n < 1024) return Either.left('privileged: $n'); return Either.right(n);} void main() { final raw = ['8080', 'x', '80', '9000']; // Fail fast: the first reason, and nothing after it ran. print(fx(raw).map(parsePort).sequence()); // Fail slow: every reason, in order. print(fx(raw).map(parsePort).flattenOrAccumulate()); // And the map-and-swap in one step, with the accumulating // applicative doing the combining. print(mapOrAccumulate( (r, String s) => r.bind(parsePort(s)), raw));}Hay una tercera cosa que podrías querer — quedarte con las filas buenas e informar de las malas — y eso no es un recorrido en absoluto, porque el resultado son dos listas en vez de un efecto. Tiene su propio nombre:
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad: $s') : Either.right(n);} void main() { final results = ['8080', 'x', '9000'].map(parsePort).toList(); final (bad, good) = separateEither(results); print('kept: $good'); print('dropped: $bad'); // …or take just one side. print(rights(results)); print(lefts(results));}Elegir entre ellos es una decisión de producto, no técnica: una herramienta de importación quiere separateEither, un cargador de configuración quiere flattenOrAccumulate, un manejador de API quiere sequenceEither.
Cambia Either por Future y la misma operación aparece vestida con la ropa del propio Dart: Future.wait es sequence para futures. Lo cual significa que la versión interesante es la que recorre y además acota el trabajo:
import 'package:fxdart/fxdart.dart'; Future<int> fetchSize(String url) async { await Future.delayed(const Duration(milliseconds: 20)); return url.length;} void main() async { final urls = ['a.com', 'bb.com', 'ccc.com', 'dddd.com']; // Sequence with unbounded concurrency: Future.wait. print(await Future.wait(urls.map(fetchSize))); // Traverse with *bounded* concurrency: three in flight, // results still in source order. final bounded = fx(urls).toAsync().mapConcurrent(3, fetchSize); print(await bounded.toList());}Future.wait es el recorrido applicative sin freno: lo arranca todo. mapConcurrent(n) es el mismo recorrido con un límite, que es lo que realmente quieres contra una API con límite de tasa. El capítulo 13 explica el canal de retorno que hace que el límite sea real y no una recomendación.
🎓 Traverse es más general que las listas. La firma completa es
traverse : T<A> × (A → F<B>) → F<T<B>>para cualquier contenedor recorribleTy cualquier applicativeF— los árboles, los mapas yOptiontambién son recorribles. Tiene dos leyes (identidad y composición, como las del functor) y un corolario famoso:traversecon el applicative identidad es simplementemap, y con el applicative constante esfold.map,foldytraverseson tres caras de una sola operación — lo cual es un resultado precioso, y requiere tipos de orden superior para enunciarlo siquiera una vez. Ese es el tema del capítulo 10, y la razón de que FxDart traiga cuatro recorridos concretos en lugar de uno genérico.
Cuenta las versiones del código de arriba: sequenceEither, flattenOrAccumulate, mapOrAccumulate, separateEither — más sequenceEitherAsync, flattenOrAccumulateAsync y mapOrAccumulateAsync para cadenas asíncronas. Siete funciones donde un lenguaje con tipos de orden superior escribe una.
Eso no es incompetencia, es el techo del lenguaje, y tiene un coste real para ti: cuando FxDart añada un nuevo tipo de efecto, ninguno de tus recorridos existentes funcionará con él hasta que alguien escriba a mano las variantes séptima, octava y novena.
Cualquier frontera donde una colección de cosas independientes y falibles debe convertirse en una decisión: parsear un fichero de configuración, validar una importación, cargar N registros, abanicarse hacia N servicios. Si has escrito for (final x in xs) { final r = f(x); if (r.isLeft) return r; out.add(...); } más de dos veces, eso es un recorrido y deberías decirlo.
Sáltatelo cuando la colección tenga un elemento (usa el Either directamente), cuando necesites semántica de éxito parcial (eso es separateEither), o cuando el bucle haga de verdad algo por elemento que no sea un map puro — un recorrido que esconde un efecto colateral es peor que el bucle al que sustituyó.
sequence sobre una lista vacía — para Either, y para Future? ¿Qué ley del capítulo 8 decide la respuesta?traverse(xs, f) y xs.map(f) seguido de sequence dan el mismo resultado. ¿Cuál es más barato en Dart, y por qué FxDart trae aun así las dos grafías?Either<E, List<A>> y quieres List<Either<E, A>> — el intercambio en la otra dirección. ¿Es siempre posible? Pruébalo con un Left.Future.wait arranca todos los futures de inmediato. Anota dos situaciones donde eso es exactamente lo correcto y dos donde mapConcurrent(n) es la única elección correcta.Right([]) y Future.value([]) — la lista vacía envuelta en el pure del applicative. El elemento identidad del monoide de listas del capítulo 8 es [], y sequence de nada debe producir la identidad; cualquier otra cosa rompería la composición de dos recorridos sobre entradas concatenadas.traverse evita construir la List<F<B>> intermedia, lo cual importa a escala pero no con diez elementos. FxDart trae ambas porque la forma en dos pasos compone dentro de una cadena perezosa existente (.map(f).sequenceEither()), mientras que la forma fusionada es la que quieres cuando la fuente ya está materializada.Left(e): ¿se convierte en [Left(e)]? ¿O en []? Ambas son defendibles, y eso delata que esta dirección no es un recorrido — no hay ninguna ley que fuerce la respuesta. El intercambio general F<T<A>> → T<F<A>> se llama ley distributiva y existe solo para pares concretos de estructuras.Future.wait sobre una lista de 100.000 elementos abre 100.000 sockets, y el modo de fallo es tu proceso, no el suyo.En este capítulo
- los kinds: los tipos de los tipos, y dónde queda
Listsin su argumento- la declaración exacta de Dart que no compila, y por qué ningún truco la recupera
- qué hacen en su lugar Scala, Haskell y Arrow de Kotlin
- la factura que paga FxDart, contada en funciones
Los valores tienen tipos. Los tipos tienen kinds.
int es un tipo completo: puedes declarar una variable de ese tipo. Su kind se escribe *. List por sí solo no es un tipo completo — List<int> sí lo es. List es una función de tipos a tipos, y su kind se escribe * → *.
| Cosa | Kind | ¿Completo? |
|---|---|---|
int, String, List<int> | * | sí |
List, Future, Fx | * → * | necesita un argumento |
Either, Map | * → * → * | necesita dos |
Los capítulos 5 a 9 trataban por completo de tipos de kind * → *: functor, applicative, mónada y traversable son propiedades de un constructor de tipos, no de un tipo. List<int> no es una mónada; List sí lo es.
Esa frase es el capítulo entero. Para escribir la interfaz, necesitas un parámetro de tipo que sea a su vez de kind * → * — un tipo de orden superior (higher-kinded type).
// Does not compile. Dart type parameters are always kind `*`,// so `M` is a complete type and cannot take an argument.abstract class Monad<M> { M<A> of<A>(A value); M<B> flatMap<A, B>(M<A> box, M<B> Function(A) f);}
El error no es de sintaxis. Las variables de tipo de Dart solo recorren tipos completos, así que M<A> carece de sentido de la misma forma que 3(4) carece de sentido. Es un punto de diseño deliberado — los genéricos de primer orden mantienen la inferencia decidible y los mensajes de error legibles — y es un techo, no un bug que rodear.
Figura 10-1. Cada piso de la torre es una afirmación sobre un constructor de tipos. Dart puede hablar de los pisos de uno en uno; la viga que los cargaría todos a la vez necesita un kind que el lenguaje no tiene.
Los rodeos fallan todos de la misma manera — compilan, y luego mienten:
// The "defunctionalisation" trick: erase the constructor to a// marker, then cast it back. It type-checks. It is not typed.abstract class Kind<F, A> {} class ListK<A> implements Kind<ListK<Never>, A> { ListK(this.value); final List<A> value;} abstract class Monad<F> { Kind<F, A> of<A>(A value); Kind<F, B> flatMap<A, B>( Kind<F, A> fa, Kind<F, B> Function(A) f);} class ListMonad implements Monad<ListK<Never>> { @override Kind<ListK<Never>, A> of<A>(A value) => ListK([value]); @override Kind<ListK<Never>, B> flatMap<A, B>( Kind<ListK<Never>, A> fa, Kind<ListK<Never>, B> Function(A) f, ) { // The cast is the whole problem: nothing checks it. final list = (fa as ListK<A>).value; return ListK(list .expand((a) => (f(a) as ListK<B>).value) .toList()); }} void main() { final m = ListMonad(); final r = m.flatMap<int, int>( m.of(3), (a) => ListK([a, a * 10])); print((r as ListK<int>).value);}Funciona, y mira el precio: tres casts, un fantasma Never, y un tipo de retorno — Kind<ListK<Never>, int> — que ningún llamador quiere. Cada sitio de uso vuelve a hacer cast al tipo real, así que la abstracción te entrega código genérico cuyos errores de tipo aparecen en tiempo de ejecución. Las primeras versiones de Arrow hicieron exactamente esto, en Kotlin, y luego lo abandonaron. El ARROW_MIGRATION_BLOCKER.md de FxDart registra la misma conclusión para Dart.
class Monad m where (>>=) :: m a → (a → m b) → m b es código ordinario, y cada instancia se comprueba contra él.F[_]), por lo que Cats puede definir Traverse[F[_]] una sola vez y obtener gratis todos los combinadores.Kind de arriba. Arrow 2.x la eliminó: la ergonomía era lo bastante mala como para que el equipo eligiera en su lugar tipos concretos más context receivers y un ámbito Raise — el diseño que FxDart porta.🎓 Qué se pierde de verdad. No expresividad — todo programa que puedes escribir con una abstracción HKT se puede escribir sin ella, a mano, por tipo. Lo que se pierde es la abstracción sobre la abstracción: un
traverseen vez de siete, unsequence, un único conjunto de leyes que probar una vez. En un lenguaje con HKT, un nuevo tipo de efecto llega ya equipado con toda la librería; en Dart llega vacío, y alguien tiene que rellenarlo. La diferencia es coste de mantenimiento de librería, no capacidad del programa — que es precisamente por qué es una decisión de diseño de lenguaje razonable y aun así irritante.
Toda abstracción de la Parte II que un lenguaje de orden superior escribe una vez, FxDart la escribe por tipo. En concreto, para una sola operación —el recorrido (traverse)— la librería ofrece:
import 'package:fxdart/fxdart.dart'; void main() { final xs = <Either<String, int>>[ Either.right(1), Either.left('bad'), Either.right(3), ]; // Four spellings of "swap the structures", because there is no // way to write one that works for every effect type. print(sequenceEither(xs)); print(flattenOrAccumulate(xs)); print(separateEither(xs)); print(fx(xs).sequence()); // …plus sequenceEitherAsync, flattenOrAccumulateAsync, // mapOrAccumulateAsync for the async chain.}Y la otra cara, para que el trato sea honesto: por ser concretas, son rápidas y sus tipos son exactos. sequenceEither devuelve Either<L, List<R>> — no Kind<F, List<R>>, ni un envoltorio que tengas que desmontar. La inferencia de Dart funciona, el editor completa, los errores señalan tu código. Una versión genérica en la codificación Kind devolvería algo que ningún lector podría usar sin un cast.
Casi nunca — hasta que sales a buscar el combinador genérico que «obviamente» debería existir. Este capítulo es la respuesta a esa búsqueda: no existe, no puede existir, y la versión concreta está ahí al lado.
Importa cuando diseñas una librería. Si te sorprendes intentando abstraer sobre «cualquier contenedor con un map», detente: en Dart, escribe las dos o tres versiones concretas y ponles buen nombre. La abstracción que persigues costará más de lo que devuelve.
También importa cuando lees Haskell o Scala en busca de ideas — que vale la pena hacer. Solo tradúcelo de forma estructural, no literal: sus definiciones genéricas de una línea se convierten en tus métodos concretos, y las leyes sobreviven la traducción aunque el polimorfismo no lo haga.
Map? ¿El de Map<String, dynamic>? ¿El de una hipotética interfaz Traverse?Kind de arriba a Either y escribe flatMap para ella. ¿Cuántos casts necesitas, y dónde explotaría uno incorrecto?Fx<T> tiene map, flatMap y terminales al estilo sequence. ¿Puedes escribir una función que acepte «cualquier tipo de FxDart con un map» sin usar dynamic ni un supertipo común? Explica la respuesta en términos de kinds.T extends Comparable<T>. ¿Por qué eso no es un contraejemplo de este capítulo?Map es * → * → * (dos argumentos); Map<String, dynamic> es *; Traverse sería (* → *) → * — toma un constructor de tipos y produce un tipo. Ese último kind es exactamente lo que Dart no puede escribir, y el paréntesis es donde el lenguaje se detiene.Kind<F, A> en EitherK<E, A>, otro sobre el resultado de f. Explotan en tiempo de ejecución, cuando alguien pasa un ListK a una instancia de mónada Either: el sistema de tipos nunca estuvo vigilando, porque ambos se borran a Kind<F, _>.* → * («algún F tal que F<A> tenga un map»), y los parámetros de Dart son todos de kind *. Los rodeos disponibles son exactamente los tres malos: dynamic, un supertipo compartido (que Fx y Either no tienen y no deberían tener), o la codificación con cast Kind.T extends Comparable<T> restringe un tipo completo — T sigue siendo kind *, y Comparable<T> es una cota sobre él, no un parámetro de orden superior. El polimorfismo F-acotado es una característica distinta que resuelve un problema distinto, y es una buena ilustración de que «lo bastante genérico para la mayoría del código» y «de orden superior» son ejes separados.En este capítulo
- descripciones frente a ejecuciones, y qué operadores son cuáles
- el modelo de coste: el trabajo es proporcional a lo que consumes, no a lo que escribes
- por qué la pereza no puede cambiar ninguna ley de la Parte II
- los dos peligros reales: efectos dentro de una tubería, y una fuente que solo se puede leer una vez
Escribe una cadena y no pasa nada:
import 'package:fxdart/fxdart.dart'; void main() { var calls = 0; final chain = fx([1, 2, 3, 4, 5]).map((n) { calls++; return n * 2; }); print('after building the chain: $calls calls'); print(chain.toList()); print('after consuming it: $calls calls');}map, filter, take, chunk, zip son perezosos — cada uno devuelve una nueva descripción con una etapa más. toList, each, fold, first, sum son terminales — tiran de los valores, y solo entonces se ejecuta algo.
La regla para distinguirlos es el tipo de retorno, y nunca miente: si te devuelve otro Fx, todavía no ha pasado nada.
Figura 11-1. La cadena discontinua es un plan: etapas conectadas entre sí, sin valores en movimiento. El operador terminal es el que tira, y un valor recorre toda la cadena antes de que empiece el siguiente.
Como el terminal decide cuánto tirar, el trabajo es proporcional a lo que consumes:
import 'package:fxdart/fxdart.dart'; void main() { var evaluated = 0; final result = fx(range(1, 1000000)) .map((n) { evaluated++; return n * n; }) .filter((n) => n.isOdd) .take(3) .toList(); print(result); print('elements evaluated: $evaluated of 999,999');}Cinco evaluaciones para tres resultados de entre un millón de candidatos. La versión ansiosa de ese programa construye una lista de un millón de elementos, luego la filtra en otra lista, y después descarta todo menos tres.
Esta es la diferencia que aparece en la propia suite de benchmarks de FxDart como los casos en los que la tubería gana a un bucle escrito a mano — el bucle suele ser más rápido por elemento, pero la cadena perezosa se niega a hacer el trabajo directamente. El capítulo 14 pone números a ambas direcciones.
De ese mismo modelo se desprenden dos consecuencias más:
first, any, find detienen el pull en cuanto tienen una respuesta, sin ningún soporte especial de las etapas anteriores.import 'package:fxdart/fxdart.dart'; void main() { // An endless cycle, consumed finitely. print(fx([1, 2, 3]).cycle().take(7).toList()); // `some` stops pulling at the first match. var checked = 0; final found = fx(range(1, 1000)).some((n) { checked++; return n > 4; }); print([found, checked]);}Esta es la parte que vale la pena decir con todas las letras, porque es lo que hace que fiarse de la pereza sea seguro. Cada ley de la Parte II es una ecuación entre valores: la ley de functor dice que m.map(f).map(g) es igual a m.map(g ∘ f), y que dos tuberías sean iguales significa que producen los mismos elementos en el mismo orden.
Cuándo ocurre la evaluación no forma parte de esa ecuación. Así que:
map es legal (ley de composición de functores);filter antes de un map es legal si el predicado no depende del mapeo — una precondición genuina, no un asunto de pereza;Esa última cláusula es toda la trampa, y es la cláusula del capítulo 2. En una tubería pura, la pereza es invisible salvo en la factura. En una impura, es visible en todas partes, porque cuándo ocurre un efecto es exactamente lo que un efecto te deja observar.
import 'package:fxdart/fxdart.dart'; void main() { final log = <String>[]; // Built, never consumed: the effect never happens. final unused = fx([1, 2, 3]).map((n) { log.add('mapped $n'); return n; }); print('log after building: $log'); // Same chain, consumed twice: the effect happens twice. final used = fx([1, 2]).map((n) { log.add('mapped $n'); return n; }); used.toList(); used.toList(); print('log after two pulls: $log'); print(unused.take(0).toList());}Las dos sorpresas son la misma sorpresa: una cadena perezosa es una receta, y las recetas se pueden cocinar cero veces o dos.
🎓 Perezoso, estricto, y lo que Haskell entiende por ello. Haskell es perezoso por defecto al nivel de cada expresión: un valor es un thunk hasta que se fuerza, lo que te da estructuras de datos infinitas y cláusulas
whereque no cuestan nada si no se usan — y fugas de memoria cuando una cadena de thunks crece sin forzarse. Dart es estricto; una tubería perezosa es la pereza recreada al nivel de las secuencias, y el mecanismo es un protocolo pull en vez de thunks. La diferencia práctica: obtienes los beneficios del cortocircuito y del streaming, no obtienes (ni tienes que depurar) una acumulación de thunks sin límite, yfx(xs).map(f)compuesto dos veces es un plan, mientras quelet y = f xya es un thunk.
Una tubería sobre una List se puede tirar repetidamente — una lista se puede releer. Una tubería sobre una fuente que se consume al leerla no puede:
import 'package:fxdart/fxdart.dart'; Iterable<int> readOnce() sync* { // A generator: iterating it again starts over, but a *stream* // or a socket would not — that is the shape to watch for. yield 1; yield 2;} void main() { final chain = fx(readOnce()).map((n) => n * 10); print(chain.toList()); print(chain.toList()); // fine here — the generator restarts // The rule that always holds: if you need the values twice, // materialise once and re-read the list. final materialised = chain.toList(); print([materialised.length, materialised.first]);}La guía es corta: consume una vez, o materializa. Si una cadena la usan dos consumidores, llama a toList() y comparte la lista, o usa fork/tee, que existen precisamente para dividir un pull en varios sin volver a ejecutar la fuente.
La pereza paga siempre que la tubería pudiera producir más de lo que necesitas: tomar los primeros N, buscar la primera coincidencia, transmitir un archivo que dejarás de leer, componer filtros cuya selectividad combinada es alta. También paga en memoria — un elemento en vuelo en vez de una lista por etapa.
Cuesta cuando todo se consume de todas formas y la fuente es pequeña: entonces el protocolo por elemento es sobrecarga frente a un bucle plano, y el capítulo 14 mide exactamente cuánto. Y cuesta en depurabilidad — una traza de pila dentro de una cadena perezosa muestra fotogramas de iterador, no tu tubería, que es el precio de la indirección.
fx(xs).map(f).toList() y xs.map(f).toList() hacen el mismo trabajo. ¿En qué punto empieza a ganar la versión de FxDart, y qué operador de la cadena lo causa?peek(print) antes de take(2) sobre una fuente de diez elementos. ¿Cuántas líneas se imprimen, y por qué?fx(range(1, 1000000)).map(expensive).first — ¿cuántas veces se ejecuta expensive? ¿Y si .first se sustituye por .last?take, first, some, find, o un filter que rechaza la mayoría de los elementos antes de un map costoso. Sin esa etapa, las dos hacen el mismo trabajo, y la versión ansiosa tiene menos maquinaria por elemento.take(2) deja de tirar después del segundo elemento, así que nunca se le pregunta a peek por los elementos tres en adelante — el pull es lo que impulsa la etapa anterior, y se detuvo.final xs = chain.toList(); y luego usa xs dos veces. Arreglo dos: usa fork/tee para dividir un pull en dos consumidores, de modo que la fuente se siga leyendo una sola vez..first — un pull le basta. Con .last, las 999.999 veces: last tiene que llegar al final, así que no queda nada que saltarse. La misma cadena, la misma pereza, coste opuesto, decidido por completo por el terminal.En este capítulo
- la dualidad:
IteratoryStreamdifieren en quién hace la llamada- lo que se desprende de ahí — backpressure, cancelación y tiempo
- por qué el modelo asíncrono de FxDart es un protocolo pull y no un
Stream- los puentes, y cómo elegir un lado para un problema dado
pull: consumer asks → producer answers iterator.moveNext()push: producer calls → consumer receives stream.listen(onData)
Esa es toda la diferencia, y todo lo demás en este capítulo es una consecuencia de ella. Una fuente pull es una función que llamas; una fuente push es un callback que registras.
Pull (Iterable, FxAsyncIterable) | Push (Stream) | |
|---|---|---|
| Marca el ritmo | el consumidor | el productor |
| Backpressure | gratis — basta con no preguntar | hay que organizarlo |
| Parar antes | dejar de tirar | cancelar una suscripción |
| Tiempo | no se modela | inherente |
| Encaje natural | colecciones, archivos, APIs paginadas | eventos de UI, sockets, timers |
Figura 12-1. Los mismos valores, flechas opuestas. En una cadena pull la petición viaja hacia arriba y el valor vuelve; en una cadena push el valor viaja hacia abajo y nadie más arriba está esperando permiso.
Formalmente son duales — uno es el espejo del otro con las flechas invertidas — por lo que los vocabularios de operadores se parecen tanto (map, filter, take, scan en ambos lados) y los modos de fallo son opuestos.
Si el consumidor es más lento que el productor, algo tiene que ceder.
En una cadena pull nada cede, porque la llamada next del consumidor es el reloj. Un consumidor lento simplemente pregunta con menos frecuencia, y el productor está inactivo entre medias:
import 'package:fxdart/fxdart.dart'; void main() async { var produced = 0; final source = fx(range(1, 1000)).map((n) { produced++; return n; }).toAsync(); // The consumer takes three and stops asking. final taken = await source.take(3).toList(); print(taken); print('produced: $produced'); // not 999}En una cadena push el productor sigue adelante de todas formas. El Stream de Dart maneja esto con pausa/reanudación para las fuentes que lo soportan, y con búferes para las que no — lo que convierte un desajuste de ritmo en crecimiento de memoria en vez de un error de compilación. El bug clásico es un stream de difusión con un listener lento: la cola crece, la latencia crece, y nada en los tipos lo decía.
StreamLas secuencias asíncronas de FxDart son FxAsyncIterable — un protocolo pull — porque su característica distintiva necesita que el consumidor esté al mando.
concurrent(n) le pide a la fuente anterior que evalúe n elementos a la vez. Esa petición tiene que viajar hacia atrás, del consumidor hacia la fuente, que es exactamente la dirección para la que un protocolo pull ya tiene una flecha. FxDart pasa un marcador a través de iterator.next(concurrent): el consumidor dice «dame el siguiente, y de paso, ejecuta n de estos en paralelo», y cada etapa anterior puede honrarlo o reenviarlo.
No hay forma de expresar eso sobre un Stream. Una fuente push ya está en marcha; el consumidor solo puede pedirle que se pause, no que se ensanche. Tendrías que inventar un canal lateral — que es lo que son los distintos operadores parallel en las librerías al estilo Rx — y luego conciliarlo a mano con el búfer y el orden.
import 'package:fxdart/fxdart.dart'; Future<String> fetch(String id) async { await Future.delayed(const Duration(milliseconds: 40)); return 'data-$id';} void main() async { final ids = ['a', 'b', 'c', 'd', 'e', 'f']; final sw = Stopwatch()..start(); // One at a time: six 40ms waits, serially. await fx(ids).toAsync().map(fetch).toList(); final serial = sw.elapsedMilliseconds; sw.reset(); // Three at a time, results still in source order. final out = await fx(ids).toAsync().map(fetch).concurrent(3).toList(); final concurrent = sw.elapsedMilliseconds; print(out.first); print('serial ~${serial}ms, concurrent(3) ~${concurrent}ms');}El protocolo pull es lo que hace que el segundo número sea aproximadamente un tercio del primero sin búfer, sin perder el orden, y sin una segunda API. El capítulo 13 trata sobre las garantías que vienen con él.
Ser una librería pull no significa ignorar push. Los programas reales tienen ambos — un evento de UI es genuinamente un push, una página de base de datos es genuinamente un pull — así que FxDart cruza en ambas direcciones:
import 'package:fxdart/fxdart.dart'; void main() async { // push → pull: a Stream becomes a pull chain. final ticks = Stream.fromIterable([1, 2, 3, 4, 5]); final doubled = await fxStream(ticks).map((n) => n * 2).take(3).toList(); print(doubled); // pull → push: a chain becomes a Stream for the framework. final asStream = fx([1, 2, 3]).toAsync().map((n) => n + 10).toStream(); print(await asStream.toList());}FxDart también incluye una capa explícitamente con forma push — fxEvents, con operadores al estilo Rx sobre Streams planos — para los problemas que genuinamente tratan sobre tiempo y difusión:
import 'package:fxdart/fxdart.dart'; void main() async { final clicks = Stream.fromIterable(['a', 'a', 'b', 'b', 'b', 'c']); // Push-side operators: same names, producer-driven semantics. final out = await fxEvents(clicks) .map((s) => s.toUpperCase()) .where((s) => s != 'B') .toList(); print(out);}La regla general para elegir: ¿quién decide cuándo existe el siguiente valor? Si la respuesta es «el mundo exterior», estás en el lado push y deberías quedarte ahí. Si la respuesta es «quien lo consuma», pull es más simple y te da backpressure gratis.
🎓 Dual, con precisión. Un iterador es
() → Option<(A, Iterator<A>)>— el consumidor lo aplica. Un observador es((A) → Unit) → Unit— el productor aplica tu callback. Invierte cada flecha de uno y obtienes el otro; en ese sentido sus diseñadores describieron Rx como «el dual deIEnumerable». La dualidad también predice qué operadores son difíciles en cada lado:zipes fácil en pull (pregunta a ambos, espera a ambos) y necesita búfer en push, mientras quedebouncees natural en push (trata sobre tiempo transcurrido) y no tiene sentido en pull, donde no pasa nada entre peticiones.
Pull, cuando los datos ya están ahí y decides tú el ritmo: colecciones, archivos, HTTP paginado, cursores de base de datos, cualquier cosa que podrías dejar de leer antes de tiempo, cualquier cosa donde «N a la vez» sea una política que quieres declarar.
Push, cuando los datos llegan estés listo o no: entrada de usuario, websockets, sensores, timers, y en cualquier sitio donde varios consumidores deban ver el mismo evento. Intentar modelar un flujo de clics como una secuencia pull significa escribir un búfer a mano, y mal.
El error que evitar es convertir hacia el otro lado solo para reutilizar un nombre de operador familiar. Cruza el puente cuando el problema cambia de forma, no cuando el vocabulario suena mejor.
take(3) sobre una cadena pull detiene al productor. ¿Cuál es el equivalente en un Stream, y qué pasa con los valores que ya estaban en vuelo?debounce no está disponible en una cadena pull? Describe qué significaría siquiera, y qué parte es incoherente.Stream tiene asBroadcastStream; las cadenas pull tienen fork/tee. Ambos dejan que dos consumidores vean una fuente. ¿Cuál es la diferencia esencial en lo que pasa cuando un consumidor es lento?subscription.cancel(). Los valores ya emitidos se pierden, y un valor que el productor estaba a mitad de calcular se termina y se descarta — el productor nunca estuvo esperando permiso, así que la cancelación es una petición, no una barrera. En una cadena pull, «parar» es simplemente la ausencia de la siguiente llamada, así que no hay nada en vuelo que descartar.debounce significa «emite solo si no llegó nada más en X». En una cadena pull nada llega por sí solo: el siguiente valor existe exactamente cuando lo pides, así que la ventana siempre estaría vacía y el operador degenera en map. Es el ejemplo más claro de un operador que trata sobre el ritmo del productor, algo que solo tiene push.FxAsyncIterable que pide una página cuando el consumidor agota la actual. Push: un Stream que pide páginas tan rápido como puede. «Parar tras la primera coincidencia» cuesta exactamente una petición en el modelo pull si la coincidencia está en la primera página; la versión push normalmente ya ha pedido varias páginas para entonces — la diferencia no tiene límite y crece con la latencia.asBroadcastStream da a cada listener los mismos eventos al ritmo del productor: un listener lento o hace búfer o descarta, y no puede frenar al productor. fork/tee dividen un pull, así que la fuente compartida avanza solo cuando ambos consumidores han preguntado — el consumidor lento retiene al rápido, que es el backpressure funcionando como está diseñado, y es la opción por defecto correcta cuando la corrección importa más que la vivacidad.En este capítulo
- la garantía: cuándo, no qué — y por qué eso la hace componible
- el canal trasero que lleva «ve n de ancho» hacia arriba
- la preservación del orden, y el coste de renunciar a ella
- las dos formas de equivocarse: compartir estado, y un fan-out sin límite
import 'package:fxdart/fxdart.dart'; Future<int> slowSquare(int n) async { await Future.delayed(const Duration(milliseconds: 40)); return n * n;} void main() async { final input = [1, 2, 3, 4, 5, 6]; final sw = Stopwatch()..start(); final serial = await fx(input).toAsync().map(slowSquare).toList(); final serialMs = sw.elapsedMilliseconds; sw.reset(); final wide = await fx(input) .toAsync() .map(slowSquare) .concurrent(3) .toList(); final wideMs = sw.elapsedMilliseconds; print(serial); print(wide); print('same result: ${serial.toString() == wide.toString()}'); print('serial ~${serialMs}ms, concurrent(3) ~${wideMs}ms');}Dos listas idénticas, una de ellas producida en un tercio del tiempo. Esa es la afirmación que hace concurrent(n), y merece la pena decirla con precisión:
concurrent(n)cambia cuándo se calculan los elementos. No cambia cuáles elementos se calculan, a qué valor se calculan, ni en qué orden llegan.
Todo lo que va después — folds, filtros, quien llama — no puede notar la diferencia salvo mirando un reloj. La concurrencia se añade como un efecto sobre la evaluación, no como un programa distinto.
Por eso compone. La asociatividad del capítulo 8 basta para un fold posterior, porque el orden se preserva; la independencia del capítulo 6 es lo que hace legal el solapamiento en primer lugar; y la pureza del capítulo 2 es lo que lo hace seguro. Cada parte de la torre aparece aquí como una precondición.
El capítulo 12 decía que un protocolo pull tiene una flecha que apunta hacia arriba. Esto es lo que FxDart envía por ella.
Un pull ordinario es «dame el siguiente elemento». El iterador asíncrono de FxDart toma un argumento: iterator.next(concurrent), donde concurrent es un marcador que lleva un ancho. Una etapa que lo recibe puede:
map no puede paralelizar nada por sí mismo, así que pasa la petición más arriba.La petición viaja por tanto del consumidor hacia la etapa que de verdad puede ensanchar, y los valores vuelven a bajar en orden.
Figura 13-1. concurrent(3) no es un búfer en medio de la cadena — es un mensaje que viaja hacia arriba hasta que algo puede actuar sobre él. Tres elementos están en vuelo; el consumidor sigue recibiendo 1, 2, 3.
Esta es también la razón de que el operador se coloque después de la etapa costosa en la cadena y aun así la afecte: el marcador sube. map(fetch).concurrent(3) se lee como «tráeme estos ya obtenidos, de tres en tres», que es exactamente lo que hace. mapConcurrent(3, fetch) es lo mismo precombinado.
Preservar el orden no es gratis: si el elemento 2 termina antes que el 1, su resultado espera. A cambio obtienes una secuencia que es igual a la serial, que es lo que te permite meter concurrent(n) en una tubería existente sin releer el resto.
Cuando de verdad no te importa, pide el orden de finalización y obtén los resultados antes:
import 'package:fxdart/fxdart.dart'; Future<String> job(String name, int ms) async { await Future.delayed(Duration(milliseconds: ms)); return name;} void main() async { final jobs = [('slow', 90), ('quick', 10), ('mid', 45)]; // Source order: 'slow' first, however long it takes. final ordered = await fx(jobs) .toAsync() .map((j) => job(j.$1, j.$2)) .concurrent(3) .toList(); print(ordered); // Completion order: whoever finishes first. final asDone = await fx(jobs) .toAsync() .map((j) => job(j.$1, j.$2)) .concurrentPool(3) .toList(); print(asDone);}concurrentPool es el nombre honesto para «estoy cambiando determinismo por latencia». Úsalo cuando cada resultado se maneja de forma independiente — escribir en un sink, actualizar una UI— y nunca cuando un paso posterior asume alineación posicional con la entrada.
🎓 Concurrencia no es paralelismo, y Dart lo hace literal. Todo esto ocurre en un solo isolate: un único hilo intercalando continuaciones mientras espera E/S. Nada de lo anterior hace más rápido el código ligado a CPU: seis cómputos de 40ms tardan 240ms con o sin
concurrent, porque no hay un segundo núcleo en juego. Lo que se solapa es la espera. Para paralelismo genuino necesitas isolates, que no pueden compartir estado mutable y por tanto convierten la pureza del capítulo 2 en un requisito mecánico en vez de una disciplina. Vale la pena mantener el vocabulario claro:concurrent(n)acota el trabajo en vuelo; los isolates compran núcleos.
Compartir estado mutable entre callbacks. Con concurrent(n), hay n callbacks en vuelo a la vez y su intercalado no está especificado. Un contador incrementado dentro de map está bien en un solo isolate (no hay apropiación entre sentencias), pero una lectura-modificación-escritura a través de un await no lo está:
import 'package:fxdart/fxdart.dart'; void main() async { var balance = 100; // Each callback reads, awaits, then writes — the read is stale // by the time the write happens. await fx([1, 2, 3]) .toAsync() .map((n) async { final read = balance; await Future.delayed(const Duration(milliseconds: 10)); balance = read - 10; return n; }) .concurrent(3) .toList(); print('balance: $balance (serial answer would be 70)');}El arreglo no es un lock; es no escribir ese código. Devuelve valores y pliégalos después — donde el orden sí está garantizado— en vez de mutar estado compartido dentro de una etapa concurrente.
Fan-out sin límite. Future.wait(items.map(fetch)) inicia todo: bien para diez elementos, una caída de servicio para diez mil. Todo el sentido de un parámetro de ancho es que el ancho lo eliges tú, y el número correcto viene de los límites del lado remoto, no de la longitud de tu lista.
Cualquier tubería cuyo trabajo por elemento sea E/S: peticiones HTTP, lecturas de archivo, idas y vueltas a una base de datos. La ganancia es aproximadamente el ancho, hasta el punto en que el lado remoto se convierte en el cuello de botella — y la medición del primer listado es la que hay que repetir contra tu propio servicio en vez de confiar en la proporción.
No hace nada por el trabajo ligado a CPU en un solo isolate, y activamente perjudica cuando la fuente es barata y corta: tres futures extra para calcular seis cuadrados es sobrecarga. Como con la pereza, el modelo te dice dónde está la ganancia — esperar, no calcular.
concurrent(3) tardaron ~90ms. Predice el tiempo con concurrent(6) y con concurrent(2), y luego ejecútalo.concurrent(n) colocado después de map(fetch) afecta a fetch en absoluto? Responde en términos de la dirección de la petición..chunk(10) posterior sigue a un concurrentPool(4). ¿Qué se rompe, y tendría concurrent(4) el mismo problema?concurrent(6) debería ser más o menos una ronda — ~45ms — porque las seis esperas se solapan. concurrent(2) tarda tres rondas, ~130ms. El patrón es ceil(items / n) × latencia, que es la fórmula que vale la pena recordar al elegir un ancho.concurrent(3) no procesa los valores que le llegan; le pide a su fuente tres a la vez, y esa fuente es la etapa map(fetch), que inicia tres fetches. En un modelo push no habría nada que pedir — los fetches ya estarían corriendo..map((n) async { …; return -10; }).concurrent(3) y luego fold(100, (a, d) => a + d). El estado se movió fuera de la región concurrente hacia la ordenada — que es el arreglo general, y la razón de que fold se ejecute después de la tubería y no dentro de ella.chunk agrupará con gusto lo que sea que llegue— pero los trozos ya no corresponden a posiciones de entrada, así que cualquier código que asuma «el trozo 0 son las primeras diez entradas» ahora está equivocado. Con concurrent(4) la correspondencia se mantiene, porque el orden se preserva. Este es el coste concreto de cambiar determinismo por latencia.En este capítulo
- la forma medida del trato, a través de 53 tareas reales
- los dos mecanismos que hacen más lenta una tubería, y el que la hace más rápida
- por qué «asignaciones» es la respuesta a casi toda pregunta de rendimiento aquí
- cómo decidir, para tu código, sin fiarte de la proporción de nadie
FxDart incluye una suite de benchmarks que compara cada uno de sus 53 ejemplos lado a lado contra una versión de Dart escrita a mano de la misma tarea, compilada AOT, mediana de ejecuciones repetidas. A la escala mayor de cada caso:
| Resultado a la escala titular | Casos |
|---|---|
| Empate (dentro del 5% o 0,6ms) | 38 |
| Dart escrito a mano más rápido | 12 |
| FxDart más rápido | 3 |
Proporción mediana entre los 53: 1,06× — la tubería es típicamente un seis por ciento más lenta, y dentro de la banda de ruido más a menudo que no.
Los extremos son más interesantes que la mediana:
| Caso | Proporción | Qué significa |
|---|---|---|
top-expenses | 0,27× | tubería casi 4× más rápida |
price-drop-detection | 0,52× | tubería 2× más rápida |
smoothed-zone-changes | 2,23× | tubería 2,2× más lenta |
anomaly-context | 1,76× | tubería 1,8× más lenta |
Misma librería, misma máquina, una diferencia de ocho veces entre el mejor y el peor. Cualquier frase de la forma «la PF es n veces más lenta en Dart» es por tanto falsa; el número depende por completo de cuál de tres mecanismos domina.
Un bucle escrito a mano lee un elemento y aplica tu código en línea. Una tubería envía cada elemento a través de un iterador por etapa: un moveNext virtual, un getter current, una llamada a closure. El compilador AOT de Dart en línea mucho de eso, pero no a través de una frontera de iterador polimórfico, y el coste se paga por elemento y por etapa.
Por eso los perdedores de arriba son los capítulos con muchas etapas baratas sobre datos uniformes: suavizado con ventana deslizante, comparaciones adyacentes, trabajo numérico pequeño. La sobrecarga por elemento es fija, el trabajo útil por elemento es diminuto, así que la proporción es mala.
import 'package:fxdart/fxdart.dart'; void main() { final data = List.generate(200000, (i) => i); final sw = Stopwatch()..start(); var loop = 0; for (final n in data) { if (n.isEven) loop += n * 2; } final loopMs = sw.elapsedMicroseconds; sw.reset(); final piped = fx(data).filter((n) => n.isEven).map((n) => n * 2).sum(); final pipeMs = sw.elapsedMicroseconds; print([loop, piped, loop == piped]); print('loop ${loopMs}us, pipeline ${pipeMs}us'); // In the browser this is JIT-compiled JS, so treat the ratio // as indicative; the book's table is AOT.}La mayoría de las diferencias grandes de la suite no son de CPU, son basura. La versión escrita a mano de una tarea de agrupación construye List y Map intermedios; la versión con tubería puede no construir ninguno, o puede construir un record por elemento. Qué lado asigna más depende de la tarea, y la asignación domina el tiempo cada vez que difiere.
Este es el diagnóstico más útil de todos: cuenta las asignaciones en ambos lados. Si son iguales, las dos versiones estarán dentro del ruido la una de la otra. Si un lado materializa una colección intermedia que el otro nunca construye, ese lado pierde sin importar cuán ajustado sea su bucle.
El modelo de coste del capítulo 11, apareciendo en el marcador. top-expenses es 3,7× más rápido en la versión con tubería no porque la tubería sea veloz, sino porque nunca ordena la lista entera: toma lo que necesita y para, mientras que la versión nativa ordena 10.000 elementos para leer los cinco primeros.
Cada una de las tres victorias de FxDart es este mecanismo. Cuando una tarea tiene la forma «la mayoría de estos datos no importa», la pereza gana a un bucle más rápido que hace todo el trabajo de todas formas.
Figura 14-1. Tres fuerzas independientes. La indirección es un impuesto fijo por elemento y por etapa; la asignación es cualquiera que sea el lado que construya más intermedios; el trabajo rechazado es el reembolso de la tubería cuando un terminal se detiene pronto.
Las propias reglas de la suite, dignas de copiar:
🎓 La notación O grande no cambia; las constantes sí. Nada de esto afecta la complejidad asintótica: un
filter+map+foldperezoso es O(n) exactamente igual que el bucle, ytop-expenseses más rápido porque la pereza cambia el algoritmo (selección parcial en lugar de una ordenación completa), no porque mejorara la constante. Cuando encuentres una gran ganancia, pregunta cuál de las dos fue — una ganancia de factor constante de 30% es un resultado de ajuste fino, una ganancia asintótica es un resultado de diseño, y solo la segunda sobrevive a un cambio de tamaño de entrada.
take, first, find, o any con salida temprana sobre una fuente grande es una razón para esperar que la tubería gane.Escribe el bucle for cuando el código está en una ruta caliente, las etapas son baratas, y la fuente se consume por completo — esa es precisamente la forma perdedora. Escríbelo también cuando la operación es genuinamente imperativa: mutar un búfer, rellenar una lista pre-dimensionada, conducir un algoritmo basado en índices. El capítulo 22 recoge el resto de estos casos; la contribución de este capítulo es que ahora puedes predecir la respuesta en lugar de adivinar, y comprobarla en diez minutos.
.first. ¿Cuál de los tres mecanismos domina, y cuál es la proporción esperada frente a un bucle que hace el mismo trabajo?smoothed-zone-changes es 2,2× más lenta como tubería. Antes de mirarla, predice qué dos características de la tarea causan eso, basándote en este capítulo..first tira de un elemento a través de cinco etapas, así que la tubería hace aproximadamente cinco llamadas a closure de trabajo mientras que la versión con bucle — si está escrita de forma ingenua — procesa la lista entera. La proporción esperada es enorme y a favor de la tubería; si el bucle también sale pronto, los dos convergen a un empate más la sobrecarga fija por etapa de la tubería.En este capítulo
- el mecanismo: qué hace realmente
r.bindcuando falla- continuaciones delimitadas frente a azúcar sintáctico monádico, y por qué Dart forzó la elección
- la regla de la fuga — la única forma de usar mal un ámbito, y cómo la librería la detecta
- qué ganas y qué pierdes frente a cadenas de
flatMap
eitherEl capítulo 7 usó el ámbito y no lo abrió. Esta es la forma:
import 'package:fxdart/fxdart.dart'; Either<String, int> half(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd: $n'); void main() { final result = either<String, int>((r) { final a = r.bind(half(20)); // 10 final b = r.bind(half(a)); // 5 final c = r.bind(half(b)); // odd → exits here return c * 100; // never reached }); print(result);}either ejecuta tu bloque con un objeto Raise<E>. r.bind mira un Either: en un Right devuelve el valor, en un Left abandona el bloque por completo y hace que either devuelva ese Left. Sin pirámide, sin flatMap, y el bloque se lee de arriba abajo.
El mecanismo es un escape de flujo de control: raise lanza un marcador privado que either captura en la frontera y convierte en un Left. Como el throw y el catch están ambos dentro de la librería, el escape es delimitado — solo puede viajar hasta el either que lo envuelve, y no más allá.
Figura 15-1. Cada bind es una posible salida, y cada salida aterriza en el mismo lugar: la frontera del ámbito que creó r. Esa frontera es lo que convierte un salto de flujo de control de vuelta en un valor ordinario.
El for de Scala y el do de Haskell son reescrituras: el compilador convierte el bloque en llamadas a flatMap antes de comprobar tipos. Eso funciona para cualquier mónada, y necesita tipos de orden superior para decir «cualquier mónada» — que el capítulo 10 explicó que Dart no tiene.
El ámbito de FxDart no es una reescritura. Nada se transforma; se pasa un objeto real, y el control sale del bloque mediante un mecanismo que el lenguaje ya tiene. El trato es exacto:
Azúcar sintáctico (do, for) | Ámbito (either, Raise de Arrow) | |
|---|---|---|
| Funciona para | cualquier mónada que los tipos puedan nombrar | los efectos que la librería escribió |
| Necesita | tipos de orden superior | nada especial |
| Salida de fallo | devolver un valor cortocircuitado | salto no local, capturado en la frontera |
Compone con async | necesita un transformador | de forma natural — eitherAsync |
| Extensible por ti | sí, definiendo una mónada | no |
Las dos últimas filas son la razón de que la elección sea defendible en vez de meramente forzada. Una torre de transformadores (EitherT[Future, E, A]) es la respuesta general, y es genuinamente difícil de leer; el ámbito maneja la única combinación que la gente realmente escribe — fallo dentro de async — sin ningún tipo nuevo:
import 'package:fxdart/fxdart.dart'; Future<Either<String, int>> lookup(String key) async { await Future.delayed(const Duration(milliseconds: 10)); return key == 'port' ? Either.right(8080) : Either.left('missing: $key');} void main() async { final ok = await eitherAsync<String, String>((r) async { final port = r.bind(await lookup('port')); final host = r.bind(await lookup('port')); return 'http://$host:$port'; }); print(ok); final bad = await eitherAsync<String, String>((r) async { final port = r.bind(await lookup('nope')); return 'never: $port'; }); print(bad);}await secuencia el tiempo, r.bind secuencia el fallo, y los dos no son conscientes el uno del otro. Ese es todo el beneficio.
FxDart incluye un ámbito por cada representación de fallo, porque — capítulo 10 otra vez — no hay forma de escribir uno que las cubra todas:
import 'package:fxdart/fxdart.dart'; int? parseTeen(String s) { final n = int.tryParse(s); return (n != null && n >= 13 && n <= 19) ? n : null;} void main() { // Failure as a typed value. print(either<String, int>((r) { final n = r.ensureNotNull( parseTeen('15'), () => 'not a teen'); return n * 2; })); // Failure as null — no error value to carry. print(nullable((r) { final n = r.bind(parseTeen('15')); return n * 2; })); print(nullable((r) { final n = r.bind(parseTeen('42')); return n * 2; })); // Failure as a thrown exception, handled at the boundary. print(catching<int>(() => int.parse('nope'), (e, _) => -1)); // …or converted straight into a Left. print(eitherCatching<String, int>( (r) => int.parse('nope'), (e, _) => 'not a number'));}Tres ámbitos, una idea: ejecutar código lineal, salir en el primer fallo, convertir la salida en lo que sea que el tipo de quien llama diga.
Hay exactamente una forma de usar mal un ámbito, y se sigue del mecanismo: r solo puede usarse mientras su ámbito está en ejecución. Captúralo en un closure que sobreviva al bloque, y su escape no tiene dónde aterrizar.
import 'package:fxdart/fxdart.dart'; void main() { late Raise<String> escaped; final result = either<String, int>((r) { escaped = r; // capturing the scope object… return 1; }); print(result); try { escaped.raise('too late'); // …and using it after it closed } catch (e) { print('caught: ${e.runtimeType}'); }}La librería lo detecta y lanza RaiseLeakedError en vez de dejar que un salto de flujo de control perdido escape a código no relacionado. En la práctica la regla muerde en un solo lugar: no uses r dentro de un callback que se ejecuta más tarde — un future sin await, un timer, un listener de stream. Dentro de eitherAsync, quédate en la cadena esperada; esa es la misma regla enunciada para async.
🎓 Esta es una idea antigua con un nombre nuevo. Una continuación delimitada captura «el resto del cómputo hasta una frontera» y te deja abandonarlo o reanudarlo;
shift/reseten Scheme,Conten Haskell, los manejadores de efectos algebraicos en OCaml 5 y Koka son todos esta maquinaria.Raiseusa solo la mitad de abandono, que es por qué puede implementarse con una excepción privada en vez de una captura real de la pila. Esa restricción es también lo que lo hace barato y predecible: sin reentrada, sin reanudación, sin re-ejecución sorprendente — una salida, una frontera, un valor.
Tres o más pasos falibles que comparten un tipo de error, especialmente con retornos anticipados y condiciones de guarda entre medias — r.ensure(cond, () => err) reemplaza el if (!cond) return Left(...) que una cadena de flatMap no puede expresar sin otro nivel de anidación.
Es la herramienta equivocada para validaciones independientes (capítulo 6 — quieres acumulación), para una única llamada falible (devuelve el Either directamente), y en cualquier lugar donde el bloque le pasa r a código que se ejecutará más tarde, que es lo que la regla de la fuga prohíbe.
flatMap. ¿Qué versión hace más fácil añadir una guarda — «falla si el valor cae por debajo de 3» — entre pasos?either cuando el bloque lanza una excepción genuina en vez de hacer raise? Pruébalo, y explica por qué ese es el comportamiento por defecto correcto.r.recover(...) que continúe el bloque después de un fallo? Responde en términos del mecanismo.nullable no tiene ningún valor de error. ¿Cuál es su tipo E, y qué te dice eso sobre la relación entre Either<E, A> y A??half(20).flatMap(half).flatMap(half).map((c) => c * 100). Añadir una guarda significa insertar un flatMap((v) => v < 3 ? Left(...) : Right(v)) — un nuevo nivel de anidación y una nueva lambda — donde la versión de ámbito añade una línea: r.ensure(a >= 3, () => 'too small'). Las guardas son donde el ámbito toma una ventaja decisiva.either sin cambios. Eso es correcto porque una excepción lanzada significa «pasó algo que este tipo de error no describe» — convertirla silenciosamente en un Left lavaría un bug hasta convertirlo en un fallo de dominio. eitherCatching existe para cuando sí quieres la conversión, y es una función separada precisamente para que la elección sea explícita. El capítulo 18 desarrolla esta frontera.either ve el fallo, los marcos de pila del bloque ya se han desenrollado y sus variables locales han desaparecido. Reanudar requeriría capturar la continuación antes del desenrollado, que es la mitad de las continuaciones delimitadas que Raise deliberadamente no implementa. La recuperación por tanto ocurre fuera, sobre el Either devuelto — result.fold(...) o getOrElse.E es efectivamente void/Null — no hay nada que llevar. A? es Either<Unit, A> con el lado de fallo sin llevar información, así que todo cómputo anulable es un Either que ha olvidado por qué falló. Ese es el trato que el capítulo 18 examina: la nulabilidad es gratis y muda, los errores tipados cuestan un parámetro de tipo y pueden decirte qué salió mal.En este capítulo
- la imagen de las dos vías, y qué operaciones se mueven entre ellas
- mapear el lado del fallo, y por qué los tipos de error necesitan eso para componer
- recuperación:
fold,getOrElse, y dónde un programa deja de ser totalEitherdentro de una tubería —sequence,separate, y la forma de una importación real
Dibuja una vía de éxito y una vía de fallo corriendo lado a lado. Cada paso falible es un cambio de agujas: o bien continúa por la vía de éxito o se desvía, una vez, hacia la vía de fallo — donde se queda.
Figura 16-1. map solo corre por la vía verde. flatMap es el cambio de agujas. mapLeft es lo único que toca la vía roja, y nada se reúne sin un fold explícito.
Esa imagen es toda la semántica:
| Operación | Vía verde (Right) | Vía roja (Left) |
|---|---|---|
map(f) | aplica f | pasa de largo |
flatMap(f) | aplica f, que puede desviar | pasa de largo |
mapLeft(g) | pasa de largo | aplica g |
fold(l, r) | aplica r | aplica l |
import 'package:fxdart/fxdart.dart'; Either<String, int> parseQty(String s) { final n = int.tryParse(s); return n == null ? Either.left('not a number') : Either.right(n);} void main() { final ok = parseQty('12'); final bad = parseQty('twelve'); print([ok.map((n) => n * 2), bad.map((n) => n * 2)]); print(ok.mapLeft((e) => 'qty: $e')); print(bad.mapLeft((e) => 'qty: $e')); print(bad.fold((e) => 'failed — $e', (n) => 'got $n'));}Una vez en la vía roja un valor está inerte: cada map y flatMap subsiguiente es un no-op. Eso es cortocircuitar, y no necesita ningún soporte especial en ningún lugar aguas abajo — por lo que puedes añadir un paso en medio de una cadena sin tocar el resto.
Dos pasos con distintos tipos de error no encadenan, y aquí es donde el código real suele atascarse:
import 'package:fxdart/fxdart.dart'; class ParseError { const ParseError(this.input); final String input; @override String toString() => 'ParseError($input)';} class RangeError2 { const RangeError2(this.value); final int value; @override String toString() => 'RangeError2($value)';} // A common error type for the pipeline to speak.sealed class OrderError { const OrderError();} class BadInput extends OrderError { const BadInput(this.detail); final String detail; @override String toString() => 'BadInput($detail)';} Either<ParseError, int> parse(String s) { final n = int.tryParse(s); return n == null ? Either.left(ParseError(s)) : Either.right(n);} Either<RangeError2, int> inStock(int n) => n <= 5 ? Either.right(n) : Either.left(RangeError2(n)); void main() { // mapLeft lifts both into the pipeline's own error type. Either<OrderError, int> order(String raw) => either((r) { final n = r.bind( parse(raw).mapLeft((e) => BadInput('$e'))); final ok = r.bind( inStock(n).mapLeft((e) => BadInput('$e'))); return ok; }); print(order('3')); print(order('nine')); print(order('9'));}mapLeft es lo que hace que un tipo de fallo sea local: cada módulo puede lanzar el error que conoce, y quien llama traduce en la frontera. Sin él acabas con un enum-dios de cada error del programa, que es el equivalente en errores tipados de capturar Exception.
Un tipo de error sealed (capítulo 3) da sus frutos aquí: un switch sobre OrderError en lo alto del programa es exhaustivo, así que añadir un caso es un error de compilación en cada manejador.
Una vía de tren solo es útil si las vías finalmente se reúnen en algo que quien llama pueda usar. Esa reunión es fold, y es el punto donde debes decidir qué significa el fallo:
import 'package:fxdart/fxdart.dart'; Either<String, int> configPort(String? raw) => raw == null ? Either.left('missing') : (int.tryParse(raw) == null ? Either.left('not a number: $raw') : Either.right(int.parse(raw))); void main() { // Substitute a default — the failure was recoverable. print(configPort(null).fold((_) => 8080, (n) => n)); // Keep the reason and report it — the failure was not. print(configPort('x') .fold((e) => 'config error: $e', (n) => '$n')); // Switch on it, exhaustively, when the type is sealed. final result = configPort('9000'); final message = switch (result) { Left(:final value) => 'no port ($value)', Right(:final value) => 'port $value', }; print(message);}Tres finales, una regla: el programa vuelve a ser total en el fold. Antes de él, un fallo es dato fluyendo por una vía; después, se ha tomado una decisión. Empujar ese punto lo más tarde posible — al manejador HTTP, a la UI, al código de salida de la CLI — es el hábito más útil que ofrece este capítulo.
El trabajo real tiene muchas filas, y los recorridos del capítulo 9 son cómo la vía de tren escala más allá de un solo valor:
import 'package:fxdart/fxdart.dart'; Either<String, int> parseRow(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad row: $s') : Either.right(n);} void main() { final rows = ['10', 'x', '30', 'y']; // All or nothing. print(fx(rows).map(parseRow).sequence()); // Everything that failed, and everything that did not. final (errors, values) = separateEither(rows.map(parseRow)); print('imported ${values.length}, rejected: $errors'); // Keep going, but report every reason at the end. print(fx(rows).map(parseRow).flattenOrAccumulate());}Tres políticas, un solo parser. Esa separación — la función por fila no sabe nada de la política, la tubería la elige — es lo que compra la forma de dos vías a escala.
🎓 Programación orientada a vía de tren, y dónde la metáfora hace agua. La charla de Scott Wlaschin sobre «railway-oriented programming» es donde la mayoría se encuentra con esta imagen, y es buena — pero describe
Eitherusado monádicamente, que es solo la mitad de la historia. La imagen no tiene forma de dibujar dos trenes que chocaron ambos (la acumulación del capítulo 6), y sugiere que los fallos son descarrilamientos raros cuando en la mayoría de los sistemas son resultados ordinarios con su propia lógica. Conserva la imagen para secuenciar y déjala cuando necesites combinar resultados independientes.
Fallos de dominio sobre los que quien llama puede actuar: validación, parseo, autorización, reglas de negocio, cualquier cosa que de otro modo expresarías como un retorno anulable más un comentario. Da sus frutos sobre todo donde los fallos deben llevar información — qué regla, qué campo, qué id.
No da sus frutos para fallos sobre los que nadie puede actuar (falta de memoria, un bug), para una única llamada cuyo único modo de fallo es «no encontrado» (A? es más pequeño), o para tipos de error que todavía no sabes nombrar — un Either<String, T> donde la cadena se construye por interpolación es una excepción disfrazada de tipo con pasos extra.
map sobre un Left no hace nada. ¿Qué ley del functor obliga a eso, y qué se rompería si una librería «útilmente» ejecutara la función de todas formas?getOrElse para Either en términos de fold. Luego escribe orElse, que toma un Either de respaldo en vez de un valor de respaldo.Either<A, T> de un módulo y Either<B, T> de otro, y quien llama quiere Either<C, T>. Esboza las tres llamadas a mapLeft y di dónde pertenecen en una aplicación por capas.separateEither devuelve (errors, values). ¿Por qué ese orden, y qué consecuencia tiene la elección para leer código de un vistazo?left.map(id) debe ser igual a left. Si map ejecutara f sobre el valor de fallo tendría que poner el resultado en algún lugar — cambiando el tipo del Left o su contenido — así que mapear la identidad ya no sería un no-op. Lo que se rompe en concreto es la composición: map(f).map(g) aplicaría ambas funciones a un error para el que ninguna fue escrita, normalmente colapsando dentro de código que asumía un valor de éxito.T getOrElse<T>(Either<Object?, T> e, T fallback) => e.fold((_) => fallback, (v) => v); y Either<E, T> orElse<E, T>(Either<E, T> e, Either<E, T> other) => e.fold((_) => other, (_) => e);. El segundo es el semigrupo sobre Either que conserva el primer éxito — un monoide si tienes un fallo identidad, que normalmente no tienes.moduleA().mapLeft(toC) y moduleB().mapLeft(toC), ambas en la costura donde los dos módulos se encuentran — típicamente la capa de caso de uso o de servicio, no dentro de ningún módulo y no en la frontera HTTP. Traducir demasiado pronto acopla el módulo al vocabulario de quien llama; demasiado tarde significa el enum-dios.(Left, Right) — el mismo orden que los parámetros de tipo y que los brazos del switch, así que nada en el código base pregunta jamás «¿cuál va primero?». La consistencia aquí vale más que cualquier argumento sobre cuál es más importante: un lector que tiene que comprobar el orden una vez tendrá que comprobarlo cada vez.En este capítulo
- la pregunta de producto que decide entre fallo rápido y fallo lento
- las cuatro herramientas, y cuál encaja con qué forma de validación
- reglas independientes y dependientes, y por qué mezclarlas mal es el bug clásico
- una validación de formulario completa, desde cadenas en bruto hasta un tipo de dominio
El capítulo 6 estableció que solo la forma aplicativa puede acumular. Este capítulo trata de cuándo debería, y la prueba no tiene nada que ver con tipos:
¿Va a leer un humano estos errores y arreglarlos en una sola pasada?
Si sí — un formulario, un archivo de configuración, una importación, el cuerpo de una petición de API — recógelos todos. Decirle a alguien que su código postal está mal, esperar un viaje de ida y vuelta, y luego decirle que su número de teléfono está mal es un mal producto, no un mal programa.
Si no — una cadena de pasos internos, una comprobación de autorización, cualquier cosa donde el segundo fallo es una consecuencia del primero — falla rápido. Diez errores en cascada de una sola causa raíz son ruido, y esconden el que importaba.
import 'package:fxdart/fxdart.dart'; Either<String, int> parseAge(String s) { final n = int.tryParse(s); return n == null ? Either.left('age: not a number') : Either.right(n);} void main() { final raw = ['31', 'x', '44', 'y']; // 1. zipOrAccumulate2..5 — a fixed set of independent branches. print(either<Nel<String>, String>((r) => r.zipOrAccumulate2( (br) { if (raw[1] != '0') br.raise('second must be 0'); return raw[1]; }, (br) { if (raw[3] != '0') br.raise('fourth must be 0'); return raw[3]; }, (a, b) => '$a/$b', ))); // 2. mapOrAccumulate — the same rule over many items. print(mapOrAccumulate( (r, String s) => r.bind(parseAge(s)), raw)); // 3. flattenOrAccumulate — you already have the Eithers. print(fx(raw).map(parseAge).flattenOrAccumulate()); // 4. accumulate — the general scope, any number of branches, // and the only one that supports dependent rules. print(either<Nel<String>, int>((r) => r.accumulate((acc) { final first = acc.accumulating( (br) => br.bind(parseAge(raw[0]))); final third = acc.accumulating( (br) => br.bind(parseAge(raw[2]))); return first.value + third.value; })));}Elegir entre ellas es mecánico:
| Forma | Herramienta |
|---|---|
| 2–5 campos nombrados e independientes | zipOrAccumulate2..5 |
| Una regla, muchos elementos | mapOrAccumulate |
Ya tienes Eithers | flattenOrAccumulate / .flattenOrAccumulate() |
| Más de cinco ramas, o reglas dependientes | accumulate |
La regla que hace correcta la acumulación es la distinción del capítulo 6, y tiene una forma de API precisa:
acc.accumulating(...) — una rama independiente. Siempre se ejecuta; sus fallos se registran en vez de propagarse.acc.dependent(...) — una regla dependiente. Solo se ejecuta cuando nada ha fallado todavía, porque lee el valor de otra rama.Leer un Accumulated.value de una rama que falló detona a propósito: lanza toda la lista acumulada de una vez. Eso es lo que hace seguro el return final — para cuando combinas valores, o bien todas las ramas tuvieron éxito o nunca llegas ahí.
Figura 17-1. Las ramas independientes se ejecutan todas y dejan caer sus fallos en un mismo cubo. Las reglas dependientes están aguas abajo de ese cubo: se ejecutan solo si está vacío, porque leen valores que podrían no existir.
import 'package:fxdart/fxdart.dart'; class Signup { const Signup(this.email, this.age, this.plan); final String email; final int age; final String plan; @override String toString() => 'Signup($email, $age, $plan)';} Either<Nel<String>, Signup> validate(Map<String, String> form) => either((r) => r.accumulate((acc) { final email = acc.accumulating((br) { final v = form['email'] ?? ''; if (!v.contains('@')) { br.raise('email: must contain @'); } return v; }); final age = acc.accumulating((br) { final n = int.tryParse(form['age'] ?? ''); if (n == null) br.raise('age: not a number'); if (n != null && n < 18) br.raise('age: must be 18+'); return n ?? 0; }); final plan = acc.accumulating((br) { final v = form['plan'] ?? ''; if (v != 'free' && v != 'pro') { br.raise('plan: unknown "$v"'); } return v; }); // Dependent: only meaningful once age and plan parsed. acc.dependent((br) { if (plan.value == 'pro' && age.value < 21) { br.raise('plan: pro requires 21+'); } return null; }); return Signup(email.value, age.value, plan.value); })); void main() { print(validate( {'email': 'a@b.co', 'age': '30', 'plan': 'pro'})); print(validate( {'email': 'nope', 'age': 'x', 'plan': 'gold'})); print(validate( {'email': 'a@b.co', 'age': '19', 'plan': 'pro'}));}Tres formas de respuesta de una sola función: un valor, cada problema independiente a la vez, y una regla dependiente que solo habla cuando existen los valores que necesita.
Fíjate en que el segundo caso reporta tres errores de tres campos, y el tercero reporta uno — la regla dependiente — porque todas las ramas independientes pasaron. Ese es el comportamiento que un usuario espera y la razón por la que existe esta maquinaria.
age arriba hace raise hasta dos veces..value dentro de una rama independiente. Para eso está dependent, y leerlo pronto detona todo el ámbito.dependent o a un ámbito de fallo rápido, no a una rama.🎓 Por qué no hay un tipo
Validated. Arrow 1.x tenía uno — unValidated<E, A>separado cuyo aplicativo acumulaba y que convertías hacia y desdeEitheren cada frontera. Arrow 2.x lo eliminó, y FxDart nunca lo tuvo: el mismo efecto está disponible como un ámbito sobreEither<Nel<E>, A>, lo que significa un solo tipo de resultado en las firmas de tu dominio en vez de dos, y ninguna llamada atoEither()esparcida por el código. La teoría no perdió nada —Validatedsiempre fue soloEithercon una instancia de aplicativo distinta, y dado que Dart no puede seleccionar instancias por tipo de todas formas (capítulo 10), nombrar el comportamiento en el sitio de la llamada es estrictamente más honesto.
Entrada de cara al usuario de cualquier tipo; importaciones por lotes donde un reporte parcial ahorra otra ejecución; configuración, donde cada clave que falta debería reportarse antes de que el proceso salga; cargas útiles de API, donde un 400 que lista todas las violaciones vale por cinco que listan una cada uno.
Cuesta donde los fallos son baratos de redescubrir (un reintento local rápido), donde los errores son para máquinas en vez de humanos (un código basta), y donde ejecutar cada rama es caro — la acumulación significa sin cortocircuito, así que cinco comprobaciones independientes lentas se ejecutan todas incluso cuando la primera ya falló.
dependent a accumulating y predice la salida para {'age': 'x', 'plan': 'pro'}.mapOrAccumulate sobre 10.000 filas recoge cada fallo. ¿Cuál es la forma de memoria de eso, y qué harías de forma distinta para una importación de 10M de filas?Nel<String> y no List<String>? Da el estado que List admite y Nel prohíbe.accumulating o dependent, y qué cambia si dos ramas llaman a la misma API?age.value de una rama fallida, y detonaría — lanzando los errores acumulados desde dentro de la rama en vez de al final del ámbito. La salida sigue siendo un Left con el error de parseo, pero el mecanismo es una salida temprana en vez de una acumulación limpia, y una regla que necesitara ejecutarse después se saltaría. dependent existe para hacer esto imposible por construcción.List<String> admite Left([]) — «esto falló, y no hay motivos» — que es el tipo exacto de estado sin sentido representable del que trata el capítulo 3. Nel hace la garantía estructural: un fallo siempre lleva al menos un motivo, así que ningún consumidor necesita una rama «¿sin errores?».accumulating, si la llamada es independiente — quieres que su fallo se reporte junto a los demás. Si dos ramas llaman a la misma API, se ejecutan secuencialmente dentro del ámbito y pagas dos veces; eleva la llamada por encima del ámbito, pasa el resultado, y mantén las ramas puras. Eso también hace la validación testeable sin red, que es el argumento del capítulo 2 llegando de nuevo desde otra dirección.En este capítulo
- los tres canales de fallo de Dart, y qué puede y no puede decir cada uno
- la regla para elegir: ¿puede quien llama hacer algo al respecto?
- por qué
Eithernunca deja un programa libre de excepciones, y por qué eso está bien- convertir en los bordes, en ambas direcciones
Dart deja que una función falle de tres maneras, y no son intercambiables.
| Canal | El tipo dice | Quien llama debe | Lleva |
|---|---|---|---|
throw | nada | nada (sin comprobar) | cualquier objeto + traza de pila |
A? | podría estar ausente | manejar null | ningún motivo |
Either<E, A> | podría fallar, con E | manejar ambos lados | un motivo tipado |
Cada uno es adecuado para algo:
import 'package:fxdart/fxdart.dart'; // 1. Nullable: absence is the whole story.int? findIndexOf(List<String> xs, String needle) { final i = xs.indexOf(needle); return i == -1 ? null : i;} // 2. Either: the caller needs to know *why*.Either<String, int> parsePort(String s) { final n = int.tryParse(s); if (n == null) return Either.left('not a number: $s'); if (n < 1024) return Either.left('privileged: $n'); return Either.right(n);} // 3. Throw: the caller cannot act, and the program is broken.int divide(int a, int b) { if (b == 0) throw ArgumentError('b must not be zero'); return a ~/ b;} void main() { print(findIndexOf(['a', 'b'], 'z')); print(parsePort('80')); try { divide(1, 0); } catch (e) { print('threw: $e'); }}La regla de elección es una sola pregunta: ¿puede quien llama hacer algo específico respecto a este fallo? Si sí, pertenece al tipo — Either si el motivo importa, A? si no. Si no, lanza una excepción: un bug, un invariante roto, o un fallo del entorno que nadie puede recuperar en ese punto de llamada.
Figura 18-1. La pregunta no es qué tan grave es el fallo, sino si quien llama tiene una reacción. Todo lo que quien llama puede accionar pertenece al tipo de retorno; todo lo demás pertenece al canal de excepciones, donde no saturará cada firma entre aquí y la cima.
Aquí está la parte incómoda, y la razón de ser de este capítulo.
Either<E, A> en la firma no significa "esta función solo falla con E". Dart tiene excepciones sin comprobar, así que cualquier código — el tuyo, el del SDK, el de una dependencia — puede lanzar en cualquier momento. Una función que devuelve Either puede igual explotar con StateError, RangeError, OutOfMemoryError, o un bug en un paquete transitivo.
Así que la afirmación honesta es más estrecha, y aun así vale mucho:
Either<E, A>dice: los fallos que esta función modela sonE, y están en el tipo. No dice nada sobre los fallos que nadie modeló.
Compáralo con un lenguaje de excepciones comprobadas, que promete el conjunto completo y lo paga con cláusulas throws en todo. Dart eligió no comprobarlas; una librería no puede deshacer esa elección. Lo que FxDart añade es un canal para los fallos en los que sí pensaste, que es de donde realmente vienen los bugs.
import 'package:fxdart/fxdart.dart'; Either<String, int> risky(String s) => either((r) { if (s.isEmpty) r.raise('empty'); // Not modelled, and not caught by the signature: return int.parse(s); // throws on 'abc' }); void main() { print(risky('')); try { print(risky('abc')); } catch (e) { print('escaped the Either: ${e.runtimeType}'); } // If you want throws folded into the failure channel, say so. print(eitherCatching<String, int>( (r) => int.parse('abc'), (e, _) => 'not a number'));}eitherCatching es la conversión explícita, y que sea explícita es el diseño: tragarse en silencio cada excepción convertiría bugs genuinos en fallos de dominio, y te enterarías en producción, un Left('Bad state: no element') a la vez.
Un programa tiene una frontera donde el estilo de fallo del mundo exterior se encuentra con el tuyo. Ambas direcciones son una línea, y ambas pertenecen a esa frontera, no dispersas por el código:
import 'package:fxdart/fxdart.dart'; class Config { const Config(this.port); final int port; @override String toString() => 'Config($port)';} // Inbound: a throwing API becomes a typed failure.Either<String, Config> loadConfig(Map<String, String> env) => eitherCatching( (r) { final raw = env['PORT']; r.ensureNotNull(raw, () => 'PORT is not set'); return Config(int.parse(raw!)); }, (e, _) => 'PORT is not a number', ); // Outbound: a typed failure becomes the framework's exception.Config loadOrThrow(Map<String, String> env) => loadConfig(env).fold( (e) => throw StateError('bad config: $e'), (c) => c, ); void main() { print(loadConfig({'PORT': '8080'})); print(loadConfig({})); print(loadConfig({'PORT': 'abc'})); try { loadOrThrow({}); } catch (e) { print('at the edge: $e'); }}La conversión de entrada ocurre donde llamas a código que no controlas. La conversión de salida ocurre donde un framework exige un throw — un método build de Flutter, un helper de test, main. En medio, los fallos son valores.
🎓 Errores versus excepciones, y qué significa eso en el propio SDK de Dart. La convención de Dart es que
Error(ArgumentError,StateError,RangeError) señala un error de programación — quien llama violó un contrato y debería corregirse, no manejarse — mientras queExceptionseñala una condición que un programa correcto puede igual encontrar (FormatException,IOException). Eso encaja perfectamente con este capítulo:Errornunca debería capturarse y convertirse en unLeft, porque hacerlo esconde un bug;Exceptiones un buen candidato paraeitherCatching. Cuando escribes una librería, seguir la convención es lo que permite que quienes la usan hagan esta distinción siquiera.
A? es el canal de fallo más barato que tiene Dart, y es genuinamente la respuesta correcta más a menudo de lo que admiten los entusiastas de los errores tipados — con una prueba: ¿"ausente" es todo el mensaje? Una búsqueda en un mapa, una primera coincidencia, un campo opcional: sí. Un parseo, una validación, una autorización: no, porque quien llama querrá saber qué salió mal.
El scope nullable de FxDart existe para que la cadena con forma de null reciba el mismo trato en línea recta:
import 'package:fxdart/fxdart.dart'; class User { const User(this.name, this.managerId); final String name; final String? managerId;} final users = <String, User>{ 'u1': User('Ada', 'u2'), 'u2': User('Grace', null),}; String? managerName(String id) => nullable((r) { final user = r.bind(users[id]); final managerId = r.bind(user.managerId); final manager = r.bind(users[managerId]); return manager.name; }); void main() { print(managerName('u1')); print(managerName('u2')); // no manager print(managerName('u9')); // no such user}Tres formas de estar ausente, un null de salida, y ninguna pirámide de ?. y ??. Nota lo que falta en la salida: cuál de las tres fue. Esa es exactamente la información que a Either le cuesta un parámetro de tipo conservar.
Las reglas de este capítulo pagan cada vez que se escribe una función nueva, lo que las convierte en la decisión de mayor frecuencia del libro. Acertar con ellas mantiene las firmas honestas y los bloques try escasos y significativos.
Dejan de pagar si se aplican dogmáticamente: un Either<String, T> en cada helper privado añade ruido sin añadir información, y una base de código donde main es el único try es una base de código que va a perder una traza de pila que alguien necesitaba. Convierte en las fronteras, modela lo que quien llama puede accionar, y deja que los bugs genuinos exploten con ruido.
A? / Either: un JSON que falla al parsearse; un parámetro de consulta opcional ausente; una longitud de array negativa pasada a tu propia función; un pago rechazado por el proveedor.int.parse lanza y int.tryParse devuelve null. ¿Qué canal habría dado Either, y qué habría tenido que inventar?eitherCatching es una función separada en lugar del comportamiento por defecto de either? Describe el bug que seguiría de la otra elección.Either<E, A> pero también lanza en algunas entradas. ¿Cómo lo descubrirías, y qué cambiarías — el código o la firma?Either si un usuario puede corregir la entrada, A? si quien llama solo ramifica sobre la validez. Parámetro opcional ausente: A? — la ausencia es el mensaje. Longitud negativa: throw ArgumentError — quien llama violó un contrato, y la corrección está en su código. Pago rechazado: Either con un motivo tipado, ya que quien llama debe mostrarlo a una persona y posiblemente reintentar.Either<FormatException, int> — y habría tenido que inventar un tipo de error. Ese es el costo completo del tercer canal: alguien debe decidir qué es el fallo, nombrarlo, y mantenerlo. tryParse lo evita diciendo solo "no", que es por qué es la llamada más común.Left blanquearía bugs convirtiéndolos en fallos de dominio. Un StateError de un bug de librería llegaría como un error de validación, quien llama lo renderizaría junto al campo de código postal, y nadie vería jamás la traza de pila. La explicitud significa que la conversión es una decisión con un nombre asociado.parse, !, first, [] sobre una lista). Cambia el código: envuelve la llamada que lanza en eitherCatching y modela el fallo, o deja que se propague deliberadamente si es un bug. Lo único que no hay que hacer es documentarlo en un comentario y dejar la firma mintiendo.En este capítulo
- refactorizar como una cadena de sustituciones, cada una justificada por una ley con nombre
- una transformación resuelta: cinco etapas reducidas a dos, sobre el papel
- leyes como property tests, con un generador y sin framework de testing
- las precondiciones que hacen válido todo el método, y cómo fallan
El capítulo 2 definió la transparencia referencial: una llamada puede reemplazarse por su resultado. Su hermano mayor es el razonamiento ecuacional — reemplazar cualquier expresión por una igual, en cualquier lugar, y saber que el programa no cambió.
Cada ley de la Parte II es una ecuación de este tipo:
| Ley | Ecuación |
|---|---|
| Composición de functor | m.map(f).map(g) = m.map(g ∘ f) |
| Identidad de functor | m.map(id) = m |
| Identidad izquierda de mónada | of(a).flatMap(f) = f(a) |
| Asociatividad de mónada | m.flatMap(f).flatMap(g) = m.flatMap((x) => f(x).flatMap(g)) |
| Asociatividad de monoide | (a + b) + c = a + (b + c) |
Leídas de izquierda a derecha son optimizaciones; de derecha a izquierda son clarificaciones. Ambas direcciones son legales, que es lo que las convierte en una herramienta y no en un simple hecho.
Empieza con código que nadie defendería:
import 'package:fxdart/fxdart.dart'; void main() { final source = [3, 8, 2, 9, 4]; // Before: five stages, two of them pointless. final before = fx(source) .map((n) => n) .map((n) => n * 2) .map((n) => n + 1) .filter((n) => n > 5) .fold(0, (a, b) => a + b); // After: two stages. Same value, by four substitutions. final after = fx(source) .map((n) => n * 2 + 1) .filter((n) => n > 5) .fold(0, (a, b) => a + b); print([before, after, before == after]);}Los cuatro pasos, cada uno con su licencia:
map((n) => n) es map(id) → elimínalo. Identidad de functor.map(f).map(g) → map(g ∘ f), dando map((n) => n * 2 + 1). Composición de functor.filter, porque el predicado lee el valor ya mapeado — ese reordenamiento necesitaría una precondición que no tenemos.fold queda intacto; + sobre int es asociativo con identidad 0, así que la semilla es genuinamente el empty del monoide. Leyes de monoide.Dos cosas vale la pena notar. Primero, la transformación es mecánica — no hace falta ingenio ni testear para creérsela. Segundo, el paso 3 es donde una "simplificación" descuidada introduciría un bug, y la ley es lo que te dice que pares.
Figura 19-1. Cada flecha es una reescritura con nombre. Si no puedes nombrar la ley, no estás refactorizando — estás reescribiendo y esperando.
Una ley es una propiedad, y una propiedad es un test que puedes correr sobre muchas entradas. No hace falta ningún framework para demostrarlo:
import 'package:fxdart/fxdart.dart'; // A tiny generator: deterministic, so a failure is reproducible.List<int> sample(int n, int seed) { final rnd = createSeededRandom(seed); return List.generate(n, (_) => (rnd() * 200).floor() - 100);} Either<String, int> half(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd: $n'); Either<String, int> dec(int n) => Either.right(n - 1); void main() { var checked = 0; var failures = 0; for (final x in sample(200, 42)) { final m = Either<String, int>.right(x); // functor identity if (m.map((v) => v) != m) failures++; // monad left identity if (Either<String, int>.right(x).flatMap(half) != half(x)) { failures++; } // monad right identity if (m.flatMap((v) => Either<String, int>.right(v)) != m) { failures++; } // associativity final lhs = m.flatMap(half).flatMap(dec); final rhs = m.flatMap((v) => half(v).flatMap(dec)); if (lhs != rhs) failures++; checked += 4; } print('$checked properties checked, $failures failures');}Doscientas entradas, cuatro leyes, una línea de salida. En una suite real esto se convierte en un archivo de package:test (o package:glados para shrinking), pero la forma no cambia: generar entradas, afirmar una ecuación, correr en cada commit.
La razón para molestarse no es que el Either de FxDart pueda estar mal. Es que tus tipos también tienen leyes — el Money que nunca debe volverse negativo, el Cache cuyo get después de put debe devolver lo que pusiste — y son exactamente así de testeables, con muchos más bugs por encontrar.
// A property test for a type of your own.class Money { const Money(this.cents); final int cents; Money operator +(Money other) => Money(cents + other.cents); static const zero = Money(0); @override bool operator ==(Object o) => o is Money && o.cents == cents; @override int get hashCode => cents;} void main() { final values = [0, 1, 99, 100, -50, 123456].map(Money.new).toList(); var bad = 0; for (final a in values) { // identity if (a + Money.zero != a) bad++; if (Money.zero + a != a) bad++; for (final b in values) { for (final c in values) { // associativity if ((a + b) + c != a + (b + c)) bad++; } } } print('monoid violations: $bad');}El razonamiento ecuacional funciona cuando lo igual es realmente igual, y hay exactamente tres formas de que eso falle:
Either, observacional para Future, igualdad de conjunto para Set. Una ley puede valer bajo una y fallar bajo otra — el ejercicio del capítulo 1 lo hizo concreto.Counted en el capítulo 5 y Logged en el capítulo 1 tenían ambos un map/flatMap con pinta de legal y rompían una ley. Leer el nombre no basta; las leyes son una afirmación que alguien tiene que haber comprobado.Ese tercer punto es por qué el test de este capítulo no es académico. Una ley que no has testeado es un comentario.
🎓 Hasta dónde llega esto. En un lenguaje total y puro el método escala hasta la demostración: la fusión
foldr/buildde Haskell, la extracción de Coq, y las reglas de reescritura de GHC son todas razonamiento ecuacional ejecutado por una máquina en tu nombre. Dart no es ni total ni puro, así que el método sigue siendo una herramienta humana más tests. Esa es una diferencia real de fuerza, no de tipo: las mismas ecuaciones, comprobadas por muestreo en vez de por demostración, que es la misma relación que los property tests tienen con las demostraciones en todas partes.
Cada vez que simplificas una tubería, extraes un helper, o fusionas dos etapas por rendimiento — ese es el método de este capítulo, lo nombres o no. Nombrarlo es lo que convierte "creo que esto es lo mismo" en "esto es lo mismo, y aquí está el porqué".
Paga más fuerte en revisión: "¿qué ley te permite mover ese filter antes del map?" es una pregunta que o tiene respuesta o encontró un bug.
No paga como ceremonia en código que no tiene leyes a las que apelar — configuración imperativa, secuenciación de IO, callbacks de UI. Ahí, el razonamiento es sobre estado y orden, y las ecuaciones no tienen nada que decir.
fx(xs).filter(p).map(f) igual a fx(xs).map(f).filter(p)? Enuncia la precondición con precisión, y luego da un p y una f que la rompan.xs.map(f).toList().map(g).toList() → xs.map((x) => g(f(x))) .toList() paso a paso. ¿Qué paso también cambia el costo?map y flatMap coinciden: m.map(f) == m.flatMap((x) => Either.right(f(x))). ¿Qué ley hace esto cierto para toda mónada legal?Cache tiene put seguido de get devolviendo el valor puesto. Escribe eso como una ecuación, y di qué implica la ecuación sobre el tipo de retorno de put.p es un predicado sobre el valor sin mapear — es decir, cuando la versión tras el intercambio testea lo mismo. Rómpelo con f = (n) => n * 2 y p = (n) => n > 5: filtrar primero conserva 6, 7, 8…, mapear primero conserva 3, 4… duplicados. Las dos respuestas difieren porque p se escribió para un tipo de valor distinto.toList() intermedio (una materialización, no un paso semántico); aplica composición de functor para fusionar los dos map; conserva el toList() final. El costo cambia en el primer paso: una lista intermedia desaparece, que es el mecanismo de asignación del capítulo 14 apareciendo en un refactor.map en términos de flatMap más la identidad izquierda: flatMap((x) => of(f(x))) aplicado a un Right(a) da of(f(a)), que es Right(f(a)), que es map(f). Toda mónada legal la satisface, que es por qué "toda mónada es un functor" es un teorema y no una convención.cache.put(k, v).get(k) == v — y nota que la ecuación solo tipa si put devuelve el caché. Un put de tipo void deja la propiedad inenunciable sin hablar de mutación y orden, que es la misma razón por la que las APIs inmutables son más fáciles de testear: las ecuaciones necesitan valores en ambos lados.En este capítulo
- qué es una categoría, en cuatro líneas, con Dart como ejemplo
- functores y transformaciones naturales, y cuál código Dart es cuál
- la definición de mónada en su forma original, y cómo se relaciona con
flatMap- la frase famosa, decodificada — y por qué no la necesitabas
Este capítulo es saltable. Todo lo que nombra ya lo has usado; nada de él cambiará cómo escribes Dart. Léelo si quieres el mapa que conecta las partes, o para poder leer un paper sin tropezar con la notación.
Una categoría es:
f : A → B y g : B → C, una flecha g ∘ f : A → C;id_A : A → A para cada objeto.Sujeto a dos leyes: la composición es asociativa, y la identidad es neutra.
Esa es toda la definición, y Dart es un ejemplo de ella. Los objetos son tipos; los morfismos son funciones; la composición es lo que el capítulo 4 escribió como compose2; las identidades son (x) => x. Las dos leyes de categoría son los dos hechos en los que el capítulo 4 se apoyó sin ceremonia.
Figura 20-1. Una categoría es flechas que componen. Nada sobre "elementos" aparece en la definición — que es exactamente por qué la misma teoría cubre tipos, y también conjuntos, espacios, y órdenes.
El paso que hace tropezar a la gente es que una categoría olvida de qué están hechos los objetos. int no es un conjunto de números aquí; es un punto con flechas que salen de él. Cada teorema de la materia es por tanto un enunciado sobre la forma de la composición, y por eso se transfiere a la programación en absoluto.
Un functor F entre categorías mapea objetos a objetos y flechas a flechas, preservando identidad y composición:
F(id_A) = id_F(A)F(g ∘ f) = F(g) ∘ F(f)
Esas son precisamente las dos leyes del capítulo 5. En programación usamos endofunctores: F mapea la categoría de los tipos de Dart a sí misma. List envía el objeto int al objeto List<int>, y envía la flecha int → String a la flecha List<int> → List<String> — esta última es map.
Así que map es la mitad-flecha de un functor, y la razón por la que no debe cambiar la estructura es que un functor se define como aquello que la preserva.
Dados dos functores F y G, una transformación natural α : F ⇒ G es una familia de flechas α_A : F(A) → G(A), una por tipo, que satisface:
α_B ∘ F(f) = G(f) ∘ α_A
En Dart: una función genérica que cambia el contenedor sin tocar el contenido, y que conmuta con map. Has escrito varias:
import 'package:fxdart/fxdart.dart'; // A natural transformation: Either<E, _> ⇒ Option-ish (_?)A? toNullable<E, A>(Either<E, A> e) => e.fold((_) => null, (a) => a); void main() { int f(int n) => n * 3; final r = Either<String, int>.right(7); final l = Either<String, int>.left('nope'); // naturality: map then transform == transform then map print([toNullable(r.map(f)), toNullable(r)?.let(f)]); print([toNullable(l.map(f)), toNullable(l)?.let(f)]);} extension Let<T> on T { R let<R>(R Function(T) f) => f(this);}Ambos lados coinciden, en ambos casos — eso es naturalidad, y es el enunciado formal de "esta conversión no mira los valores". toList(), toAsync(), first, sequence y flatten son todas transformaciones naturales, que es por qué ninguna puede sorprender: no pueden depender del contenido que están moviendo.
Una mónada sobre una categoría C es una tripla (T, η, μ):
T — un endofunctor;η : Id ⇒ T — una transformación natural, "unidad";μ : T² ⇒ T — una transformación natural, "multiplicación" o "join";que satisface tres condiciones de coherencia:
μ ∘ T(μ) = μ ∘ μ_T (associativity)μ ∘ T(η) = id = μ ∘ η_T (unit, both sides)
Traducido:
| Teoría de categorías | Dart |
|---|---|
T | el constructor de tipo — Either<E, _>, List, Future |
η (unidad) | of / Either.right / [x] / Future.value |
μ (join) | flatten — List<List<A>> → List<A> |
flatMap(f) | μ ∘ T(f) — map, y luego flatten |
| las condiciones de coherencia | las tres leyes del capítulo 1 |
import 'package:fxdart/fxdart.dart'; void main() { // μ: T² ⇒ T. Dart spells it `flat` / `expand(id)`. final nested = [ [1, 2], [3], [4, 5] ]; print(fx(nested).flat().toList()); // flatMap = μ ∘ T(f): map to a nested structure, then join. int f(int n) => n; final viaMapThenJoin = fx([1, 2, 3]).map((n) => [n, n * 10]).flat().toList(); final viaFlatMap = fx([1, 2, 3]).flatMap((n) => [n, n * 10]).toList(); print([viaMapThenJoin, viaFlatMap, f(1)]);}Las dos definiciones — flatMap frente a map + join — son intercambiables, que es por qué algunos lenguajes te dan una y otros la otra, y por qué el capítulo 1 pudo definir la mónada sin mencionar μ en absoluto.
Una mónada es un monoide en la categoría de los endofunctores.
Ahora tienes todas las piezas:
List y Future, las flechas son transformaciones naturales entre ellos.F y luego G), que juega el papel de la multiplicación.T con μ : T ∘ T ⇒ T (combinar) y η : Id ⇒ T (unidad), obedeciendo asociatividad e identidad — las dos leyes del capítulo 8, un nivel más arriba.Que es exactamente la definición de arriba. La frase es cierta, precisa, e inútil como primera explicación — define el caso especial señalando al caso general, que es el orden correcto para las matemáticas y el equivocado para aprender.
🎓 Lo que compra la teoría, honestamente. No código — has escrito cada construcción de este libro sin ella. Lo que compra es transferencia: los mismos teoremas se aplican a parsers, distribuciones de probabilidad, sistemas de build y máquinas de estados, así que un resultado probado una vez está disponible en todas partes. Y compra un vocabulario lo bastante preciso como para que dos personas puedan discrepar productivamente. Si quieres ir más allá, los siguientes objetos útiles son las adjunciones (que explican por qué
flatMapymapvienen en pares) y las mónadas libres (que explican los intérpretes); ninguna es necesaria para nada de la Parte I a la IV.
Leyendo. Papers, librerías de Haskell, Cats de Scala, y cualquier discusión donde alguien diga "eso es solo una transformación natural" — este capítulo es el anillo decodificador para eso.
También nombrando. Una vez que puedes decir "esta conversión es natural", tienes una forma precisa de enunciar una regla de diseño ("no debe inspeccionar el contenido") que ninguna cantidad de prosa en un comentario de documentación logra.
No se gana el sueldo en la revisión de código, en los mensajes de commit, ni en conversación con un colega que no lo ha leído. El vocabulario es una herramienta para pensar, y usarlo como credencial es cómo la materia se ganó su reputación.
compose2?List.reversed una transformación natural de List a List? Comprueba la naturalidad con f = (n) => n * 2, y luego di qué la hace natural pese a cambiar el orden.first mapea List<A> a A?. ¿Es natural? ¿Y sortBy, que mapea List<A> a List<A>?flatMap como μ ∘ T(f) para Either<E, _>. ¿Qué es μ para Either, concretamente?compose2(compose2(f, g), h) y compose2(f, compose2(g, h)) ambos llaman a h(g(f(x))). Identidad: compose2(id, f) y compose2(f, id) ambos llaman a f(x). Te estás apoyando en que compose2 sea solo aplicación — sin logging, sin memoización, sin nada extra. Un compose2 impuro rompería las leyes de categoría, que es la misma observación que hizo el capítulo 2 sobre la sustitución.xs.map(f).reversed y xs.reversed.map(f) dan la misma lista, porque reversed reordena posiciones sin consultar los valores. Natural no significa "que preserva la estructura" en el sentido de orden — significa "independiente del contenido", y una permutación califica.first es natural: xs.map(f).first equivale a f(xs.first) cuando no está vacía, y ambas están ausentes cuando lo está. sortBy no lo es — inspecciona los valores para decidir el orden, así que xs.map(f).sortBy(k) y xs.sortBy(k).map(f) difieren en general. Esa es la prueba más clara de una línea para la naturalidad: ¿mira el contenido?μ : Either<E, Either<E, A>> → Either<E, A> colapsa las dos capas — Left(e) sigue siendo Left(e), Right(Left(e)) se vuelve Left(e), Right(Right(a)) se vuelve Right(a). Entonces flatMap(f) es map(f) seguido de ese colapso, que es exactamente lo que hace la implementación cuando hace pattern-matching sobre ambos niveles.En este capítulo
- dónde se inventó cada idea de este libro, y qué problema resolvía ahí
- las cuatro traducciones, y lo específico que perdió cada una
- por qué la API de FxDart se ve como se ve — los nombres de FxTS, los errores de Arrow
- qué tomar de cada ancestro cuando lees su documentación
| Introdujo / popularizó | Perdido en la traducción | |
|---|---|---|
| Haskell (1990) | typeclasses, mónadas como interfaz, do | nada — es la fuente; el costo es el propio lenguaje |
| Scala (2004) | mónadas en un lenguaje OO, for-comprehensions, Cats | complejidad de resolución de implícitos; dos sintaxis para todo |
| Kotlin + Arrow (2017) | errores tipados sin HKTs, Raise, context receivers | abstracción genérica sobre efectos — eliminada en Arrow 2 |
| FxTS (2021) | tuberías perezosas, concurrent(n), en TypeScript | leyes como contrato declarado; los tipos de TS se borran en tiempo de ejecución |
| FxDart (2025) | el modelo de FxTS + los errores de Arrow, en Dart | pipe curried, y toda abstracción HKT |
Lee la columna derecha como una sola frase: cada traducción conservó la forma y descartó lo que su lenguaje anfitrión no podía cargar.
Figura 21-1. Las ideas viajan; los mecanismos no. Cada flecha es un port que preservó el vocabulario y reimplementó la maquinaria con lo que tenía el nuevo lenguaje.
Las mónadas se introdujeron en Haskell para resolver un problema específico — cómo un lenguaje puro puede hacer IO — y la respuesta fue convertir los efectos en valores con una interfaz común. Esa interfaz es un typeclass, que es por qué cada capítulo de la Parte II tiene la forma de uno: un constructor de tipo, un par de operaciones, y leyes que las instancias deben satisfacer.
Lo que Haskell aportó y sobrevive en todas partes: las leyes son el contrato. Una instancia de Monad que rompe la asociatividad es un bug, no una variante. Cada librería de este linaje hereda ese estándar aunque no pueda imponerlo.
Lo que no se traduce: la pereza por defecto, la pureza impuesta por el compilador, y la resolución de typeclasses. Leer Haskell por las ideas vale la pena; copiar sus firmas a Dart no.
Scala demostró que el vocabulario funciona en un lenguaje con subtipado y métodos — flatMap como método en vez de función libre, for como desugaring, y (en Cats) toda la torre de typeclasses recreada con implícitos y tipos de orden superior.
También demostró el modo de fallo que hizo cautos a los diseñadores posteriores: EitherT[Future, E, A] y compañía. Los monad transformers son la respuesta general a "las mónadas no componen", y en la práctica producen código con un lift en cada nivel y mensajes de error imposibles para un recién llegado. La caja de profundidad del capítulo 7 registra por qué tanto Arrow como FxDart rechazaron esta ruta.
Arrow 1.x intentó ser Cats para Kotlin, incluyendo la codificación Kind que demostró el capítulo 10. Arrow 2.x eliminó casi todo eso y lo reconstruyó alrededor de una idea: un scope con una salida no local.
either { val x = parse(raw).bind(); ... } // Kotlin, Arrow 2either((r) { final x = r.bind(parse(raw)); ... }) // Dart, FxDart
Ese es el ancestro directo del capítulo 15, y la razón por la que la API de errores tipados de FxDart usa el vocabulario de Arrow — Raise, bind, ensure, accumulate, NonEmptyList, zipOrAccumulate — en vez de inventar nombres nuevos. Cuando la documentación de Arrow explica una sutileza sobre la acumulación, también se aplica aquí.
Kotlin tiene algo que Dart no: context receivers, que dejan que bind() sea una extensión disponible implícitamente dentro del scope. Dart necesita el prefijo explícito r.. Esa es una pérdida ergonómica real y la razón por la que los scopes de FxDart son "scope-first por diseño": escribes r. y el editor lista el vocabulario.
Todo en las Partes I y III viene del otro padre. FxTS aportó:
concurrent(n) y su canal de retorno — el mecanismo del capítulo 13, inventado ahí;Lo que no se pudo portar es el tema del capítulo 4: el pipe curried de FxTS necesita genéricos variádicos, que TypeScript simula con overloads y Dart no puede simular en absoluto. La cadena tipada de FxDart es el reemplazo, y WHY_CURRIED.md es el registro escrito de esa decisión — vale la pena leerlo como ejemplo de documentar las desviaciones de un port en vez de fingir que no existen.
import 'package:fxdart/fxdart.dart'; Either<String, int> parse(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad: $s') : Either.right(n);} void main() async { // FxTS ancestry: lazy chain, bounded concurrency. final ports = await fx(['8080', '9000', '7000']) .toAsync() .mapConcurrent(2, (s) async => s) .toList(); // Arrow ancestry: typed failures, scope, accumulation. final parsed = fx(ports).map(parse).flattenOrAccumulate(); print(parsed); print(fx(['1', 'x']).map(parse).flattenOrAccumulate());}Dos linajes, una librería, y la costura entre ellos es deliberada: la mitad de la tubería nunca menciona Either, y la mitad de errores nunca menciona la pereza. Se encuentran solo en los recorridos del capítulo 9.
🎓 Ideas más viejas que todas ellas. Las mónadas entraron en la computación a través del trabajo de Eugenio Moggi de 1989 sobre la semántica categórica de los programas, y los papers de Philip Wadler las convirtieron en una técnica de programación.
NonEmptyList, la validación aplicativa y la imagen del "ferrocarril" vienen de la práctica de ML y Haskell de ese mismo período. Las continuaciones delimitadas — el mecanismo del capítulo 15 — son todavía más viejas, del Scheme de los años
- Casi nada de este libro se inventó en la última década; lo que cambió
es que los lenguajes convencionales crecieron suficiente sistema de tipos para hospedar las ideas, que es por qué el mismo conjunto llegó a Kotlin, TypeScript, Swift y Dart en unos pocos años entre sí.
Cuando estás atascado. Casi toda pregunta que puedas hacer sobre este vocabulario ha sido respondida largamente en una de las cuatro comunidades ancestrales, y saber cuál buscar es la mayor parte del trabajo.
También se gana el sueldo como inoculación: ver que cada lenguaje pagó un precio distinto por las mismas ideas deja claro que las ideas no son propiedad de ninguna sintaxis — y que un port de Dart que rechaza una característica de Haskell suele ser una decisión de diseño, no una carencia.
Kind y su tipo Validated. ¿Qué costó cada eliminación, y qué compró?pipe de FxTS toma un valor y una lista de operadores curried; la cadena de FxDart es métodos. Nombra algo que FxTS puede expresar y FxDart no, y algo que FxDart obtiene y FxTS no.Future + Either con EitherT; FxDart lo resuelve con eitherAsync. ¿Cuál de los dos generaliza a un tercer efecto, y qué hace el otro en su lugar?Kind costó la abstracción genérica sobre efectos — sin traverse único, sin combinadores compartidos — y compró tipos legibles y mensajes de error, además de una API que un recién llegado puede usar sin aprender la codificación. Eliminar Validated costó un tipo acumulador dedicado y compró un único tipo de resultado en cada firma; el comportamiento de acumulación se movió a un scope, que es estrictamente más explícito en el punto de llamada.map(f) como un valor y pasarlo — los operadores son de primera clase, así que puedes construir una tubería dinámicamente a partir de una lista de etapas. FxDart obtiene tipos estáticos completos a lo largo de toda la cadena, incluida la inferencia dentro de los callbacks, que el pipe de FxTS solo logra a través de un muro de overloads escritos a mano.EitherT generaliza: es un wrapper por mónada, así que un tercer efecto es otro transformer más en la pila (al costo de lifts en todas partes). eitherAsync no generaliza — FxDart escribe cada combinación útil a mano, y hay pocas, porque las combinaciones que la gente realmente usa son pocas.En este capítulo
- cinco formas en las que la versión imperativa es sencillamente mejor
- el coste que nadie pone en el README: lectura, depuración, contratación
- una lista de comprobación que puedes aplicar antes de escribir la tubería
- qué conservar incluso cuando descartas el resto
Todo lo que hay en este libro es una herramienta con un precio. Veintiún capítulos han defendido las herramientas; este les pone precio, porque una técnica contra la que no puedes argumentar es una creencia, no una decisión de ingeniería.
1. Caliente, uniforme, consumido por completo. La forma perdedora del capítulo 14: muchas etapas baratas, cada elemento usado, en una ruta que se ejecuta constantemente. La indirección por elemento es puro coste añadido y no hay trabajo rechazado que lo recompense.
void main() { final xs = List.generate(8, (i) => i); // Sometimes this is just the right code. var sum = 0; for (final x in xs) { if (x.isOdd) sum += x * x; } print(sum);}2. Aritmética de índices. Comparaciones deslizantes con zancadas irregulares, transformaciones in situ, algoritmos definidos sobre posiciones (búsqueda binaria, dos punteros, tablas de programación dinámica). El vocabulario de tuberías describe secuencias de valores; cuando tu algoritmo trata sobre posiciones en un buffer, traducirlo cuesta claridad y no compra nada.
3. Un solo paso. Un único map sobre una lista de cinco elementos es list.map(f).toList(). Envolverlo en fx(...) añade un nombre que aprender y un tipo que explicar, por cero beneficio. Lo mismo vale para una única llamada falible: que int.tryParse devuelva null es completo; convertirlo en un Either<String, int> significa inventar un mensaje de error que nadie lee.
4. Trabajo genuinamente imperativo. Construir un buffer, dirigir una máquina de estados, escribir bytes en un socket, orquestar un script de migración. Son secuencias de efectos, y el consejo del capítulo 2 — empuja los efectos hacia los bordes — significa que los bordes existen y deben parecer lo que son.
5. Problemas con forma de empuje. La regla del capítulo 12. Eventos de interfaz, sockets, temporizadores, y cualquier caso en el que varios consumidores deban ver el mismo evento: usa Stream (o la capa fxEvents) y deja de intentar tirar.
El coste de lectura es real y está distribuido de forma desigual. Una cadena de diez etapas es más densa que el bucle que sustituyó. La densidad es buena cuando quien lee conoce el vocabulario y mala cuando no lo conoce, y tu equipo es la variable que decide cuál de las dos.
Depurar es peor. Una traza de pila dentro de una tubería perezosa muestra marcos del iterador, no los nombres de tus etapas. Un punto de interrupción en un callback salta entremezclado con otras etapas (la fusión del capítulo 5, funcionando como está diseñada). La depuración por impresión necesita peek. Nada de esto es fatal; todo ello es más lento que recorrer un bucle paso a paso.
La abstracción puede desbordar el problema. El modo de fallo no es una cadena ingeniosa aislada — es una base de código donde quien la lee debe mantener cuatro nombres de typeclass en la cabeza para seguir una función de tres líneas. Si nombrar la abstracción tarda más que el código que ahorra, ha perdido.
El coste de equipo se acumula. Cada construcción de este libro es algo que enseñar. Eso es aceptable para map/filter/fold, que cualquier desarrollador de Dart conoce; es una inversión real para accumulate, traverse y el ámbito Raise. Gástalo donde compense y no en todas partes.
Figura 22-1. Cuatro preguntas, formuladas antes de escribir la tubería. Tres noes y un bucle es la respuesta correcta — lo cual es un resultado normal, no una falta de coraje.
Antes de recurrir al vocabulario de tuberías:
take, first, un filter selectivo, una salida anticipada. Si es así, la pereza se está ganando su sueldo (capítulo 11).concurrent(n) justifica la cadena por sí solo (capítulo 13).Tres o cuatro síes: usa las herramientas. Un sí: usa las herramientas solo para esa parte. Cero: escribe el bucle, y no pidas disculpas.
Aunque no vuelvas a usar FxDart nunca más, cuatro cosas de este libro sobreviven:
sealed de Dart y sus records, y evita más errores que todo lo demás junto.A? para la ausencia, un valor tipado para todo aquello sobre lo que quien llama puede actuar.Esas cuatro son independientes del estilo. El resto es una caja de herramientas, y las cajas de herramientas se eligen según el trabajo.
🎓 La versión más fuerte del contraargumento. No es «la FP es lenta» (el capítulo 14 lo midió: normalmente un empate) ni «es difícil» (el vocabulario son una docena de palabras). Es localidad: un bucle imperativo pone todo lo que quien lee necesita en ocho líneas consecutivas, mientras que una tubería distribuye el comportamiento entre callbacks, leyes y semántica de biblioteca que quien lee ya debe conocer. La abstracción cambia claridad local por estructura global. Cuando una base de código tiene poca estructura global que ganar — un script, algo puntual, una herramienta pequeña — el cambio es sencillamente malo, y ninguna cantidad de elegancia cambia la aritmética.
Cada vez que estás a punto de escribir una tubería porque se siente sofisticada en lugar de porque es más corta o más segura. La lista de comprobación tarda diez segundos y es la revisión de código más barata que harás jamás.
map sobre una lista fija pequeña que podría ser un for — y reescribirlas es una mejora pequeña y real, no una derrota.for (final x in xs) if (x.isEven) sum += x; frente a una cadena más un fold con semilla. Depurar a las 2 de la madrugada favorece la versión donde cada valor es visible en una variable local — lo cual es un argumento genuino, no una concesión.take/first después de una etapa cuyo equivalente nativo hacía el trabajo completo (una ordenación completa, un recorrido completo). Si tu ruta caliente consume todo lo que produce, ese mecanismo no está disponible para ti y la proporción no favorecerá a la tubería.Either obliga a cada llamador a gestionar un caso cuyo único manejo sensato es rendirse — y esconde la traza de pila que habría localizado el error.Cada término en negrita del libro, con los nombres que recibe en otros sitios y el lugar donde se escribe en Dart. El número de capítulo es donde se presenta.
| Término | También llamado | En Dart / FxDart | Cap. |
|---|---|---|---|
| Functor | — | cualquier tipo con un map legal | 5 |
| Applicative | functor aplicativo | map2, zipOrAccumulate, Future.wait | 6 |
| Mónada | — | cualquier tipo con of + un flatMap legal | 1 |
| Monoide | — | una semilla de fold más una combinación asociativa | 8 |
| Semigrupo | — | combinación asociativa, sin identidad — Nel | 8 |
| Recorrible | Traversable | sequence, mapOrAccumulate, Future.wait | 9 |
| Composición de Kleisli | composición monádica, >=> | (a) => f(a).flatMap(g) | 7 |
| Transformación natural | — | una conversión genérica que ignora el contenido | 20 |
| Tipo de orden superior | HKT, polimorfismo de constructores de tipos | no expresable en Dart | 10 |
| Término | También llamado | En Dart / FxDart | Cap. |
|---|---|---|---|
| of | pure, return, unit, η | Either.right, [x], Future.value, fx([x]) | 1 |
| map | fmap, <$> | map, Future.then | 5 |
| flatMap | bind, >>=, chain | flatMap, expand, Future.then, r.bind | 1 |
| join | flatten, μ | flat(), expand(id) | 20 |
| map2 | zipWith, liftA2 | map2, zipOrAccumulate2 | 6 |
| traverse | — | mapOrAccumulate, .map(f).sequence() | 9 |
| sequence | — | sequenceEither, Future.wait | 9 |
| fold | catamorfismo, reduce con semilla | fold, Either.fold | 8 |
| Término | También llamado | En Dart / FxDart | Cap. |
|---|---|---|---|
| Perezoso | diferido, no estricto | cualquier etapa Fx — nada se ejecuta hasta un terminal | 11 |
| Operador terminal | consumidor, sumidero | toList, each, fold, first, sum | 11 |
| Pull | interactivo, con forma de Iterable | Iterable, FxAsyncIterable | 12 |
| Push | reactivo, observable | Stream, FxEvents | 12 |
| Contrapresión | control de flujo | no pedir el siguiente valor | 12 |
| Fusión | fusión de etapas, deforestación | una sola pasada por toda una cadena | 5 |
| Concurrencia | — | concurrent(n), mapConcurrent — esperas solapadas | 13 |
| Paralelismo | — | isolates — cómputo solapado | 13 |
| Término | También llamado | En Dart / FxDart | Cap. |
|---|---|---|---|
| Either | Result, Validation, unión disjunta | Either<L, R>, Left, Right | 16 |
| Ámbito Raise | ámbito de receptor de contexto, ámbito de efecto | either((r) { … }), r.bind, r.ensure | 15 |
| Continuación delimitada | shift/reset, manejador de efectos | la salida no local dentro de either | 15 |
| Cortocircuito | fallo rápido | el primer Left termina la cadena | 16 |
| Acumulación | fallo lento, validación aplicativa | accumulate, zipOrAccumulate, mapOrAccumulate | 17 |
| NonEmptyList | Nel | NonEmptyList<E> — extension type sobre List | 8 |
| Transformador de mónadas | EitherT, OptionT | no se usa — en su lugar, eitherAsync | 7 |
| Término | También llamado | En Dart / FxDart | Cap. |
|---|---|---|---|
| Función pura | — | mismas entradas, misma salida, nada observable | 2 |
| Transparencia referencial | sustituibilidad | reemplazar una llamada por su resultado | 2 |
| Efecto | efecto secundario | cualquier cosa observable además del valor de retorno | 2 |
| Función total | — | definida para toda entrada — fold lo es, reduce no | 8 |
| Tipo producto | record, tupla, struct | (A, B), campos de una clase | 3 |
| Tipo suma | unión etiquetada, variante, coproducto | sealed class + switch | 3 |
| Tipo de dato algebraico | ADT | sumas y productos juntos | 3 |
| Currificación | — | .curried / .uncurried | 4 |
| Aplicación parcial | — | un closure que captura algunos argumentos | 4 |
| Función de orden superior | — | toma o devuelve una función | 4 |
| Razonamiento ecuacional | — | reemplazar iguales por iguales, según una ley | 19 |
| Ley | propiedad, contrato | una ecuación que las instancias deben cumplir | 1, 5, 8 |
| Categoría | — | objetos + flechas componibles + identidades | 20 |
Un breve decodificador para la lectura entre lenguajes:
flatMap = bind = >>= = chain = SelectMany (C#) = expand (el Iterable de Dart).of = pure = return = unit = just = Right = Future.value.map = fmap = <$> = Select (C#) = then (el Future de Dart, que también es su flatMap).Either<E, A> = Result<A, E> (Rust — nótese los parámetros invertidos) = Validation (cuando el applicative acumula).NonEmptyList = Nel = NonEmptyChain (Cats).Cada ley, en una línea de código, con el refactor que permite. Todo lo que hay aquí es comprobable tal como lo comprobó el capítulo 19: genera entradas, comprueba la ecuación.
| Ley | Ecuación |
|---|---|
| Identidad | m.map((x) => x) == m |
| Composición | m.map(f).map(g) == m.map((x) => g(f(x))) |
Permite: eliminar un map que no hace nada; fusionar dos map en una sola pasada; dividir un map en dos por legibilidad.
Se rompe cuando: map hace algo además de aplicar la función — contar, registrar, cachear, reordenar (Counted en el capítulo 5).
| Ley | Ecuación |
|---|---|
| Identidad por la izquierda | of(a).flatMap(f) == f(a) |
| Identidad por la derecha | m.flatMap(of) == m |
| Asociatividad | m.flatMap(f).flatMap(g) == m.flatMap((x) => f(x).flatMap(g)) |
Permite: insertar en línea un valor envuelto; eliminar un paso que no hace nada; reagrupar una cadena — que es lo que hace «extrae esto en una función auxiliar».
Se rompe cuando: el propio encadenamiento tiene un coste que el tipo registra (Logged en el capítulo 1).
Corolario: m.map(f) == m.flatMap((x) => of(f(x))) — toda mónada legal es un functor legal.
| Ley | Ecuación |
|---|---|
| Identidad | of(id).ap(m) == m |
| Homomorfismo | of(f).ap(of(a)) == of(f(a)) |
| Intercambio | u.ap(of(a)) == of((f) => f(a)).ap(u) |
| Composición | of(compose).ap(u).ap(v).ap(w) == u.ap(v.ap(w)) |
En términos de map2, la consecuencia útil es: map2 debe ejecutar ambas estructuras y combinarlas, nunca inspeccionar una para decidir sobre la otra.
Permite: ejecutar ramas independientes de forma concurrente; acumular sus fallos; reordenar ramas independientes (los resultados se combinan igual).
Se rompe cuando: las ramas «independientes» dependen en secreto una de otra — el estado mutable compartido en una rama de validación es el culpable habitual.
| Ley | Ecuación |
|---|---|
| Asociatividad | (a + b) + c == a + (b + c) |
| Identidad por la izquierda | empty + a == a |
| Identidad por la derecha | a + empty == a |
Permite: trocear un fold; la reducción paralela o incremental; usar la identidad como semilla de fold para que el caso vacío sea total.
Se rompe cuando: la operación tiene forma de resta, o la «identidad» se adivina a partir del tipo en lugar de la operación (0 para la multiplicación).
No implica: conmutatividad — a + b == b + a es una ley distinta y más fuerte que la mayoría de los monoides útiles no tienen.
| Ley | Enunciado |
|---|---|
| Identidad | recorrer con el applicative identidad es map |
| Composición | recorrer con dos applicatives en secuencia == recorrer una vez con su composición |
| Naturalidad | una transformación natural conmuta con traverse |
Permite: elegir dónde recorrer dentro de una cadena; cambiar de fallo rápido a acumulación sin tocar la función por elemento.
| Ley | Ecuación |
|---|---|
| Naturalidad | α(m.map(f)) == α(m).map(f) |
Permite: mover una conversión (toList, toAsync, toNullable, first) a través de un map, en cualquier dirección.
Se rompe cuando: la conversión inspecciona los valores — sortBy es el contraejemplo estándar.
| Ley | Ecuación |
|---|---|
| Asociatividad | (h ∘ g) ∘ f == h ∘ (g ∘ f) |
| Identidad | id ∘ f == f == f ∘ id |
Permite: extraer o insertar en línea cualquier composición de funciones puras, incluidas las etapas de una tubería.
Either y Money; observacional para Future; igualdad de conjuntos para Set. Una ley puede cumplirse bajo una y fallar bajo otra (capítulo 19).import 'package:fxdart/fxdart.dart'; // Generate → assert the equation → report. The seed is fixed so// a failure can be reproduced exactly.void main() { final rnd = createSeededRandom(7); final inputs = List.generate(100, (_) => (rnd() * 100).floor()); Either<String, int> f(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd'); Either<String, int> g(int n) => Either.right(n + 1); var violations = 0; for (final x in inputs) { final m = Either<String, int>.right(x); if (m.map((v) => v) != m) violations++; final lifted = Either<String, int>.right(x); if (lifted.flatMap(f) != f(x)) violations++; if (m.flatMap(f).flatMap(g) != m.flatMap((v) => f(v).flatMap(g))) { violations++; } } print('violations: $violations');}Ordenadas por lo pronto que resultan útiles, con una nota clara sobre su dificultad. Nada de esto es obligatorio; cada capítulo de este libro se sostiene por sí solo.
| Fuente | Buena para | Dificultad |
|---|---|---|
| Tutoriales de FxDart 101 | la superficie de la API, una función cada vez, con demos ejecutables | fácil |
| Dart vs FxDart (52 ejemplos) | si una tubería es la herramienta adecuada para una tarea dada, con veredictos | fácil |
| RxDart vs FxDart | la decisión pull/push del capítulo 12, aplicada a 50 problemas reales | fácil |
WHY_CURRIED.md | el razonamiento detrás del capítulo 4 — qué le debe un port a su fuente | moderada |
ARROW_MIGRATION_BLOCKER.md | el muro de los HKT del capítulo 10, documentado tal como se topó con él | moderada |
benchmark/AUTHORING.md | cómo se producen las cifras del capítulo 14, y cómo añadir un caso | moderada |
| Fuente | Buena para | Dificultad |
|---|---|---|
| Arrow (Kotlin) — guía de errores tipados | la especificación de la parte IV, en la práctica; el ámbito Raise, la acumulación y la razón de ser del diseño | moderada |
| Documentación de FxTS | el catálogo de operadores y concurrent(n); los nombres se corresponden casi exactamente con FxDart | fácil |
| Cats (Scala) — documentación de typeclasses | la torre enunciada de forma genérica: functor → applicative → mónada → recorrido | difícil sin Scala |
Data.Functor / Control.Monad de Haskell | las leyes en su forma original, con concisión | difícil |
| Quieres saber | Ve a |
|---|---|
| «¿Qué función de FxDart hace X?» | los tutoriales 101 |
| «¿Debería usar aquí una tubería?» | Dart vs FxDart, y el capítulo 22 |
| «¿Cómo modelo este fallo?» | el capítulo 18, y luego la guía de errores tipados de Arrow |
«¿Por qué no hay un traverse genérico?» | el capítulo 10, y luego ARROW_MIGRATION_BLOCKER.md |
| «¿Es legal mi tipo?» | el capítulo 19 y el apéndice B, y luego escribe el test de propiedades |
| «¿Qué es realmente una mónada?» | el capítulo 1, luego Wadler, luego el capítulo 20 |
El orden que funciona es: úsalo, nómbralo, y luego formalízalo. Cada capítulo de este libro se escribió así, y las fuentes de arriba se abordan mejor de la misma manera — encuentra la construcción que ya has estado usando, lee la sección que la nombra, y detente ahí hasta la próxima vez que te la encuentres.
Leer un libro de teoría de principio a fin sin código delante es el enfoque que produce la fama, y no funciona.