Capítulo 12 · Efectos algebraicos
Este es el capítulo donde kaikai paga su novedad. Hasta ahora hemos visto tipos, pattern matching, protocolos, unidades de medida, contratos. Todas son piezas elegantes pero no únicas: las encontrarías parecidas en Haskell, en Rust, en F#. Los efectos algebraicos son lo que distingue a kaikai de casi cualquier lenguaje de uso real hoy.
La idea es simple de enunciar y rara al principio: una función declara en su firma qué efectos usa, pero no cómo se realizan. Imprimir a pantalla, leer un archivo, fallar con un error, suspender la ejecución, generar un número aleatorio: todos son efectos. La función dice “yo necesito esta capacidad”; otro código más arriba decide qué significa “esta capacidad” en este contexto. La separación entre qué y cómo es el corazón del sistema.
Si esto suena a dependency injection, mantén la idea cerca. Si suena a excepciones, también. Si suena a generadores, igualmente. La razón por la que un solo mecanismo se parece a tantas cosas es que en la teoría detrás, todas esas cosas son lo mismo. Los efectos algebraicos son la generalización.
Vamos despacio. Este capítulo se lee mejor con paciencia que con prisa: la primera lectura es para que cada idea aparezca; la intuición sólida llega cuando vuelvas un mes después y todo se sienta obvio.
12.1 La fricción que los efectos resuelven
Antes de mostrar la sintaxis, veamos los problemas concretos que los efectos resuelven. Si reconoces el patrón en tu trabajo, sabrás por qué pagar el costo de aprender una herramienta nueva.
Excepciones invisibles
En Java o Python, cualquier llamada a una función puede lanzar una excepción y nada en la firma te lo dice. Lees el código y no sabes qué puede fallar. Lo descubres en producción.
def cargar_usuario(id):
return db.get(id) # ¿puede fallar? ¿con qué excepciones?
Lenguajes con excepciones verificadas (Java) intentaron arreglarlo
forzándote a declarar throws. El resultado fue que la gente
escribió throws Exception para callarlo, y volvimos al inicio.
Lenguajes funcionales modernos (Rust, OCaml, kaikai con el cap. 5)
empujan a usar Result o Option: el costo de fallar aparece en
el tipo, y quien llama decide qué hacer. Bien para fallas locales,
pero pesado cuando hay muchas funciones que fallan: la firma se
llena de wrapping y unwrapping.
async/await que infecta
En JavaScript, Python, C#, Rust, marcar una función como async
te obliga a marcar como async también a todas las que la llaman.
Un cambio aparentemente local contagia el árbol entero de llamadas.
async function leer(path) { ... }
async function procesar(path) {
const data = await leer(path); // procesar también tuvo que ser async
return transformar(data);
}
Bob Nystrom le puso nombre a esto en 2015 en un ensayo famoso:
“What Color is Your Function?”. La idea es que async divide
las funciones en dos colores: las rojas (async) y las azules
(no-async). Una roja puede llamar a una azul, pero una azul no
puede llamar a una roja. Si tienes una función azul y necesitas
usar una roja adentro, tienes que repintar la azul, y la que la
llamaba, y así hasta arriba. Es una infección que no se queda
local.
El punto del ensayo no es que async sea malo, sino que esta
clase de marcadores en la firma, cuando son específicos a un solo tipo
de efecto, crean dos sistemas paralelos que no componen. async
no compone con generadores: necesitas async function*. No
compone con excepciones de la misma manera (las excepciones en
funciones async se vuelven rejected promises). Cada combinación
nueva requiere su propia sintaxis.
Los efectos algebraicos resuelven el problema al nivel de raíz:
no hay colores especiales, hay una sola dimensión que es la
fila de efectos. async no necesita ser una propiedad sintáctica
de la función; es simplemente un efecto entre otros. Y la fila se
extiende sin sintaxis nueva: / Async + Fail no es más raro que
/ Async.
Inyección de dependencias
Para hacer una función testeable, le pasas como parámetro las “cosas externas” que usa: el reloj, el logger, el acceso a base de datos. Le pasas mocks en los tests, las cosas reales en producción.
class Procesador {
public Procesador(Clock clock, Logger logger, DbClient db) { ... }
public void run() { ... }
}
Funciona, pero ensucia las firmas y obliga a wiring manual. Y cuando una función nueva quiere usar uno de los servicios, hay que agregarlo a constructores arriba en la jerarquía.
El patrón común
Las tres frustraciones tienen la misma forma:
- Una función necesita una capacidad (la capacidad de fallar, la capacidad de suspenderse, la capacidad de loggear).
- La capacidad debe ser explícita (visible en el tipo) para no sorprender.
- La capacidad debe ser provista por el contexto (quien llama, test, framework) sin que la función se entere.
- Múltiples capacidades deben componerse limpio.
Los efectos algebraicos son una sola construcción que cubre
los cuatro puntos. Lo que las excepciones, async/await y la
inyección de dependencias hacen por separado y con frecuencia mal,
los efectos lo hacen con un solo mecanismo.
12.2 Declarar un effect
Un efecto es una interfaz. Declara qué operaciones existen, con qué firma, pero no cómo se implementan.
effect Log {
log(msg: String) : Unit
}
Esto introduce un efecto llamado Log con una operación log que
recibe un string y no devuelve nada útil. Cualquier función que
llame a Log.log(...) está usando el efecto Log.
Lo que no introduce: implementación. Aquí no hay cuerpo, no
hay if, no hay print. Solo la firma. Esa es la diferencia
fundamental con un protocolo o una interfaz tradicional: el efecto
no decide nada, solo declara lo que se puede pedir.
Las operaciones de un efecto se declaran sin la palabra fn y sin
cuerpo: solo nombre, parámetros y tipo de retorno. Un mismo efecto
puede tener varias operaciones:
effect Io {
print(s: String) : Unit
read_line() : String
}
12.3 Llamar a una operación: la firma cambia
Para usar una operación, llamas al método del efecto:
fn greet(name: String) : Unit / Log {
Log.log("hola, " ++ name)
}
Dos cosas nuevas:
Log.log(...)es la sintaxis de invocación. El efecto es el nombre del namespace; la operación es el método.: Unit / Logen la firma. La barra introduce la fila de efectos (effect row). Es la lista de efectos que esta función necesita para correr. Sin ese/ Log, la función no compila: está usando una capacidad que no declaró.
El sistema de tipos garantiza que toda función que usa un efecto
lo declara. Si llamas Log.log(...) desde una función sin
/ Log, el compilador rechaza con un mensaje claro. No hay
escape: los efectos son visibles en la firma, siempre.
Y esto vale recursivamente. Si greet usa Log y main llama a
greet, entonces main también necesita Log en su firma, a
menos que maneje el efecto antes (eso es §12.4).
fn main() : Unit / Log { # propaga el efecto
greet("kaikai")
}
Es el mismo principio del contagio que vimos en async/await,
pero sin el problema de los colores: aquí no hay sintaxis especial
para “pagar el efecto” en la llamada. Log.log(...) es una
llamada como cualquier otra. La fila en la firma es donde vive la
disciplina, y agregar un efecto nuevo no introduce un color nuevo
incompatible con el resto: solo extiende la fila.
Varios efectos: la fila
Si una función usa más de un efecto, los enumera con +:
fn procesar() : Int / Log + Fail {
Log.log("inicio")
if condicion_mala() {
Fail.fail("no se puede")
}
42
}
Log + Fail es la fila de efectos. El orden no importa: la
fila es un conjunto, no una secuencia. Log + Fail y Fail + Log
son la misma fila para el compilador. El operador + es solo
sintaxis para construirla.
Si esto te suena al capítulo 10 — donde m * s y s * m eran
la misma unidad — no es coincidencia. Los efectos son otra de
las familias de etiquetas del §2.6: habitan su propio kind, con
su propia álgebra (la fila, donde el orden no cuenta y los
duplicados colapsan), y el compilador la aplica al unificar
firmas igual que aplica el álgebra de unidades. El capítulo 19
muestra las dos como entradas del mismo catálogo.
12.4 Manejar un efecto con handle ... with
La parte interesante: decidir qué significa un efecto en un
punto del programa. Eso es lo que hace handle ... with.
fn main() {
handle {
greet("kaikai")
greet("Eduardo")
} with Log {
log(msg, resume) -> {
println("[INFO] " ++ msg)
resume(())
}
}
}
Lectura literal: “ejecuta este bloque, y para cuando alguien dentro
invoque Log.log, hazlo así”. El handler intercepta cada llamada
a log, decide qué pasa, y reanuda con resume(...).
Tres detalles que vale fijar:
handle { body } with Effect { clauses }es una construcción de control, en la misma familia queifymatch. No es una llamada a función.handleywithson palabras reservadas.bodyes dondeEffectqueda manejado. Adentro, llamar aLog.log(...)es legal sin tenerLogen la fila de la función que envuelve, porque el handler la provee. Afuera delwith, el efecto vuelve a ser exigido por el sistema de tipos.resume(())continúa la ejecución del body desde el punto donde se llamólog, con el valor que pasaste como argumento.
Lo notable: el main no necesita declarar Log en su firma.
El handle lo “consume”: el bloque de adentro lo usa, el with
lo provee, y la fila de efectos del main queda sin Log. El
sistema de tipos sigue siendo estricto, pero el handler es la
puerta por la que un efecto sale del scope.
El mismo body con dos handlers
Lo poderoso aparece cuando notas que greet no cambia entre
ejecuciones. Con un handler obtienes logs verbosos; con otro,
silencio:
# Handler verboso
handle {
greet("modo verboso")
} with Log {
log(msg, resume) -> {
println("[INFO] " ++ msg)
resume(())
}
}
# Handler silencioso
handle {
greet("modo silencioso")
} with Log {
log(msg, resume) -> resume(()) # ignora el mensaje
}
greet no se entera de la diferencia. Lo mismo vale para un
handler que escribe a archivo, uno que acumula los mensajes en una
lista, uno que los manda por red. La función greet es agnóstica
al handler.
Esto es lo que reemplaza la inyección de dependencias. No hay constructor que pasar ni servicio que mockear; el handler es la implementación.
12.5 resume: el handler decide qué pasa después
resume es la pieza que más confunde al inicio y la que más rinde
cuando se entiende. Es el continuation del body desde el punto
de la operación.
Cuando el body invoca Log.log("hola"), el control de la
ejecución salta al handler. El handler recibe dos cosas:
- El argumento que se pasó a la operación:
"hola". - Una función
resumeque, si se llama, continúa el body desde donde se quedó.
log(msg, resume) -> {
println("[INFO] " ++ msg) # decide qué hacer con el efecto
resume(()) # devuelve el control al body
}
resume(()) pasa () como valor de retorno de la operación
log (que devuelve Unit). El body continúa después del
Log.log(...) con ese valor.
Operaciones que devuelven valores
Log.log no devuelve nada útil, pero otras operaciones sí. Un
efecto puede proveer un valor:
effect Ask {
name() : String
}
fn saludar() : String / Ask {
"hola, " ++ Ask.name()
}
fn main() : Unit / Stdout {
let mensaje = handle {
saludar()
} with Ask {
name(resume) -> resume("mundo")
}
println(mensaje) # imprime "hola, mundo"
}
Ask.name() suspende el body. El handler recibe resume y decide
qué String darle a quien llamó: aquí, "mundo". resume("mundo")
continúa el body con ese valor, y el ++ lo concatena.
Esto reemplaza muchos usos de la inyección de dependencias: en vez
de pasar nombre como parámetro hasta el fondo del árbol de
llamadas, lo “preguntas” via efecto, y el handler en main decide
qué responder. En tests, otro handler responde algo distinto.
Handlers que NO llaman a resume
Si el handler no llama a resume, el body se descarta y el
valor del handler se vuelve el valor de todo el handle. Esto es
como las excepciones se construyen:
effect Fail {
fail(motivo: String) : Nothing
}
fn dividir(a: Int, b: Int) : Int / Fail {
if b == 0 { Fail.fail("división por cero") }
else { a / b }
}
fn main() : Unit / Stdout {
let r = handle {
let x = dividir(10, 2)
let y = dividir(20, 0) # aquí se invoca Fail.fail
x + y # nunca llega
} with Fail {
fail(motivo, resume) -> {
println("falló: " ++ motivo)
0 # valor de reemplazo
}
}
println("resultado: #{r}") # imprime: resultado: 0
}
La firma fail(motivo: String) : Nothing declara que la operación
nunca regresa. Nothing es el tipo vacío de kaikai (el bottom
type del cap. 3): no tiene habitantes, no se puede construir un
valor de Nothing. Por construcción, no hay nada que pasar a
resume, así que el sistema de tipos garantiza que no se puede
continuar el body después de Fail.fail. El programa no se rompe
si lo intentas, el compilador no te deja escribir el código.
Esa es la clave: las “excepciones” de kaikai son un caso particular
del mecanismo general. No hay sintaxis especial para try/catch;
hay handle y un efecto cuya operación devuelve Nothing.
12.6 Handlers con estado: el patrón State
Hasta aquí los handlers fueron sin estado: solo decidían qué hacer y reanudaban. Pero un handler puede llevar su propio estado sin que el body se entere. Esto reemplaza la mutación global.
effect State[T] {
get() : T
set(v: T) : Unit
}
fn suma(xs: [Int]) : Int {
handle {
list.foreach(xs, (x) => State.set(State.get() + x))
State.get()
} with State[Int](0) {
get(resume) -> resume(state) # devuelve estado
set(v, resume) -> resume((), v) # cambia estado
return(x) -> x # descarta estado al final
}
}
Tres cosas nuevas:
State[T]es paramétrico. El tipoTes lo que el estado guarda. Aquí va a serInt.with State[Int](0)instala el handler con estado inicial0. El argumento entre paréntesis es el valor inicial.statees un identificador especial disponible dentro de las cláusulas del handler. Se refiere al valor actual del estado.resume(value, new_state)tiene dos argumentos cuando el handler tiene estado: el valor de retorno de la operación, y el estado nuevo.resume(value)sin segundo argumento deja el estado igual.return(x) -> xse ejecuta cuando el body termina normalmente.xes el resultado del body. Aquí descartamos el estado final y devolvemos solox; si quisieras ambos, haríasreturn(x) -> (x, state).
Para el body, no hay mutación: solo invocaciones a operaciones
puras. La mutación vive completa dentro del handler. Desde afuera
del handle, no se ve nada.
Este patrón es genérico: con la misma forma se construyen
Reader (entorno de lectura), Writer (acumulación de salida),
contadores, caches, sesiones. Todos sin tocar variables globales y
sin propagar parámetros.
12.7 var, Ref[T] y Array[T]: dos mecanismos distintos
State[T] es la herramienta general para llevar un valor que
cambia, pero escribir un handle ... with State[Int](0) cada
vez que quieres un contador local sería tedioso. Por eso kaikai
trae azúcar sintáctica y, separadamente, un efecto del stdlib
para casos donde la memoria sobrevive al bloque. Son dos
construcciones distintas que vale la pena no confundir.
var: azúcar sobre State[T]
La forma corta de una celda local:
fn contar_pares(xs: [Int]) : Int {
var n := 0
list.foreach(xs, (x) => {
if x % 2 == 0 {
n := n + 1
}
})
n
}
El := es la única marca de mutabilidad:
var n := 0declara la celda con su valor inicial.n := vescribev.nlee el valor actual: un nombre desnudo, sin marca.
¿Cómo funciona por dentro? var es azúcar sintáctico sobre
State[T]. El compilador reescribe
var n := 0
... resto del bloque ...
a
handle {
... resto del bloque ...
} with State[Int](0) as n {
get(resume) -> resume(state)
set(v, resume) -> resume((), v)
return(x) -> x
}
Como el handle que se inserta queda dentro del mismo
bloque donde el var aparece, el efecto State[Int] se
cierra ahí mismo y no escapa a la firma de la función. Para
quien la llama, contar_pares es : Int. Sin efectos.
El efecto no se enmascara: el handle está literalmente al
lado del var. La fila se cierra en el lugar exacto donde la
celda se declara.
Y no es caro: el compilador detecta el patrón “celda local con
resume de un disparo” y lo especializa a una posición de
stack frame, equivalente a una variable mutable de C. Costo
cero comparado con el código imperativo equivalente.
Mutable: el efecto detrás de Ref[T] y Array[T]
var cubre celdas locales. Pero hay casos donde la memoria
tiene que sobrevivir al bloque: un array que vas a
devolver, una celda que pasas entre funciones, una estructura
que comparten varias rutinas. Para esos casos kaikai trae dos
tipos del stdlib, Ref[T] y Array[T], y ambos viven bajo el
efecto Mutable.
fn rellenar(n: Int) : Array[Int] {
let a = Mutable.array_make(n, 0)
var i := 0
list.foreach([0..n], (_) => {
a[i] := i * 2
i := i + 1
})
a
}
Mutable.array_make(n, init)crea un array de tamañoncon valor inicialinit.a[i]lee la posicióni. Azúcar paraMutable.array_get(a, i).a[i] := vescribe la posicióni. Azúcar paraMutable.array_set(a, i, v).
Ref[T] es la versión de una sola celda: Mutable.ref_make(v),
Mutable.ref_get(r), Mutable.ref_set(r, v). No tiene azúcar
de indexación, pero el resto es paralelo a Array[T].
Mira la firma de rellenar: dice : Array[Int], sin
Mutable. ¿Por qué, si la función claramente muta?
Porque Mutable sigue la disciplina de efectos observables:
el efecto aparece en la firma solo cuando la mutación es
visible para quien llama. Y aquí no lo es. El array se
crea adentro, se llena adentro, y se devuelve cuando ya está
listo. Quien recibe el array obtiene un valor ya armado,
no observa ninguna mutación.
Cuándo Mutable se vuelve visible
Si la mutación es observable, el efecto aparece en la firma:
fn rellenar_en_sitio(a: Array[Int]) : Unit / Mutable {
let n = Mutable.array_length(a)
var i := 0
list.foreach([0..n], (_) => {
a[i] := i * 2
i := i + 1
})
}
Aquí a llega de afuera. Quien llama tiene una referencia al
mismo array que estamos modificando. La mutación es visible
para el caller, y la firma debe declararlo.
La regla del cap. 12 §12.3 se aplica igual que con cualquier otro efecto: el sistema de tipos garantiza que toda función que produce un efecto observable lo declara. Las “asignaciones secretas” no existen.
Mutable versus State[T]
Ambos representan estado mutable. ¿Cuándo conviene cada uno?
var(que esState[T]): la celda vive dentro del bloque. No tiene que sobrevivir a la función, no se pasa a otras rutinas, solo es un acumulador o contador local. La firma queda limpia.MutableconRef[T]oArray[T]: la memoria sobrevive al bloque o se comparte entre funciones. Aparece en la firma cuando la mutación es observable para quien llama.
Si pasas un Ref[T] o un Array[T] como argumento, o lo
devuelves después de mutarlo, estás en territorio de
Mutable. Si solo necesitas un contador local, es var.
12.8 Componer efectos: handlers anidados
Las funciones reales usan más de un efecto. Una que logguea y
acumula puede declarar / Log + State[Int], y en main la
manejas con dos handles anidados:
fn acumular(xs: [Int]) : Int / Log + State[Int] {
list.foreach(xs, (x) => {
Log.log("sumando #{x}")
State.set(State.get() + x)
})
State.get()
}
fn main() : Unit / Stdout {
let total = handle {
handle {
acumular([10, 20, 30])
} with State[Int](0) {
get(resume) -> resume(state)
set(v, resume) -> resume((), v)
return(x) -> x
}
} with Log {
log(msg, resume) -> {
println("[LOG] " ++ msg)
resume(())
}
}
println("total = #{total}")
}
El orden de anidación importa cuando los handlers interactúan.
Aquí no interactúan: State solo lee y escribe su estado, Log
solo imprime. Cualquiera de los dos órdenes funciona. Pero si
tuvieras Fail adentro de State, el orden decide si el estado
sobrevive a una falla (Fail externo) o se descarta (Fail interno).
Cada combinación tiene una semántica explícita y verificable.
Esa es una diferencia profunda con try/catch + variables globales: ahí el orden es implícito y depende del runtime. Aquí es
explícito y lo decides en la firma de los handle.
12.9 Instancias nombradas: el handler como valor
Hasta aquí, cada operación encuentra su handler por el nombre del
efecto: Log.log(...) sube hasta el handle ... with Log más
cercano. Eso deja una pregunta sin respuesta: ¿y si necesitas
dos handlers del mismo efecto, vivos a la vez, y quieres
elegir a cuál le hablas?
La respuesta es darle un nombre a cada instancia:
handle {
...
} with Cell(10) as a {
get(resume) -> resume(state)
set(v, resume) -> resume((), v)
return(x) -> x
}
El as a liga a como un capability value: un valor cuyo
tipo es el efecto mismo (Cell, State[Int]). Dentro del cuerpo,
a.get() opera contra esa instancia — no contra “el Cell más
cercano”, sino contra el handler que tiene nombre a.
Lo interesante empieza cuando el capability viaja. Una instancia nombrada se puede pasar como argumento:
fn add(c1: Cell, c2: Cell, dst: Cell) : Unit =
dst.set(c1.get() + c2.get())
Mira esa firma con calma, porque tiene una sutileza. Cell
aparece como tipo de parámetro, no en la fila de efectos.
Son dos modos distintos de pedir lo mismo:
- En la fila (
fn f() : T / Cell): el efecto se demanda — lo satisface unhandleque envuelva la llamada, o un default. - Como parámetro (
fn f(c: Cell) : T): el capability lo provee quien llama, pasándolo explícito. No va en la fila, porque la función no le pide nada al contexto: ya tiene su evidencia en la mano.
Con eso, tres instancias del mismo efecto conviven sin pisarse
(ejemplos/cap12/11_instancias.kai):
handle {
handle {
handle {
add(a, b, destino)
println("destino = #{destino.get()}")
} with Cell(0) as destino { ... }
} with Cell(20) as b { ... }
} with Cell(10) as a { ... }
$ kai run ejemplos/cap12/11_instancias.kai
destino = 30
add suma las celdas que le den, sin saber cuál es cuál. Antes
de las instancias nombradas, la única salida era declarar tres
efectos idénticos — CellA, CellB, CellDst — y manejar cada
uno por separado. Ceremonia pura, y no escala.
Una restricción importante: el capability es de segunda
clase. Puede bajar por la pila — como argumento de llamada,
como receptor de operaciones — pero no puede escapar:
guardarlo en un record, devolverlo desde la función, capturarlo
en una closure que sobreviva al handle, o cruzarlo a una fibra
con spawn. Un handler es una promesa con alcance léxico; un
valor que necesita vivir más que su handle no es un
capability, es un Ref[T] bajo Mutable (§12.7) o un actor
(capítulo 14).
Si vienes de la inyección de dependencias, esta sección te debería sonar: pasar el capability como parámetro es inyectar la dependencia, con la diferencia de que aquí el tipo la rastrea y el compilador rechaza los usos que la harían escapar de su vida útil. Y si el wiring explícito te estorba, la fila de efectos sigue ahí: son los dos extremos del mismo mecanismo, y eliges por llamada.
12.10 Alias de filas de efectos
Cuando una combinación aparece muchas veces, le das un nombre con
type:
effect Log { log(msg: String) : Unit }
effect Audit { audit(usuario: String, accion: String) : Unit }
type Tracing = Log + Audit
A partir de ahí, escribir : Unit / Tracing es lo mismo que
escribir : Unit / Log + Audit. El alias es transparente: no
introduce un efecto nuevo, solo abrevia la fila.
fn realizar_compra(usuario: String, monto: Int) : Unit / Tracing {
Log.log("inicio compra")
Audit.audit(usuario, "compra($#{monto})")
Log.log("fin compra")
}
Una restricción: los alias deben ser cerrados. No puedes
escribir type WithIo[e] = Io + e (con variable de fila). Esa
restricción evita complicaciones en la unificación que el
compilador no necesita pagar.
12.11 Default handlers: el efecto trae el suyo
Hasta aquí cada handle que vimos lo escribimos a mano. Pero hay
efectos donde una de las implementaciones es tan obvia que pedirle
al usuario que la escriba cada vez es ceremonia pura: si tu efecto
es Log y la implementación “razonable” imprime con timestamp,
querrías que esa implementación venga con el efecto.
Kaikai permite declarar un bloque default { } dentro de la
declaración del efecto. Es exactamente lo mismo que un handle ... with, pero vive al lado de las operaciones y el compilador lo
instala alrededor de main cuando nadie maneja el efecto a mano.
effect Log {
info(msg: String) : Unit
warn(msg: String) : Unit
default {
info(msg, resume) -> $extern_handler("kai_default_log_info")
warn(msg, resume) -> $extern_handler("kai_default_log_warn")
}
}
Las cláusulas del bloque default tienen la misma forma que
las de un handle: nombre de operación, parámetros, resume,
flecha, cuerpo. Lo que cambia es dónde viven y quién las dispara.
Si main() declara : Unit / Log y no hay ningún handle ... with Log envolviéndolo, el compilador genera código equivalente a:
handle {
main_original()
} with Log {
info(msg, resume) -> ... # las cláusulas del default
warn(msg, resume) -> ...
}
El usuario no escribe ese wrapping. El compilador lo deriva del
bloque default y lo emite al entrar al programa.
$extern_handler: el sigil y el puente a C
El cuerpo de cada cláusula del ejemplo anterior es
$extern_handler("kai_default_log_info"). Eso necesita
explicación.
$ es un sigil: un carácter que marca una forma sintáctica
especial. En kaikai introduce un compiler intrinsic, una
construcción que el compilador resuelve directamente en vez de
buscar una función definida en código kaikai. La forma general es
$nombre(args). Hoy hay uno solo: $extern_handler. Mañana puede
haber más; el sigil queda reservado para esa categoría.
$extern_handler("kai_default_log_info") significa: “el cuerpo
de esta cláusula es una llamada al símbolo C kai_default_log_info”.
Cuando el efecto se dispara, el compilador no busca una función
kaikai que se llame así; emite directo una llamada al runtime de C
que está enlazado al programa.
Esto es el puente entre los efectos algebraicos de alto nivel y el
mundo concreto: archivos, sockets, syscalls. Las primitivas del
sistema operativo viven en C; los efectos viven en kaikai;
$extern_handler los conecta.
Cuándo dispara el default y cuándo no
La regla de búsqueda es la misma de §12.4 pero con un escalón extra al final:
- El
handle ... with Effmás cercano que cubre la operación gana. - Si no hay handle envolvente y la operación está en la fila de
main, el compilador instala el default del efecto. - Si ni siquiera el default cubre la operación, el compilador
rechaza el programa al typecheckar
main.
Mira el detalle: el default solo se instala cuando la operación
escaparía a main. Si tu función está dentro de un handle, gana
el handle, no el default. No hay ambigüedad ni precedencia
sorpresiva: lo más cercano siempre gana.
Volver al ejemplo desde otro ángulo
Esto explica por qué println compila sin que cada firma cargue
/ Stdout:
fn hola() {
println("hola")
}
fn main() {
hola()
}
Stdout viene del stdlib con un bloque default cuyas cláusulas
llaman a $extern_handler("kai_default_stdout_print") y similares.
El símbolo C escribe al stdout real del proceso. Como main no
maneja Stdout, el compilador instala ese default y el programa
imprime.
Cuando quieras controlar la salida (silenciar en tests, redirigir
a archivo, capturar para asertar contra ella), pones tu propio
handle ... with Stdout y dentro del bloque el default del runtime
no participa. Lo más cercano gana: el handle que escribiste
está más cerca que el wrapping implícito al entrar a main.
Tu propio default: el ejemplo completo
Si declaras un efecto y lo equipas con un default, los programas
que solo usan main se ven igual de simples:
effect MyLog {
info(msg: String) : Unit
default {
info(msg, resume) -> $extern_handler("kai_default_log_info")
}
}
fn greet(name: String) : Unit / MyLog {
MyLog.info("hola, " ++ name)
}
fn main() : Unit / Stdout {
let r = handle {
MyLog.info("hello from extern_handler")
7
} with MyLog {
info(msg, resume) -> resume(()) # silencia adentro del handle
}
print("result: #{int_to_string(r)}")
}
Adentro del handle ... with MyLog, las cláusulas explícitas
ganan: el info queda silenciado. Si en otro main no hubiera
ese handle, el default dispararía e imprimiría vía el runtime de C.
La función greet no se entera: para ella, MyLog es lo que sea
que el contexto haya decidido.
Cuándo no hay default: el efecto sin red
No todos los efectos traen default. Fail es el contraejemplo
claro: si una operación puede abortar el programa, no quieres que
“olvidarse de manejarla” sea legal. La declaración pública de
Fail (apéndice D) no tiene bloque default, y por eso una
función que produce / Fail obliga a ser manejada en algún lado
antes de main, o el compilador rechaza con un mensaje claro.
Lo mismo vale para State[T], Reader[T], Writer[W]: efectos
genéricos en los que no existe una implementación razonable
sin contexto, así que pedirle al usuario que la escriba no es
ceremonia sino disciplina.
La regla mental: un efecto trae default cuando hay una sola
implementación obvia (escribir a stdout, leer del reloj del
sistema, generar números pseudo-aleatorios). Si “razonable” depende
del programa, no hay default y el usuario lo provee.
Función envoltorio: la alternativa cuando no hay default
Cuando un efecto no trae default, o cuando el default existe pero
tu programa siempre quiere otro, el patrón idiomático es una
función envoltorio:
fn with_test_log[A](body: () -> A / MyLog) : A {
handle {
body()
} with MyLog {
info(msg, resume) -> resume(()) # silencia en tests
}
}
fn main() {
with_test_log { ->
greet("kaikai")
greet("ada")
}
}
Es lo que el stdlib usa para construcciones como try { body } y
with_state(0) { body }. La diferencia con un default es que la
envoltura es explícita en el código: quien lee main ve la
línea, abre la función, sabe qué hace. Un default vive en la
declaración del efecto.
Si tu efecto tiene un default razonable para producción y uno
distinto para tests, expón los dos como funciones envoltorio
(with_test_log, with_quiet_log) y deja que main use el
default. Quien escribe tests llama a la envoltorio.
12.12 Los handlers del stdlib son código kaikai
Cuando un programa println("hola") simplemente funciona, es fácil
imaginar que el compilador trae un caso especial para Stdout. No
es así. Los handlers que el runtime instala alrededor de main
para Stdout, Stdin, Random, Clock, File, Env, NetTcp,
y los demás están escritos en stdlib kaikai normal: cada uno
es un effect ... { ops; default { ... } } que usa el mismo sigil
$extern_handler que tú usarías para conectar tu efecto con C.
# stdlib/io/console.kai (forma esquemática)
effect Stdout {
print(s: String) : Unit
default {
print(s, resume) -> $extern_handler("kai_default_stdout_print")
}
}
El compilador no conoce a Stdout por su nombre. Conoce bloques
default y $extern_handler. Para Stdout, instala el
default igual que para tu MyLog: caminando el AST de la
declaración del efecto, no leyendo una tabla hardcoded.
El motivo no es estético: el AST se vuelve la única fuente de
verdad, sin tablas internas que mantener en paralelo, y los
efectos de usuario obtienen exactamente las mismas garantías
que los del stdlib. Si tu efecto declara default { } con
$extern_handler, el compilador lo instala como builtin.
La consecuencia práctica: puedes leer cómo está implementado
Stdout. kai doc effects.Stdout te muestra su firma y su
default; el efecto es código kaikai como el tuyo. Si
te aparece una duda sobre la semántica del default (¿qué pasa si el
pipe está cerrado?, ¿quién captura EPIPE?), la respuesta vive
en la cláusula print(s, resume) -> ... o en el símbolo C al que
puentea. No hay un comportamiento secreto del runtime separado del
código que puedes leer.
Vale repetir la regla para amarrar el modelo: el compilador resuelve
un efecto buscando, en orden, (1) el handle ... with más cercano,
(2) el default { } block del efecto si la operación escapa a
main, (3) error de compilación. Los handlers del stdlib no son
una cuarta categoría; son instancias de (2).
Por qué el sigil tiene un nombre raro
$extern_handler puede sonar largo. La razón es que el sigil es
un sistema, no una sola operación. La trilogía #533 introdujo $
como prefijo para una familia de intrinsics; $extern_handler
es el primero. Si más adelante kaikai necesita exponer otros
puentes al runtime (pedir el errno actual, llamar a un símbolo
de plataforma específica), vivirán bajo el mismo sigil con nombres
descriptivos: $os_name, $panic_with_trace, lo que sea. Reservar
$<ident>(args) deja la puerta abierta sin reabrir el debate
sintáctico cada vez.
Para tu día a día: si nunca conectas un efecto a C, nunca vas a
escribir $extern_handler. Pero cuando lo veas en stdlib sabes
qué es: una cláusula que cede el cuerpo a un símbolo del runtime,
declarada con la misma sintaxis que cualquier otro handler.
12.13 Caso de estudio: procesador de configuración
Cerramos con un ejemplo que mezcla los tres patrones que vimos:
logueo, estado, fallo. El programa procesa una lista de líneas
con formato clave=valor, las parsea, registra cada paso, cuenta
cuántas pasaron, y aborta si alguna línea no tiene el formato
esperado.
effect Log {
log(msg: String) : Unit
}
effect State[T] {
get() : T
set(v: T) : Unit
}
effect Fail {
fail(motivo: String) : Nothing
}
type Entrada = { clave: String, valor: String }
fn parsear(linea: String) : Entrada / Fail {
match string.split(linea, "=") {
[c, v] -> Entrada { clave: c, valor: v }
_ -> Fail.fail("línea inválida: '#{linea}'")
}
}
fn procesar(lineas: [String]) : Int / Log + State[Int] + Fail {
list.foreach(lineas, (l) => {
let e = parsear(l)
Log.log("#{e.clave} = #{e.valor}")
State.set(State.get() + 1)
})
State.get()
}
procesar declara los tres efectos en su firma y los usa
libremente: parsea (puede fallar), registra (loggea), acumula
(estado). Pero no decide nada sobre el contexto en que corre.
En main, los tres handlers anidados deciden:
fn main() : Unit / Stdout {
let n = handle {
handle {
handle {
procesar(["nombre=ada", "edad=42", "rol=admin"])
} with State[Int](0) {
get(resume) -> resume(state)
set(v, resume) -> resume((), v)
return(x) -> x
}
} with Log {
log(msg, resume) -> {
println("[LOG] " ++ msg)
resume(())
}
}
} with Fail {
fail(motivo, resume) -> {
println("error: " ++ motivo)
0 - 1
}
}
println("entradas procesadas: #{n}")
}
Salida:
$ kai run ejemplos/cap12/08_parser_config.kai
[LOG] nombre = ada
[LOG] edad = 42
[LOG] rol = admin
entradas procesadas: 3
Y si una línea es inválida, el Fail exterior la atrapa, imprime
el motivo, y n queda en -1. El Log y el State interiores
ya emitieron lo que alcanzaron antes del fallo.
¿Por qué este es un buen ejemplo para terminar el capítulo? Porque
muestra los tres patrones cooperando, cada uno aportando algo
distinto, y porque la función procesar es directamente
testeable: sin tocar archivos, sin tocar IO, sin mocks. En un
test, los tres handlers tienen otras implementaciones: el Log
acumula en una lista en vez de imprimir, el Fail propaga en un
Result, el State parte del valor que el test quiera.
12.14 Filosofía: tres ideas que vale recordar
Si esto te parece muchas piezas, vale fijar las tres ideas que todo lo demás sostiene:
-
Los efectos son visibles en el tipo. Si una función puede fallar, suspenderse, mutar, o llamar a IO, su firma lo dice. Sin excepciones invisibles, sin
asyncinfeccioso, sin dependencias ocultas. -
El handler decide qué pasa. El cuerpo de una función declara que necesita una capacidad. El handler en el contexto decide cómo materializarla. Ese desacople es lo que reemplaza inyección de dependencias, mocking, configuración global.
-
Costo cero cuando no se usa. El compilador resuelve los handlers en tiempo de compilación cuando puede (la mayoría de los casos), y el código generado es tan rápido como si hubieras escrito un
ifdirecto. No hay sobrecarga estructural por tener efectos en el tipo. Es la misma promesa de las unidades de medida y los contratos: información rica en el tipo, código eficiente abajo.
Los efectos algebraicos vienen de la academia (Pretnar, Plotkin,
Power) y aparecieron en lenguajes como Koka, Eff y Effekt antes
que en kaikai. Lo que kaikai aporta es una sintaxis legible (la
notación / Eff en la firma), una integración con el resto del
lenguaje (filas en vez de listas, alias), y un modelo de defaults
que no tiene casos especiales: los handlers del stdlib están
declarados en kaikai con la misma forma que los tuyos. Pero la
idea de base es vieja y sólida.
Si después de este capítulo todavía no estás cómodo, no te preocupes. Los efectos son la pieza que más tiempo toma asentar. Volverás a leer este capítulo varias veces. Cada lectura agarra una capa más.
Ejercicios
12.1. Escribe un efecto Clock con una operación now() : Int
(milisegundos desde el inicio). Escribe una función medir que
ejecute un bloque y devuelva cuánto tiempo tomó usando Clock.
Después escribe dos handlers: uno real (consulta el reloj del
sistema) y uno simulado (avanza un contador). ¿Para qué sirve el
segundo?
12.2. Modifica suma de §12.6 para que devuelva tanto el
total como la cantidad de elementos sumados, sin agregar
parámetros. Pista: cambia return(x) -> x.
12.3. El caso de estudio §12.13 imprime con [LOG] cada
entrada. Cambia el handler de Log para que en vez de imprimir,
acumule los mensajes en una lista y los devuelva como parte del
resultado final, junto con n. Pista: necesitas otro State.
12.4. Escribe fn contar_pares(xs: [Int]) : Int de dos formas:
una con var y list.foreach, otra sin var, usando list.filter
y list.length. ¿Cuál te parece más clara? ¿Por qué la versión con
var no agrega efectos a la firma?
12.5. Construye un efecto Choice con una operación
choose(opciones: [Int]) : Int que entrega “alguna” de las
opciones. Escribe un handler que siempre elija la primera y otro
que elija la última. ¿Cómo cambiaría la implementación si quisieras
un handler que explore todas las opciones (backtracking)?
Pista: necesitarías llamar a resume más de una vez. Eso es
multi-shot y vive bajo resume_multishot.
12.6. Toma un programa que tengas en otro lenguaje donde uses inyección de dependencias para mockear servicios en tests. Anota en pseudocódigo qué efectos declararías y cómo serían los handlers de tests vs producción. ¿Cuánto código del programa original sobrevive sin cambios?
12.7. Investiga la diferencia entre resume (one-shot) y
resume_multishot en la doc del lenguaje. ¿Por qué kaikai hace
que el caso común sea barato y obliga a marcar explícitamente el
caso caro?
12.8. Declara un efecto MyLog con una operación info(msg: String) : Unit y un bloque default { } que puentee a un símbolo
C ficticio my_log_info_to_stderr vía $extern_handler. Después
escribe una función envoltorio with_quiet_log que silencie los
mensajes. Un main sin envoltorio dispara el default; un main
envuelto en with_quiet_log no. Compara con cómo harías lo mismo
en un lenguaje con inyección de dependencias clásica.
12.9. ¿Por qué Fail no trae bloque default { }? Anota tres
efectos hipotéticos (los tuyos o de stdlib que imagines) y para
cada uno decide si llevaría default. Argumenta en una línea por
qué sí o por qué no.
12.10. Lee la declaración de Stdout en
stdlib/io/console.kai del repositorio del lenguaje. ¿Qué hace la
cláusula del default cuando el pipe está cerrado (EPIPE)? ¿Dónde
vive esa lógica: en kaikai o en el símbolo C al que puentea?
12.11. El capítulo 13 cubre fibras: tareas concurrentes
manejadas como efectos. Anota antes de leerlo: ¿qué operaciones
tendría que tener un efecto Spawn? ¿Qué decisión tomarías como
handler cuando una fibra hija aborta?