Módulos y crates en Rust

Aprende Rust → Lección 9

Todo lo anterior cabía en un archivo. Un programa real, no. Esta lección es la menos conceptual de la guía y la más práctica: cómo se llama cada cosa en un proyecto de Rust, cómo se reparte el código en varios archivos, quién puede ver qué, y cómo se añade una biblioteca de otra persona sin más ceremonia que una línea.

El vocabulario, que es lo que más confunde

Rust usa tres palabras que se solapan y conviene separar antes de nada:

Paquete, crate y módulo EL PAQUETE — lo que describe Cargo.toml crate binario src/main.rs fn main() se ejecuta crate de biblioteca src/lib.rs mod red mod datos mod util Un paquete puede traer un crate binario, uno de biblioteca o los dos. Los módulos son divisiones internas de un crate. Lo que se publica en crates.io y lo que se pone en «dependencies» es el paquete.
Un paquete se describe con Cargo.toml; un crate es una unidad de compilación; un módulo es una carpeta dentro de un crate.

Un proyecto nuevo se crea con una orden, y Cargo deja la estructura mínima hecha:

$ cargo new mi_tienda
     Created binary (application) `mi_tienda` package

mi_tienda/
├── Cargo.toml
└── src/
    └── main.rs

Con --lib en lugar de nada, Cargo crea un crate de biblioteca con src/lib.rs en vez de un binario. Y las órdenes del día a día son pocas:

Orden Qué hace
cargo build Compila en modo de depuración, con comprobaciones
cargo run Compila y ejecuta
cargo build --release Compila optimizado, para entregar
cargo check Comprueba que compila sin generar el binario: mucho más rápido
cargo test Ejecuta las pruebas
cargo add nombre Añade una dependencia y la escribe en Cargo.toml
cargo fmt Formatea el código con el estilo estándar
cargo clippy Avisa de cosas que compilan pero se escriben mejor de otra forma

cargo check merece una mención aparte: mientras escribes, es el que se usa, porque hace todo el trabajo de comprobación de tipos y préstamos sin pagar el coste de generar código máquina.

Repartir el código en archivos

Un módulo se declara con mod. Puede estar escrito ahí mismo, entre llaves, o vivir en su propio archivo. Entre llaves se ve bien la idea:

mod cocina {
    pub fn servir() -> String {
        preparar()
    }

    fn preparar() -> String {
        String::from("plato listo")
    }
}

fn main() {
    println!("{}", cocina::servir());
}

En cuanto crece, el módulo se saca a un archivo. Aquí es donde Rust tiene una regla que hay que aprenderse una vez: declarar mod algo; con punto y coma le dice al compilador que busque el contenido en algo.rs. No hay importaciones por ruta de archivo; el árbol de módulos es el que manda.

mi_tienda/
├── Cargo.toml
└── src/
    ├── main.rs            <- aquí va: mod inventario;
    ├── inventario.rs      <- y aquí: pub mod articulo;
    └── inventario/
        └── articulo.rs

La carpeta inventario/ guarda los submódulos de inventario. Los tres archivos quedan así:

// src/inventario/articulo.rs
#[derive(Debug)]
pub struct Articulo {
    pub nombre: String,
    pub precio: f64,
    unidades: u32,
}

impl Articulo {
    pub fn nuevo(nombre: &str, precio: f64, unidades: u32) -> Articulo {
        Articulo {
            nombre: String::from(nombre),
            precio,
            unidades,
        }
    }

    pub fn valor(&self) -> f64 {
        self.precio * self.unidades as f64
    }
}
// src/inventario.rs
pub mod articulo;

use articulo::Articulo;

pub fn valor_total(articulos: &[Articulo]) -> f64 {
    articulos.iter().map(|a| a.valor()).sum()
}
// src/main.rs
mod inventario;

use inventario::articulo::Articulo;

