wandres.dev
EL LENGUAJE: TIPOS · estáticos e inferidos

Las firmas: leer la flecha y por qué toda función tiene un solo argumento

Una firma de Elm no es una lista de parámetros seguida de un tipo de retorno, aunque a primera vista lo parezca. Esta lección enseña a leer y a escribir firmas partiendo del hecho estructural que las explica todas: la flecha asocia a la derecha, de modo que una función de tres parámetros es en realidad una función de un parámetro que devuelve una función de un parámetro que devuelve una función de un parámetro. Se cubren la anatomía de la firma, el porqué de que el último elemento sea el retorno, la currificación como propiedad del lenguaje y no como truco de biblioteca, la aplicación parcial que se sigue de ella, el papel del paréntesis cuando un argumento es a su vez una función, el orden de los parámetros como decisión de diseño y su relación con el operador pipe, y las firmas polimórficas y de orden superior como map, filter y foldl. La conclusión es que en Elm no existen funciones de varios argumentos: solo cadenas de funciones de uno, y esa uniformidad es la que hace que componer sea trivial.

⏱ 18 min

La firma de una función en Elm parece, la primera vez que la ves, una notación caprichosa para algo que otros lenguajes escriben con paréntesis y comas. Nombres separados por flechas, sin distinguir tipográficamente los parámetros del retorno, sin ninguna marca que diga aquí terminan las entradas y empieza la salida. Esa impresión de capricho dura hasta que descubres que la notación no es una convención estética sino una descripción literal de lo que ocurre: en Elm no existen funciones de varios argumentos. Existen únicamente funciones de un argumento que, cuando hace falta más de uno, devuelven otra función de un argumento. La flecha no separa una lista de parámetros del resultado; la flecha es el constructor de tipos de función, y aparece tantas veces como funciones anidadas hay. Aprender a leer una firma es, por tanto, aprender esa asociatividad, y el premio no es cosmético: de ahí salen la aplicación parcial, el operador pipe y la facilidad con que las piezas de Elm encajan unas en otras.

🎯 Al terminar esta lección sabrás
  • Leer con soltura una firma cualquiera identificando qué es entrada, qué es salida y qué es una función anidada.
  • Entender que la flecha asocia a la derecha y que por eso toda función de Elm tiene exactamente un argumento.
  • Aplicar parcialmente funciones y explicar por qué la currificación hace que eso funcione sin esfuerzo.
  • Escribir firmas de orden superior y elegir el orden de los parámetros pensando en la composición con el pipe.

Anatomía de una firma

Una firma se declara en la línea inmediatamente anterior a la definición, con el nombre, dos puntos y el tipo. La regla de lectura básica cabe en una frase: lo que hay después de la última flecha es lo que la función devuelve, y todo lo anterior son cosas que recibe. Con eso ya puedes descifrar la mayoría del código que encontrarás.

-- El ultimo elemento es el retorno; los anteriores, entradas
edadEnMeses : Int -> Int
edadEnMeses anios =
    anios * 12

nombreCompleto : String -> String -> String
nombreCompleto nombre apellido =
    nombre ++ " " ++ apellido

esMayor : Int -> { a | edad : Int } -> Bool
esMayor limite persona =
    persona.edad > limite

Las tres firmas anteriores se leen así: la primera recibe un Int y devuelve un Int; la segunda recibe dos String y devuelve un String; la tercera recibe un Int y un record con campo edad y devuelve un Bool. Fíjate en un detalle que delata la naturaleza del lenguaje: los nombres de los parámetros no aparecen en la firma, solo sus tipos. La firma habla de la forma de la función, no de su implementación, y por eso dos funciones completamente distintas pueden compartirla.

ℹ️
La firma va arriba, y va primero

La convención de Elm sitúa la anotación encima de la definición y la comunidad recomienda escribirla antes que el cuerpo. No es un ritual: escribir primero procesar : List Pedido -> Resumen fija el problema antes de resolverlo, y a partir de ahí el compilador te acompaña señalando qué falta para cumplir esa promesa. En un lenguaje con inferencia total, la firma no sirve para que el compilador entienda tu código, sino para que tú entiendas tu propio problema antes de escribirlo.

La flecha asocia a la derecha

