Teoría de programación funcional

Las ideas detrás del pipeline — para desarrolladores Dart en activo

Cómo leer este libro

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.

Pasar páginas

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, B son tipos corrientes (int, User). M<A> es un valor de tipo A situado dentro de alguna estructura MList<A>, Future<A>, Either<E, A>. Una función escrita A → 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.

Part I · Shapes you already use
The structures hiding in code you write every day — and the vocabulary that names them.
1
Qué es realmente una mónada

En este capítulo

Empieza por el código, no por la definición

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 valorencadenar 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.

Las dos operaciones, con precisión

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:

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 definir map(f) como flatMap((a) => of(f(a))). Lo contrario no es cierto, y por eso la torre tiene más de un piso.

Llevas escribiendo flatMap disfrazado

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 tres leyes

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.

  1. Identidad por la izquierda. of(a).flatMap(f) = f(a). Encajar un valor y encadenar un paso inmediatamente es lo mismo que llamar al paso.
  2. Identidad por la derecha. m.flatMap(of) = m. Sacar el valor de una caja y volver a meterlo tal cual no cambia nada.
  3. Asociatividad. 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)));}

Lo que cuesta una ley rota

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 → C con dos transformaciones naturales, η : Id ⇒ T (eso es of) y μ : T² ⇒ T (eso es flatten, de donde flatMap(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.

Qué implementa FxDart realmente

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.

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.

Cuándo el vocabulario se gana el sueldo

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.

Ejercicios

  1. 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?
  2. Escribe 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.
  3. 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?
  4. Arregla Logged para que las tres leyes se cumplan, encadena después dos pasos en ambas agrupaciones y demuestra que las cuentas coinciden.

Soluciones

  1. Sí, con una salvedad sobre la igualdad. Las tres leyes se cumplen cuando la igualdad es la de conjuntos, porque 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.
  2. 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.
  3. No son el mismo objeto, y == 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.
  4. Quita el + 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.
2
Pureza y efectos

En este capítulo

La prueba de la sustitución

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.

Qué compra la pureza

Cuatro capacidades, y ya dependes de las cuatro:

CapacidadPor qué hace falta pureza
MemoizarCachear un resultado supone que la segunda llamada habría hecho lo mismo
ReordenarMover una línea supone que nadie más observa cuándo se ejecutó
ParalelizarEjecutar dos llamadas a la vez supone que ninguna puede ver a la otra
ProbarAseverar 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ú.

Dónde se esconden los efectos en Dart

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:

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».

La costura

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);}

Cuándo se gana el sueldo

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.

Ejercicios

  1. ¿Es List.of(items) pura? Considera tanto == como identical como la forma en que quien llama podría observar el resultado.
  2. Escribe una función que sea pura a ojos de Dart pero que dependa de un campo mutable que nunca cambia tras la construcción. ¿Es referencialmente transparente? ¿Qué se rompería en el momento en que alguien quitara el final?
  3. 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?
  4. Toma la tubería receipts de arriba y añade un requisito: registrar cada pedido que quedó filtrado. Hazlo sin volver impura a receipts.

Soluciones

  1. Pura según ==, 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.
  2. Algo como 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.
  3. 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.
  4. Devuelve los pedidos rechazados en lugar de registrarlos — 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.
3
Hacer irrepresentables los estados ilegales

En este capítulo

Contar los estados

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:

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.

El tipo suma, y la característica que lo hace rentable

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.

Productos: los records, y dónde se detienen

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 funciones A → B tienen |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.

El refactor, en tres movimientos

  1. Cuenta. Anota cuántos estados admite el tipo y cuántos tiene el dominio. Si difieren, la diferencia es tu presupuesto de bugs.
  2. Nombra los casos reales. Una subclase sealed para cada uno, cargando exactamente los datos que ese caso necesita — Loaded tiene data y no message, y nada nulable.
  3. Borra las guardas. Cada 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.

Cuándo se gana el sueldo

Ú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.

Ejercicios

  1. ¿Cuántos valores tiene (bool, String?) si String tiene n valores? ¿Y Either<bool, bool>?
  2. Modela un semáforo que sea rojo, ámbar, verde o «ámbar intermitente con un motivo». ¿Qué casos llevan datos, y cuántos estados admite tu tipo?
  3. Toma la clase 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?
  4. Tanto Either<String, int> como (String?, int?) pueden representar «un fallo o un número». Da una razón concreta para preferir el primero.

Soluciones

  1. (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.
  2. Tres casos constantes más uno que lleva un 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.
  3. Los dieciséis menos los cuatro reales: 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.
  4. 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.
4
Las funciones como valores

En este capítulo

Composició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.

Aplicación parcial frente a currificación

Se usan como sinónimos y no son lo mismo.

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.

Por qué el pipe de FxTS no se pudo portar

FxTS 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) → C y A → (B → C) llevan exactamente la misma información — puedes convertir en cualquier dirección sin pérdida, que es lo que demuestran .curried / .uncurried en 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.

Funciones de orden superior que ya usas

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());}

Cuándo se gana el sueldo

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.

Ejercicios

  1. 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?
  2. Escribe 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.
  3. 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?
  4. Reescribe fx(xs).filter(small).filter(odd) como un único filter. ¿Es siempre un refactor seguro? ¿De qué propiedad de filter depende?

