Capítulo 8 · Módulos, imports, organización del código
Hasta ahora todo tu código ha vivido en un archivo. Es la forma correcta de partir, pero cuando el programa crece el archivo único estorba: quien lo lee se pierde y los cambios chocan. Toca partir el código en pedazos con nombre.
kaikai lo resuelve con un sistema simple, y esa simpleza la
elegí a propósito. Un archivo es un módulo. No hay
declaraciones module Foo, ni archivos especiales que reabran
un módulo desde otro lado; el nombre del módulo sale de la ruta
del archivo. Por encima viene el proyecto, descrito por un
kai.toml, que organiza sus módulos y dependencias externas. Y
por encima del proyecto, un package manager que resuelve
dependencias entre proyectos. Recorres las tres escalas de
menos a más.
8.1 Un archivo, un módulo
Crea un archivo aritmetica.kai con algunas funciones:
pub fn duplicar(x: Int) : Int = x * 2
fn helper(x: Int) : Int = x + 1
pub fn doble_mas_uno(x: Int) : Int = helper(duplicar(x))
Tres decisiones de diseño aparecen en estas tres líneas:
pubmarca lo que el módulo exporta. Una declaración es privada al archivo salvo que llevepub; solo lo marcado es visible desde otro módulo que importe este. Es al revés de Kotlin o Scala, donde lo público es el default y hay que recordar marcar lo privado.- Los nombres del módulo no necesitan declararse. El
archivo
aritmetica.kaies el móduloaritmetica. Si lo pones bajo un subdirectorio comoutil/aritmetica.kai, pasa a ser el móduloutil.aritmetica. El nombre se deriva de la ruta relativa a la raíz del proyecto. - Cualquier
fn,type,effectoletpuede llevarpub. No hay diferencia en visibilidad entre tipos y funciones: una construcción es visible afuera solo si está marcadapub(opub(pkg), que aparece en la sección 8.5).
Las funciones privadas te dejan descomponer una pública sin
contaminar el namespace de quien importa: helper solo existe
dentro de aritmetica.
El ejemplo que corre es ese mismo archivo con un main que
usa las dos funciones públicas:
# ejemplos/cap08/01_un_archivo/main.kai
pub fn duplicar(x: Int) : Int = x * 2
fn helper(x: Int) : Int = x + 1
pub fn doble_mas_uno(x: Int) : Int = helper(duplicar(x))
fn main() {
println("doble_mas_uno(5) = #{doble_mas_uno(5)}")
}
$ kai run ejemplos/cap08/01_un_archivo/main.kai
doble_mas_uno(5) = 11
8.2 import y nombres calificados
Para usar aritmetica desde otro archivo:
import aritmetica
fn main() {
println("doble_mas_uno(5) = #{aritmetica.doble_mas_uno(5)}")
}
import aritmetica deja accesibles las cosas pub de ese
módulo bajo el prefijo aritmetica.. La función
doble_mas_uno se llama como aritmetica.doble_mas_uno.
El prefijo es a propósito, y sé que a alguna gente le molesta.
Cuando un proyecto crece a quince o veinte módulos, ver
aritmetica.doble_mas_uno en una expresión te dice de
inmediato de dónde sale. Importar todos los nombres sueltos
ahorra teclas pero pierde esa pista.
El mismo prefijo se usa para los tipos que el módulo exporta y para construirlos:
# ejemplos/cap08/02_dos_archivos/geometria.kai
pub type Punto = { x: Real, y: Real }
pub fn distancia(a: Punto, b: Punto) : Real {
let dx = a.x - b.x
let dy = a.y - b.y
real_sqrt(dx * dx + dy * dy)
}
fn cuadrado(x: Real) : Real = x * x
# ejemplos/cap08/02_dos_archivos/main.kai
import geometria
fn main() {
let a: geometria.Punto = geometria.Punto { x: 0.0, y: 0.0 }
let b: geometria.Punto = geometria.Punto { x: 3.0, y: 4.0 }
println("distancia = #{geometria.distancia(a, b)}")
}
$ kai run ejemplos/cap08/02_dos_archivos/main.kai
distancia = 5
El prefijo va en la anotación de tipo (: geometria.Punto),
en la construcción del record (geometria.Punto { ... }) y en
la llamada (geometria.distancia(a, b)). Un único patrón
mental.
Si el módulo está bajo un subdirectorio, el import usa el mismo nombre punteado que el módulo:
# ejemplos/cap08/05_proyecto/util/matematica.kai
pub fn pow(base: Int, exp: Int) : Int {
if exp == 0 {
1
} else {
base * pow(base, exp - 1)
}
}
# ejemplos/cap08/05_proyecto/main.kai
import util.matematica
fn main() {
println("3^4 = #{matematica.pow(3, 4)}")
}
$ kai run ejemplos/cap08/05_proyecto/main.kai
3^4 = 81
El archivo está en util/matematica.kai y el módulo se llama
util.matematica, pero al usarlo el prefijo es solo
matematica, el último segmento. Así evitas prefijos largos
sin perder el origen estructural.
Tres formas de importar
kaikai admite tres formas:
import math.vector # use qualified: vector.dot(a, b)
import math.vector as V # alias: V.dot(a, b)
import math.vector.{dot, cross} # nombres específicos en alcance
La primera es la más común y la opción por omisión: el prefijo
vector. deja claro de dónde sale cada nombre. La segunda
sirve cuando el nombre del módulo es largo o aparece muchas
veces, y un alias corto gana legibilidad sin perder la pista.
La tercera es la importación selectiva: una lista explícita
de nombres que se traen al namespace local. Conviene solo
cuando esos nombres son los protagonistas del archivo y el
prefijo se vuelve ruido (un Punto que aparece cuarenta veces,
por ejemplo). A cambio, quien lee ya no sabe de dónde salió
Punto sin mirar arriba.
No hay wildcard import: no existe import math.vector.* ni
nada equivalente que traiga “todo lo que el módulo exporta” al
namespace local. Lo decidí así porque el wildcard es cómodo al
escribir, pero deja al lector futuro sin saber de dónde vino un
nombre.
Las tres formas como tres proyectos que corren. Cada uno trae
su propio geometria.kai; lo que cambia es la línea del
import y, con ella, cómo se escribe la llamada:
# ejemplos/cap08/03_qualified/main.kai
import geometria
fn main() {
let d = geometria.distancia_origen(3.0, 4.0)
println("distancia al origen = #{d}")
}
# ejemplos/cap08/04_alias/main.kai
import geometria as G
fn main() {
let d = G.distancia_origen(3.0, 4.0)
println("distancia al origen = #{d}")
}
# ejemplos/cap08/02b_selectivo/main.kai
import geometria.{Punto, distancia}
fn main() {
let a: Punto = Punto { x: 0.0, y: 0.0 }
let b: Punto = Punto { x: 3.0, y: 4.0 }
println("distancia = #{distancia(a, b)}")
}
$ kai run ejemplos/cap08/03_qualified/main.kai
distancia al origen = 5
$ kai run ejemplos/cap08/04_alias/main.kai
distancia al origen = 5
$ kai run ejemplos/cap08/02b_selectivo/main.kai
distancia = 5
Cuando dos módulos exportan el mismo nombre
Tarde o temprano dos dependencias tuyas le ponen la misma
palabra a cosas distintas. No es que una esté mal: cada dominio
nombra lo suyo con el término que le corresponde, y Lectura
es tan buen nombre para un sensor como para una alarma.
# ejemplos/cap08/07_colisiones/sensores.kai
pub type Lectura = { celsius: Real }
pub const MAX: Int = 100
# ejemplos/cap08/07_colisiones/alarmas.kai
pub type Lectura = { severidad: Int }
pub const MAX: Int = 5
Si importas los dos y escribes MAX a secas, el compilador no
elige por ti:
error: bare name 'MAX' is exported by multiple modules: sensores, alarmas
--> main.kai:5:26
|
5 | Stdout.print("tope = #{MAX}")
| ^
= note: constant candidates: sensores.MAX, alarmas.MAX
= help: qualify the constant, e.g. `sensores.MAX`
Elegir por ti sería peor que el error. Con cualquier regla de
desempate, el orden de los imports digamos, cuál de los dos
MAX obtienes dependería de cómo quedaron ordenadas las
líneas, y el día que alguien las ordene alfabéticamente el
programa cambia de significado sin que nadie toque una
expresión.
Un tipo, un efecto o un protocolo en disputa dan el mismo
reporte que la constante, con los candidatos nombrados y la
palabra que corresponde: type candidates, effect candidates.
La salida es el calificador, y funciona en todas partes donde
aparece un nombre: tipos, constructores (en expresión y en
patrón), funciones, constantes, protocolos en la cabeza de un
impl y dentro de #[derive(...)], y efectos tanto en la fila
como en la cabeza de un handle y en sus operaciones.
# ejemplos/cap08/07_colisiones/main.kai
import sensores
import alarmas
fn main() : Unit / Stdout {
let t: sensores.Lectura = sensores.Lectura { celsius: 21.5 }
let a: alarmas.Lectura = alarmas.Lectura { severidad: 3 }
Stdout.print("#{t.celsius} grados (tope #{sensores.MAX})")
Stdout.print("severidad #{a.severidad} (tope #{alarmas.MAX})")
}
$ kai run ejemplos/cap08/07_colisiones/main.kai
21.5 grados (tope 100)
severidad 3 (tope 5)
El calificador es solo un desempate: sensores.MAX y MAX
nombran la misma constante cuando no hay colisión, así que no
tienes que calificar por si acaso. Y cada módulo se queda con
su propia declaración: dos dependencias pueden declarar cada
una su effect Emit sin pisarse, y with ea.Emit alcanza solo
al de ea.
Hay un caso que no llega a disputa: el homónimo de algo que ya
venía puesto. Si importas un módulo que declara su propio
effect Log, en ese archivo Log a secas es el del módulo: lo
que importaste le gana a lo que venía puesto, y el del stdlib
queda a un calificador de distancia, como effects.Log.
La regla vale igual para las funciones, y ahí es más fácil
tropezar, porque los nombres del core son los más obvios del
oficio: map, filter, each, reduce. Un archivo que
importa gen (los generadores del §12.13, que traen sus propias
etapas con esos nombres) cambia a quién llama map a secas:
import gen
let xs = [1, 2, 3]
map(xs, doble) # ERROR: este `map` es el de gen, no el de list
error: type mismatch in function call
= note: expected: (Gen[Int, _], (Int) -> Int) -> Gen[Int, _]
= note: found: ([Int], (Int) -> Int) -> ?t4
= note: in argument 1 of `gen.map`
No hay que adivinar: el error te muestra la firma que encontró
y, en la última nota, de qué módulo salió el map que se
quedó con el nombre. Las salidas son las de siempre, calificar o usar el
pipe:
list.map(xs, doble) # [2, 4, 6]
xs | doble # [2, 4, 6]
Es la consecuencia de que el import gane, y vale la pena: la
alternativa es que un nombre del core sea intocable y que el
autor de un módulo no pueda llamar map a su map.
La otra salida es el import selectivo, que desempata para todo el archivo en vez de hacerlo en cada uso:
import sensores.{MAX} # `MAX` a secas es la de sensores
import alarmas
# MAX -> 100
# sensores.MAX -> 100
# alarmas.MAX -> 5
Nombrar MAX en el import selectivo lo saca de la disputa: el
nombre pelado pasa a ser el de sensores, y el otro sigue
disponible calificado. Nada se esconde.
Elige según qué tan parejo sea el reparto. Si el archivo es noventa por ciento sensores y toca alarmas dos veces, el import selectivo saca ruido. Si los usa por igual, calificar los dos en el punto de uso deja el texto más honesto: el lector ve de dónde sale cada nombre sin subir a mirar los imports.
Un import que no usas
Un import que el archivo nunca menciona, ni como calificador
ni a través de ningún nombre que el módulo exporte, es una
advertencia:
warning: unused import `actor`
Hoy es advertencia y en la edición Orongo pasa a ser error, como en Go. La migración es mecánica: borrar la línea.
Una excepción, para que no la confundas con un falso negativo:
un módulo que solo trae impl al scope se usa por estar
cargado, no por nombrarse, y ese import no se reporta aunque
no aparezca en ninguna línea.
Antes de borrar, eso sí, mira dos veces: puede que el import sobre, o que el nombre que ibas a usar lo estés sacando de otra parte sin darte cuenta. En los dos casos te conviene saberlo.
8.3 Visibilidad: el contrato del módulo
pub es el contrato que tu módulo le hace al mundo. Todo lo
no marcado es privado, y al marcar algo pub estás diciendo
“voy a sostener este nombre, esta firma, este tipo”.
Una regla que el lenguaje no impone pero que conviene aplicar: lo público debe ser estrecho. Si exportas una función auxiliar útil hoy pero específica de tu implementación, el día que refactorices tendrás que elegir entre romper a quien la usa o cargar con código que ya no quieres. Lo público se mide en lo que prometes; lo privado, en lo que puedes cambiar sin avisar.
En la práctica:
-
Tipos exportados: sí, suelen ser parte del contrato.
-
Funciones helper: rara vez. Si las necesitas exportar, pregúntate si está en el lugar correcto.
-
Constantes: sí, si forman parte del API. Si son detalles de implementación, no.
pubva delante igual que en todo lo demás, y quien importa la usa calificada:# limites.kai pub const PUERTO_MAX: Int = 65535 # main.kai import limites println("max = #{limites.PUERTO_MAX}")
Entre pub y privado hay un grado intermedio: pub(pkg).
Una declaración marcada así la ven todos los módulos de su
propio paquete, y no existe para quien lo use como
dependencia. Es lo que quieres para el helper que dos de tus
módulos comparten y que no es parte del contrato:
# util/interno.kai
pub(pkg) fn secreto() : Int = 42
pub fn publico() : Int = 1
Desde otro módulo del mismo paquete los dos nombres están ahí.
Desde un paquete que te declara como dependencia solo está
publico, y pedir el otro es un error que nombra lo que sí
hay:
error: module 'interno' does not export 'secreto'; available exports: publico
La mayoría de los lenguajes con visibilidad pública por defecto acaban con módulos cuyo “API real” se mezcla con todo el resto. kaikai invierte eso: lo público es lo que nombraste explícitamente.
Campos privados dentro de un tipo público
Cuando declaras pub type T = { ... }, los campos del record
son públicos por defecto: si el tipo está en el contrato,
sus campos también, y casi siempre es lo que quieres. Pero a
veces algún campo es detalle de implementación. La palabra
priv antes del nombre del campo lo oculta a otros módulos:
pub type Cuenta = {
nombre: String,
priv saldo: Real,
}
Desde fuera del módulo que declara Cuenta, c.saldo no
compila y un literal Cuenta { nombre: "x", saldo: 100.0 }
tampoco. Solo el módulo declarante puede leer el campo y
mencionarlo al construir. El detalle del patrón lo conociste
en §4.1.1; aquí importa que priv opera a granularidad de
campo y complementa a pub, que opera por declaración.
Juntos cubren un caso muy común: “el tipo se exporta, su
interior no”.
Un proyecto de dos archivos lo muestra desde el otro lado del límite del módulo. El cliente construye y lee la cuenta solo por las funciones del módulo; las dos líneas comentadas son los rechazos:
# ejemplos/cap08/06_priv/caja.kai
pub type Cuenta = {
nombre: String,
priv saldo: Real
}
pub fn abrir(nombre: String) : Cuenta =
Cuenta { nombre: nombre, saldo: 0.0 }
pub fn depositar(c: Cuenta, monto: Real) : Cuenta =
Cuenta { ...c, saldo: c.saldo + monto }
pub fn saldo_de(c: Cuenta) : Real = c.saldo
# ejemplos/cap08/06_priv/main.kai
import caja
fn main() {
let c0 = caja.abrir("ahorros")
let c1 = caja.depositar(c0, 100.0)
let c2 = caja.depositar(c1, 50.0)
println("#{c2.nombre}: #{caja.saldo_de(c2)}")
# println("#{c2.saldo}")
# let _ = caja.Cuenta { nombre: "x", saldo: 999.0 }
}
$ kai run ejemplos/cap08/06_priv/main.kai
ahorros: 150
8.4 El stdlib que ya tienes
Hay un módulo especial que no necesitas importar: el
core. Funciones como println, assert, string_concat,
real_sqrt viven en el namespace global, disponibles sin
trámite en todo archivo .kai. En otros lenguajes esto se llama
prelude (Haskell) o built-ins (Python); kaikai lo llama
core, y vive bajo stdlib/core/ en el árbol del compilador.
Lo que vive en core es deliberadamente pequeño: los tipos
primitivos, las operaciones aritméticas, los Option y Result,
los módulos list y string (por eso escribes list.map(xs, f)
sin importar nada), y builtins como println, length y
reduce. El resto del stdlib (encoding, redes, archivos,
criptografía) vive en módulos separados que se importan como
cualquier otro:
import stream # pipelines lazy sobre streams
import fs.path # manipulación de rutas
import encoding.json # parseo de JSON
El criterio para core es severo: solo entra ahí lo que casi todo programa usa.
8.5 Proyectos: kai.toml
Hasta aquí trabajaste con módulos dentro de un mismo árbol de archivos. Cuando esos módulos forman una unidad que quieres versionar o distribuir, o que depende de otras, pasas a la segunda escala: el proyecto.
Un proyecto kaikai se describe con un archivo kai.toml en su
raíz. La forma mínima es esta:
name = "miapp"
version = "0.1.0"
[dependencies]
name es el nombre del proyecto. Tiene que ajustarse a una
gramática simple: minúsculas, dígitos, guion bajo y guion, sin
empezar con dígito. Es la misma forma que usan Cargo, Go y
Hex.pm, y evita problemas de path traversal, colisiones con
flags y otros sustos.
version es la versión del proyecto. Antes de 1.0,
0.MINOR.PATCH con cambios incompatibles subiendo MINOR
(convención cz). Después de 1.0, semver estándar.
[dependencies] es la tabla donde declaras qué otros proyectos
necesita el tuyo. Queda vacía si solo dependes del stdlib.
El paquete como frontera
El kai.toml también marca una frontera de visibilidad. El
árbol de archivos bajo él es un paquete, y entre lo privado
y pub cabe un tercer nivel: pub(pkg) exporta una
declaración a los módulos del mismo paquete, sus tests
incluidos, y a nadie más.
# fragmento: util.kai dentro de un proyecto
pub(pkg) fn auxiliar(n: Int) : Int = n * 2 # todo módulo bajo este kai.toml
pub fn api(n: Int) : Int = auxiliar(n) # lo que ven otros paquetes
Un módulo de otro paquete que lea auxiliar recibe un error
que nombra la declaración, y kai doc la deja fuera. Ahí va lo
que tus módulos comparten entre sí sin comprometerlo como API.
Dentro de un paquete, los módulos también pueden importarse en
ciclo: par.kai importa impar e impar.kai importa par.
Como toda firma de primer nivel es explícita, el compilador
recoge las de todo el ciclo antes de leer un cuerpo. Un ciclo
que cruza paquetes, que pasa por el stdlib o que une archivos
sin kai.toml es un error.
edition
Hay un campo opcional más que conviene fijar en cuanto un proyecto deja de ser un experimento:
name = "miapp"
version = "0.1.0"
edition = "hanga-roa"
[dependencies]
edition amarra el código fuente a una versión del contrato
del lenguaje: sintaxis, semántica del sistema de tipos,
firma del stdlib, API del binario kai. Mientras una edición
esté activa, kaikai garantiza que tu proyecto seguirá
compilando al subir el compilador. Cuando una edición se
cierra y entra una nueva, kaikai sigue aceptando la anterior:
solo tienes que decir cuál usas.
Si omites el campo, el compilador asume la edición
predeterminada de la instalación. Para un borrador basta; en
un proyecto que va a vivir, fijar la edición evita que una
actualización silenciosa te cambie el suelo. El resto lo
encuentras en el cap. 16: cómo se eligen las ediciones, cómo
migrar entre ellas y qué pasa con las APIs marcadas como
#[unstable].
Crear un proyecto nuevo
$ mkdir miapp && cd miapp
$ kai init miapp
kai-pkg: wrote kai.toml for package 'miapp'
kai init escribe el kai.toml esqueleto. Después agregas
archivos .kai y los importas como en §8.2.
8.6 Dependencias: git, path, lock
Una dependencia se declara de tres formas:
[dependencies]
manutara = { source = "github.com/kaikailang-org/manutara", ref = "v0.1.0" }
kohau = "github.com/kaikailang-org/kohau@v0.2.0"
local = { path = "../mi-otra-lib" }
La primera (tabla con source y ref) es la forma canónica
para dependencias de git. source es la URL del repo. ref
es lo que git entiende como referencia: una tag (v0.1.0),
una branch (main), o un commit (abc1234). Las tags son la
convención; branches y commits son escapes para casos puntuales.
La segunda (string "<source>@<ref>") es lo mismo abreviado,
con @ separando source de ref.
La tercera ({ path = "..." }) es la dependencia local.
Apunta a otro proyecto en tu disco, normalmente porque lo estás
desarrollando en paralelo. Editas la dependencia, vuelves a
correr tu app y los cambios se ven al instante, sin republicar.
Agregar una dependencia
$ kai add github.com/kaikailang-org/manutara@v0.1.0
kai add hace dos cosas atómicamente: clona la dependencia y
escribe la entrada en kai.toml. Si el clone falla (URL mala,
ref que no existe, error de red), ni el manifest ni el lockfile
cambian: el árbol de trabajo nunca queda inconsistente.
El lockfile
Cuando resuelves dependencias por primera vez, kai clona los
repos, captura el commit exacto (la SHA), y escribe un archivo
kai.lock:
# kai.lock — generated by kai-pkg. Do not edit by hand.
[[package]]
name = "manutara"
source = "github.com/kaikailang-org/manutara"
ref = "v0.1.0"
sha = "abc123def456..."
Si dos programadores corren kai fetch con el mismo
kai.toml y el mismo kai.lock, bajan exactamente la misma
SHA, el mismo árbol de archivos, y terminan con el mismo
binario. Esa reproducibilidad es la promesa central del
lockfile.
Por eso kai.lock se commitea junto al código: es parte del
contrato del proyecto. kai.toml declara qué quieres; kai.lock
declara qué obtuviste.
Cuándo se actualiza el lock
kai fetchlo crea si no existe; si existe, lo respeta.kai updatelo regenera con la última versión de cada dependencia que cumpla la ref declarada.kai addlo refresca cuando agregas una dependencia nueva.kai runykai buildlo refrescan automáticamente si detectan deps enkai.tomlque no están en el lock (la primera vez después de ungit clone, por ejemplo).
8.7 Selección de versiones: MVS
Un proyecto puede depender transitivamente de otro por dos rutas, con versiones distintas declaradas en cada una. kaikai decide cuál gana con minimum-version selection (MVS), el algoritmo que usa Go modules. La regla es brutal pero clara: de las versiones declaradas en toda la cadena transitiva, gana la máxima.
Si tu app declara manutara@v0.1.0 y una dependencia tuya
declara manutara@v0.2.0, todo el proyecto usa v0.2.0. MVS
supone que las versiones son backwards-compatible dentro de
un major: si todo el mundo respeta semver, subir al mayor
número no debería romper a nadie.
MVS contrasta con el algoritmo de Cargo o npm, que resuelve restricciones complejas y a veces baja a una versión menor para satisfacer a alguien. MVS es predecible: dado el mismo árbol, el resultado es el mismo, sin resolvedor fallido ni diamond-dependency hell.
A cambio, la responsabilidad de mantener compatibilidad queda en los autores de bibliotecas. Si subes a una versión que rompe, todo el mundo que dependa transitivamente de ti se rompe. Eso te obliga a tomar el versionado en serio.
8.8 Cache y kai fetch
Cuando bajas una dependencia, no se queda en tu proyecto: se
queda en un cache compartido entre proyectos, por defecto
en ~/Library/Caches/kai/pkg (macOS) o
~/.cache/kai/pkg (Linux).
La estructura del cache es:
~/Library/Caches/kai/pkg/
github.com/kaikailang-org/manutara/
abc123def456.../ # contenido fijado a esa SHA
789abc012def.../ # otra SHA del mismo repo
Cada entrada se identifica por su SHA, no por la ref que tú
ves. Así, tres proyectos que piden manutara@v0.1.0 comparten
el mismo árbol en disco, y si v0.1.0 cambia upstream (mover
una tag no debería pasar, pero pasa), el cache fijado al SHA
original sigue intacto.
Puedes correr kai fetch a mano, pero kai run y
kai build lo invocan automáticamente cuando detectan deps
no resueltas. Después de clonar un proyecto, basta con
kai run main.kai: el driver baja lo que falta y corre.
El cache no crece sin límite. Una entrada que ningún build usa
en cinco días se borra sola, y si la vuelves a necesitar, el
siguiente kai run la baja de nuevo. KAI_CACHE_TRIM=0 apaga
ese recorte y KAI_CACHE_TRIM_DAYS cambia el plazo. Para liberar
espacio de inmediato, kai clean --cache borra los caches
compartidos, y kai clean a secas, el .kai-cache del proyecto.
8.9 Caso de estudio: refactor de un proyecto monolítico
Imagina un proyecto que partió como un solo archivo main.kai
de 800 líneas. Adentro hay tres responsabilidades mezcladas:
parseo de un formato de configuración, lógica de negocio, e IO
con archivos. La velocidad de cambio cayó: cualquier
modificación obliga a leer demasiado.
El refactor procede en cuatro pasos.
Paso 1: separar en módulos del mismo proyecto
Identificas las tres responsabilidades y las separas en archivos:
miapp/
├── kai.toml
├── main.kai # solo el flujo principal
├── config.kai # parseo del formato
└── negocio.kai # lógica de dominio
En main.kai:
import config
import negocio
fn main() {
let cfg = config.cargar("./config.toml")
let resultado = negocio.procesar(cfg)
println("resultado: #{resultado}")
}
config.kai y negocio.kai exportan solo las funciones que
main necesita. Todo lo demás (helpers, tipos internos) queda
privado.
Paso 2: revisar pub
Repasa cada pub y pregúntate si de verdad es parte del
contrato del módulo o un helper que quedó exportado por
inercia. Cada pub extra es un compromiso futuro.
Paso 3: extraer una dependencia local
config.kai resulta útil más allá de este proyecto. La
extraes a su propio repositorio:
config-lib/
├── kai.toml # name = "config_lib"
└── config.kai
miapp/
├── kai.toml # ahora depende de config_lib
├── main.kai
└── negocio.kai
En miapp/kai.toml:
name = "miapp"
version = "0.1.0"
[dependencies]
config_lib = { path = "../config-lib" }
Mientras desarrollas, config_lib queda como dependencia
local. main.kai cambia su import a import config_lib.config
(o un alias, si el nombre largo molesta).
Paso 4: publicar
Cuando config_lib está estable, le pones una tag:
$ cd ../config-lib
$ git tag v0.1.0
$ git push origin v0.1.0
Y en miapp cambias el path a git:
[dependencies]
config_lib = { source = "github.com/tuusuario/config-lib", ref = "v0.1.0" }
kai fetch baja la versión fijada por el lockfile. El
código de main.kai y negocio.kai no cambia: los imports
siguen siendo los mismos.
Esa es la trayectoria completa: de archivo único a módulos, de módulos a proyectos, de proyecto local a dependencia distribuida. Cada paso es reversible, y ninguno te obliga al siguiente.
8.10 Filosofía: simple y previsible
Hay lenguajes con sistemas de módulos más expresivos: re-exports automáticos, alias dinámicos, módulos parametrizados por valores. kaikai elige una variante austera y se queda ahí.
La razón es la misma que en el resto del lenguaje: lo que ahorra dolores de cabeza vale más que lo que agrega potencia. Un sistema de módulos donde nada se importa sin que aparezca en una lista visible, donde el nombre de un módulo se deriva de su ruta, y donde las dependencias se fijan a un SHA exacto se entiende leyéndolo, sin tener que ejecutarlo mentalmente.
Es el mismo principio que en pattern matching exhaustivo: si el compilador te puede llevar al lugar exacto donde tienes que hacer algo, no necesitas otra herramienta más sofisticada.
Ejercicios
8.1. Toma un programa que hayas escrito en los capítulos
anteriores y pártelo en dos archivos. Identifica qué declaraciones
necesitan ser pub y cuáles pueden quedarse privadas. ¿Sentiste
la tentación de marcar algo pub “por si acaso”?
8.2. Crea un proyecto con kai init. Agrégale un archivo
auxiliar bajo un subdirectorio (por ejemplo, util/strings.kai).
Importa una función desde main.kai. ¿Cómo se llama el módulo
desde el punto de vista del import?
8.3. Escribe dos proyectos hermanos en disco: una biblioteca
mi_lib con una función pública, y una app mi_app que la usa
con { path = "../mi_lib" }. Edita mi_lib, vuelve a correr
mi_app. ¿Cuánto demora el ciclo edit-run?
8.4. Mira un proyecto en github.com/kaikailang-org/kaikai (el
compilador mismo) y abre su kai.toml si lo tiene. ¿Qué
dependencias declara? ¿Reconoces el patrón de versionado?
8.5. Si mi_app declara manutara@v0.1.0 y una
dependencia transitiva declara manutara@v0.2.0, ¿qué versión
usa el proyecto? ¿Por qué ese diseño descarta el “diamond
dependency hell”? Da un escenario donde la regla de MVS te
puede sorprender.
8.6. Una biblioteca tuya tenía una función pub fn parse_legacy(s: String) que ya nadie usa. ¿Bajo qué
condiciones podrías removerla sin liberar una versión 1.0?
¿Qué tipo de comunicación con tus usuarios necesitas antes?