Ahora la revelación estructural. La firma nombreCompleto : String -> String -> String no significa lo que tu intuición de otros lenguajes te sugiere. Significa esto, con los paréntesis implícitos hechos visibles: String -> (String -> String). Es decir, nombreCompleto es una función que toma un String y devuelve otra función, la cual toma un String y devuelve un String. La flecha asocia a la derecha, siempre, y esa regla explica por qué no hace falta ninguna marca que separe los parámetros del retorno: no hay tal separación, porque no hay varios parámetros.

flowchart LR
A[String] -->|aplicar nombre| B[Funcion String hacia String]
B -->|aplicar apellido| C[String final]
style B fill:#cba6f7,color:#11111b
style C fill:#a6e3a1,color:#11111b

A esto se le llama currificación, en honor a Haskell Curry, y en Elm no es una utilidad de biblioteca ni una técnica que se activa: es la forma en que el lenguaje representa toda función sin excepción. La consecuencia inmediata y utilísima es la aplicación parcial. Si aplicas menos argumentos de los que la función parece pedir, no obtienes un error: obtienes la función que espera los que faltan. Y como eso es lo normal, la biblioteca estándar entera está diseñada contando con ello.

-- Aplicacion parcial: dar menos argumentos devuelve una funcion
saludarA : String -> String -> String
saludarA saludo nombre =
    saludo ++ ", " ++ nombre

hola : String -> String
hola =
    saludarA "Hola"

-- hola "Ada" produce "Hola, Ada"

-- El mismo mecanismo, usado sin darte cuenta, en toda la biblioteca
mayores : List Int -> List Int
mayores =
    List.filter (\n -> n > 18)

En hola no hay ningún parámetro escrito y sin embargo la función recibe uno: es lo que devuelve saludarA "Hola". A ese estilo, en el que se omiten los argumentos que simplemente se pasarían al final, se le llama estilo sin puntos, y en Elm resulta natural precisamente porque la currificación es universal. La contrapartida es que abusar de él vuelve el código críptico, así que la comunidad lo usa con moderación, ahí donde el nombre resultante es más claro que la versión explícita.

Cuándo hacen falta paréntesis

Como la flecha asocia a la derecha, el único momento en que necesitas paréntesis en una firma es cuando quieres decir lo contrario: que algo agrupa a la izquierda porque un argumento es a su vez una función. Comparar las dos formas es el ejercicio que asienta la lectura definitivamente.

-- Sin parentesis: tres entradas, un Int de salida
a : Int -> Int -> Int -> Int

-- Con parentesis: una funcion como PRIMER argumento
b : (Int -> Int) -> Int -> Int

-- Firmas reales de orden superior
map : (a -> b) -> List a -> List b
filter : (a -> Bool) -> List a -> List a
foldl : (a -> b -> b) -> b -> List a -> b

Lee map despacio, porque contiene toda la gramática del lenguaje en una línea. Recibe una función que transforma un a en un b, recibe una lista de a, y devuelve una lista de b. Las letras minúsculas son variables de tipo: se rellenan en cada uso, pero dos apariciones de la misma letra obligan al mismo tipo. Por eso map no puede hacer trampas: la única forma de que salga una lista de b es aplicando la función recibida a los elementos de la lista, ya que b no puede fabricarse de ninguna otra manera.

➡️

Asociatividad derecha

a -> b -> c es siempre a -> (b -> c). Nunca al revés. Toda función tiene un argumento y, como mucho, devuelve otra función.

🧱

Paréntesis a la izquierda

Solo se escriben para meter una función como argumento, como en (a -> b) -> List a -> List b. Fuera de eso, sobran.

✂️

Aplicación parcial

Dar menos argumentos de los esperados devuelve una función que aguarda el resto. Es la base de las funciones de configuración y de los ayudantes especializados.

🔤

Variables de tipo

Minúsculas como a o msg: huecos que el llamante rellena. La misma letra repetida obliga al mismo tipo, y esa obligación es lo que hace demostrables ciertas propiedades.

El orden de los parámetros es una decisión de diseño

Si toda función se puede aplicar parcialmente, entonces el orden en que colocas los parámetros deja de ser una preferencia y se convierte en una decisión arquitectónica. La regla que sigue la biblioteca estándar de Elm es explícita: el dato principal, aquel sobre el que la función opera, va siempre en último lugar. Por eso es List.map funcion lista y no al revés, y por eso String.replace viejo nuevo texto deja el texto al final.

-- El dato principal va al final: eso habilita el pipe
resumen : List Pedido -> String
resumen pedidos =
    pedidos
        |> List.filter esPagado
        |> List.map .total
        |> List.sum
        |> String.fromFloat

