wandres.dev
FUNCIONES · currying y composición

Funciones de orden superior: el vocabulario que sustituye al bucle

Elm no tiene bucles. No hay for, no hay while, no hay break ni continue, y no existe ninguna forma de incrementar un contador porque no existe la reasignación. Esta lección reconstruye desde cero el vocabulario que ocupa ese hueco y demuestra que no es un sustituto empobrecido sino una descomposición más fina del mismo problema. Se estudian map como transformación que preserva la longitud, filter como selección que preserva el contenido, y foldl y foldr como la operación fundamental de la que las dos anteriores son casos particulares, incluida su reconstrucción explícita a partir del pliegue. Se analiza la diferencia real entre el pliegue por la izquierda y el por la derecha en un lenguaje estricto, con sus consecuencias sobre el orden de recorrido, la construcción de listas y la pila. Se completa el repertorio con filterMap, concatMap, any, all y sortBy, y se cierra con el argumento central del nivel: por qué nombrar la forma del recorrido en vez de escribirla convierte una intención en un tipo verificable.

⏱ 19 min

La primera vez que alguien acostumbrado a los lenguajes imperativos busca cómo escribir un bucle en Elm, descubre con incredulidad que la respuesta es que no se escribe. No hay for, no hay while, no hay break, no hay continue y no hay ninguna manera de incrementar un índice, porque no hay reasignación que lo permita. La reacción natural es pensar que se ha perdido algo básico, y merece la pena examinar despacio qué es exactamente lo que se ha perdido, porque no es lo que parece. Un bucle imperativo hace siempre dos cosas a la vez y las mezcla en el mismo bloque de texto: describe cómo recorrer una estructura y describe qué hacer con cada elemento. La primera parte es idéntica en el noventa y nueve por ciento de los bucles que se escriben en la vida, es la parte donde se cometen los errores de índice y de condición de parada, y no aporta información sobre el problema que se está resolviendo. La segunda parte es la única que importa. Lo que hace Elm no es prohibir el bucle sino separar esas dos cosas: la forma del recorrido se da por nombrada de antemano en un puñado de funciones, y lo único que escribes es la parte específica de tu problema. Aprender ese puñado de nombres no es aprender una biblioteca de utilidades; es aprender el vocabulario con el que se habla de recorridos cuando ya no hace falta describirlos.

🎯 Al terminar esta lección sabrás
  • Sustituir cualquier bucle imperativo por la función de orden superior que nombra la forma exacta de su recorrido.
  • Distinguir map y filter por lo que cada una preserva, la longitud en un caso y el contenido de los elementos en el otro.
  • Reconstruir map y filter a partir de foldl para entender que el pliegue es la operación fundamental.
  • Elegir entre foldl y foldr sabiendo qué cambia realmente en un lenguaje de evaluación estricta.

map y filter: lo que cada una promete conservar

Las dos funciones más usadas de la biblioteca se distinguen mejor por lo que garantizan que por lo que hacen. map promete que la lista resultante tendrá exactamente la misma longitud y el mismo orden que la de partida, y que cada elemento habrá sido sustituido por el resultado de aplicarle la función. filter promete lo contrario: los elementos que sobrevivan serán idénticos a los originales, y lo único que puede cambiar es cuántos hay.

-- map : (a -> b) -> List a -> List b
-- Conserva la longitud, cambia los elementos
List.map (\n -> n * 2) [ 1, 2, 3 ]        -- [ 2, 4, 6 ]
List.map .nombre usuarios                 -- List String

-- filter : (a -> Bool) -> List a -> List a
-- Conserva los elementos, cambia la longitud
List.filter (\n -> n > 2) [ 1, 2, 3, 4 ]  -- [ 3, 4 ]

-- El bucle imperativo tipico hacia ambas cosas mezcladas;
-- aqui se leen como dos intenciones separadas
usuarios
    |> List.filter .activo
    |> List.map .correo

Esa promesa no es una descripción informal: está escrita en el tipo. Que filter devuelva List a y no List b es una garantía verificada por el compilador de que ninguna función de filtrado podrá modificar los elementos por descuido, algo que en un bucle que hace las dos cosas a la vez depende exclusivamente de la disciplina de quien lo escribe. Leer map en una línea de código es saber sin mirar más que la cantidad de resultados no cambia; leer filter es saber que los datos no se han tocado. Un bucle no dice ninguna de las dos cosas hasta que se lee entero.

Conviene además notar que estos nombres no pertenecen a las listas. La misma pareja de operaciones existe con la misma forma para Maybe, para Result, para Dict, para Set, para Array y para los decodificadores, porque lo que nombran no es un recorrido sobre una secuencia sino una manera de relacionarse con un contenedor cualquiera. Quien aprendió List.map en la primera semana ya sabe leer Maybe.map sin que nadie se lo explique, y esa transferencia gratuita entre módulos es uno de los motivos por los que la biblioteca estándar de Elm se aprende mucho más rápido de lo que su tamaño sugiere.