fn main() {
    let existencias = vec![
        Articulo::nuevo("tornillos", 1.50, 120),
        Articulo::nuevo("tuercas", 0.80, 80),
    ];

    for a in &existencias {
        println!("{}: {:.2}", a.nombre, a.valor());
    }

    println!("total: {:.2}", inventario::valor_total(&existencias));
}
Dos estilos, los dos válidos. Lo que acabas de ver —inventario.rs junto a la carpeta inventario/— es el estilo recomendado desde la edición 2018. El antiguo ponía el contenido del módulo en inventario/mod.rs y sigue funcionando, así que te lo encontrarás en proyectos con años. Lo que no conviene es mezclar los dos en el mismo proyecto.

pub: todo es privado hasta que digas lo contrario

En Rust la visibilidad va al revés que en muchos lenguajes: nada se exporta por defecto. Un módulo puede ver lo que hay dentro de sus hijos solo si está marcado, y eso convierte la superficie pública en una decisión explícita en vez de un descuido.

Visibilidad de un módulo mod cocina fn preparar() pub fn servir() no se ve desde fuera accesible desde fuera Un módulo hijo sí puede usar lo privado de su padre; nunca al revés. La privacidad protege hacia fuera, no hacia dentro.
Por eso una función auxiliar que no marcas se queda dentro: el compilador no la deja escapar por accidente.

Hay más de dos niveles, y el intermedio se usa mucho más de lo que parece:

Marca Quién lo ve
sin nada Solo el módulo donde está, y sus hijos
pub(crate) Todo tu crate, pero nada de fuera. Ideal para utilidades internas
pub(super) El módulo padre
pub Todo el mundo, incluido quien use tu biblioteca
Dos trampas de visibilidad. Marcar un struct como pub no hace públicos sus campos: cada campo necesita su propio pub, y por eso en el ejemplo de arriba unidades queda privado aunque Articulo sea público. Con los enums es justo al revés: si el enum es pub, todas sus variantes lo son automáticamente, porque un enum con variantes ocultas no serviría para nada.

use: dejar de escribir rutas largas

use no importa nada ni ejecuta nada: solo crea un atajo para un camino del árbol de módulos. Los caminos pueden ser absolutos, desde la raíz del crate, o relativos:

use std::collections::{HashMap, HashSet};
use std::io::Write as _;
use std::fmt::Result as ResultadoFmt;

mod tienda {
    pub mod almacen {
        pub fn contar() -> u32 { 42 }
    }

    pub mod caja {
        pub fn informe() -> u32 {
            super::almacen::contar()
        }
    }
}

use crate::tienda::caja;

fn main() {
    let mut m: HashMap<&str, u32> = HashMap::new();
    m.insert("cajas", caja::informe());

    let s: HashSet<u32> = m.values().copied().collect();
    println!("{:?} {:?}", m.get("cajas"), s.len());

    let _: Option<ResultadoFmt> = None;
}

Las llaves agrupan varios atajos del mismo camino. as renombra, que sirve cuando dos cosas se llaman igual. super:: sube al módulo padre, como .. en una ruta de carpetas, y crate:: arranca desde la raíz.

pub use: enseñar una fachada ordenada

Hay un uso de use que cambia el diseño de una biblioteca. Si lo marcas como público, además de crear el atajo para ti, lo expones para quien use tu crate:

mod interno {
    pub mod muy {
        pub mod profundo {
            pub struct Cliente {
                pub nombre: String,
            }
        }
    }
}

pub use interno::muy::profundo::Cliente;

fn main() {
    let c = Cliente { nombre: String::from("Ada") };
    println!("{}", c.nombre);
}

Quien use la biblioteca escribe mi_crate::Cliente y no necesita saber que por dentro vive tres niveles más abajo. Eso te deja reorganizar las tripas sin romperle el código a nadie, y es la razón de que las bibliotecas buenas tengan una API plana aunque por dentro estén muy divididas.

Usar código de otros