Soluciones

  1. La cadena aplica de izquierda a derecha: 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.
  2. 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.
  3. 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.
  4. 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.
Part II · The tower
Functor, applicative, monad, monoid: what each one buys, and what it costs.
5
Functor

En este capítulo

Una operación

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.

Las dos leyes

  1. Identidad. m.map((x) => x) == m. Mapear la función identidad no cambia absolutamente nada — ni los valores, ni la forma, ni nada observable.
  2. Composición. 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.

Qué prohíben las leyes

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.

La ley de composición es una característica de rendimiento

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: F lleva el tipo A al tipo F<A>, y map eleva una flecha A → B a una flecha F<A> → F<B>. El capítulo 20 dibuja el diagrama; nada de lo anterior depende de él.

Functores que no son contenedores

«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.

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.

Cuándo se gana el sueldo

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.

Ejercicios

  1. Demuestra — informalmente, por casos — que Either.map cumple la ley de identidad. ¿Cuántos casos hay, y por qué ese número es la demostración entera?
  2. 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.
  3. Si un tipo tiene 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?
  4. El 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?

Soluciones

  1. Dos casos. 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.
  2. La cumple. {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.
  3. Para 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.
  4. 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.
6
Applicative

En este capítulo

Dos formas de «y luego»

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.

Fallo rápido con map2

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<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.

Por qué una mónada no puede acumular

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.

Acumular, en FxDart

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ás ap : F<A → B> × F<A> → F<B> (una función dentro de la estructura, aplicada a un valor dentro de la estructura). map2 y ap son interdefinibles, y map2 se 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: pure no añade nada, y la aplicación es asociativa igual que lo es la composición. Toda mónada es un applicative (map2 vía flatMap); el recíproco falla, y la validación de este capítulo es el contraejemplo estándar.

Elegir entre ellos

NecesitasUsaPorque
El paso 2 necesita el valor del paso 1flatMap / ámbito eitherLa dependencia es real
Los pasos son independientes, basta el primer fallomap2Lo más barato, y cortocircuita
Los pasos son independientes, informa de todos los falloszipOrAccumulate / accumulateSolo la forma applicative puede
Los pasos son independientes y lentosApplicative + concurrenciaLa 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.

Cuándo se gana el sueldo

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.

Ejercicios

  1. 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.
  2. Escribe 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.
  3. En el ejemplo 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?
  4. ¿Es Set un applicative? ¿Qué significaría map2, y encaja con tu intuición sobre «combinar dos conjuntos»?

Soluciones

  1. 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.
  2. 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.
  3. Con 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.
  4. Sí: 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.
7
La mónada, en serio

En este capítulo

Las funciones que devuelven cajas no componen

El capítulo 4 compuso A → B con B → C y obtuvo A → C. Prueba lo mismo con pasos que pueden fallar:

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.

La pirámide, y cuatro salidas

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:

LenguajeSintaxisQué emite el compilador
Haskelldo { id <- parseId raw; … }una cadena de >>=
Scalafor { id <- parseId(raw) } yield …una cadena de flatMap/map
Kotlin (Arrow)either { val id = parseId(raw).bind() }un ámbito con una salida no local
Darteither((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ónada

Dart 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ún flatMap ú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: eitherAsync te da un ámbito Raise dentro de un cuerpo async, así que await se ocupa del tiempo y r.bind del 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.

Una mónada cada vez

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.

Cuándo se gana el sueldo

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).

Ejercicios

  1. Escribe kleisli para Future — compón A → Future<B> con B → Future<C>. ¿De qué método existente de Dart es una envoltura fina?
  2. La flecha identidad de Kleisli para 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.
  3. Reescribe el bloque 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?
  4. await aplana Future<Future<T>>. ¿Qué te dice eso sobre la firma de Future.then, comparada con el map del capítulo 5?

Soluciones

  1. 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.
  2. 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.
  3. La versión con 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.
  4. 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.
8
Monoide y semigrupo

En este capítulo

El álgebra útil más pequeña

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.

Qué compra cada ley

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 conmutatividad es una ley distinta

La asociatividad dice que la agrupación no importa. La conmutatividada + 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 semigrupo

El 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 A y B son 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 con identity como unidad — que es la frase escondida dentro de «una mónada es un monoide en la categoría de los endofunctores»: flatten es la combinación, of es la identidad, y las tres leyes monádicas del capítulo 1 son estas dos leyes disfrazadas.

Cuándo se gana el sueldo

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».

Ejercicios

  1. ¿Es max un semigrupo sobre int? ¿Un monoide? ¿Cuál tendría que ser el elemento identidad, y lo tiene Dart?
  2. Da un monoide cuyo empty no sea el valor «obviamente vacío» — es decir, uno donde quien lea lo adivinaría mal.
  3. 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?
  4. Tanto la acumulación de Either como Future.wait combinan resultados independientes. ¿Qué monoide usa Future.wait, y qué hace con los fallos?

Soluciones

  1. Sí y sí. 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.
  2. Varios: 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.
  3. No es asociativa — ni siquiera tiene la forma adecuada, porque el tipo de la semilla 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).
  4. 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.
9
Traverse

En este capítulo

La forma que llevas escribiendo a mano

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.

Por qué necesita un applicative y no solo un functor

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:

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.

La gemela asíncrona

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 recorrible T y cualquier applicative F — los árboles, los mapas y Option también son recorribles. Tiene dos leyes (identidad y composición, como las del functor) y un corolario famoso: traverse con el applicative identidad es simplemente map, y con el applicative constante es fold. map, fold y traverse son 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.

El coste de no tenerlo genéricamente

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.

Cuándo se gana el sueldo

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ó.

Ejercicios

  1. ¿Qué es sequence sobre una lista vacía — para Either, y para Future? ¿Qué ley del capítulo 8 decide la respuesta?
  2. 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?
  3. Tienes 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.
  4. 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.

Soluciones

  1. 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.
  2. Es el mismo trabajo; 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.
  3. Sí, pero el caso interesante es 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.
  4. Correcto: un puñado de cálculos locales rápidos; un abanico donde el lado remoto está explícitamente construido para carga en paralelo. Incorrecto: cualquier API con límite de tasa o de pago (un abanico sin acotar te lleva al estrangulamiento o a la factura), y cualquier lista cuya longitud controle quien usa el programa — Future.wait sobre una lista de 100.000 elementos abre 100.000 sockets, y el modo de fallo es tu proceso, no el suyo.
10
El piso que falta

En este capítulo

Kinds

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 * → *.

CosaKind¿Completo?
int, String, List<int>*
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).