-- El pipe no es magia: solo aplica el argumento por la izquierda
-- x |> f  equivale exactamente a  f x

La razón de la convención es esta: con el dato al final, List.filter esPagado aplicado parcialmente es ya una función de lista en lista, y por tanto encaja como eslabón en una tubería. El operador |> no hace nada sofisticado —solo pasa el valor de la izquierda como último argumento de la derecha—, pero es la aplicación parcial la que garantiza que cada eslabón tenga exactamente la forma que el siguiente espera. Ordenar mal los parámetros no rompe nada, simplemente hace imposible componer sin envolver todo en funciones anónimas.

Hay un segundo operador que vive de la misma propiedad y que conviene conocer, aunque se use menos. Mientras |> aplica un valor a una función, >> compone dos funciones sin mencionar ningún valor, y su firma es la definición misma de encajar tuberías.

-- Composicion: pegar dos funciones sin nombrar el dato
-- (>>) : (a -> b) -> (b -> c) -> (a -> c)

totalDePagados : List Pedido -> Float
totalDePagados =
    List.filter esPagado >> List.map .total >> List.sum

Observa que totalDePagados no declara parámetro alguno y aun así es una función de lista en número. Eso solo puede escribirse en un lenguaje donde cada eslabón es una función de un argumento cuyo tipo de salida coincide con el de entrada del siguiente. La firma de >> es literalmente la regla de encaje: dame algo de a en b, dame algo de b en c, te devuelvo algo de a en c.

⚠️
Un error clásico al leer firmas de orden superior

Cuando una firma incluye una función como argumento, es fácil confundir su retorno con el retorno de la función entera. En foldl : (a -> b -> b) -> b -> List a -> b, el b que aparece dentro del paréntesis pertenece al acumulador que la función de plegado devuelve en cada paso, no al resultado final, aunque ambos sean el mismo tipo. Léela siempre de fuera hacia dentro: primero identifica el último elemento no parentizado, que es el retorno, y solo después descifra lo que hay dentro de cada paréntesis.

Una sola forma de función es lo que hace posible componer

La currificación suele presentarse como una comodidad: qué práctico poder fijar el primer argumento y reutilizar el resto. Esa lectura se queda corta y oculta lo que de verdad está en juego. Lo que la currificación consigue es la uniformidad total del universo de funciones. En un lenguaje donde existen funciones de cero, uno, dos y siete argumentos, cada aridad es una categoría distinta de objeto, y componer dos funciones exige preguntarse cuántas entradas tiene cada una y cómo casarlas; por eso esos lenguajes acaban con familias de combinadores, con sobrecargas para cada aridad y con tuplas usadas como parche. En Elm no existe esa taxonomía. Solo hay funciones de un argumento a un valor, y ese valor puede resultar ser otra función. Cuando todos los objetos de un dominio tienen la misma forma, la composición se vuelve una operación total: cualquier salida encaja en cualquier entrada del tipo adecuado, sin adaptadores. De ahí se sigue todo lo demás. El operador pipe puede definirse en una línea porque solo tiene que aplicar un argumento. La convención del dato al final funciona porque aplicar parcialmente es lo natural, no la excepción. Los constructores de los custom types son funciones y por eso se pueden pasar a map como cualquier otra. Incluso Cmd y Sub se componen limpiamente porque todo el lenguaje habla la misma gramática. La lección profunda excede a Elm: la potencia de un sistema rara vez viene de añadir formas, casi siempre de eliminarlas hasta que solo queda una y todo encaja con todo. La flecha, ese símbolo mínimo que asocia a la derecha, es el lugar exacto donde ese principio está inscrito en el lenguaje.

⚔️ Lee y escribe firmas hasta que la flecha desaparezca
  1. Escribe con paréntesis explícitos la firma Int -> String -> Bool -> Int y explica cuántas funciones anidadas contiene realmente.
  2. Define una función de tres parámetros, aplícala a uno solo y anota la firma exacta de lo que obtienes.
  3. Distingue en un ejemplo propio (Int -> Int) -> Int de Int -> Int -> Int y describe con palabras qué recibe cada una.
  4. Escribe la firma de una función que reciba una función de a en Bool y una List a y devuelva un Bool; después impleméntala.
  5. Reordena los parámetros de una función tuya para poner el dato principal al final y reescribe una cadena de tres pasos usando |>.
  6. Argumenta por qué map : (a -> b) -> List a -> List b no podría inventarse un valor de tipo b por su cuenta, y qué garantiza eso.