wandres.dev
FUNCIONES · currying y composición

Currying y aplicación parcial: toda función toma un argumento

La firma de una función en Elm esconde una afirmación que casi nadie lee literalmente la primera vez: no existen las funciones de dos parámetros. Toda función recibe exactamente un argumento y devuelve, o bien un valor final, o bien otra función que espera el siguiente. Esta lección desarrolla esa afirmación desde su base sintáctica, la asociatividad a la derecha de la flecha, hasta sus consecuencias prácticas más cotidianas. Se estudia por qué aplicar una función a menos argumentos de los que aparentemente pide no es un error sino un resultado perfectamente tipado, cómo esa aplicación parcial convierte cualquier función general en una familia entera de funciones especializadas sin escribir una sola línea de fábrica, y por qué la convención de colocar el dato principal al final no es un capricho estilístico de la biblioteca estándar sino la condición que hace utilizable el encadenamiento de la próxima lección. Se examinan además los operadores como funciones mediante secciones, los constructores de tipos como funciones curradas y los casos reales donde el currying aparece a diario sin que nadie lo nombre.

⏱ 18 min

Hay una frase que se repite en todos los textos sobre lenguajes funcionales y que casi siempre se recibe como una curiosidad teórica: en Elm todas las funciones toman un solo argumento. Quien la oye por primera vez suele archivarla junto a las anécdotas del cálculo lambda y seguir escribiendo sumar a b sin darle más vueltas, porque el código funciona igual sin haberla entendido. Y sin embargo esa frase no describe una sutileza matemática sino el mecanismo concreto que hace que el resto del nivel tenga sentido. Cuando escribes una firma Int -> Int -> Int no estás declarando una función que recibe dos enteros: estás declarando una función que recibe un entero y devuelve otra función, la cual recibe un entero y devuelve un entero. La flecha asocia a la derecha, siempre, sin excepciones y sin sintaxis especial que lo indique. De esa única regla se sigue todo lo demás. Se sigue que aplicar una función a menos argumentos de los que parece pedir sea legal y útil en vez de un error. Se sigue que puedas fabricar funciones especializadas escribiendo el nombre de una general seguido de un dato. Y se sigue, sobre todo, que el orden en que decides colocar los parámetros de tus propias funciones sea una decisión de diseño con consecuencias reales sobre cómo se leerá el código que las use.

🎯 Al terminar esta lección sabrás
  • Leer cualquier firma de Elm sabiendo que la flecha asocia a la derecha y que cada paso devuelve una función nueva.
  • Aplicar parcialmente funciones y operadores para crear funciones especializadas sin definir envoltorios ni fábricas.
  • Justificar el orden de los parámetros de una función propia según el uso que se le vaya a dar en una tubería.
  • Reconocer el currying en situaciones cotidianas donde aparece sin nombrarse, como constructores, decodificadores y manejadores de eventos.

Una flecha, un argumento, ninguna excepción

La sintaxis de Elm no distingue entre una función de dos parámetros y una función que devuelve otra función, porque para el lenguaje son literalmente lo mismo. Las dos formas de escribir la firma siguiente designan exactamente el mismo tipo, y el compilador no guarda en ningún sitio la información de cuál escribiste.

-- Estas dos firmas son el mismo tipo; la flecha asocia a la derecha
sumar : Int -> Int -> Int
sumar : Int -> (Int -> Int)

sumar a b =
    a + b

-- Y estas dos aplicaciones son la misma aplicacion
sumar 2 3        -- 5
(sumar 2) 3      -- 5

-- Porque aplicar liga a la izquierda: primero sumar 2, despues el 3

Merece la pena observar la simetría entre las dos reglas de asociatividad, porque es lo que hace que el sistema encaje sin ceremonia. El tipo asocia a la derecha, de modo que la firma se lee como una cadena de funciones de un argumento. La aplicación asocia a la izquierda, de modo que escribir varios argumentos seguidos consume esa cadena de izquierda a derecha, uno a uno. Ninguna de las dos reglas necesita paréntesis para funcionar, y esa ausencia de paréntesis es precisamente lo que hace que la aplicación parcial resulte invisible: no hay ninguna sintaxis que te obligue a declarar que vas a aplicar todos los argumentos, así que tampoco hay ninguna que te impida aplicar solo algunos.

Aplicar a medias es un resultado, no un error

En un lenguaje con aridad fija, invocar una función con menos argumentos de los que declara es un error de compilación o una llamada con valores indefinidos. En Elm es simplemente evaluar la mitad de la cadena y quedarse con lo que queda, que es una función perfectamente tipada y perfectamente utilizable.