foldl y foldr: el pliegue del que salen los demás

El pliegue es la operación fundamental sobre una lista, y todas las anteriores son casos particulares suyos. Recibe una función que combina un elemento con un acumulador, un valor inicial para ese acumulador y la lista, y va aplicando la combinación elemento a elemento hasta agotarla.

-- foldl : (a -> b -> b) -> b -> List a -> b
List.foldl (+) 0 [ 1, 2, 3, 4 ]          -- 10
List.foldl (::) [] [ 1, 2, 3 ]           -- [ 3, 2, 1 ], invierte
List.foldr (::) [] [ 1, 2, 3 ]           -- [ 1, 2, 3 ], reconstruye

-- map y filter son pliegues disfrazados
mapConFold : (a -> b) -> List a -> List b
mapConFold f lista =
    List.foldr (\x acc -> f x :: acc) [] lista

filterConFold : (a -> Bool) -> List a -> List a
filterConFold p lista =
    List.foldr
        (\x acc ->
            if p x then
                x :: acc

            else
                acc
        )
        []
        lista

La diferencia entre las dos direcciones se explica mal con metáforas y muy bien con un caso concreto: el que aparece arriba. foldl recorre desde el principio y va colocando cada elemento delante de lo acumulado, de modo que el primero acaba al fondo y la lista sale invertida. foldr recorre desde el final y reconstruye la lista tal cual. La regla práctica que se deriva es sencilla: cuando el acumulador es un número, un Bool o un Dict, la dirección casi nunca importa y conviene usar foldl, que en Elm es recursiva de cola y no crece en la pila; cuando el acumulador es una lista que quieres en el mismo orden, usa foldr. Conviene también desmontar aquí un mito heredado de los lenguajes perezosos: en Elm la evaluación es estricta, así que foldr no puede detenerse antes de tiempo ni operar sobre estructuras infinitas, y su única ventaja real es el orden de construcción.

💡
Antes de plegar, pregúntate si hay un nombre mejor

El pliegue lo puede todo, y por eso mismo es la peor herramienta cuando existe otra más específica. Un foldl que solo suma debería ser List.sum; uno que solo cuenta condiciones debería ser un filter seguido de List.length; uno que descarta y transforma a la vez es List.filterMap; uno que devuelve un Bool según se cumpla algo es List.any o List.all. La ventaja de la función específica no es la brevedad sino que su nombre anuncia la forma del cálculo antes de leerlo, mientras que un pliegue obliga a estudiar la función acumuladora para averiguar qué está pasando. Reserva foldl y foldr para cuando de verdad estés construyendo algo que no tiene nombre propio.

El repertorio completo y el bucle que ya no se escribe

Con media docena de nombres más queda cubierto prácticamente todo lo que un bucle imperativo hace en el código real, incluidas las combinaciones que en el mundo imperativo obligan a acumular banderas o a salir a mitad del recorrido.

-- Filtrar y transformar de una vez, descartando lo que no convierte
List.filterMap String.toInt [ "1", "dos", "3" ]    -- [ 1, 3 ]

-- Aplanar una lista de listas producida por la transformacion
List.concatMap etiquetasDe articulos

-- Los que sustituyen a un bucle con salida anticipada
List.any esUrgente tareas        -- Bool
List.all estaValidado campos     -- Bool
List.member "elm" lenguajes      -- Bool

-- Ordenar por un criterio derivado, sin comparador manual
List.sortBy .apellido usuarios
List.sortWith compararPrioridad tareas

-- Y el equivalente de un indice, cuando de verdad hace falta
List.indexedMap (\i x -> String.fromInt i ++ ": " ++ x) filas

Merece la pena notar que any y all conservan la eficiencia del bucle con salida anticipada, porque su implementación deja de recorrer en cuanto el resultado está decidido. La diferencia con el bucle imperativo equivalente es que aquí la salida anticipada no es algo que tú escribes y puedes equivocarte al escribir, sino una propiedad de la función que usas. Lo mismo ocurre con List.indexedMap, que es la respuesta correcta cuando de verdad necesitas la posición: el índice te llega como argumento y no puedes desalinearlo, que es exactamente el error más común del bucle con contador.

