Capítulo 04: Adiós al if/else, adiós al while (Pattern Matching, Guardas y TCO)
Cuando le cuentas a alguien que en Ridgun no existe la palabra clave if, ni else, ni switch, ni for, ni while, suele mirarte como si le hubieras dicho que vas a construir una casa sin martillo ni clavos:
"¿Que no hay
ifniwhile? ¿Y cómo narices tomo una decisión o recorro un millón de datos?"
Pues resulta que if/else y while son herramientas bastante toscas heredadas de cómo funcionaban los saltos (GOTO) en los años 50. En Ridgun los sustituimos por tres piezas que encajan como un guante:
- *Pattern Matching* (cuando decides según la forma o valor exacto del dato).
- Guardas booleanas
|(cuando decides según una condición o rango). - Recursión de cola con TCO (que el compilador convierte por debajo en un bucle
loopnativo ultrarrápido).
Vamos a verlas en acción.
4.1. Pattern Matching: Cuando el argumento ya te da la respuesta
En el capítulo anterior ya vimos un adelanto. En lugar de recibir una variable genérica x y abrir dentro de la función cinco if (x == ...), en Ridgun pones los casos directamente en la puerta de entrada de la función:
fn :: codigo http Int => String
codigo http 200 = "OK: Todo fue sobre ruedas"
codigo http 301 = "Movido permanentemente"
codigo http 404 = "No encontrado (¿has mirado debajo del sofá?)"
codigo http 500 = "Fuego en el servidor"
codigo http n = "Código desconocido: {n}"
Ridgun prueba las filas de arriba abajo:
- Si entra
404, salta directo a la tercera fila. - Si entra
418, ninguna de las cuatro primeras filas coincide, así que cae en la última (n), que atrapa cualquier entero y lo nombranpara que puedas imprimirlo. - ¿Y si en el último caso ni siquiera te importa qué número era? Pones un guion bajo
_(comodín) y te olvidas:codigo http _ = "Otro código".
4.2. Guardas booleanas (|): Tu bisturí para rangos, variables condicionales y árboles de decisión
"Vale, el pattern matching mola cuando busco el 200 o el 404. ¿Pero qué hago si quiero comprobar si nota >= 5 o si edad > 18 and edad < 65? ¡No voy a escribir 47 filas con todos los números del 18 al 65!"
¡Claro que no! Para eso están las guardas (|).
Cuando el resultado depende de una desigualdad o de una combinación lógica, después del = de la función (o de cualquier variable local) abres una escalera de barras verticales |:
calificar Int => String
calificar nota =
| nota < 0 = "Nota imposible (¿puntos negativos?)"
| nota < 5 = "Suspenso"
| nota < 7 = "Aprobado"
| nota < 9 = "Notable"
| nota <= 10 = "Sobresaliente"
| otherwise = "Te has pasado de la escala"
Míralo bien:
- El signo
=del encabezado (calificar nota =) se alinea debajo de=>igual que en cualquier otra función. - Debajo se lee de arriba abajo igual que una tabla de tarifas: cada línea
| condicion = resultadose comprueba en orden. En cuanto una daTrue, devuelve ese resultado y se acabó. - Al final pones
| otherwise = ..., que significa literalmente "en cualquier otro caso" (tu red de seguridad para que nunca quede un camino sin retorno).
Lógica booleana en minúsculas claras (and, or, xor, not)
¿Te has fijado en que muchos lenguajes usan | para bits, || para booleanos, ! para negar... y cuando lees una condición compleja parece que un gato ha caminado sobre el teclado (if (!a && (b || c)))?
En Ridgun, como | ya es el símbolo sagrado de las guardas y de los tipos algebraicos, y || es el operador de paralelismo, los operadores booleanos se escriben con palabras claras en minúsculas:
and: se cumplen las dos cosas con evaluación en cortocircuito (edad >= 18 and tiene_carnet: si el lado izquierdo esFalse, el derecho ni siquiera se evalúa).or: se cumple al menos una con evaluación en cortocircuito (dia == "sabado" or dia == "domingo": si el lado izquierdo esTrue`, el derecho no se evalúa).xor: se cumple una u otra, pero no las dos a la vez.not: invierte el booleano (not (esta vacio texto)).<,>,<=,>=: comparan tanto números (Int,Float) como cadenas de textoStringen orden lexicográfico UTF-8 ("alfa" < "beta").
Mira qué limpio queda el clásico problema de los años bisiestos:
fn :: es bisiesto Int => Bool
es bisiesto a =
| (a % 400) == 0 = True
| (a % 4) == 0 and (a % 100) != 0 = True
| otherwise = False
Cero símbolos crípticos. Lo lee alguien que no sabe Ridgun y lo entiende a la primera.
Variables locales condicionales (x = + |)
Como en Ridgun todo = sigue la misma regla, también puedes usar | para asignar una variable local sin tener que crear una función auxiliar aparte:
fn :: precio billete Int Bool => Int
precio billete base vip =
descuento =
| vip = 20
| base > 100 = 10
| otherwise = 0
base - (base * descuento / 100)
Árboles de decisión anidados y el limitador compacto |.
"¿Y si dentro de una rama | cond = o | otherwise = quiero abrir otro sub-árbol de guardas | de 3 o 4 niveles de profundidad y luego volver a un nivel superior?"
Puedes anidar | dentro de | tantos niveles como quieras. Y cuando un bloque | anidado termina pero debajo siguen más ramas de un nivel padre (o más sentencias del bloque), colocas |. al final de su última línea (| otherwise = valor |.) para indicar sin ninguna ambigüedad que ese sub-árbol se cierra y vuelves al nivel anterior:
fn :: enrutar peticion Int Int Bool Bool Int Int => Int
enrutar peticion metodo rol firmada admin kb lim =
| metodo == 0 =
| kb == 0 = 204
| otherwise = 200 |.
| metodo == 1 =
| not firmada = 401
| otherwise =
-- Nivel 2: petición POST firmada
| lim > 0 =
-- Nivel 3: control de rate-limit
| admin =
-- Nivel 4: excepción para administradores
| kb > 10000 = 413
| otherwise = 202 |.
| otherwise = 429 |.
| kb > 1000 =
-- Nivel 3: hemos vuelto desde el Nivel 4 gracias a `|.`
| rol >= 2 and admin = 201
| otherwise = 413 |.
| otherwise = 201 |.
| otherwise = 405
Fíjate en lo limpio que es:
- En un árbol simple de 1 nivel (o al final de toda la función) nunca hace falta poner
|.(sería ruido innecesario). - Solo escribes
|.al cerrar una rama anidada cuando debajo continúa una rama de un nivel superior.
Bloques de varias líneas dentro de una guarda (| cond = + bloque indentado)
Igual que después del = de una función normal puedes abrir un bloque indentado de varias líneas, después del = de cualquier guarda | cond = o | otherwise = también puedes hacer un salto de línea y abrir un bloque secuencial:
fn :: ajustar ventana Int Int => Int
ajustar ventana idx limite =
| idx >= limite =
tope = limite - 1
escalado = tope * 2
escalado + 10
| otherwise =
siguiente = idx + 1
siguiente * 2
Cada rama de la guarda tiene su propio bloque local limpio y su última línea es el valor que devuelve esa rama.
4.3. "¿Sin while? ¿Y no va a reventar la pila si recorro un archivo grande?"
Hablemos del elefante en la habitación.
Si vienes de Python, Java o JavaScript, te han enseñado desde pequeñito que "la recursión es elegante en la pizarra de la universidad, pero en el mundo real desborda la pila (StackOverflowError) a las 1.000 vueltas; usa un bucle while".
Eso es verdad en lenguajes que no optimizan las llamadas de cola. Pero en un lenguaje funcional de sistemas como Ridgun, una recursión de cola ES un bucle while disfrazado de función pura.
¿Qué diferencia hay entre una recursión normal y una recursión de cola?
Fíjate en estas dos funciones que cuentan hacia atrás desde n hasta 0 sumando los números:
-- Versión A: Recursión NORMAL (deja trabajo pendiente para la vuelta)
fn :: sumar lento Int => Int
sumar lento 0 = 0
sumar lento n = n + (sumar lento (n - 1))
-- Versión B: Recursión DE COLA (lleva la cuenta al día en `acumulado`)
fn :: sumar rapido bucle Int Int => Int
sumar rapido bucle 0 acumulado = acumulado
sumar rapido bucle n acumulado = sumar rapido bucle (n - 1) (acumulado + n)
- En la Versión A, antes de poder sumar
n + ..., la función tiene que esperar a que terminesumar lento (n - 1). Eso obliga al ordenador a guardarnen la pila en cada paso. - En la Versión B, la llamada a
sumar rapido buclees lo último que ocurre en la línea. No queda nada pendiente detrás de ella. Ya le pasas la suma actualizada enacumulado + n.
El truco de magia de ridgunc (TCO: Tail-Call Optimization)
Cuando ridgunc compila sumar rapido bucle, ve que la función se llama a sí misma al final de la cláusula y dice:
"¿Para qué voy a crear un marco de pila nuevo si el actual ya no sirve para nada? Voy a sobrescribir
nconn - 1, sobrescribiracumuladoconacumulado + ny saltar al principio con unloop { ... continue; }nativo."
Resultado:
- Puedes llamar a
sumar rapido bucle 100_000_000 0(cien millones de vueltas) y gastará exactamente 0 bytes extra de pila. - Va igual de rápido que un bucle
whileescrito a mano en Rust o C, pero sin haber tenido que declarar ni una sola variable mutable en tu código Ridgun.
4.4. El patrón maestro que usarás en todo programa Ridgun
Cada vez que en otro lenguaje pensarías "voy a hacer un bucle while con un índice i y un acumulador", en Ridgun escribes una pareja de funciones como esta (la función principal limpia y su ayudante de cola):
fn :: buscar caracter bucle String Int Int Int => Int
buscar caracter bucle texto pos fin buscado =
| pos >= fin = -1
| (str char at texto pos) == buscado = pos
| otherwise = buscar caracter bucle texto (pos + 1) fin buscado
fn :: buscar caracter String Int => Int
buscar caracter texto buscado = buscar caracter bucle texto 0 (str len texto) buscado
Mira las tres guardas de buscar caracter bucle:
- ¿Llegué al final (
pos >= fin)? Devuelvo-1(fin del bucle). - ¿Encontré el carácter en
pos? Devuelvopos(fin del bucle). - ¿No es este? Paso al siguiente (
pos + 1) con una llamada de cola (que el compilador convierte encontinue;).
Limpio, imposible de tener bugs de variables globales pisadas y rápido como un rayo.
En el Capítulo 05 vamos a meterle mano a las colecciones de datos: listas, tuplas, funciones que aceptan infinitos argumentos (...) y las máscaras de secuencias estilo J/APL.