Añadir una dependencia es una orden, y Cargo escribe la línea en Cargo.toml por ti:

$ cargo add rand

[package]
name = "mi_tienda"
version = "0.1.0"
edition = "2021"

[dependencies]
rand = "0.10.3"

Ese número no significa «exactamente esta versión». Por defecto Cargo lo interpreta como «esta o cualquiera posterior que sea compatible»: para 0.10.3 acepta hasta antes de 0.11.0, y para un 1.2.3 aceptaría hasta antes de 2.0.0. En las versiones 0.x el número del medio hace de versión mayor, porque se asume que el paquete todavía puede romper cosas.

Lo que de verdad se usó queda anotado en Cargo.lock, que Cargo genera y actualiza solo:

Sobre versionar Cargo.lock, con cuidado. Durante años circuló la regla «los binarios sí, las bibliotecas no». La documentación actual de Cargo ya no lo plantea así: cargo new lo versiona por defecto y recomienda hacerlo cuando quieras compilaciones reproducibles, útiles para git bisect, para integración continua y para fijar la versión mínima de Rust. El matiz que importa es otro: tu Cargo.lock no afecta a quien consume tu paquete, solo lo hace tu Cargo.toml. Por eso en una biblioteca da una falsa sensación de control.

Con la dependencia declarada, se usa como cualquier módulo:

use rand::RngExt;

fn main() {
    let mut generador = rand::rng();
    let tirada: u32 = generador.random_range(1..=6);

    println!("salió un {tirada}");
}

Dónde van las pruebas

Rust pone las pruebas unitarias en el mismo archivo que el código, dentro de un módulo marcado para que solo se compile al probar. Suena raro y resulta muy cómodo, porque la prueba ve también lo privado:

pub fn con_descuento(precio: f64, porcentaje: f64) -> f64 {
    precio - precio * porcentaje / 100.0
}

#[cfg(test)]
mod pruebas {
    use super::*;

    #[test]
    fn aplica_el_descuento() {
        assert_eq!(con_descuento(100.0, 20.0), 80.0);
    }

    #[test]
    fn sin_descuento_no_cambia() {
        assert_eq!(con_descuento(50.0, 0.0), 50.0);
    }
}

El #[cfg(test)] hace que ese módulo no exista en la compilación normal, así que no engorda el binario que entregas. El use super::*; trae todo lo del módulo padre, que es lo que permite llamar a la función sin repetir su camino. Se ejecutan con cargo test.

En resumen

Lo que quieres hacer Qué usar
Empezar un programa cargo new nombre
Empezar una biblioteca cargo new --lib nombre
Comprobar rápido mientras escribes cargo check
Partir el código en otro archivo mod algo; y crear algo.rs
Meter submódulos dentro de uno una carpeta algo/ junto a algo.rs
Exponer algo fuera del módulo pub
Compartirlo solo dentro del crate pub(crate)
Acortar un camino largo use
Subir al módulo padre super::
Dar una API plana a tu biblioteca pub use
Añadir una biblioteca de terceros cargo add nombre
Escribir pruebas un mod con #[cfg(test)]

La idea que conviene llevarse es que el árbol de módulos de Rust es independiente del sistema de archivos: los archivos son solo el sitio donde se guarda el texto, y lo que el compilador ve es el árbol que tú declaras con mod. En cuanto se interioriza eso, los errores de ruta dejan de parecer caprichosos y se vuelven previsibles.

Para verlo explicado

Miniatura del vídeo «35.- Curso Rust. El Arte de Organizar el Código: Paquetes y Crates.», del canal Jesús Conde

«35.- Curso Rust. El Arte de Organizar el Código: Paquetes y Crates.», del canal Jesús Conde (15:12). Material de otro canal que recomendamos como complemento, no una producción de decodigo.com. El reproductor solo se carga al pulsar, así que YouTube no recibe nada tuyo hasta entonces. También puedes verlo en YouTube.