Hay además una consecuencia menos evidente de este vocabulario y que solo se aprecia al mantener un programa durante meses. Como cada función de orden superior tiene un tipo que declara la forma del recorrido, cambiar de opinión sobre esa forma es un cambio que el compilador acompaña. Convertir un map en un filterMap porque ahora algunos elementos pueden no producir resultado no es una edición delicada dentro de un bucle con banderas, sino una sustitución de nombre que provoca un error de tipo en cada punto que dependía del resultado anterior, y ese error es la lista exacta de sitios que hay que revisar. En un bucle imperativo el mismo cambio se hace añadiendo una condición en medio, no rompe nada visiblemente y deja al resto del programa recibiendo silenciosamente menos elementos de los que esperaba. La diferencia entre las dos experiencias no es de elegancia: es la diferencia entre un cambio guiado y un cambio a ciegas.

🎨

map

Misma longitud, mismo orden, elementos transformados. El tipo List a -> List b ya te dice que nada se pierde por el camino.

🧹

filter

Mismos elementos, longitud menor o igual. El tipo List a -> List a garantiza que el predicado no puede modificar nada.

🌀

foldl y foldr

La operación fundamental. Todo lo demás se puede escribir con ella, y por eso conviene usarla solo cuando lo demás no llega.

🧰

El resto del vocabulario

filterMap, concatMap, any, all, sortBy, indexedMap. Cada nombre es una forma de recorrido que ya no hay que escribir.

flowchart TD
L[Lista de entrada] --> M[map transforma cada uno]
L --> F[filter selecciona algunos]
L --> D[foldl acumula en un valor]
M --> S[Lista de igual longitud]
F --> T[Lista mas corta o igual]
D --> U[Un unico resultado]
style M fill:#89b4fa,color:#11111b
style F fill:#f9e2af,color:#11111b
style D fill:#a6e3a1,color:#11111b
No es que falten bucles: es que un bucle mezcla dos cosas que conviene separar

El argumento decisivo a favor de este vocabulario no es la elegancia ni la brevedad, y conviene decirlo con precisión porque la versión superficial del argumento es fácil de rebatir. Un bucle imperativo es una construcción que fusiona el mecanismo del recorrido con la lógica del problema, y esa fusión tiene tres consecuencias que se pagan todos los días. La primera es que el mecanismo, siendo siempre el mismo, se reescribe una y otra vez, y cada reescritura es una oportunidad nueva de equivocarse en la condición de parada, en el índice inicial o en el manejo del último elemento; la literatura sobre errores fuera de rango no existe por casualidad. La segunda es que la intención queda oculta: leer las tres primeras líneas de un bucle no te dice si vas a obtener la misma cantidad de elementos, menos, o un único valor, y para saberlo tienes que leerlo entero y simular su ejecución en la cabeza, mientras que leer map te lo dice en una palabra y con garantía del compilador. La tercera es más profunda y es la que de verdad sostiene todo el edificio: al no poder mutar nada, cada forma de recorrido tiene un tipo distinto y honesto, de manera que la elección entre map, filter y foldl no es cuestión de gusto sino de qué transformación estás haciendo, y el sistema de tipos verifica que la que dices sea la que haces. Esto convierte una convención de estilo en una propiedad comprobada. Ahora bien, la crítica habitual merece respuesta y no negación. Es cierto que ciertos algoritmos se expresan con más naturalidad con estado mutable, que un pliegue con un acumulador de cinco campos es más difícil de leer que su bucle equivalente, y que hay problemas donde el recorrido irregular no encaja en ninguna de las formas nombradas. La respuesta de Elm a esos casos es la recursión explícita, que sigue estando disponible y que es donde se acaba escribiendo el recorrido a mano cuando ninguna función lo captura. Pero esos casos son mucho más raros de lo que la intuición imperativa sugiere, y la experiencia de cualquier programa real de Elm es que el noventa y cinco por ciento del código que en otro lenguaje serían bucles aquí son tres o cuatro nombres bien elegidos. Aprender ese vocabulario es lo que separa el código de Elm escrito por alguien que trajo su bucle en la cabeza y lo tradujo trabajosamente a un pliegue, del escrito por alguien que ya piensa en términos de transformaciones y para quien la pregunta no es cómo recorro esto sino qué le estoy haciendo.

⚔️ Traduce tus bucles a un vocabulario
  1. Toma tres bucles de un proyecto tuyo en otro lenguaje y clasifícalos antes de traducirlos: transformación, selección o acumulación.
  2. Escribe map y filter usando solo foldr, y después intenta escribir foldl usando solo map; explica por qué lo segundo no es posible.
  3. Ejecuta el mismo pliegue con foldl y con foldr construyendo una lista y razona el orden del resultado antes de mirarlo.
  4. Sustituye un foldl que solo suma, otro que solo cuenta y otro que solo comprueba una condición por la función específica que les corresponde.
  5. Encadena filter seguido de map sobre la misma lista y después reescríbelo con filterMap; compara qué versión comunica mejor la intención.
  6. Encuentra un caso donde ninguna de las funciones del repertorio encaje, escríbelo con recursión explícita y argumenta por qué ahí la excepción está justificada.