07 El Libro de Ridgun
Funciones de Orden Superior y Paralelismo sin Sudor (Ni Mutex, ni Deadlocks)
En casi todos los lenguajes de programación hay un momento dramático en la vida de un proyecto: el día en que alguien dice en una reunión:
"Esto va lento. Hay que paralelizarlo con hilos."
Se hace el silencio en la sala. Todo el mundo sabe lo que viene después: semanas cazando race conditions fantasma que solo ocurren los martes con luna llena, bloqueos mutuos (deadlocks) y errores irreproducibles.
En Ridgun, cuando alguien dice "hay que paralelizarlo", basta con anteponer || a tus operaciones de lista (||map y ||filter) o poner tus cálculos en un bloque paralelo || con flechas -> y te vas a tomar un café.
¿Por qué? Porque cuando tus funciones son puras y tus datos son inmutables por defecto, dos núcleos de CPU trabajando a la vez jamás pueden pisarse la manguera.
Vamos a verlo paso a paso.
7.1. Funciones que reciben funciones: map, ||map, filter, ||filter y fold
En Ridgun una función es un valor más. Puedes pasarla por parámetro igual que pasas el número 7.
El lenguaje trae de serie los combinadores de colecciones para transformar listas sin escribir ni una sola recursión manual, tanto en modo secuencial (map, filter, fold) como en modo paralelo multicore con el prefijo || (||map, ||filter):
1. map (secuencial) y ||map (paralelo multicore)
Supón que tienes una función costoso Int => Int y una lista con números:
costoso Int => Int
costoso x = (x * x) + (x * 3)
main => ()
main =
datos = [1, 2, 3, 4, 5, 6, 7, 8]
-- Secuencial estricto de izquierda a derecha:
r1 = map costoso datos
-- Paralelo multicore con el prefijo `||`:
r2 = ||map costoso datos
echo r1
echo r2
- Con
map, Ridgun aplica la función elemento a elemento de izquierda a derecha en el hilo actual (ideal para listas pequeñas o funciones con efectos de entrada/salida ordenados). - Con
||map, el runtime de Ridgun reparte los elementos dedatosentre los hilos trabajadores de tu procesador y reconstruye la lista final exactamente en el mismo orden, garantizando que||map f xs == map f xs.
2. filter (secuencial) y ||filter (paralelo): Quedarte solo con los que cumplan una condición
Le pasas una función que devuelva Bool (un predicado) y una lista:
fn :: es positivo Int => Bool
es positivo n = n > 0
fn :: main => ()
main =
limpios = filter es positivo [-5, 10, 0, -2, 42]
limpios_par = ||filter es positivo [-5, 10, 0, -2, 42]
echo limpios -- Imprime [10, 42]
echo limpios_par -- Imprime [10, 42]
3. fold: Reducir toda una lista a un solo resultado
Le pasas una función combinadora de dos argumentos (Acumulado Elemento => Acumulado), el valor inicial y la lista:
fn :: acumular producto Int Int => Int
acumular producto acc x = acc * x
fn :: main => ()
main =
echo (fold acumular producto 1 [2, 3, 4, 5]) -- 1 * 2 * 3 * 4 * 5 = 120
¿Cómo escribo yo mi propia función que reciba otra función?
Muy fácil: en la firma de tipos pones la firma de la función invitada entre paréntesis (Entrada => Salida):
fn :: ejecutar dos veces (A => A) A => A
ejecutar dos veces f x = f (f x)
4. Clausuras léxicas y funciones anónimas ((x => expr), (acc x => expr))
Cuando quieres pasar una función corta al vuelo o capturar variables locales de tu ámbito actual (como un desplazamiento, un prefijo o una sesión activa) sin declarar una función global auxiliar, usas una clausura léxica entre paréntesis con =>:
fn :: procesar con sesion => [Int]
procesar con sesion =
base = 10
bono = 3
sumados = map (x => x + base) [1, 2, 3]
filtrados = filter (x => x >= bono) [1, 2, 3, 4]
total = fold (acc x => acc + x + bono) 0 [1, 2, 3]
pares = map ((a b) => a + b + base) [(1, 2), (3, 4)]
sumados :: filtrados :: [total] :: pares
Cinco reglas mantienen las lambdas limpias y sin ambigüedad:
- Paréntesis obligatorios: toda lambda va siempre entre paréntesis
(... => ...). - Lambda sin parámetros (
(=> expr)): igual quefn :: main => ()no lleva()antes de=>, una lambda sin parámetros se escribe(=> expr)(y su tipo es(=> Tipo)), nunca(() => expr). - Varios parámetros frente a tupla (
(a b => ...)vs.((a b) => ...)):(acc x => ...)recibe dos parámetros separados;((a b) => ...)recibe una sola tupla y la desestructura. - Cuerpo de varias líneas: si pones un salto de línea tras
=>, puedes escribir un bloque sangrado de varios pasos antes del)de cierre:
dobles = map (x =>
paso = x * 2
paso + base
) [1, 2, 3]
- Mayúsculas frente a minúsculas:
(Conn => Conn)es un tipo de función (inicial mayúscula) y(conn => conn)es un valor lambda (inicial minúscula).
Las variables capturadas (base, bono) se empaquetan automáticamente con gestión determinista de memoria (RMM1/RMM2), sin fugas y sin pausas de recolector de basura.
5. El estilo más Ridgunero (recomendado): Tuberías fluidas con ->, invocación por la izquierda y bloques sin _ = (FR-COMP-014)
Escribir map f xs o asignar cada paso intermedio a una variable (r1 = ..., r2 = ...) está muy bien, pero donde Ridgun se disfruta al 100% es encadenando transformaciones de izquierda a derecha con -> y ligando el resultado final directamente con -> variable:
op :: [Int] compact limit Int => [Int]
[] compact limit _ = []
(x:xs) compact limit lim
| x <= lim = [x] :: (xs compact limit lim)
| otherwise = xs compact limit lim
fn :: main => ()
main =
xs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
-- Estilo recomendado (2b): arranque infijo natural + `->` entre etapas + `-> results`
xs map (x => x * 2) -> filter (x => x > 6) -> compact limit 14 -> results
echo results -- Imprime [8, 10, 12, 14]
Ridgun unifica las cuatro formas de escribir la cadena en el mismo árbol sintáctico (AST), para que elijas la que mejor se lea en cada caso:
- Estilo
2b(recomendado):xs map f -> filter g -> compact limit 14 -> results(arranque infijo natural con el sujeto a la izquierda,->separando cada etapa y-> resultsal final). - Estilo
2(->desde el valor inicial):xs -> map f -> filter g -> compact limit 14 -> results. - Estilo
1(infijo continuo sin->intermedios):xs map f filter g compact limit 14 -> results. - Estilo
3(arranque prefijo clásico +->):map f xs -> filter g -> compact limit 14 -> results.
Y si la tubería es larga, puedes partirla en varias líneas sangradas que empiezan por ->:
xs map (x => x * 3)
-> filter (x => x >= 12)
-> fold 0 (acc x => acc + x)
-> total
La regla universal de -> es sencilla y consistente en todo el lenguaje:
- El valor que viene por la izquierda entra como PRIMER argumento de la función u operador de la derecha (
x -> f a b$\equiv$x f a b$\equiv$f x a b), salvo enmap/filter/fold(donde entra como la colección sobre la que iterar) o si colocas un comodín explícito_(payload -> write file ruta_salida _). - Envío directo a agentes:
[5, 10, 15] -> sumar lista -> send to agy[1, 2, 3] -> sumar lista -> ask to ag -> total. - Máscaras de secuencia en tránsito (
-> (+ _ _ +) ->):parsear paquete "v0.9.9" -> (+ _ _ +) -> combinar codigo -> resumen. - Bloques secuenciales limpios sin
_ =y descarte explícito-> (): las llamadas con efectos laterales (echo,write file,shell exec,log, etc.) se escriben directamente sin_ =. Si la última línea de una función=> ()llama a una función que devuelve un valor (comoInt), basta con terminar esa línea con-> ()para descartar el resultado explícitamente y devolver().
7.2. La joya de la corona: Bloques || con -> como Grafos Paralelos (DAGs)
Aquí es donde Ridgun cambia tu forma de pensar.
En un lenguaje imperativo normal, cuando escribes tres líneas seguidas:
- Primero se ejecuta la línea 1.
- Luego la línea 2 (aunque no tenga nada que ver con la línea 1).
- Luego la línea 3.
Eso es obligar al ordenador a trabajar con orejeras. En Ridgun, cuando usas barras paralelas || con flechas -> (sin necesidad de escribir ninguna palabra clave como where), no estás dando órdenes secuenciales: estás dibujando un Grafo Acíclico Dirigido (DAG) que dice qué cálculo alimenta a qué variable:
fn :: fib Int => Int
fib 0 = 0
fib 1 = 1
fib n = (fib (n - 1)) + (fib (n - 2))
fn :: fact Int => Int
fact 0 = 1
fact n = n * (fact (n - 1))
fn :: super calculo => Int
super calculo = rama_izquierda + rama_derecha
|| fib 10 -> rama_izquierda
|| fact 6 -> rama_derecha
Léelo en voz alta:
- "Quiero calcular
rama_izquierda + rama_derecha, mientras en paralelo (||)fib 10va arama_izquierda(->) yfact 6va arama_derecha(->)."
Lo que hace ridgunc entre bambalinas con tus líneas ||:
- Mira las flechas
->y arma el árbol de dependencias (DAG). - Caza ciclos estáticamente: si por error escribes
|| a + 1 -> by|| b + 1 -> a, el compilador te dice en el acto "¡Quietos! Hay una dependencia circular" y no te deja compilar un cuelgue infinito. - Ve que
fib 10yfact 6no dependen el uno del otro, así que tienen luz verde para correr en paralelo antes de sumarse enrama_izquierda + rama_derecha.
Incluso puedes montar un pipeline de varias plantas dentro del mismo bloque || —ya sea al pie de la expresión o justo después del =— y el compilador deducirá solito qué plantas pueden ir a la vez y cuáles tienen que esperar:
fn :: balance trimestral Int => Int
balance trimestral base =
|| base * 100 -> ventas_brutas
|| base * 40 -> costes_fijos
|| ventas_brutas - costes_fijos -> beneficio_bruto ||.
beneficio_bruto * 80 / 100
Fíjate en el detalle ||. al final de la última línea ||: igual que |. cierra un árbol de guardas anidado, ||. cierra el bloque paralelo || cuando debajo siguen sentencias secuenciales normales. Aquí ventas_brutas y costes_fijos arrancan en paralelo; cuando ambos están listos se calcula beneficio_bruto, y finalmente se devuelve beneficio_bruto * 80 / 100. Todo sin un solo async, Future, Promise ni Mutex.
7.3. El atajo || sin nombres: Tuberías paralelas que devuelven una tupla
¿Y si solo quieres lanzar dos o tres cálculos en paralelo y recogerlos juntos en una tupla sin siquiera inventar nombres de variables con ->?
Usas || directamente:
fn :: par paralelo Int => Int Int
par paralelo n =
|| fib n
|| fact n
Directo, visual y devuelve la tupla (fib n, fact n).
7.4. Cuando quieres el volante manual: el Modelo Nativo de Agentes (agent, send ... to, receive from) y Canales CSP
El paralelismo automático de || y ||map cubre el 90% de los casos. Pero cuando programas un servidor persistente, sesiones de usuario con estado aislado, colas de trabajo o un pipeline productor-consumidor, quieres agentes concurrentes con memoria aislada y paso de mensajes tipado.
En Ridgun no hace falta inventar una sintaxis extraña como actor ::: cualquier función normal (fn :: u op ::) se convierte en un agente concurrente en una línea con agent:
1. Agentes sin estado y con estado aislado (agent, send ... to, receive from, stop)
ag = agent (mi funcion): convierte una funciónM => Ren un agente sin estado de tipoAgent M R.ag = agent (mi funcion con estado) estado_inicial: convierte una funciónS M => S(oS M => S R) en un agente con estado aislado que recuerda su estado mensaje tras mensaje sin un soloMutex.send msg to ag(o con varios argumentos:send a b to ag): envía un mensaje tipado al buzón del agente.res = receive from ag(otry receive from agsin bloquear): recoge la siguiente respuesta del buzón de salida del agente.stop ag: detiene el agente y libera su estado automáticamente medianteRMM1/RMM2.
fn :: sumar visita Int Int => Int
sumar visita acum n = acum + n
fn :: procesar lote Int Int => Int
procesar lote id t = (id * 100) + t
fn :: main => ()
main =
contador = agent (sumar visita) 10
send 5 to contador
send 7 to contador
v1 = receive from contador
v2 = receive from contador
stop contador
trabajador = agent (procesar lote)
send 3 25 to trabajador
lote = receive from trabajador
stop trabajador
echo "Visitas:" v1 "->" v2 "| Lote:" lote
2. Elección inteligente en tiempo de compilación (Green Threads vs. Hilos Reales del Kernel)
Una pregunta habitual es: "¿Los bloques ||, ||map y los agentes agent crean siempre hilos del sistema operativo o corren como tareas ligeras?"
En Ridgun, el compilador toma esa decisión por ti analizando estáticamente cada función:
- Funciones ligeras (
TierTrivial/StratInlineSequential): Si la función que pasas a||,||map,||filteroagenttiene poca profundidad (aritmética, formateo de cadenas, transición de estado rápida de una sesión HTTP), calentar hilos del sistema operativo costaría más que el propio cálculo. El compilador la ejecuta como un paso green-thread ultraligero sin sobrecarga deSYS_clone. - Cálculos pesados o recursivos profundos (
TierDynamicRecursive/TierHeavyCPU): Si la función tiene recursión profunda (comofibofact) o álgebra de tensores (einsum,hadamard,@), el compilador genera automáticamente el despacho multinúcleo sobre hilos reales del kernel (SYS_clone), procesando las ramas||o la cola de mensajes delagenten paralelo real sobre todos tus núcleos de CPU.
3. Pasar mensajes por canales acotados (new bounded channel)
Igual que en Go o Erlang, además de agentes también puedes crear tubos explícitos para comunicar etapas:
new bounded channel capacidad: crea un canal con espacio paracapacidadmensajes.send through valor canal(ochan send valor canal): mete un mensaje en el tubo.receive from canal(ochan recv canal): saca el siguiente mensaje del tubo.try receive from canal: mira si hay mensaje sin quedarse esperando; devuelveSome valoroNone.receive from timeout canal ticks: espera un límite de tiempo y devuelveOk valoroErr Timeout.
fn :: main => ()
main =
tubo = new bounded channel 4
send through "Tarea A completada" tubo
send through "Tarea B completada" tubo
primero = receive from tubo
segundo = receive from tubo
echo primero "|" segundo
4. Agentes sumidero (fire-and-forget agent sink), buzones globales (agent ::) y pruebas deterministas (with agent)
Cuando necesitas auditoría asíncrona, métricas, trazas o rate limiting transversal sin ensuciar todas las firmas con un parámetro extra ni acumular respuestas sin leer en una cola de salida, Ridgun ofrece agentes sumidero (agent sink) y buzones globales de nivel superior (agent ::):
rec = agent sink (paso) [estado_inicial], mailbox N drop|block, drain 2_s: procesa cada mensaje en orden FIFO (S M => SoM => ()), libera el estado anterior conRMM1/RMM2y no acumula cola de salida (receive from,try receive fromyasksobre unSinkAgentson errores de compilación).agent :: audit_log = sink (paso) [], mailbox 10_000 drop, drain 2_s: declara un agente sumidero global inicializado antes demainy vaciado ordenadamente al salir del programa.agent dropped ag,agent flush ag,agent state agywith agent audit_log = espia do ...: permiten observar mensajes descartados por contrapresión (drop), drenar el buzón, inspeccionar el estado y redirigir temporalmente un buzón global a un agente espía aislado en los tests.- Además, los descriptores de
Channel,AgentySinkAgentpueden compartirse libremente entre múltiples trabajadores (send ch 1 to w1,send ch 2 to w2), mientras que los datos enviados por el canal mantienen su propiedad afín (ErrUseAfterSend).
Y ahora, abróchate el cinturón para el Capítulo 08, el gran final: tensores con dimensiones verificadas en compilación, variables de ámbito (context/shared), regiones de memoria deterministas, acceso directo a hardware MMIO y programación de sistemas UNIX/POSIX.