-- Una funcion general
sumar : Int -> Int -> Int
sumar a b =
    a + b

-- Y una familia de funciones especializadas, sin escribir ninguna fabrica
incrementar : Int -> Int
incrementar =
    sumar 1

sumarDiez : Int -> Int
sumarDiez =
    sumar 10

-- Los operadores tambien se aplican parcialmente si se les ponen parentesis
saludar : String -> String
saludar =
    (++) "Hola, "

-- Y por eso esto se lee sin ruido
List.map (sumar 10) [ 1, 2, 3 ]          -- [ 11, 12, 13 ]
List.filter (\n -> n > 2) [ 1, 2, 3, 4 ] -- [ 3, 4 ]
List.map (String.repeat 3) [ "ab", "c" ] -- [ "ababab", "ccc" ]

Fíjate en que incrementar no tiene parámetros a la izquierda del igual. No hace falta escribirlos: sumar 1 ya es una función que espera un entero, así que ligarla a un nombre basta para tener la función especializada. Escribir incrementar n = sumar 1 n daría exactamente el mismo resultado, y es una variante que también verás con frecuencia, pero la primera versión revela mejor lo que está ocurriendo. No estamos definiendo una función que llama a otra: estamos dándole un nombre a una función que ya existía en cuanto aplicamos el primer argumento.

💡
Cuenta las flechas que quedan y sabrás qué tienes en la mano

Cuando dudes de qué te ha devuelto una aplicación parcial, aplica una aritmética trivial sobre la firma: tacha una flecha por cada argumento que hayas escrito y lee lo que sobra. Si String.replace tiene el tipo String -> String -> String -> String y escribes String.replace "," ";", has consumido dos flechas y te queda String -> String, es decir, una función lista para entrar en un List.map. Este cálculo mental de tachar flechas resuelve en dos segundos la mayoría de los errores de tipo del principiante, porque casi todos consisten en haber pasado un argumento de más o de menos y encontrarse con una función donde se esperaba un valor.

El orden de los parámetros es una decisión de API

Si aplicar parcialmente consiste en fijar los primeros argumentos, entonces el orden en que los declaras determina qué especializaciones son fáciles y cuáles resultan incómodas. Esta es la razón profunda de que la biblioteca estándar de Elm coloque de manera casi universal la configuración delante y el dato principal al final.

-- Dato al final: la aplicacion parcial produce una funcion util
String.repeat : Int -> String -> String
List.map : (a -> b) -> List a -> List b
Maybe.withDefault : a -> Maybe a -> a
Dict.insert : comparable -> v -> Dict comparable v -> Dict comparable v

-- Y por eso todas estas expresiones son funciones de un argumento
String.repeat 3          -- String -> String
Maybe.withDefault 0      -- Maybe Int -> Int
Dict.insert "clave" 42   -- Dict String Int -> Dict String Int

La regla práctica que se deduce es directa: coloca al final el argumento que más varía y que representa el sujeto de la operación, y delante los que actúan como configuración. Una función de dominio propia que valide un texto contra una longitud máxima debería recibir primero el límite y después el texto, porque así podrás fijar el límite una vez y reutilizar la función especializada en muchos sitios. Invertir ese orden no rompe nada, pero condena a quien la use a escribir una función anónima cada vez, y esa fricción se acumula. Conviene además señalar una excepción conocida dentro del propio Elm: algunas funciones de acceso reciben la clave antes de la estructura, lo que resulta natural al leer pero incómodo al encadenar, y por eso conviven en el código real con envoltorios que le dan la vuelta al orden.

➡️

La flecha asocia a la derecha

Una firma a -> b -> c es en realidad a -> (b -> c). No hay funciones de dos argumentos, solo cadenas de funciones de uno.

✂️

Aplicación parcial

Dar menos argumentos de los que parece pedir devuelve una función tipada, no un error. Es el mecanismo, no un truco.

📐

Dato al final

Configuración delante, sujeto al final. Es la convención que hace que la especialización y la tubería resulten cómodas.

🔤

Operadores en sección

Con paréntesis, un operador es una función corriente: (++) "Hola, " y (*) 2 son funciones de un argumento.

flowchart LR
A[sumar] -->|recibe 2| B[funcion que espera el segundo]
B -->|recibe 3| C[valor 5]
A -->|se detiene aqui| D[incrementar y sumarDiez]
style B fill:#89b4fa,color:#11111b
style C fill:#a6e3a1,color:#11111b
style D fill:#f9e2af,color:#11111b

Dónde aparece el currying sin que nadie lo nombre

Lo notable del currying en Elm no es que exista sino que resulte imposible de evitar. Está presente en cada línea de una aplicación real, aunque quien la escribe no piense en él ni una sola vez.

