Desarrollo en Rust - Día 1
Una base práctica para escribir aplicaciones Rust de calidad desde el primer commit. No es un libro de texto. Se centra en las diferencias con otros lenguajes, las decisiones del primer día que condicionan todo lo demás, y el pequeño conjunto de crates que cubren la mayoría de las aplicaciones reales.
Cuándo usarlo
- Al iniciar un nuevo proyecto Rust (CLI, servicio, biblioteca)
- Al venir a Rust desde Python, JavaScript, Go, Java/C# o C++
- Al elegir entre tipos owned/borrowed, punteros inteligentes, trait objects vs genéricos
- Al seleccionar una estrategia de manejo de errores (
anyhowvsthiserror) - Al decidir qué crates utilizar
- Al configurar un
Cargo.tomlmínimo pero con opiniones, clippy y rustfmt
Configuración del Día 1
# 1. Instalar el toolchain (rustup es el gestor de toolchains)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 2. Confirmar componentes (rustfmt y clippy vienen con stable, rust-src habilita funciones del IDE)
rustup component add rustfmt clippy rust-src
# 3. Crear un proyecto
cargo new my-app # binario (src/main.rs)
cargo new --lib my-lib # biblioteca (src/lib.rs)
# 4. El ciclo de desarrollo (memoriza estos cuatro)
cargo check # verificación rápida de tipos, sin generación de código
cargo run # compilar y ejecutar (binario)
cargo test # compilar y ejecutar pruebas (incl. doctests)
cargo clippy --all-targets # lint, incl. tests/ y benches/ (ejecutar antes de subir)
cargo fmt # formatear
# 5. Gestionar dependencias sin editar Cargo.toml a mano
cargo add tokio --features full
cargo remove tokio
cargo update # recalcular Cargo.lock dentro de los rangos semver existentes
cargo update solo se mueve dentro de los rangos de versiones ya presentes en Cargo.toml. Cruzar una versión mayor (1.x a 2.0) requiere editar Cargo.toml o usar cargo add <crate>@2.
rust-analyzer es obligatorio. Es el servidor de lenguaje que usa todo editor (VS Code, Zed, Neovim, Helix, RustRover usa su propio motor pero es comparable). En VS Code, instala la extensión rust-analyzer y configura rust-analyzer.check.command como "clippy" para recibir feedback de lint al guardar.
¿Quieres un vigilante de archivos más adelante? cargo install --locked bacon, luego ejecuta bacon en tu proyecto. No es necesario el día 1.
El Modelo Mental de Rust en 5 Ideas
Rust cambia dos cosas que das por sentadas en la mayoría de los lenguajes (un recolector de basura y las excepciones) por garantías en tiempo de compilación sobre memoria, condiciones de carrera y manejo de errores. La forma del lenguaje se deriva de ese intercambio.
1. Ownership: cada valor tiene exactamente un dueño
Piensa en los valores como objetos físicos. Un libro, un archivo, una conexión de red. En cualquier momento, una variable es dueña de él. Puedes:
- Moverlo:
let b = a;transfiere la propiedad ab.aya no existe. - Prestarlo inmutablemente:
&apermite que otros lo miren. Muchos lectores permitidos. - Prestarlo mutablemente:
&mut apermite que una persona lo modifique. Acceso exclusivo. - Clonarlo:
a.clone()hace una copia profunda. Ambos conservan la suya.
Cuando el dueño sale del ámbito, el valor se elimina (memoria liberada, archivo cerrado, bloqueo liberado). Sin GC, sin free manual. Esto es RAII, exigido por el compilador.
2. Aliasing XOR mutabilidad
En cualquier momento, un dato tiene o:
- una referencia mutable (
&mut T), o - cualquier número de referencias inmutables (
&T),
nunca ambas. Esta única regla elimina las condiciones de carrera y la mayoría de los errores de uso después de liberar. El borrow checker la exige. Cuando se queja, te está diciendo que tu historia de propiedad de datos no es clara, no que el lenguaje sea difícil.
3. Los errores son valores, no excepciones
No hay try/catch. Las funciones que pueden fallar devuelven Result<T, E>. Las funciones que pueden no devolver nada útil devuelven Option<T>. El compilador te obliga a manejar ambos. El operador ? propaga errores hacia arriba en la pila de llamadas con un solo carácter:
fn read_config() -> Result<Config, anyhow::Error> {
let text = std::fs::read_to_string("config.toml")?; // ? = retorno temprano en Err
let config = toml::from_str(&text)?;
Ok(config)
}
No existe null. Option<T> es None o Some(value). El compilador no te dejará olvidar el caso None.
4. Los traits no son interfaces de Java
Un trait define comportamiento. Los tipos implementan (impl) traits. Hasta aquí familiar. Las diferencias:
- El dispatch estático es el predeterminado. Cuando escribes
fn f<T: Display>(x: T), el compilador genera una copia separada defpara cadaTconcreto con el que la llames (monomorfización, como las plantillas de C++). Sin sobrecarga en tiempo de ejecución. - El dispatch dinámico es opt-in mediante
dyn Trait(normalmenteBox<dyn Trait>o&dyn Trait). Una búsqueda en vtable por llamada. - Sin herencia. Los traits se componen. Si te encuentras usando
Derefpara "extender" un tipo, detente y usa composición o un enum. - Regla del huérfano: puedes implementar
TuTraitparaTipoDeOtrooTraitDeOtroparaTuTipo, pero no ambos foráneos. Esto mantiene la resolución de dependencias sensata.
5. El borrow checker es un oráculo de diseño
El error más común de los principiantes es tratar los errores del compilador como obstáculos que silenciar. No lo son. Casi cada error de borrow-check revela un problema real sobre quién es dueño de qué. Cuando te atasques, la pregunta rara vez es "¿cómo hago que esto compile?" y casi siempre "¿cuál es la relación de propiedad real que quiero aquí?". Lee el error. El compilador es inusualmente informativo.
Las 3 Preguntas para Cada Firma de Función
Antes de escribir una función, pregúntate: ¿necesita ser dueña, leer o modificar la entrada?
fn consume(s: String) // es dueña: la función toma responsabilidad, la llamante la pierde
fn read(s: &str) // lee: la función lo mira, la llamante lo conserva
fn modify(s: &mut String) // modifica: la función lo cambia en el lugar
Valores predeterminados que funcionan el 90% del tiempo:
- Parámetros de función: prefiere
&strsobreString,&[T]sobreVec<T>(son slices, aceptan tanto llamantes owned como borrowed). - Retornos de función: devuelve tipos owned (
String,Vec<T>). Devolver referencias implica lifetimes; evítalos hasta que los necesites. - Campos de struct: prefiere tipos owned (
String,Vec<T>). Almacenar&stren un struct es la trampa más común para principiantes y propaga anotaciones de lifetime por cada tipo que contenga tu struct.
Tabla de Decisiones del Día 1
Respuestas de una línea para las elecciones que surgen primero.
| Decisión | Predeterminado | Cuándo elegir el otro |
|---|---|---|
String vs &str (campo de struct) | String | Casi nunca &str hasta que tengas una razón real y entiendas los lifetimes |
String vs &str (parámetro de función) | &str | Usa String solo si debes ser dueño/almacenarlo internamente |
Vec<T> vs &[T] (parámetro) | &[T] | Vec<T> solo si debes ser dueño |
Box<T> vs Rc<T> vs Arc<T> | Box<T> (único dueño, heap) | Arc<T> para propiedad compartida entre hilos. Evita Rc<T> como predeterminado; usa Arc<T> para no refactorizar cuando vayas a async |
RefCell<T> vs Mutex<T> | Mutex<T> (o RwLock<T>) | Misma razón: funciona en async/hilos, mientras que RefCell no |
Option<T> vs Result<T, E> | Option<T> para "sin valor", Result<T, E> para "falló por una razón" | Si la ausencia tiene un significado que la llamante debe manejar, Result |
dyn Trait vs impl Trait / <T: Trait> | Genérico (<T: Trait> o impl Trait) - dispatch estático | Box<dyn Trait> cuando necesites una colección heterogénea (Vec<Box<dyn Animal>>) |
| Errores en código de aplicación | anyhow::Result<T> en todas partes | - |
| Errores en código de biblioteca | Enum derivado de thiserror | Nunca Box<dyn Error> en APIs públicas de bibliotecas - obliga a las llamantes a hacer downcast |
&self vs &mut self vs self | &self para getters, &mut self para setters, self para builders/operaciones consumidoras | - |
| Diseño de módulos | Módulos en línea hasta que un archivo se alargue, luego dividir | Un módulo = un archivo es un instinto de Java/C#, no de Rust |
Modismos para Interiorizar Temprano
Estos aparecen en casi todos los programas Rust. Apréndelos en la primera semana.
? para propagación de errores. Reemplaza nueve líneas de match con un solo carácter.
let body = reqwest::get(url).await?.text().await?;
Cadenas de iteradores sobre bucles manuales. Compilan al mismo código máquina que los bucles escritos a mano (LLVM inlinea closures). El Rust idiomático es funcional en estilo.
let active_emails: Vec<String> = users
.iter()
.filter(|u| u.active)
.map(|u| u.email.clone())
.collect();
Exhaustividad de match. Añade una nueva variante a un enum y cada match que no la maneje se convierte en un error de compilación. Úsalo. Es una de las herramientas de refactorización más poderosas en cualquier lenguaje.
Deja que el compilador guíe las refactorizaciones. El mismo truco funciona con structs: añade un campo obligatorio sin Default y cada constructor existente se convierte en un error de compilación: una lista de trabajo exhaustiva y gratuita de todos los sitios a actualizar.
if let y let else para el caso común de un solo brazo.
if let Some(name) = user.name { println!("hi {name}"); }
let Some(name) = user.name else { return Err(anyhow!("no name")); };
// `name` está en ámbito de aquí en adelante, sin anidamiento
From / Into para conversiones de tipos. Implementa From, obtén Into gratis. ? usa From para convertir tipos de error automáticamente.
Combinadores en Option / Result. Recurre a .map, .and_then, .unwrap_or, .unwrap_or_else, .ok_or antes de recurrir a match.
Macros de derive. #[derive(Debug, Clone, PartialEq)] te da el 80% del código repetitivo gratis. Añade #[derive(Serialize, Deserialize)] para JSON.
Azúcar reciente. Características estabilizadas que vale la pena conocer, de más nuevas a más antiguas: bool::ok_or y ok_or_else (1.98) convierten una condición directamente en un Result (ready.ok_or(Error::NotReady)?). cfg_select! es un match en tiempo de compilación sobre predicados cfg, que reemplaza la mayoría de los usos del crate cfg-if, y los guards if let funcionan en brazos de match (match x { Some(v) if let Ok(n) = v.parse::<i32>() => ... }) - ambos en 1.95. assert_matches! y debug_assert_matches! (1.96) afirman sobre un patrón en lugar de igualdad, que es la afirmación natural para un enum. Dos más antiguos que verás constantemente y no debes confundir con exóticos: let-chains (if let Some(x) = a && x > 3 { ... }), estable en edition 2024 desde 1.88, y closures asíncronos (async || { ... }, con la familia de bounds AsyncFn), estable desde 1.85.
Viniendo de X, Aquí lo que te Muerde
Desde Python o JavaScript:
let b = a;para un valor en el heap (comoString,Vec) lo mueve.aya no es utilizable. Usa&apara prestar oa.clone()para copiar.- Sin null.
Option<T>es obligatorio. - Sin excepciones.
Result<T, E>y?. El compilador no te dejará ignorar errores. - Sin herencia. Composición + traits + enums.
- Las variables son inmutables por defecto. Añade
mutpara mutar. Igual para referencias:&vs&mut. - Volver a declarar un nombre está permitido y es idiomático:
let input = input.trim();sombrea el antiguo binding con uno nuevo, posiblemente de un tipo diferente. No es mutación, y es cómo el código Rust convierte un valor en el lugar sin inventarinput2. - Los tipos enteros son explícitos y la indexación requiere
usize. También desbordan de manera diferente según la compilación: debug "incluye comprobaciones de desbordamiento de enteros que hacen que tu programa entre en pánico en tiempo de ejecución", mientras que--releaselas elimina y realiza "wrapping de complemento a dos". Un conjunto de pruebas que pasa puede aún así hacer wrapping en producción, así que usachecked_*/saturating_*/wrapping_*donde la aritmética pueda realmente alcanzar el límite, en lugar de confiar en el pánico de debug para encontrarlo.strict_addy similares (estables desde 1.91) entran en pánico por desbordamiento en toda compilación, yoverflow-checks = truebajo[profile.release]mantiene el comportamiento de debug en producción. Yasentre tipos enteros trunca silenciosamente (300_u32 as u8es44) - usau8::try_from(x)?donde el valor pueda no caber. - Un
Stringno es indexable.s[0]no compila - consultareferences/ownership-and-types.mdpara ver qué usar en su lugar.
Desde Go:
- Errores como valores - mismo instinto, pero usa
?en lugar deif err != nil. - Sin
nil.Option<T>. - Sin GC y sin gorutinas: ownership + borrowing, async/await con
tokio. El modelo async es cooperativo (awaites un punto de rendición explícito), no preventivo. interface{}se convierte en traits. Por defecto, genéricos para dispatch estático;Box<dyn Trait>solo cuando lo necesites.- El enlace estático es el predeterminado. Los binarios son más grandes pero autocontenidos.
panic!debe reservarse para bugs irrecuperables en código de aplicación; no lo uses como el "log y continúa" de Go.
Desde Java o C#:
- Los traits no son interfaces con dispatch virtual por defecto.
<T: Trait>se monomorfiza.dyn Traites la versión dinámica opt-in. - Sin referencias nulas.
Option<T>. - Sin excepciones.
Result<T, E>y?. - Sin herencia de clases. Usa enums para tipos suma, traits para comportamiento compartido.
- Sin GC: ownership y borrowing deciden los lifetimes.
Arc<T>es lo más parecido a una referencia de Java. - Los genéricos se monomorfizan, no se borran.
Desde C++:
- Como RAII, pero el borrow checker lo exige en tiempo de compilación.
- Sin constructores de copia/movimiento.
Clonees explícito yCopyes un trait marcador para copias bit a bit baratas. - Sin comportamiento indefinido en código seguro (en teoría).
&es un préstamo verificado en tiempo de compilación, no un puntero crudo. Los punteros crudos existen (*const T,*mut T) pero requierenunsafepara desreferenciar.- Los punteros inteligentes son
Box<T>(unique_ptr),Rc<T>(shared_ptr, un solo hilo),Arc<T>(shared_ptr, seguro para hilos). - Las macros son higiénicas. Las macros procedurales (derive, atributo, similares a funciones) son cómo funcionan
serde,tokio::main, etc.
La Lista Corta de Crates
Estos cubren la mayoría de las aplicaciones reales. Añádelos según sea necesario; no todos son obligatorios.
| Crate | Qué te da |
|---|---|
serde + serde_json | Serialización. #[derive(Serialize, Deserialize)] y ya está |
tokio | Runtime asíncrono. #[tokio::main], tokio::spawn, E/S asíncrona |
anyhow | Tipo de error para aplicaciones. anyhow::Result<T>, bail!, context() |
thiserror | Enums de error para bibliotecas. #[derive(thiserror::Error)] |
clap | Análisis de argumentos de CLI. #[derive(Parser)], genera completado automático y --help |
axum | Servidor web (capa sobre tokio + tower). Maneja async, extracte, middlewares |
sqlx | Acceso a bases de datos. Compila consultas, con tipos, soporte para PostgreSQL, MySQL, SQLite |
reqwest | Cliente HTTP. reqwest::get(url).await?.text().await? |
tracing | Registro y traza estructurados. info!("listening on {port}"), con spans, reemplaza log |
chrono | Fechas y horas. Utc::now(), Duration, zonas horarias |
uuid | UUIDs. Uuid::new_v4() |
rand | Números aleatorios. thread_rng().gen_range(0..100) |
regex | Expresiones regulares. Se compilan una vez por ejecución |
tempfile | Archivos y directorios temporales. tempfile::NamedTempFile::new()? |
criterion | Benchmarks (en benches/). Se ejecuta con cargo bench |
testcontainers | Dependencias de prueba (PostgreSQL, Redis, etc. con Docker). Inicia/limpia automáticamente |
insta | Pruebas de snapshot. Verifica que la salida de funciones coincida con un archivo snapshot aprobado |
Herramientas de línea de comandos
Instaladas globalmente con cargo install; no las añadas como dependencias de proyecto.
| Herramienta | Propósito |
|---|---|
cargo-watch | Re-ejecuta cargo check/cargo test al guardar archivos |
bacon | Re-ejecuta cargo test al guardar, con colas de trabajos y diseño mejorado (cargo install --locked bacon) |
cargo-audit | Busca CVEs conocidos en Cargo.lock |
cargo-deny | Aplica políticas de licencias y dependencias |
cargo-outdated | Muestra dependencias que tienen versiones más recientes |
cargo-msrv | Determina la versión mínima de Rust que puede compilar tu proyecto |
cargo-expand | Expande las macros y muestra el código resultante (para depurar derive macros) |
Qué Evitar el Día 1 / Elecciones de Arquitectura
Librerías vs Aplicaciones
La regla es clara: usa thiserror en bibliotecas, anyhow en aplicaciones.
// biblioteca (público): enum de error tipado
#[derive(thiserror::Error)]
pub enum ParseError {
#[error("se esperaban {expected} bytes, se obtuvieron {got}")]
LengthMismatch { expected: usize, got: usize },
#[error("código de estado inválido: {0}")]
InvalidStatus(u8),
}
// aplicación (binario): anyhow envuelve cualquier error
use anyhow::{Context, Result};
fn main() -> Result<()> {
let data = std::fs::read("input.bin").context("no se pudo leer el archivo de entrada")?;
let parsed = parse_frame(&data).context("el archivo no tiene un formato de trama válido")?;
Ok(())
}
Una biblioteca que devuelve anyhow::Error obliga a cada usuario a manejar una caja negra. Una biblioteca que devuelve thiserror::Error permite a los usuarios emparejar variantes con match, propagar con ?, o envolver con anyhow. En el otro sentido, una aplicación que usa thiserror para cada error interno añade ruido: el tipo exacto rara vez importa más allá de su mensaje y contexto. anyhow::Error es una caja de Box<dyn Error + Send + Sync> con un backtrace adjunto, suficiente para aplicaciones.
dyn Trait vs Genéricos (impl Trait o <T: Trait>)
La mayoría de los nuevos en Rust alcanzan Box<dyn Trait> por familiaridad con interfaces de otros lenguajes. Suele estar mal. El dispatch estático (genéricos / impl Trait) es el predeterminado correcto:
- La función se monomorfiza: sin búsqueda en vtable, sin asignación en heap.
- El compilador puede inlinear en todos los sitios de llamada.
- El único coste es el tamaño del binario (bloat de código), que rara vez importa.
Necesitas dyn Trait solo cuando:
- Colecciones heterogéneas:
Vec<Box<dyn Animal>>donde cada elemento es un tipo diferente que implementaAnimal. - Reducir el tamaño de tipos grandes: un enum de tales con diez variantes puede ser enorme si cada una contiene un genérico diferente.
Box<dyn Trait>lo reduce a un puntero. - Módulos dinámicamente cargados (plugins, FFI).
Arc<Mutex<T>> es el Nuevo Box<dyn Trait>
Cuando vengas de un lenguaje con un recolector de basura y referencias compartidas y mutables por todas partes, Arc<Mutex<T>> se siente como la respuesta a todo. No lo es. Pregunta primero: ¿es el árbol de llamadas secuencial? Si solo es cuestión de cuándo se accede, no dónde, entonces &mut T prestado funciona en un contexto sincrónico, y las variantes de ejecución única de tokio (por ejemplo, tokio::task::LocalSet) permiten RefCell a través de puntos await. Arc<Mutex<T>> introduce contención real de bloqueo, puntos muertos potenciales y complejidad de inferencia de tipos cuando se mezcla con closures y async. Resérvalo para el estado compartido que cruza hilos reales (incluyendo tareas tokio enviadas con spawn).
Para estado compartido dentro de una sola tarea tokio (que se ejecuta en un solo hilo a la vez), tokio::sync::Mutex es mejor que std::sync::Mutex (abandona el bloqueo en await, no bloquea el hilo). Pero el mejor enfoque suele estar libre de bloqueos por completo: pasa los datos como &mut a través de los puntos await, no los envuelvas en un Mutex.
Cómo Empezar: cargo init + mi Cargo.toml
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
# App
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
# Añadir cuando sea necesario:
# clap = { version = "4", features = ["derive"] }
# axum = "0.8"
# sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
# reqwest = { version = "0.12", features = ["json"] }
# tracing = "0.1"
# tracing-subscriber = "0.3"
[dev-dependencies]
# Los marcos de prueba recomendados no son crates; las pruebas de snapshot y los containers son
# opcionales y normalmente se añaden proyecto por proyecto.
[profile.release]
# Opcional: mantener comprobaciones de desbordamiento incluso en compilaciones release.
# overflow-checks = true
[lints.clippy]
# Opcional: habilitar lints adicionales de clippy
# cargo = { level = "warn", priority = -1 }
# pedantic = { level = "warn", priority = -1 }
Configuraciones del Editor / Lint
rustfmt
# rustfmt.toml
max_width = 100
# Prefiere la forma "a la izquierda" de Rust estándar (a la derecha de `=`, a la izquierda de la línea)
use_small_heuristics = "Max"
# Mantén las cadenas de iteradores cortas; no dividas cada paso
chain_one_line_max = 80
.clippy.toml
msrv = "1.85" # o tu msrv real; clippy no sugerirá características más nuevas
Depuración
dbg!()es la forma más rápida:dbg!(x)imprime[src/main.rs:10] x = 42y devuelve la propiedad dex. Úsalo para depuración rápida, no lo subas.eprintln!va a stderr, no stdout. Úsalo para errores y registros de depuración (aunquetracinges mejor para producción).- Depuradores:
rust-gdbyrust-lldbenvuelven gdb/lldb con scripts de pretty-printing de Rust.cargo run -- foopasafoocomo argumento a tu binario.cargo run -- --barpasa--bar. - Los backtraces de pánico requieren
RUST_BACKTRACE=1. Para backtraces completos con números de línea:RUST_BACKTRACE=full. Establece esto en tu perfil de shell o archivo.env. cargo-expand(herramienta de línea de comandos) expande macros para que puedas ver el código real que genera#[derive(...)].- "Rust Analyzer: Open Docs" en un símbolo abre la documentación.
- "Rust Analyzer: Show References" encuentra todos los usos.
cargo doc --opencompila la documentación de tu proyecto y todas las dependencias y la abre en el navegador.
Advertencia sobre la Documentación
La documentación de Rust (std docs, cargo doc, docs.rs) es consistentemente la mejor de cualquier ecosistema que hayas usado. Está enlazada desde cada error del compilador, cada crate publica en docs.rs, cada función muestra su firma, ejemplos y trait bounds. No uses StackOverflow ni ChatGPT como primera opción. Lee la documentación primero. Te ahorrará horas.
¿Y rustc --explain E0277? Pega el código de error y el compilador te dará una explicación detallada con ejemplos. Pruébalo.
La guía del día 1 termina aquí. Si tienes dudas sobre escenarios específicos (async, rendimiento, FFI), pregunta. Pero este material por sí solo hará que tu primer proyecto Rust sea drásticamente más suave que sin él.