La línea en la que Dart se detiene

// 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.

Qué hacen otros lenguajes

🎓 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 traverse en vez de siete, un sequence, 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.

La factura, contada

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.

Cuándo te importa esto

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.

Ejercicios

  1. ¿Cuál es el kind de Map? ¿El de Map<String, dynamic>? ¿El de una hipotética interfaz Traverse?
  2. Extiende la codificación Kind de arriba a Either y escribe flatMap para ella. ¿Cuántos casts necesitas, y dónde explotaría uno incorrecto?
  3. 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.
  4. Dart sí te deja escribir T extends Comparable<T>. ¿Por qué eso no es un contraejemplo de este capítulo?

Soluciones

  1. 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.
  2. Dos casts como mínimo — uno para desenvolver 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, _>.
  3. No. Tal función necesita un parámetro de kind * → * («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.
  4. T extends Comparable<T> restringe un tipo completoT 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.
Part III · Evaluation
Laziness, pull versus push, and concurrency as an effect.
11
La pereza

En este capítulo

Dos tipos de operador

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.

El modelo de coste

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:

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]);}

La pereza no puede cambiar el significado

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:

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 where que 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, y fx(xs).map(f) compuesto dos veces es un plan, mientras que let y = f x ya es un thunk.

El segundo peligro: fuentes de un solo uso

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.

Cuándo se gana el sueldo

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.

Ejercicios

  1. 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?
  2. Predice la salida de una cadena que llama a peek(print) antes de take(2) sobre una fuente de diez elementos. ¿Cuántas líneas se imprimen, y por qué?
  3. Escribe una cadena cuyos callbacks se ejecuten dos veces por accidente. Luego arréglala de dos formas distintas.
  4. fx(range(1, 1000000)).map(expensive).first — ¿cuántas veces se ejecuta expensive? ¿Y si .first se sustituye por .last?

Soluciones

  1. Gana en cuanto una etapa descarta trabajo que la versión ansiosa ya ha hecho — 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.
  2. Dos líneas. 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.
  3. Cualquier cadena asignada a una variable y consumida por dos terminales, como en el listado de arriba. Arreglo uno: 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.
  4. Una vez con .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.
12
Pull y push

En este capítulo

Quién llama a quién

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 ritmoel consumidorel productor
Backpressuregratis — basta con no preguntarhay que organizarlo
Parar antesdejar de tirarcancelar una suscripción
Tiempono se modelainherente
Encaje naturalcolecciones, archivos, APIs paginadaseventos 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.

Backpressure es la diferencia práctica

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.

Por qué FxDart no está construido sobre Stream

Las 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.

Los puentes

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 de IEnumerable». La dualidad también predice qué operadores son difíciles en cada lado: zip es fácil en pull (pregunta a ambos, espera a ambos) y necesita búfer en push, mientras que debounce es natural en push (trata sobre tiempo transcurrido) y no tiene sentido en pull, donde no pasa nada entre peticiones.

Cuándo se gana el sueldo cada uno

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.

Ejercicios

  1. 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?
  2. ¿Por qué debounce no está disponible en una cadena pull? Describe qué significaría siquiera, y qué parte es incoherente.
  3. Una API HTTP paginada devuelve 100 filas por petición. Modélala de las dos formas, y di cuál hace más barato «parar tras la primera coincidencia» — y por cuántas peticiones.
  4. 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?

Soluciones

  1. 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.
  2. 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.
  3. Pull: un 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.
  4. 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.
13
La concurrencia como efecto

En este capítulo

Una palabra, una garantía

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 canal trasero

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:

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.

El orden, y lo que cuesta mantenerlo

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.

Dos formas de equivocarse

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 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.

