El operador pipe: leer el código como una receta
El código funcional escrito sin ayuda se lee al revés de como se ejecuta: la operación que ocurre primero aparece en el interior de los paréntesis, y la última en aparecer es la primera que lee el ojo. Esta lección estudia el operador de tubería como la respuesta de Elm a ese desajuste entre orden de lectura y orden de cálculo. Se analiza su definición exacta, su asociatividad a la izquierda y su precedencia baja, y por qué esas dos propiedades bastan para que una cadena de transformaciones se escriba en el mismo orden en que se piensa. Se examina su relación estructural con la aplicación parcial de la lección anterior, sin la cual el operador sería inservible, y la convención de colocar el dato al final que hace que la biblioteca estándar encaje en tuberías sin adaptadores. Se estudia también el operador inverso, su uso legítimo para eliminar paréntesis finales en las vistas y en las llamadas a comandos, y el criterio para decidir cuándo una tubería aclara el código y cuándo conviene romperla en definiciones locales con nombre.
Hay un desajuste incómodo en el corazón de la notación funcional que casi nadie declara en voz alta. Cuando escribes una expresión compuesta, el orden en que aparecen las operaciones sobre la página es exactamente el inverso del orden en que ocurren. Para calcular el número de palabras distintas de un texto tienes que escribir primero la operación que se hace al final, luego ir hacia dentro, y quien lea ese código deberá localizar el paréntesis más profundo, empezar ahí y salir hacia fuera reconstruyendo mentalmente una secuencia que la sintaxis se empeña en presentar del revés. El problema empeora rápido: con dos pasos es tolerable, con cuatro empieza a costar, y con seis hay que contar paréntesis con el dedo. La respuesta de Elm a ese desajuste no es una construcción especial del lenguaje ni una sintaxis nueva, sino un operador definido en una biblioteca corriente que hace una única cosa: tomar un valor y una función y aplicarle la función. Parece poco, y sin embargo cambia por completo la experiencia de leer código, porque convierte una expresión anidada en una lista de pasos que se recorren de izquierda a derecha y de arriba abajo, igual que se lee una receta de cocina o un guion de montaje. Nada de lo que hace es mágico, y precisamente ahí está su interés: es un ejemplo perfecto de cómo un lenguaje pequeño resuelve un problema grande sin crecer.
- Explicar la definición del operador de tubería y por qué su asociatividad a la izquierda y su baja precedencia bastan para encadenar transformaciones.
- Reescribir una expresión con paréntesis anidados como una tubería lineal y justificar en qué mejora la lectura.
- Relacionar la convención de colocar el dato al final con la posibilidad de encadenar funciones de la biblioteca estándar sin adaptadores.
- Usar el operador inverso para eliminar paréntesis de cierre y decidir con criterio cuándo una tubería deja de aclarar.
Un operador que no hace casi nada
El operador de tubería no es una palabra reservada ni una construcción del compilador. Es una función corriente definida en el módulo Basics, con dos argumentos, cuya implementación cabe holgadamente en una línea. Lo único que tiene de especial es su declaración de fijeza: asocia a la izquierda y tiene una precedencia deliberadamente baja, más baja que la de cualquier operador aritmético o de comparación.
-- La definicion completa, sin trucos
(|>) : a -> (a -> b) -> b
(|>) valor funcion =
funcion valor
-- Por eso estas dos expresiones son la misma
String.toUpper "elm"
"elm" |> String.toUpper
-- Y por eso una cadena se aplana
palabrasDistintas : String -> Int
palabrasDistintas texto =
texto
|> String.toLower
|> String.words
|> List.filter (\p -> String.length p > 3)
|> Set.fromList
|> Set.size
Compara esa tubería con su equivalente anidado, que sería Set.size (Set.fromList (List.filter ... (String.words (String.toLower texto)))). La versión anidada obliga a leer de dentro hacia fuera y a mantener en la cabeza cuántos paréntesis quedan abiertos. La versión encadenada se lee como una lista de instrucciones: pasa a minúsculas, parte en palabras, quédate con las largas, elimina duplicados, cuenta. La cantidad de trabajo que hace la máquina es idéntica; la que hace el lector, no.
Por qué la tubería solo funciona con currying
Es tentador tomar el operador como un mero recurso cosmético, pero conviene ver la maquinaria que lo sostiene, porque sin la lección anterior sería inservible. Cada eslabón de una tubería debe ser una función de exactamente un argumento, y casi ninguna de las funciones que aparecen ahí lo es. List.filter recibe dos, String.replace recibe tres. Lo que se escribe en cada eslabón no es la función sino una aplicación parcial de ella, ya saturada de todo salvo el dato.
-- Cada eslabon es una aplicacion parcial: le falta exactamente el dato
"a,b,c"
|> String.split "," -- String.split "," : String -> List String
|> List.map String.trim -- List.map f : List String -> List String
|> List.filter esValido -- List.filter g : List a -> List a
|> String.join " y " -- String.join s : List String -> String
-- El dato al final no es estetica: es lo que deja el hueco en el sitio correcto
-- String.split : String -> String -> List String
-- separador dato
Aquí se cierra el círculo del nivel. La convención de colocar el sujeto de la operación al final, que en la lección anterior parecía una recomendación de estilo, resulta ser la condición necesaria para que la biblioteca entera sea encadenable. Si String.split recibiera el texto primero y el separador después, no habría manera de usarla en una tubería sin envolverla en una función anónima, y cada eslabón se llenaría de ruido. Las dos decisiones, currying y dato al final, no son independientes: se diseñaron la una para la otra, y el operador es simplemente el punto donde se hacen visibles.
Para saber si una expresión puede ser un eslabón, comprueba una sola cosa: que le falte exactamente un argumento y que ese argumento sea justo lo que viene por la tubería. Si te falta más de uno, tienes una función donde esperabas un valor y el compilador te lo dirá con esas palabras. Si no te falta ninguno, has escrito un valor donde se esperaba una función. Y si lo que falta no es el último parámetro sino uno intermedio, el remedio no es forzar la tubería sino escribir una función anónima explícita en ese eslabón, que es preferible a una cadena de reordenaciones ingeniosas que nadie podrá leer dentro de seis meses.
El operador inverso y el paréntesis que desaparece
Existe también el operador simétrico, que aplica una función a un valor escrito a su derecha. Su utilidad no es encadenar, para eso ya está el otro, sino eliminar el paréntesis que envuelve un argumento largo, sobre todo cuando ese argumento ocupa varias líneas.
-- La definicion, igual de escueta
(<|) : (a -> b) -> a -> b
(<|) funcion valor =
funcion valor
-- Sin el operador: el parentesis se abre arriba y se cierra muy abajo
text (String.fromInt (List.length modelo.tareas))
-- Con el operador: sin parentesis de cierre pendientes
text <| String.fromInt <| List.length modelo.tareas
-- Su uso mas habitual y mas legitimo, en vistas y en comandos
div [ class "aviso" ]
[ text <| "Quedan " ++ String.fromInt restantes ++ " tareas" ]
El operador inverso divide opiniones y conviene tener una postura razonada. Su virtud es cerrar la distancia entre un paréntesis de apertura y el de cierre, que en una vista de veinte líneas puede ser considerable y es una fuente real de errores al editar. Su defecto es que invierte el sentido de lectura respecto al operador principal, de modo que un archivo que mezcle ambos obliga al ojo a cambiar de dirección constantemente. La práctica más extendida en la comunidad es usar el directo para todo lo que sea transformación de datos y reservar el inverso para el último argumento de una llamada, sin encadenarlo nunca más de una vez.
La tubería dentro de update
Donde el operador deja de ser una comodidad y se convierte en un patrón arquitectónico es en la función update, el corazón de la arquitectura que estudia este track. Un mensaje suele exigir varios cambios sucesivos sobre el modelo, y sin tubería esos cambios se escriben como una sucesión de definiciones locales cuyo único contenido es pasar el resultado a la siguiente.
-- Sin tuberia: tres nombres intermedios que no significan nada
actualizar msg modelo =
let
conFiltro =
aplicarFiltro modelo
ordenado =
reordenar conFiltro
recontado =
recalcularTotales ordenado
in
( recontado, Cmd.none )
-- Con tuberia: los tres pasos, sin los tres nombres inventados
actualizarLimpio msg modelo =
( modelo
|> aplicarFiltro
|> reordenar
|> recalcularTotales
, Cmd.none
)
La segunda versión no es solo más corta: elimina tres nombres que no pertenecían al dominio y que existían únicamente para sostener el resultado intermedio. Ese detalle importa más de lo que parece, porque cada nombre inventado es una invitación a usarlo por error en el sitio equivocado, y en una función update que crezca a treinta líneas basta con escribir conFiltro donde tocaba ordenado para introducir un fallo silencioso que compila perfectamente. La tubería hace ese error imposible por construcción, ya que nunca llega a existir un nombre al que referirse.
Se lee como una receta
La tubería alinea el orden de lectura con el orden de ejecución. Primer paso arriba, último paso abajo, sin contar paréntesis.
No es sintaxis
Es una función corriente con una declaración de fijeza. Podrías definirla tú mismo, y ese es precisamente el argumento a favor.
Vive del currying
Cada eslabón es una aplicación parcial a la que le falta el dato. Sin aridad uno y sin dato al final, no habría tubería.
El inverso
Sirve para borrar el paréntesis de un argumento largo, no para encadenar. Úsalo una vez por expresión y nunca en cascada.
flowchart LR A[texto crudo] --> B[toLower] B --> C[words] C --> D[filter longitud] D --> E[fromList] E --> F[size] style B fill:#89b4fa,color:#11111b style D fill:#f9e2af,color:#11111b style F fill:#a6e3a1,color:#11111b
Detrás de este operador hay una decisión de diseño que merece contemplarse en toda su extensión, porque explica el carácter de Elm mejor que muchas de sus características mayores. En la mayoría de los lenguajes, la manera de encadenar operaciones sobre un dato es el encadenamiento de métodos, y ese mecanismo tiene una propiedad que rara vez se examina: solo puedes encadenar lo que el autor de la clase decidió incluir en ella. Si el tipo no trae el método que necesitas, la cadena se rompe, tienes que salir a una variable temporal o a una función envolvente, y el privilegio de encadenar queda reservado a las operaciones que alguien previó de antemano. La tubería invierte esa relación de poder por completo. Como el operador no sabe nada de tipos ni de pertenencia, cualquier función escrita por cualquiera, hoy, en tu módulo, sobre un tipo que no controlas, entra en la cadena con exactamente los mismos derechos que las funciones de la biblioteca estándar. No hay ciudadanos de primera y de segunda. Esa simetría es lo que en otros ecosistemas se persigue con métodos de extensión, con reapertura de clases o con envoltorios, siempre a costa de introducir un mecanismo nuevo con sus propias reglas de resolución; aquí sale gratis porque nunca hubo un mecanismo que romper. Conviene además notar la modestia técnica del asunto: el operador no está en el compilador, está en una biblioteca, y su comportamiento entero deriva de dos declaraciones de fijeza. Un lenguaje que resuelve la legibilidad de las transformaciones encadenadas sin añadir una sola construcción al núcleo está afirmando algo sobre dónde cree que debe residir la expresividad, y la afirmación es que debe residir en las funciones y no en la gramática. Ahora bien, la tubería también tiene un límite que conviene reconocer para no convertirla en dogma. Una cadena de tres o cuatro pasos con nombres claros es más legible que cualquier alternativa; una de doce pasos, con eslabones que llevan funciones anónimas de tres líneas, ha dejado de ser una receta y se ha convertido en un párrafo sin puntos. Cuando eso ocurre, el remedio no es más ingenio para acortarla sino romperla en dos o tres definiciones locales con nombres del dominio, porque un nombre bien elegido documenta un tramo entero mejor que la sucesión de sus pasos. La tubería sirve para que el código se lea como se piensa, y nadie piensa doce cosas seguidas sin respirar.
- Escribe
palabrasDistintasprimero con paréntesis anidados y después con tubería; cuenta cuántos paréntesis desaparecen y en qué dirección se lee cada versión. - Toma un eslabón cualquiera de una tubería y anota su tipo antes y después de recibir el dato; comprueba que efectivamente le faltaba un solo argumento.
- Construye una tubería que use una función tuya cuyo dato principal esté en primera posición; observa la fricción y reordena los parámetros para eliminarla.
- Reescribe una vista que tenga tres paréntesis de cierre acumulados usando el operador inverso una sola vez, y valora si mejoró la lectura.
- Encadena a propósito el operador inverso tres veces seguidas, léelo en voz alta y explica por qué la comunidad desaconseja esa forma.
- Localiza en tu código una tubería de más de ocho eslabones y pártela en dos definiciones locales con nombres del dominio; compara ambas versiones sin mirar el detalle de los pasos.