Combinar decodificadores: map2, map3 y el estilo pipeline
Con primitivos y combinadores se lee un valor suelto, pero un modelo real es un registro con cinco, ocho o quince campos y hay que construirlo entero a partir de un solo documento. Esta lección desarrolla el mecanismo que lo permite y la idea que lo sostiene: un constructor de registro es una función ordinaria, y decodificar un registro es aplicar esa función a valores que todavía no existen porque viven dentro de decodificadores. De ahí salen map2 y map3, que aplican una función de dos o tres argumentos a otros tantos decodificadores; de ahí sale también el techo incómodo de las aridades fijas, y de ahí sale la solución idiomática, el estilo pipeline, donde un decodificador de función se va alimentando campo a campo hasta quedar saturado. Al final se explica por qué el pipeline es exactamente la misma idea escrita de otra manera, y qué precio tiene su comodidad.
Hasta aquí cada decodificador extraía una cosa. Un modelo real no es una cosa: es un registro con nombre, correo, edad, fecha de alta y una lista de permisos, y todos esos valores tienen que salir del mismo documento en un solo acto. La tentación es imaginar que hará falta un mecanismo nuevo, alguna clase de constructor especial que sepa recorrer el objeto y rellenar campos. No hace falta nada de eso, y entender por qué es el paso que abre el nivel entero. En Elm, declarar un alias de registro define automáticamente una función que recibe los campos en orden y devuelve el registro; esa función es tan ordinaria como cualquier otra. El problema, entonces, deja de ser cómo construir un registro y pasa a ser otro mucho más general: cómo aplicar una función corriente a argumentos que aún no tengo, porque cada uno está encerrado dentro de un decodificador que todavía no se ha ejecutado. La respuesta a esa pregunta es map2, es map3, y cuando el registro crece es el estilo pipeline, que no es otra cosa que la misma respuesta escrita de forma que no se acabe nunca.
- Reconocer que el alias de un registro define una función constructora y usarla como cualquier otra función.
- Aplicar
map2ymap3para construir valores a partir de varios decodificadores independientes. - Explicar por qué las aridades fijas tienen un techo y qué problema práctico provoca ese techo.
- Escribir un decodificador en estilo pipeline y describir con precisión qué ocurre en cada paso.
El constructor del registro ya es una función
Antes de combinar nada conviene ver la pieza que se va a combinar. Cuando defines un alias de registro con tres campos, el compilador te regala una función de tres argumentos que los recibe en el orden de declaración y devuelve el registro construido. No es un método, no es un inicializador especial y no vive en ningún espacio de nombres aparte: es un valor de función que puedes pasar, componer y aplicar parcialmente.
type alias Usuario =
{ nombre : String
, edad : Int
, activo : Bool
}
-- El alias define esta funcion, sin escribirla
-- Usuario : String -> Int -> Bool -> Usuario
-- Y map2 aplica una funcion de dos argumentos a dos decodificadores
map2 : (a -> b -> valor) -> Decoder a -> Decoder b -> Decoder valor
map3 : (a -> b -> c -> valor) -> Decoder a -> Decoder b -> Decoder c -> Decoder valor
Lee la firma de map2 sustituyendo mentalmente los nombres por casos concretos y verás que no hay nada específico de JSON en ella. Dice: si me das una manera de combinar dos valores y dos planes para obtener esos valores, te devuelvo un plan para obtener la combinación. La misma forma aparece en Maybe, en Result, en las tareas y en las listas, y reconocerla aquí es reconocer un patrón que ya conoces de niveles anteriores aplicado a un contenedor nuevo.
El alias construye
Declarar un registro define su función constructora con los campos en orden. Decodificar un registro es aplicarla a valores diferidos.
map2 combina planes
Recibe una función de dos argumentos y dos decodificadores. Si cualquiera falla, falla el conjunto, y el error señala cuál.
El techo de la aridad
La biblioteca llega hasta ocho argumentos. Un registro de doce campos se queda fuera, y el orden posicional se vuelve frágil mucho antes.
El pipeline no se acaba
Alimenta un decodificador de función campo a campo. No tiene límite superior y cada línea nombra su clave junto a su tipo.
Aridades fijas y el techo que imponen
Con dos o tres campos el resultado es limpio y perfectamente legible. El decodificador se lee como una declaración: este registro se construye con esta función a partir de estos tres campos, cada uno con su nombre y su tipo. Todo está a la vista y el compilador comprueba que la función y los decodificadores encajan en número y en tipo.
usuarioDecoder : Decoder Usuario
usuarioDecoder =
map3 Usuario
(field "nombre" string)
(field "edad" int)
(field "activo" bool)
El problema aparece cuando el registro crece, y crece siempre. La biblioteca ofrece variantes hasta ocho argumentos, así que un registro de doce campos simplemente no tiene función que lo cubra. Pero el límite duro no es lo peor: mucho antes de llegar a él, la correspondencia entre la posición del decodificador y el campo del registro se vuelve un ejercicio de contar con el dedo. Si dos campos contiguos son ambos cadenas y alguien los intercambia por descuido, el programa compila sin una queja y el usuario ve el correo donde debería ver el nombre. El sistema de tipos no puede protegerte ahí, porque desde su punto de vista ambas cosas son texto y el error es de significado, no de forma.
Este es el único punto de todo el nivel donde un error puede pasar inadvertido al compilador, y por eso conviene señalarlo con claridad. Cuando varios campos comparten tipo, la aplicación posicional deja de estar comprobada en la práctica: el tipo encaja, el número encaja, y sin embargo el significado está permutado. La respuesta no es tener más cuidado, que nunca ha sido una estrategia de ingeniería, sino cambiar de notación. El estilo pipeline coloca el nombre de la clave en la misma línea que el tipo esperado y elimina la aritmética mental de las posiciones; y para los casos que aun así preocupan, la defensa real es un tipo envoltorio por concepto del dominio, de modo que un correo y un nombre dejen de ser ambos una cadena desnuda.
map: transformar el valor recién obtenido
Antes de pasar a la notación definitiva conviene rescatar el caso más simple de la familia, que es el de un solo decodificador. La función de un argumento aplica una transformación al valor extraído sin tocar el resto del plan, y su utilidad no es cosmética: es el sitio donde una cadena desnuda se convierte en un tipo del dominio y deja de poder confundirse con cualquier otra cadena. Combinada con los envoltorios de un solo constructor, resuelve de raíz el fallo silencioso de la sección anterior.
map : (a -> valor) -> Decoder a -> Decoder valor
-- Envoltorios del dominio: dos cadenas que ya no son intercambiables
type Correo
= Correo String
type Nombre
= Nombre String
correoDecoder : Decoder Correo
correoDecoder =
map Correo (field "correo" string)
Con el registro definido sobre estos tipos, intercambiar dos decodificadores deja de compilar, porque un Correo y un Nombre ya no son la misma cosa para el compilador aunque ambos contengan texto. El coste es un constructor por concepto y una función para extraer el contenido cuando haga falta; el beneficio es que la comprobación posicional vuelve a ser una comprobación real y no una coincidencia de tipos primitivos.
El pipeline: saturar una función campo a campo
El estilo pipeline resuelve las dos cosas a la vez y lo hace sin ningún mecanismo nuevo, solo con aplicación parcial. La idea, una vez vista, resulta casi obvia. Se empieza con un decodificador que no mira el documento y siempre tiene éxito devolviendo la función constructora todavía sin aplicar. A partir de ahí, cada paso toma ese decodificador de función, extrae un campo del documento y aplica la función a lo extraído, devolviendo un decodificador de una función con un argumento menos. Cuando ya no quedan argumentos, lo que tienes es un decodificador del registro.
import Json.Decode.Pipeline exposing (hardcoded, optional, required)
-- Cada paso consume un argumento de la funcion constructora
-- required : String -> Decoder a -> Decoder (a -> b) -> Decoder b
usuarioDecoder : Decoder Usuario
usuarioDecoder =
succeed Usuario
|> required "nombre" string
|> required "edad" int
|> optional "activo" bool True
Detente en la firma de required, porque es la única cosa realmente nueva de la lección. Su último parámetro es un decodificador cuyo contenido es una función pendiente de un argumento, y su retorno es un decodificador de lo que esa función devuelve una vez alimentada. Encadenar es, literalmente, ir descontando parámetros. El primer paso parte de una función de tres argumentos, el segundo trabaja sobre una de dos, el tercero sobre una de uno, y el último produce el registro. Si sobra o falta un paso, el tipo final no será el registro y el compilador lo dirá con precisión.
flowchart LR S[succeed Usuario funcion de tres] --> A[required nombre] A --> B[funcion de dos] B --> C[required edad] C --> D[funcion de uno] D --> E[optional activo] E --> F[Decoder Usuario] style S fill:#89b4fa,color:#11111b style F fill:#a6e3a1,color:#11111b
Hay dos maneras de aprender esta notación y solo una de ellas sirve a largo plazo. La primera es memorizarla como una receta: se empieza con succeed, se enchufan líneas con required y sale un decodificador. Funciona para copiar y deja de funcionar el día que aparece un caso que la receta no contempla. La segunda es reconocer qué está ocurriendo realmente, y lo que ocurre es que estás aplicando una función a argumentos dentro de un contexto que puede fallar. Ese contexto podría ser una decodificación, pero también podría ser una validación que acumula errores, una tarea asíncrona, una lista de posibilidades o una lectura de configuración; el patrón es idéntico en todos ellos porque no depende del contenedor, depende únicamente de que exista una manera de meter un valor puro dentro del contexto y una manera de aplicar una función encerrada a un argumento encerrado. Cuando se ve así, tres cosas dejan de parecer arbitrarias de golpe. La primera es por qué existe succeed: no es un caso especial, es la operación que introduce un valor que no necesita mirar la entrada. La segunda es por qué el orden de los pasos tiene que coincidir con el orden de los parámetros del constructor: no es una convención de la biblioteca, es lo que significa aplicar una función a sus argumentos. Y la tercera es por qué la aridad no impone ningún límite: como cada paso devuelve un objeto del mismo tipo que el que consumió, la cadena puede alargarse indefinidamente sin que nadie tenga que escribir la variante número nueve. Conviene además ser honesto sobre el precio. El pipeline es una biblioteca de la comunidad y no del núcleo, y su comodidad esconde un peligro real: optional invita a inventar un valor por defecto cada vez que un campo incomoda, y ese gesto, repetido sin criterio, convierte un desajuste con el servidor en un dato falso que atraviesa la frontera sin ruido. La notación es excelente; la disciplina de preguntarse si la ausencia de ese campo es de verdad legítima sigue siendo tuya.
- Declara un alias de registro de tres campos y escribe su función constructora a mano para comprobar que coincide con la que el compilador genera.
- Construye ese registro con
map3y luego intercambia dos decodificadores del mismo tipo; explica por qué el compilador no protesta. - Reescribe el mismo decodificador en estilo pipeline y señala en qué línea la función constructora queda saturada.
- Lee la firma de
requireden voz alta y traduce a palabras qué consume y qué devuelve cada paso. - Añade dos campos más al registro y observa que el pipeline no necesita ninguna variante nueva; razona por qué
map3sí la necesitaría. - Justifica un uso de
optionalen tu dominio y un caso en que sería un error usarlo, apoyando la distinción en si la ausencia es legítima.