Cuándo se gana el sueldo

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.

Ejercicios

  1. Seis fetches de 40ms con concurrent(3) tardaron ~90ms. Predice el tiempo con concurrent(6) y con concurrent(2), y luego ejecútalo.
  2. ¿Por qué 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.
  3. Reescribe el ejemplo del saldo para que la respuesta sea determinista sin reducir la concurrencia. ¿Qué cambió el arreglo sobre dónde vive el estado?
  4. Un .chunk(10) posterior sigue a un concurrentPool(4). ¿Qué se rompe, y tendría concurrent(4) el mismo problema?

Soluciones

  1. 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.
  2. Porque la petición viaja hacia arriba. 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.
  3. Devuelve el delta desde cada callback y pliega después: .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.
  4. Nada se rompe mecánicamentechunk 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.
14
Lo que cuestan las abstracciones

En este capítulo

Los números

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 titularCasos
Empate (dentro del 5% o 0,6ms)38
Dart escrito a mano más rápido12
FxDart más rápido3

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:

CasoProporciónQué significa
top-expenses0,27×tubería casi 4× más rápida
price-drop-detection0,52×tubería 2× más rápida
smoothed-zone-changes2,23×tubería 2,2× más lenta
anomaly-context1,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.

Mecanismo 1 — indirección por elemento (te cuesta)

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.}

Mecanismo 2 — asignación de memoria (cuesta a ambos, de forma distinta)

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.

Mecanismo 3 — trabajo rechazado (te paga)

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 reglas de medición que mantienen esto honesto

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 + fold perezoso es O(n) exactamente igual que el bucle, y top-expenses es 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.

Cómo decidir para tu propio código

  1. Asume un empate. Dos tercios de las tareas reales lo son, y la legibilidad es entonces el único criterio que queda.
  2. Busca trabajo rechazado. Cualquier take, first, find, o any con salida temprana sobre una fuente grande es una razón para esperar que la tubería gane.
  3. Busca intermedios. Cuéntalos en ambos lados; el lado con más pierde.
  4. Mide el caso real, dos veces. Con una banda de empate, en AOT, al tamaño que tu programa realmente ve.
  5. Luego elige. Un coste mediano del 6% es un precio justo por código que tu equipo puede leer — y no es un precio que debas pagar en el bucle interno de un renderizador de frames.

Cuándo recurrir al bucle

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.

Ejercicios

  1. Una tubería tiene cinco etapas sobre 1M de elementos y termina con .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?
  2. ¿Por qué el RSS pico es a menudo un mejor discriminador que el tiempo transcurrido al comparar dos versiones de una tarea de agrupación?
  3. La suite llama empate a una diferencia por debajo del 5%. Construye un caso donde una diferencia del 4% importe genuinamente, y di qué tendrías que cambiar de la medición para detectarlo de forma fiable.
  4. 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.

Soluciones

  1. Domina el trabajo rechazado. .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.
  2. Porque la agrupación es donde se esconden los intermedios. Ambas versiones pueden tardar el mismo tiempo en una máquina caliente con RAM de sobra, mientras una mantiene cada grupo en memoria a la vez y la otra transmite en flujo; el RSS muestra esa diferencia de inmediato y predice cuál de las dos se cae en una entrada más grande.
  3. Un bucle de renderizado ajustado a 120fps tiene un presupuesto de 8,3ms, así que el 4% de un frame de 5ms es un tercio de milisegundo de margen — real. Para detectarlo necesitas muchas más iteraciones por medición (para elevar la señal por encima de la resolución del temporizador), una máquina tranquila, y ejecuciones A/B entrelazadas y emparejadas en lugar de ejecuciones una tras otra, para que la deriva afecte a ambos lados por igual.
  4. Muchas etapas baratas (una ventana deslizante más una comparación más un map) sobre datos numéricos uniformes, con todo consumido — ninguna etapa rechaza trabajo, y la indirección por elemento se paga varias veces por elemento con muy poco cómputo real para amortizarla.
Part IV · Failure
Typed errors, short-circuiting, accumulation, and the honest boundary with exceptions.
15
El ámbito de Raise

En este capítulo

Qué es either

El 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.

Continuación delimitada, no azúcar sintáctico

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 paracualquier mónada que los tipos puedan nombrarlos efectos que la librería escribió
Necesitatipos de orden superiornada especial
Salida de fallodevolver un valor cortocircuitadosalto no local, capturado en la frontera
Compone con asyncnecesita un transformadorde forma natural — eitherAsync
Extensible por tisí, definiendo una mónadano

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.

Los tres sabores de ámbito

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.

La regla de la fuga

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/reset en Scheme, Cont en Haskell, los manejadores de efectos algebraicos en OCaml 5 y Koka son todos esta maquinaria. Raise usa 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.

Cuándo se gana el sueldo

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.

Ejercicios

  1. Reescribe el primer listado como una cadena de flatMap. ¿Qué versión hace más fácil añadir una guarda — «falla si el valor cae por debajo de 3» — entre pasos?
  2. ¿Qué devuelve 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.
  3. ¿Por qué un ámbito no puede reanudarse — es decir, por qué no hay un r.recover(...) que continúe el bloque después de un fallo? Responde en términos del mecanismo.
  4. 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??

