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 apenas el programa crece, un archivo único se vuelve un problema: los lectores se pierden, los cambios chocan, las búsquedas confunden módulos lógicos que están físicamente revueltos. Hay que partir el código en pedazos con nombres.
kaikai resuelve esto con un sistema deliberadamente simple. 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 se deriva de la ruta del archivo y eso es
todo. Por encima de los módulos viene una segunda escala: un
proyecto, descrito por un kai.toml, que organiza sus
módulos y sus dependencias externas. Y por encima del proyecto,
un package manager que resuelve dependencias entre proyectos.
Este capítulo recorre 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. Por defecto, una declaración es privada al archivo. Solo las cosas marcadas conpubson visibles desde otro módulo que importe este. Es exactamente al revés de Java o C++, 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 si y solo si está marcadapub.
Las funciones privadas son útiles para descomponer una pública
sin contaminar el namespace del consumidor. En aritmetica,
helper solo existe dentro del archivo; quien importa el
módulo no la ve.
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 deliberado. 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. La alternativa,
importar todos los nombres sueltos al namespace del
consumidor, ahorra teclas pero pierde esa pista.
El mismo prefijo se usa para los tipos que el módulo exporta y para construirlos:
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)}")
}
Tres usos del prefijo, los tres coherentes: en la anotación
de tipo (: geometria.Punto), en la construcción del record
(geometria.Punto { ... }), y en la llamada a la función
(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:
import util.matematica
fn main() {
println("3^4 = #{matematica.pow(3, 4)}")
}
Fíjate en dos detalles: el archivo está en util/matematica.kai,
el módulo se llama util.matematica, pero al usarlo, el
prefijo es solo matematica (el último segmento). Eso evita
prefijos largos sin perder el origen estructural.
Tres formas de importar
kaikai admite tres formas que cubren el espacio:
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 que debería ser tu opción por defecto: el
prefijo vector. deja claro de dónde sale cada nombre. La
segunda es útil cuando el nombre del módulo es largo o aparece
muchas veces y un alias corto te 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. Te conviene
solo cuando los nombres son los protagonistas del archivo y el
prefijo se vuelve ruido (un Punto que aparece cuarenta veces,
por ejemplo). El precio es que el lector ya no sabe de dónde
salió Punto sin mirar arriba.
No hay wildcard import. Es decir, no existe
import math.vector.* o equivalente que traiga “todo lo que el
módulo exporta” al namespace local. Es una decisión
deliberada: el wildcard parece cómodo al escribir pero hace que
el lector futuro no sepa de dónde vino un nombre.
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. Cada nombre pub es un
contrato que vas a tener que mantener. Si exportas una
función auxiliar útil hoy pero específica de tu
implementación, mañana cuando refactorices vas a tener 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, esto se traduce a:
- 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.
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
Hay una asimetría útil. Cuando declaras pub type T = { ... },
los campos del record son públicos por defecto: el tipo
está en el contrato, sus campos también. Eso es lo que
quieres la mayoría de las veces.
Pero a veces el tipo en sí merece ser parte del contrato y
algún campo es detalle de implementación. La palabra priv
antes del nombre del campo lo marca como invisible para
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 §4.1.1 mostró el detalle del
patrón; lo importante para el cap. 8 es que priv opera a
granularidad de campo, complementa el pub que controla
visibilidad a nivel de declaración, y juntos cubren un caso
muy común: “el tipo se exporta, su interior no”.
El ejemplo cap08/06_priv/ es un proyecto de dos archivos
que demuestra exactamente este rechazo desde el otro lado del
límite del módulo.
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 y están disponibles en
todo archivo .kai sin trámite. 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í hablamos de módulos dentro de un mismo árbol de archivos. Cuando esos módulos se convierten en una unidad que quieres versionar, distribuir, o que depende de otras unidades similares, pasas a una 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. Vacío si tu proyecto no depende de nadie más
fuera del stdlib.
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 ata 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, aunque internamente
cambien cosas. 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 default de
la instalación. Eso está bien para borradores; pero en un
proyecto que va a vivir, fijar la edición es lo que evita
que un upgrade silencioso te cambie el suelo. El cap. 16
cuenta el resto: cómo se eligen las ediciones, cómo
migrar entre ellas, y qué pasa con 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 vimos en §8.2.
8.6 Dependencias: git, path, lock
Cuando declaras una dependencia, tienes 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 la misma cosa pero
abreviada. El @ separa 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. Edita el código de la dependencia,
vuelve a correr tu app, 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. Esto es importante: el árbol de trabajo nunca queda
en un estado 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..."
El lockfile cierra el contrato. Si dos programadores corren
kai install con el mismo kai.toml y el mismo kai.lock,
bajan exactamente la misma SHA, exactamente el mismo árbol
de archivos, exactamente el mismo binario al final. La
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 installlo 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
Cuando un proyecto depende transitivamente del mismo otro proyecto por dos rutas distintas con versiones distintas declaradas, ¿qué versión gana?
kaikai resuelve esto con minimum-version selection (MVS), el algoritmo que usa Go modules. La regla es brutal pero clara: del conjunto de versiones declaradas por la cadena transitiva entera, 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. La
suposición subyacente es 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.
El precio es que 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 install
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 el
usuario ve. Esto significa que tres proyectos distintos que
piden manutara@v0.1.0 comparten el mismo árbol en disco. Y
si en algún momento v0.1.0 se actualiza upstream (movimiento
de tag, que no debería pasar pero pasa), el cache fijado al
SHA original sigue intacto.
kai install se puede correr explícitamente, pero kai run
y kai build lo invocan automáticamente cuando detectan
deps no resueltas. En la práctica, después de git clone de un
proyecto, basta con kai run main.kai: el driver baja lo que
falta y corre.
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. Pregunta para cada uno: ¿es de verdad parte
del contrato del módulo, o es un helper que dejé 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 install 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 opcional; ninguno te obliga al siguiente.
8.10 Filosofía: simple y previsible
Las decisiones de este capítulo no son las más expresivas que existen. Hay lenguajes con sistemas de módulos más potentes: 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 es un sistema que 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?