Apéndice D · Catálogo de efectos del stdlib
Los efectos que expone el stdlib, como referencia: ante una
firma : Unit / X en la documentación de una función, vienes
aquí a confirmar qué provee X.
La especificación completa vive en
github.com/kaikailang-org/kaikai/docs/effects-stdlib.md. Aquí
tienes la declaración de cada efecto y para qué sirve.
D.1 IO básico
Stdout, Stderr y Stdin
Los tres flujos estándar son efectos separados, uno por descriptor. Esa granularidad le permite a un arnés de tests capturar stdout sin tocar lo que se escribe a stderr.
effect Stdout {
print(s: String) : Unit
is_tty() : Bool
}
effect Stderr {
eprint(s: String) : Unit
is_tty() : Bool
}
effect Stdin {
read_line() : Option[String]
read_bytes(n: Int) : String
is_tty() : Bool
}
print y eprint agregan un newline. Ninguna de las dos lleva
tipo de falla: bajo el handler por defecto, el fallo recuperable
común (el pipe cerrado del otro lado, EPIPE) se absorbe en
silencio, y lo que quede es lo bastante catastrófico como para
entrar en pánico. read_line devuelve None en EOF.
is_tty() responde si el flujo es una terminal, según
isatty(3) sobre su propio descriptor. Es la compuerta estándar
para color ANSI: emites escapes cuando Stdout.is_tty(), texto
plano bajo un pipe o una redirección. La otra mitad de esa
convención la cubre Env.get("NO_COLOR").
El alias Console = Stdout + Stderr + Stdin agrupa a los tres
cuando la distinción no aporta.
Env
effect Env {
args() : [String]
get(name: String) : Option[String]
set_var(name: String, value: String) : Result[Unit, String]
unset_var(name: String) : Result[Unit, String]
vars() : [Pair[String, String]]
}
Acceso a los argumentos de línea de comando (argv) y a las
variables de entorno, tanto de lectura como de escritura.
File
perm read
perm write
effect File {
read_file(path: String) : Result[String, String]
write_file(path: String, contents: String) : Result[Unit, String]
open_read(path: String) : Result[FileHandle<read>, String]
read_chunk(h: FileHandle<read>, max: Int) : Result[String, String]
open_write(path: String) : Result[FileHandle<read + write>, String]
write_chunk(h: FileHandle<write>, data: String) : Result[Unit, String]
close_file(h: FileHandle) : Unit
}
Dos niveles. read_file / write_file mueven el archivo
entero de una vez, que es lo que quieres casi siempre. El
resto es la ruta por chunks para archivos que no caben o que
no quieres cargar completos.
Fíjate en el <...> de los handles: FileHandle<read> y
FileHandle<read + write> llevan la capacidad en el tipo.
Un handle abierto para lectura no tipa donde se espera
FileHandle<write>, y eso lo decide el compilador, no un
chequeo en runtime: es el kind Perm del cap. 19 §19.9. Lo
que el tipo declara es lo que el código
pidió al abrir, no el permiso que el sistema operativo tenga
sobre el archivo: esa falla sigue viajando por el Result de
cada operación.
Para directorios y metadata, ver los módulos de fs.
Log
effect Log {
debug(msg: String) : Unit
info(msg: String) : Unit
warn(msg: String) : Unit
error(msg: String) : Unit
}
Logging con niveles. El handler por defecto escribe cada
mensaje a stderr con el formato [ISO8601Z] NIVEL mensaje;
instalar un handler propio te deja capturarlo o filtrarlo.
En el capítulo 12 declaraste un effect Log { log(msg: String) : Unit } propio, más chico, para aprender el
mecanismo. No hay conflicto: una declaración local hace
shadowing del nombre del stdlib dentro de su archivo. Si
quieres el de cuatro niveles, no lo declares.
Trace
effect Trace {
log(msg: String) : Unit
checkpoint(name: String) : Unit
}
Trazas ad-hoc, más chico que Log y con otro propósito:
log emite una línea suelta, checkpoint marca un punto con
nombre. Se importa desde trace.
A diferencia del resto de esta sección, Trace no trae
handler por defecto: sin uno instalado el programa no
compila, porque el nombre no está en alcance. El módulo trae
dos:
trace.with_trace_default(body)escribe cada operación a stdout como[trace] mensaje, y los checkpoints como[trace] checkpoint: nombre.trace.with_log_prefix(prefijo, body)anteponeprefijo ++ ": "a cada operación y la reemite víaTrace.logal siguiente handler de la pila, así que se encadena con el anterior.
D.2 Tiempo y aleatoriedad
Clock
effect Clock {
wall_now() : WallTime
monotonic_now() : Instant
sleep_ns(ns: Int) : Unit
}
Dos relojes distintos a propósito. wall_now da la hora del
calendario (WallTime es { secs, nanos } desde epoch), que
salta cuando alguien ajusta el reloj del sistema. monotonic_now
da un Instant que solo avanza: es el que quieres para medir
cuánto tardó algo. sleep_ns parkea la fibra, no el hilo.
Random
effect Random {
int_range(lo: Int, hi: Int) : Int
}
Una sola operación, uniforme en [lo, hi] con ambos extremos
incluidos. El handler por defecto siembra un PCG64 desde el
reloj y el pid del proceso. Los helpers de más alto nivel,
construidos sobre esta op, viven en el módulo Random. No
sirve para criptografía.
SecureRandom
effect SecureRandom {
int_range(min: Int, max: Int) : Int
bytes(n: Int) : [Int]
}
Aleatoriedad criptográficamente segura, deliberadamente separada
de Random para que un handler de tests que stubea Random no
pueda debilitar sin querer una ruta sensible. bytes(n) entrega
n bytes como enteros en [0, 255]. El handler por defecto
puentea al CSPRNG del sistema (getrandom / arc4random).
D.3 Red
NetTcp
effect NetTcp {
connect(host: String, port: Int) : Result[Conn, String]
listen(host: String, port: Int) : Result[Listener, String]
accept(l: Listener) : Result[Conn, String]
send(c: Conn, data: [Int]) : Result[Int, String]
recv(c: Conn, max: Int) : Result[[Int], String]
recv_timeout(c: Conn, max: Int, nanos: Int) : Option[Result[[Int], String]]
close(c: Conn) : Unit
}
Sockets TCP a nivel de bytes; los bytes viajan como [Int] en
[0, 255]. Las operaciones bloqueantes (connect, accept,
send, recv) parkean la fibra vía el reactor del runtime, no
el hilo del sistema. recv_timeout devuelve None si se vence
el plazo.
NetUnix
effect NetUnix {
listen(path: String, mode: Int) : Result[Listener, String]
connect(path: String) : Result[Conn, String]
accept(l: Listener) : Result[Conn, String]
send(c: Conn, data: [Int]) : Result[Int, String]
recv(c: Conn, max: Int) : Result[[Int], String]
recv_timeout(c: Conn, max: Int, nanos: Int) : Option[Result[[Int], String]]
peer_uid(c: Conn) : Result[Int, String]
close(c: Conn) : Unit
close_listener(l: Listener) : Unit
}
Sockets de dominio Unix: el hermano local de NetTcp, con un
archivo en el lugar del par host y puerto. Se importa desde
net.unix. send, recv, recv_timeout y close se comportan
igual que en TCP. listen crea el archivo del socket con los
permisos de mode; como kaikai no tiene literales octales, el
módulo exporta owner_only para el 0600 de siempre.
close_listener cierra el listener y borra el archivo.
peer_uid devuelve el uid efectivo del proceso al otro lado, tal
como lo registró el kernel al conectar. Nada de lo que el par
envíe puede falsificarlo.
NetUdp y NetDns
effect NetUdp {
bind(host: String, port: Int) : Result[UdpSocket, String]
send(s: UdpSocket, dst: SocketAddr, data: [Int]) : Result[Int, String]
recv(s: UdpSocket, max: Int) : Result[Pair[SocketAddr, [Int]], String]
close(s: UdpSocket) : Unit
}
effect NetDns {
resolve(host: String) : Result[[IpAddr], String]
}
Mismo estilo que NetTcp. El stdlib no trae un alias que
agrupe a los efectos de red: solo declara Console e Io
(§D.9). Si una función usa varios, los sumas en su fila,
/ NetTcp + NetUdp + NetDns, o defines el alias tú, en una
línea una vez importados los módulos:
import net.tcp
import net.udp
import net.dns
type Net = NetTcp + NetUdp + NetDns
D.4 Procesos y señales
Process
type Child = { pid: Int }
type Exit = Exited(Int) | Signaled(Int)
effect Process {
start(cmd: String, args: [String]) : Child
wait(c: Child) : Result[Exit, String]
kill(c: Child, sig: Int) : Result[Unit, String]
exit(code: Int) : Nothing
start_piped(cmd: String, args: [String],
pipe_stdin: Bool, pipe_stdout: Bool,
pipe_stderr: Bool) : Result[Child, String]
write_stdin(c: Child, data: String) : Result[Unit, String]
close_stdin(c: Child) : Result[Unit, String]
read_stdout(c: Child) : Result[String, String]
read_stderr(c: Child) : Result[String, String]
start_group(cmd: String, args: [String],
pipe_stdin: Bool, pipe_stdout: Bool,
pipe_stderr: Bool) : Result[Child, String]
kill_group(c: Child, sig: Int) : Result[Unit, String]
}
Lanzar y controlar subprocesos. start hace fork/exec y
devuelve un Child (un handle opaco con el pid); wait lo
cosecha y entrega un Exit, que distingue explícitamente entre
terminar con un código y morir por una señal. kill manda un
signo POSIX crudo.
exit(code) termina el proceso actual vía _exit(2), así
que los buffers de stdio no se vacían: imprime todo lo que
quieras imprimir antes de llamarlo. La operación no resume
(devuelve Nothing) y los handlers de Cancel no corren. Para
el caso normal, terminar con un código de salida, basta con
devolver un Int desde main (cap. 16 §16.1), que sí usa el
camino de salida completo de la libc.
start_piped es la forma popen: conecta pipes al stdin, al
stdout y/o al stderr del hijo, con un flag para cada uno, y desde
ahí write_stdin, close_stdin, read_stdout y read_stderr
manejan la conversación. Con stderr en un pipe aparte puedes
distinguir un diagnóstico de la salida; fusionar los dos flujos
no se ofrece, porque una vez fusionados ya no se separan.
kill alcanza solo al hijo, no a los procesos que ese hijo lanzó.
Para eso está start_group: es start_piped con el hijo como
líder de un grupo de procesos nuevo, y kill_group le manda la
señal al grupo completo, de modo que una cadena como
sh -> lanzador -> programa muere junta. kill_group sobre un
hijo que arrancó de otra forma devuelve Err. El grupo es
optativo porque un hijo fuera del grupo en primer plano de la
terminal no recibe Ctrl-C y queda detenido si intenta leer de
ella: un paginador o un editor siguen yendo por start o
start_piped.
Signal
type Sig = SigInt | SigTerm | SigHup | SigUsr1 | SigUsr2
effect Signal {
on(sig: Sig) : Unit
off(sig: Sig) : Unit
await() : Sig
}
Esperar a una señal POSIX sin bloquear las demás fibras.
on(sig) suscribe el proceso a sig; el runtime bloquea
la señal a nivel del proceso y el kernel encola la entrega
en vez de aplicar la disposición por defecto. await()
parkea la fibra que llama hasta que llegue cualquiera de
las señales suscritas y devuelve la variante Sig
correspondiente. off(sig) desuscribe.
El handler default vive alrededor de main cuando Signal
está en la fila. Solo una fibra puede estar en await() a
la vez; un segundo llamado concurrente entra en pánico.
Útil para limpieza ordenada al apagar el proceso:
Signal.on(SigInt); match Signal.await() { ... } reemplaza
el típico handler de SIGINT escrito a mano.
D.5 Estado
State[T]
effect State[T] {
get() : T
set(v: T) : Unit
}
El efecto canónico para llevar estado mutable de forma
encapsulada. La sintaxis var nombre = init es azúcar sobre
un handle de State[T] (cap. 12 §12.7).
Reader[T] y Writer[W]
effect Reader[T] {
ask() : T
}
effect Writer[W] {
tell(w: W) : Unit
}
Entorno de lectura (configuración inmutable) y acumulación de salida (logging, traza). Patrones clásicos del cálculo de efectos.
Mutable
effect Mutable {
array_make[T](n: Int, init: T) : Array[T]
array_length[T](a: Array[T]) : Int
array_get[T](a: Array[T], i: Int) : T
array_set[T](a: Array[T], i: Int, v: T) : Array[T]
array_grow[T](a: Array[T], n: Int, init: T) : Array[T]
ref_make[T](init: T) : Ref[T]
ref_get[T](r: Ref[T]) : T
ref_set[T](r: Ref[T], v: T) : Unit
}
El efecto detrás de Ref[T] y Array[T]. Sigue la
disciplina de efectos observables (cap. 12 §12.7): un
array_set requiere Mutable en la fila solo cuando la
mutación es visible para quien llama. Un array creado local y
devuelto no requiere Mutable.
array_set y array_grow devuelven el Array[T] en vez de
Unit, sin copiar: devuelven el mismo arreglo para que Perceus
(apéndice B) pueda razonar sobre el último uso y decidir si
mutar en el lugar. Encadenar la operación es idiomático.
D.6 Errores y control
ReadFault
effect ReadFault {
bad_chunk(msg: String) : Unit # reanudable: saltar y seguir
open_fault(msg: String) : Nothing # solo abortar
}
La falla recuperable de las lecturas por streaming, declarada
en stdlib/stream.kai. Es el único efecto con forma de falla
que el stdlib declara, y su diseño explica por qué: bad_chunk
devuelve Unit, así que un handler que reanuda descarta el
chunk malo y continúa (política de salteo), y uno que abandona
la continuación aborta. open_fault devuelve Nothing: un
stream cuyo origen no abre no tiene a dónde reanudar.
ReadFault no trae handler por defecto. Un consumidor que
fuerza el stream tiene que elegir política con handle ... with ReadFault, o el typer reporta effect not handled: ReadFault.
El camino de aborto igual libera el handle del productor:
read_lines y write_lines encierran el archivo en un handler
con initially / finally (cap. 12 §12.8), así que
close_file corre en el desarme aunque la falla salte limpio
sobre el loop de lectura.
Cancel
effect Cancel {
raise() : Nothing
}
Cancelación cooperativa. El scheduler inyecta Cancel.raise()
en una fibra cancelada en el próximo punto de yield. La fibra
puede instalar un handler de Cancel para limpieza (cap. 13).
No hay un efecto Fail
kaikai no trae un efecto Fail. Toda API que puede fallar
devuelve Result y propaga con ! postfijo: no hay una sola
fila / Fail en el stdlib.
Qué usar, según lo que necesites:
| Necesitas | Usa |
|---|---|
| Falla inspeccionable | Result[a, e] con ! postfijo |
| Falla cuya política elige el consumidor | un efecto de dominio cuya op devuelva Unit, para que el handler pueda reanudar y saltear |
| Salida no local profunda | Cancel.raise() : Nothing |
| Error de programación | panic |
Fail sigue siendo un buen ejemplo didáctico de operación que
devuelve Nothing, y por eso el capítulo 12 lo declara local
en varios ejemplos. Un effect Fail declarado por ti no trae
default, así que un fail sin manejar es un error de
compilación (effect not handled: Fail), no un aborto en
tiempo de ejecución.
D.7 Concurrencia
Spawn
effect Spawn {
yield() : Unit
spawn[T](thunk: () -> T / e) : Fiber[T]
await[T](fiber: Fiber[T]) : T
select[T](fibers: [Fiber[T]]) : T
cancel[T](fiber: Fiber[T]) : Unit
set_trap_exit(on: Bool) : Unit
scope_enter() : Unit
scope_exit() : Unit
}
Crear fibras, esperarlas, racear, ceder, cancelar.
nursery { n -> ... } del cap. 13 es azúcar sobre
handle ... with Spawn as n { ... }, y las dos últimas
operaciones son la maquinaria detrás de esa azúcar:
scope_enter y scope_exit delimitan el alcance que hace el
join de las hijas. set_trap_exit decide si la fibra recibe
la caída de un peer enlazado como mensaje en vez de morir con
él (cap. 14 §14.6).
Actor[Msg]
effect Actor[Msg] {
self() : Pid[Msg]
send(pid: Pid[Msg], msg: Msg) : Unit
receive() : Msg
receive_timeout(nanos: Int) : Option[Msg]
}
El efecto que da forma al modelo de actores del cap. 14.
with_mailbox { ... } y spawn_actor(...) son los wrappers
del stdlib que instalan este handler. receive_timeout
devuelve None si no llegó nada en el plazo, y así evita que
un actor quede colgado esperando un mensaje que no
va a llegar.
Link y Monitor
effect Link {
link(peer: Pid[Nothing]) : Unit
}
effect Monitor {
monitor(target: Pid[Nothing]) : Pid[Nothing]
demonitor(ref: Pid[Nothing]) : Unit
}
Supervisión al estilo BEAM (cap. 14 §14.6). Links son
bidireccionales: si uno cae, el otro recibe Cancel.raise().
Monitores son unidireccionales: el observador recibe un
mensaje MonitorDown cuando el observado termina.
D.8 Interoperabilidad
Ffi
effect Ffi {}
El efecto que cargan todas las funciones declaradas con
extern "C" fn. Sin operaciones propias: es un marcador
para que el sistema de tipos sepa qué funciones tocan
código no auditado por kaikai. El capítulo 16 §16.12
cubre la sintaxis de declaración, el mapeo de tipos en el
borde, el enlazado con shims C, y qué soporta y qué no
FFI v1.
D.9 Composición: los alias Console e Io
type Console = Stdout + Stderr + Stdin
type Io = Console + Env + File
Dos niveles de agrupación. Console junta los tres flujos
estándar; Io le suma el entorno y los archivos. Una función
que dice / Io declara que puede tocar consola, leer variables
de entorno y manipular archivos: el equivalente a “esta función
no es pura, hace cosas con el sistema”.
Fíjate en quiénes no están en Io: Clock, Random,
SecureRandom, los efectos de red y Process quedan fuera a
propósito. Una función que “logguea y lee configuración” no
debería ganar en silencio la capacidad de salir a la red o de
lanzar subprocesos solo porque ambas cosas viven bajo un nombre
cómodo. Esos efectos aparecen explícitos en la firma o no
aparecen.
D.10 Handlers por defecto
Cuando main declara uno de estos efectos en su fila, el
runtime instala automáticamente un handler por defecto:
Stdout,Stderr,Stdin,Env,File→ IO al sistema.Clock,Random,SecureRandom→ reloj y RNG del sistema.NetTcp,NetUnix,NetUdp,NetDns→ POSIX sockets.Process,Signal→ llamadas POSIX.Log→ cada mensaje a stderr como[ISO8601Z] NIVEL mensaje.Mutable→ asignaciones reales en heap.Spawn,Cancel→ el scheduler de fibras del runtime.
Estos handlers se pueden interceptar: cualquier handle ... with X { ... } que instales gana sobre el handler del runtime
en todo el bloque del body. Así haces mocking en tests,
capturas la salida o simulas el reloj.