Soluciones

  1. La cadena es 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.
  2. La excepción se propaga fuera de 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 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.
  3. Porque el escape está implementado como un throw: para cuando 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.
  4. Su 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.
16
Either como una vía de tren

En este capítulo

Dos vías

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ónVía verde (Right)Vía roja (Left)
map(f)aplica fpasa de largo
flatMap(f)aplica f, que puede desviarpasa de largo
mapLeft(g)pasa de largoaplica g
fold(l, r)aplica raplica 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.

Los tipos de error también tienen que componer

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.

Recuperación, y dónde termina la totalidad

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.

Either en una tubería

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 Either usado 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.

Cuándo se gana el sueldo

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.

Ejercicios

  1. 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?
  2. Escribe getOrElse para Either en términos de fold. Luego escribe orElse, que toma un Either de respaldo en vez de un valor de respaldo.
  3. Tienes 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.
  4. separateEither devuelve (errors, values). ¿Por qué ese orden, y qué consecuencia tiene la elección para leer código de un vistazo?

Soluciones

  1. La ley de identidad: 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.
  2. 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.
  3. 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.
  4. Coincide con (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.
17
Acumulando el fallo

En este capítulo

La pregunta de producto

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.

Las cuatro herramientas

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:

FormaHerramienta
2–5 campos nombrados e independienteszipOrAccumulate2..5
Una regla, muchos elementosmapOrAccumulate
Ya tienes EithersflattenOrAccumulate / .flattenOrAccumulate()
Más de cinco ramas, o reglas dependientesaccumulate

Independiente, luego dependiente

La regla que hace correcta la acumulación es la distinción del capítulo 6, y tiene una forma de API precisa:

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.

Un formulario, de principio a fin

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.

Las reglas que mantienen esto honesto

  1. Una rama por preocupación independiente. Una rama que valida dos campos no puede reportar sobre el segundo si el primero falló.
  2. Haz raise más de una vez en una rama cuando tenga sentido. Una rama puede contribuir varios errores; age arriba hace raise hasta dos veces.
  3. Nunca leas .value dentro de una rama independiente. Para eso está dependent, y leerlo pronto detona todo el ámbito.
  4. Ordena los errores como el usuario lee el formulario. El orden de las ramas es el orden del reporte, y es gratis acertar con él.
  5. No acumules consecuencias. Si el paso B no tiene sentido cuando A falló, B pertenece a 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 — un Validated<E, A> separado cuyo aplicativo acumulaba y que convertías hacia y desde Either en cada frontera. Arrow 2.x lo eliminó, y FxDart nunca lo tuvo: el mismo efecto está disponible como un ámbito sobre Either<Nel<E>, A>, lo que significa un solo tipo de resultado en las firmas de tu dominio en vez de dos, y ninguna llamada a toEither() esparcida por el código. La teoría no perdió nada — Validated siempre fue solo Either con 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.

Cuándo se gana el sueldo

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ó.

Ejercicios

  1. En el formulario de registro, mueve la regla «pro requiere 21+» de dependent a accumulating y predice la salida para {'age': 'x', 'plan': 'pro'}.
  2. 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?
  3. ¿Por qué el tipo de error es Nel<String> y no List<String>? Da el estado que List admite y Nel prohíbe.
  4. Una rama llama a una API. ¿Debería ser accumulating o dependent, y qué cambia si dos ramas llaman a la misma API?

Soluciones

  1. Se ejecutaría, leería 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.
  2. Cada fallo se retiene hasta el final, así que en el peor caso mantienes 10.000 cadenas de error — está bien. A 10M de filas no lo está: transmitirías en flujo y reportarías, poniendo un tope a los errores recogidos (los primeros N, más un contador) o escribiéndolos en un archivo de rechazados según ocurren. La acumulación está acotada por el recuento de fallos, y ese es el número que hay que sanity-check antes de elegirla.
  3. 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?».
  4. 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.
18
La frontera honesta

En este capítulo

Tres canales

Dart deja que una función falle de tres maneras, y no son intercambiables.

CanalEl tipo diceQuien llama debeLleva
thrownadanada (sin comprobar)cualquier objeto + traza de pila
A?podría estar ausentemanejar nullningún motivo
Either<E, A>podría fallar, con Emanejar ambos ladosun 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.

Lo que los errores tipados no pueden prometer

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 son E, 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 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.

Convertir en los bordes

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 que Exception señala una condición que un programa correcto puede igual encontrar (FormatException, IOException). Eso encaja perfectamente con este capítulo: Error nunca debería capturarse y convertirse en un Left, porque hacerlo esconde un bug; Exception es un buen candidato para eitherCatching. Cuando escribes una librería, seguir la convención es lo que permite que quienes la usan hagan esta distinción siquiera.

El término medio nulable

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.

Cuándo se gana el sueldo

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.

Ejercicios

  1. Clasifica esto como throw / 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.
  2. int.parse lanza y int.tryParse devuelve null. ¿Qué canal habría dado Either, y qué habría tenido que inventar?
  3. ¿Por qué 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.
  4. Una función devuelve 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?

Soluciones

  1. Fallo de parseo de JSON: 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.
  2. Habría dado 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.
  3. Porque plegar cada excepción en un 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.
  4. Descúbrelo con tests sobre las entradas que fallan, o leyendo en busca de llamadas que puedan lanzar (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.
Part V · Laws and lineage
Equational reasoning, category theory in the right dose, and where these ideas came from.
19
Razonamiento ecuacional

En este capítulo

Refactorizar es sustitución

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:

LeyEcuación
Composición de functorm.map(f).map(g) = m.map(g ∘ f)
Identidad de functorm.map(id) = m
Identidad izquierda de mónadaof(a).flatMap(f) = f(a)
Asociatividad de mónadam.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.

Una transformación resuelta

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:

  1. map((n) => n) es map(id) → elimínalo. Identidad de functor.
  2. map(f).map(g)map(g ∘ f), dando map((n) => n * 2 + 1). Composición de functor.
  3. Nada se movió a través del filter, porque el predicado lee el valor ya mapeado — ese reordenamiento necesitaría una precondición que no tenemos.
  4. El 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.

Leyes como tests

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');}

Las precondiciones

El razonamiento ecuacional funciona cuando lo igual es realmente igual, y hay exactamente tres formas de que eso falle:

  1. Impureza. Si un callback registra, muta, o lee el reloj, dos expresiones con el mismo valor no son el mismo programa. Capítulo 2.
  2. La igualdad equivocada. Las leyes se enuncian sobre una igualdad: estructural para 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.
  3. Un tipo que no obedece. 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/build de 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.

Cuándo se gana el sueldo

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.

Ejercicios

  1. ¿Es 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.
  2. Justifica xs.map(f).toList().map(g).toList()xs.map((x) => g(f(x))) .toList() paso a paso. ¿Qué paso también cambia el costo?
  3. Extiende el property test para comprobar que map y flatMap coinciden: m.map(f) == m.flatMap((x) => Either.right(f(x))). ¿Qué ley hace esto cierto para toda mónada legal?
  4. Tu 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.

Soluciones

  1. No en general. Solo vale cuando 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.
  2. Elimina el 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.
  3. Es la definición de 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.
  4. 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.
20
Teoría de categorías, en la dosis correcta

En este capítulo

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, en cuatro líneas

Una categoría es:

  1. una colección de objetos;
  2. para cada par de objetos, una colección de morfismos (flechas) entre ellos;
  3. una operación de composición: dados f : A → B y g : B → C, una flecha g ∘ f : A → C;
  4. una flecha de identidad 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.

Functores, otra vez

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.

Transformaciones naturales

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.

La mónada, con su ropa original

Una mónada sobre una categoría C es una tripla (T, η, μ):

que satisface tres condiciones de coherencia:

μ ∘ T(μ)  = μ ∘ μ_T          (associativity)μ ∘ T(η)  = id  =  μ ∘ η_T   (unit, both sides)

Traducido:

Teoría de categoríasDart
Tel constructor de tipo — Either<E, _>, List, Future
η (unidad)of / Either.right / [x] / Future.value
μ (join)flattenList<List<A>> → List<A>
flatMap(f)μ ∘ T(f) — map, y luego flatten
las condiciones de coherencialas 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.

La frase famosa

Una mónada es un monoide en la categoría de los endofunctores.

Ahora tienes todas las piezas:

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é flatMap y map vienen en pares) y las mónadas libres (que explican los intérpretes); ninguna es necesaria para nada de la Parte I a la IV.

Cuándo se gana el sueldo

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.

Ejercicios

  1. Demuestra que los tipos y funciones de Dart sí satisfacen las dos leyes de categoría. ¿En qué te estás apoyando en realidad sobre compose2?
  2. ¿Es 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.
  3. first mapea List<A> a A?. ¿Es natural? ¿Y sortBy, que mapea List<A> a List<A>?
  4. Escribe flatMap como μ ∘ T(f) para Either<E, _>. ¿Qué es μ para Either, concretamente?

Soluciones

  1. Asociatividad: 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.
  2. Sí. 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.
  3. 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?
  4. μ : 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.
21
Linaje

En este capítulo

Cinco lenguajes, un conjunto de ideas

Introdujo / popularizóPerdido en la traducción
Haskell (1990)typeclasses, mónadas como interfaz, donada — es la fuente; el costo es el propio lenguaje
Scala (2004)mónadas en un lenguaje OO, for-comprehensions, Catscomplejidad de resolución de implícitos; dos sintaxis para todo
Kotlin + Arrow (2017)errores tipados sin HKTs, Raise, context receiversabstracción genérica sobre efectos — eliminada en Arrow 2
FxTS (2021)tuberías perezosas, concurrent(n), en TypeScriptleyes 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 Dartpipe 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.

Haskell: de dónde vino la interfaz

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: las mónadas se encuentran con los objetos

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 de Kotlin: errores tipados sin la torre

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.

FxTS: la mitad de la tubería

Todo en las Partes I y III viene del otro padre. FxTS aportó:

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.

FxDart: qué es, exactamente

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

  1. 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í.

Leyendo a los ancestros

Cuándo se gana el sueldo

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.

Ejercicios

  1. Arrow 2 eliminó su codificación Kind y su tipo Validated. ¿Qué costó cada eliminación, y qué compró?
  2. El 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.
  3. Scala resuelve 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?
  4. ¿Qué capítulos de este libro habría que reescribir si Dart ganara tipos de orden superior mañana? ¿Cuáles no cambiarían en absoluto?

Soluciones

  1. Eliminar 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.
  2. FxTS puede guardar 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.
  3. 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.
  4. El capítulo 10 habría que reescribirlo (trata sobre la ausencia), y la sección de "cuatro grafías" del capítulo 9 colapsaría a una sola. Los capítulos 1, 5, 6, 7, 8 y 19 no cambiarían en absoluto — las definiciones y leyes son neutrales al lenguaje, que es la razón entera por la que la teoría valía la pena aprenderla por separado de la librería.
22
Cuándo no usar nada de esto

En este capítulo

La postura honesta

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.

Cinco formas en las que gana el bucle

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.

Los costes que nadie enumera

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.

La lista de comprobación

Antes de recurrir al vocabulario de tuberías:

  1. ¿Se descarta algo? take, first, un filter selectivo, una salida anticipada. Si es así, la pereza se está ganando su sueldo (capítulo 11).
  2. ¿Hay espera? IO independiente que podría solaparse. Si es así, concurrent(n) justifica la cadena por sí solo (capítulo 13).
  3. ¿Los fallos son datos? Varios pasos falibles cuyos motivos necesita quien llama. Si es así, los errores tipados compensan (capítulos 15–18).
  4. ¿Necesitaría el bucle un comentario? Agrupación anidada, tres acumuladores, un conjunto de «ya visto» — si la versión imperativa necesita un párrafo, la tubería suele ser más corta y más clara.

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.

Qué conservar en cualquier caso

Aunque no vuelvas a usar FxDart nunca más, cuatro cosas de este libro sobreviven:

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.

Cuándo este capítulo se gana el sueldo

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.

Ejercicios

  1. Toma una tubería de tu propio código y aplícale la lista de comprobación. ¿Cuántos síes obtiene? Si son menos de dos, reescríbela como un bucle y compara el diff.
  2. Escribe la peor tubería razonable para «suma los números pares de una lista», y luego el bucle. ¿Cuál es más corta? ¿Cuál preferirías depurar a las 2 de la madrugada?
  3. El capítulo 14 encontró la tubería más rápida en tres de 53 casos. ¿Qué tenían en común esos tres, y lo tiene tu ruta caliente?
  4. Nombra un fragmento de código de tu proyecto donde los errores tipados serían peores que una excepción, y explica con precisión por qué.

Soluciones

  1. La mayoría de las tuberías existentes puntúan dos o tres, que es por lo que se escribieron así. Las que puntúan cero suelen ser un 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.
  2. El bucle es más corto y más fácil de depurar: 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.
  3. Los tres rechazaban trabajo: usaban 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.
  4. Cualquier cosa sobre la que quien llama no pueda actuar: una aserción fallida sobre un invariante interno, un archivo de caché corrupto al arrancar, un error de programación en un argumento. Modelar esos casos como 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.
Appendices
Reference: every term, every law, and where to read more.
Apéndice A · Glosario

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.

La torre

TérminoTambién llamadoEn Dart / FxDartCap.
Functorcualquier tipo con un map legal5
Applicativefunctor aplicativomap2, zipOrAccumulate, Future.wait6
Mónadacualquier tipo con of + un flatMap legal1
Monoideuna semilla de fold más una combinación asociativa8
Semigrupocombinación asociativa, sin identidad — Nel8
RecorribleTraversablesequence, mapOrAccumulate, Future.wait9
Composición de Kleislicomposición monádica, >=>(a) => f(a).flatMap(g)7
Transformación naturaluna conversión genérica que ignora el contenido20
Tipo de orden superiorHKT, polimorfismo de constructores de tiposno expresable en Dart10

Operaciones

TérminoTambién llamadoEn Dart / FxDartCap.
ofpure, return, unit, ηEither.right, [x], Future.value, fx([x])1
mapfmap, <$>map, Future.then5
flatMapbind, >>=, chainflatMap, expand, Future.then, r.bind1
joinflatten, μflat(), expand(id)20
map2zipWith, liftA2map2, zipOrAccumulate26
traversemapOrAccumulate, .map(f).sequence()9
sequencesequenceEither, Future.wait9
foldcatamorfismo, reduce con semillafold, Either.fold8

Evaluación

TérminoTambién llamadoEn Dart / FxDartCap.
Perezosodiferido, no estrictocualquier etapa Fx — nada se ejecuta hasta un terminal11
Operador terminalconsumidor, sumiderotoList, each, fold, first, sum11
Pullinteractivo, con forma de IterableIterable, FxAsyncIterable12
Pushreactivo, observableStream, FxEvents12
Contrapresióncontrol de flujono pedir el siguiente valor12
Fusiónfusión de etapas, deforestaciónuna sola pasada por toda una cadena5
Concurrenciaconcurrent(n), mapConcurrent — esperas solapadas13
Paralelismoisolates — cómputo solapado13

Fallo

TérminoTambién llamadoEn Dart / FxDartCap.
EitherResult, Validation, unión disjuntaEither<L, R>, Left, Right16
Ámbito Raiseámbito de receptor de contexto, ámbito de efectoeither((r) { … }), r.bind, r.ensure15
Continuación delimitadashift/reset, manejador de efectosla salida no local dentro de either15
Cortocircuitofallo rápidoel primer Left termina la cadena16
Acumulaciónfallo lento, validación aplicativaaccumulate, zipOrAccumulate, mapOrAccumulate17
NonEmptyListNelNonEmptyList<E> — extension type sobre List8
Transformador de mónadasEitherT, OptionTno se usa — en su lugar, eitherAsync7

Fundamentos

TérminoTambién llamadoEn Dart / FxDartCap.
Función puramismas entradas, misma salida, nada observable2
Transparencia referencialsustituibilidadreemplazar una llamada por su resultado2
Efectoefecto secundariocualquier cosa observable además del valor de retorno2
Función totaldefinida para toda entrada — fold lo es, reduce no8
Tipo productorecord, tupla, struct(A, B), campos de una clase3
Tipo sumaunión etiquetada, variante, coproductosealed class + switch3
Tipo de dato algebraicoADTsumas y productos juntos3
Currificación.curried / .uncurried4
Aplicación parcialun closure que captura algunos argumentos4
Función de orden superiortoma o devuelve una función4
Razonamiento ecuacionalreemplazar iguales por iguales, según una ley19
Leypropiedad, contratouna ecuación que las instancias deben cumplir1, 5, 8
Categoríaobjetos + flechas componibles + identidades20

Nombres que significan lo mismo

Un breve decodificador para la lectura entre lenguajes:

Apéndice B · Referencia de leyes

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.

Functor — capítulo 5

LeyEcuación
Identidadm.map((x) => x) == m
Composiciónm.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).