-- Los constructores de un custom type son funciones curradas
type Msg
    = Borrar Int
    | Renombrar Int String

-- Borrar : Int -> Msg, asi que sirve directamente como manejador
botonBorrar id =
    button [ onClick (Borrar id) ] [ text "Borrar" ]

-- Y en un map, el constructor viaja sin envoltorio
List.map Borrar [ 1, 2, 3 ]

-- Un decodificador se construye aplicando parcialmente el constructor
decodificarUsuario =
    Decode.map2 Usuario
        (Decode.field "nombre" Decode.string)
        (Decode.field "edad" Decode.int)

Ese último caso es el más instructivo, porque el currying deja de ser una comodidad y pasa a ser el andamiaje que sostiene la construcción. Decode.map2 recibe una función de dos argumentos, pero lo que le pasas es el constructor Usuario, que es una función currada; al aplicarle el primer campo decodificado queda a la espera del segundo, y esa espera es exactamente lo que permite que la biblioteca vaya alimentándolo campo a campo. La misma mecánica gobierna la construcción de comandos, la de generadores aleatorios y la de validaciones acumulativas. Sin currying, cada una de esas bibliotecas necesitaría un mecanismo propio para ir rellenando huecos, y con currying el mecanismo ya venía incluido en el lenguaje.

El currying no es azúcar sintáctico: es lo que hace que un lenguaje pequeño baste

Vale la pena preguntarse qué se gana de verdad al decidir que todas las funciones tengan aridad uno, porque a primera vista parece una complicación gratuita para justificar una elegancia teórica. La respuesta es que el currying elimina de raíz una familia entera de conceptos que otros lenguajes tienen que inventar por separado. Piensa en lo que hace falta en un lenguaje de aridad fija para conseguir lo que aquí sale gratis: necesitas una construcción para fijar argumentos, un mecanismo de argumentos por defecto, otro de argumentos con nombre, otro para reordenar parámetros al vuelo, y una biblioteca de utilidades para componer todo eso. Cada una de esas construcciones tiene su sintaxis, sus reglas de interacción con el sistema de tipos y sus casos límite. En Elm no existe ninguna, y no porque falten sino porque una sola regla las vuelve innecesarias. El coste de esa unificación se paga en dos monedas y conviene reconocerlas. La primera es que el orden de los parámetros deja de ser indiferente y se convierte en una decisión pública de diseño que es costosa de cambiar después, exactamente igual que el nombre de una función; una biblioteca que se equivoque en el orden condena a sus usuarios a escribir funciones anónimas para siempre. La segunda es que ciertos mensajes de error se vuelven desconcertantes al principio, porque olvidar un argumento no produce el error de que falta un argumento sino el de que se esperaba un valor y se encontró una función, un diagnóstico impecablemente correcto y completamente ajeno a la intuición del recién llegado. A cambio, se obtiene una propiedad que atraviesa el lenguaje entero: cualquier función es reutilizable a cualquier nivel de saturación sin adaptadores. Esta propiedad es la que hace posible el operador de encadenamiento de la próxima lección, que solo funciona porque toda expresión parcialmente aplicada es una función esperando su último dato. Es la que hace posible la composición de la lección siguiente, que solo tiene sentido si los tipos encajan de a uno. Y es la que explica que la biblioteca estándar de Elm sea comparativamente diminuta: no necesita ofrecer variantes de cada función para distintos usos, porque cualquier variante se fabrica en el sitio de uso fijando los primeros argumentos. Lo que parecía una restricción, quedarse con un solo argumento, resulta ser la razón de que baste con un solo mecanismo.

⚔️ Fabrica funciones sin escribir funciones
  1. Escribe la firma Int -> Int -> Int -> Int con todos los paréntesis explícitos que la asociatividad a la derecha añade, y comprueba en el compilador que es el mismo tipo.
  2. Define una función de tres parámetros y liga con un nombre las tres aplicaciones parciales posibles; anota la firma de cada una antes de consultarla.
  3. Crea tres funciones especializadas a partir de String.replace sin escribir ningún parámetro a la izquierda del igual.
  4. Escribe la misma función de dominio con dos órdenes de parámetros distintos y úsala dentro de un List.map; justifica cuál de los dos órdenes elegirías para publicarla.
  5. Provoca a propósito el error de olvidar un argumento, lee el mensaje del compilador y explica por qué habla de una función donde esperabas oír hablar de un argumento que falta.
  6. Usa un constructor de tu tipo Msg directamente como argumento de List.map y razona qué firma tiene ese constructor para que aquello compile.