Mónada — capítulo 1

LeyEcuación
Identidad por la izquierdaof(a).flatMap(f) == f(a)
Identidad por la derecham.flatMap(of) == m
Asociatividadm.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.

Applicative — capítulo 6

LeyEcuación
Identidadof(id).ap(m) == m
Homomorfismoof(f).ap(of(a)) == of(f(a))
Intercambiou.ap(of(a)) == of((f) => f(a)).ap(u)
Composiciónof(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.

Monoide / semigrupo — capítulo 8

LeyEcuación
Asociatividad(a + b) + c == a + (b + c)
Identidad por la izquierdaempty + a == a
Identidad por la derechaa + 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.

Traverse — capítulo 9

LeyEnunciado
Identidadrecorrer con el applicative identidad es map
Composiciónrecorrer con dos applicatives en secuencia == recorrer una vez con su composición
Naturalidaduna 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.

Transformación natural — capítulo 20

LeyEcuació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.

Categoría — capítulo 20

LeyEcuación
Asociatividad(h ∘ g) ∘ f == h ∘ (g ∘ f)
Identidadid ∘ f == f == f ∘ id

Permite: extraer o insertar en línea cualquier composición de funciones puras, incluidas las etapas de una tubería.

Las condiciones previas detrás de todas ellas

  1. Pureza. Toda ley anterior se enuncia sobre valores; un efecto convierte dos valores iguales en dos programas distintos (capítulo 2).
  2. La igualdad correcta. Estructural para Either y Money; observacional para Future; igualdad de conjuntos para Set. Una ley puede cumplirse bajo una y fallar bajo otra (capítulo 19).
  3. Alguien lo comprobó. Las leyes de un tipo son una afirmación. Hasta que existe un test de propiedades, son un comentario (capítulo 19).

Plantilla de prueba

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');}
Apéndice C · Lecturas adicionales

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.

Dentro de este proyecto

FuenteBuena paraDificultad
Tutoriales de FxDart 101la superficie de la API, una función cada vez, con demos ejecutablesfácil
Dart vs FxDart (52 ejemplos)si una tubería es la herramienta adecuada para una tarea dada, con veredictosfácil
RxDart vs FxDartla decisión pull/push del capítulo 12, aplicada a 50 problemas realesfácil
WHY_CURRIED.mdel razonamiento detrás del capítulo 4 — qué le debe un port a su fuentemoderada
ARROW_MIGRATION_BLOCKER.mdel muro de los HKT del capítulo 10, documentado tal como se topó con élmoderada
benchmark/AUTHORING.mdcómo se producen las cifras del capítulo 14, y cómo añadir un casomoderada

La documentación de los antecesores

FuenteBuena paraDificultad
Arrow (Kotlin) — guía de errores tipadosla especificación de la parte IV, en la práctica; el ámbito Raise, la acumulación y la razón de ser del diseñomoderada
Documentación de FxTSel catálogo de operadores y concurrent(n); los nombres se corresponden casi exactamente con FxDartfácil
Cats (Scala) — documentación de typeclassesla torre enunciada de forma genérica: functor → applicative → mónada → recorridodifícil sin Scala
Data.Functor / Control.Monad de Haskelllas leyes en su forma original, con concisióndifícil

Artículos y charlas que merecen el tiempo

Libros

Qué leer para una pregunta concreta

Quieres saberVe 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

Una nota final sobre cómo leer teoría

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.