Rust: memoria segura y rendimiento
Rust te da el rendimiento de C sin los dolores de cabeza de la gestión manual de memoria. A través de su modelo de ownership, el compilador elimina errores de memoria en tiempo de compilación. Ideal si ya conoces punteros en C y quieres escribir código rápido sin garbage collector.
Qué es Rust
Rust nació en 2006 como un proyecto personal de Graydon Hoare en Mozilla y se hizo público en 2010. La motivación era construir un lenguaje de sistemas seguro: sin garbage collector, sin fugas de memoria y sin accesos a memoria no válida, pero con el rendimiento de C/C++.
La clave es su modelo de ownership: el compilador rastrea en tiempo de compilación quién posee cada dato, cuándo se libera y cuándo se puede acceder a él. Si tu código tiene un use-after-free, un doble free o una carrera de datos, no compila. Errores que en C causan vulnerabilidades de seguridad aquí se detectan antes de ejecutar nada.
El ecosistema gira alrededor de Cargo, la herramienta oficial que combina gestor de paquetes, build system y runner de tests, similar a lo que npm es para Node pero integrado desde el primer día. El registro de paquetes se llama crates.io y cada paquete se llama crate.
Instalar
La forma oficial de instalar Rust es con rustup, el gestor de toolchains. Se instala con un script y luego te da acceso a cargo (build/gestor) y rustc (compilador):
# Instalar rustup (macOS y Linux)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Recargar la configuración del shell
source "$HOME/.cargo/env"
# Verificar
cargo --version
rustc --versionCrea un proyecto nuevo con cargo new. Te genera la estructura estándar y un repositorio git inicializado:
cargo new mi-proyecto
cd mi-proyecto
# Estructura generada:
# Cargo.toml → metadatos y dependencias
# src/main.rs → código fuente
# Compilar y ejecutar
cargo runEl Cargo.toml generado es mínimo:
[package]
name = "mi-proyecto"
version = "0.1.0"
edition = "2021"
[dependencies]Y el main.rs inicial:
fn main() {
println!("Hello, world!");
}cargo new inicializa un repositorio git automáticamente. Si estás dentro de un repo ya existente, usa cargo new mi-proyecto --vcs none para evitarlo.Variables y tipos
Por defecto las variables en Rust son inmutables. Para poder modificarlas hay que marcarlas con mut. Es la decisión más segura: la mutabilidad pasa a ser explícita y solo donde realmente la necesitas:
fn main() {
let mut contador = 0; // mutable
contador += 1;
let nombre = "Ana"; // inmutable: no se puede reasignar
// nombre = "Luis"; // ERROR: no se puede asignar a variable inmutable
println!("{contador} {nombre}");
}El shadowing permite re-declarar un nombre con un nuevo valor o incluso un nuevo tipo, sin necesidad de mut:
fn main() {
let x = 5;
let x = x + 1; // sombrea la anterior
let x = x * 2; // vuelve a sombrear
println!("{x}"); // 12
let texto = "42";
let numero: i32 = texto.parse().expect("no es un número");
}Los tipos básicos de Rust son explícitos y con tamaños definidos:
let activo: bool = true; // booleano
let edad: u8 = 30; // entero sin signo, 8 bits
let saldo: f64 = 99.5; // coma flotante de 64 bits
let inicial: char = 'A'; // carácter Unicode
let letra = b'B'; // u8 literal de byte
// Tuplas: varios valores de tipos distintos
let par: (i32, &str) = (42, "respuesta");
// Arrays: tamaño fijo
let dias = [1, 2, 3, 4, 5];
// Vectores: dinámicos
let mut numeros = vec![1, 2, 3];
numeros.push(4);Enteros: i8 a i128 (con signo) y u8 a u128 (sin signo), más isize/usize que dependen de la arquitectura (64 bits en tu máquina). Usa i32 por defecto: es el más rápido en la mayoría de CPU.
Ownership
Esta es la sección más importante de Rust. Cada valor tiene un dueño (owner): la variable que lo contiene. Cuando el dueño sale del ámbito, el valor se libera automáticamente. Las reglas son tres:
// Regla 1: cada valor tiene un único dueño.
// Regla 2: al asignar o pasar como argumento, el valor se MUEVE
// (el dueño original deja de ser válido).
// Regla 3: cuando el dueño sale del ámbito, el valor se libera.
fn main() {
let s1 = String::from("hola");
let s2 = s1; // s1 se MUEVE a s2
// println!("{s1}"); // ERROR: el valor fue movido
println!("{s2}"); // OK
}s2 = s1 con punteros crea dos punteros al mismo dato: el famoso doble free. En Rust, esa asignación mueve el valor: el compilador impide usar s1 después, eliminando la clase entera de errores. Los tipos que implementan Copy (números, booleanos, chars) se copian en vez de moverse:fn main() {
let a = 5;
let b = a; // i32 implementa Copy: ambos siguen válidos
println!("{a} {b}"); // OK: 5 5
}Para usar un valor sin moverlo, se presta (borrow) con una referencia &. El préstamo inmutable permite leer sin tomar la propiedad:
fn longitud(s: &String) -> usize {
s.len() // solo lee, no mueve
}
fn main() {
let texto = String::from("hello");
let largo = longitud(&texto); // préstamo inmutable
println!("{texto} tiene {largo} chars");
}Si la función necesita modificar el valor, el préstamo debe ser mutable con &mut:
fn anadir_palabra(s: &mut String, palabra: &str) {
s.push(' ');
s.push_str(palabra);
}
fn main() {
let mut saludo = String::from("hola");
anadir_palabra(&mut saludo, "mundo");
println!("{saludo}"); // hola mundo
}Slices y referencias
Un slice es una vista (referencia + longitud) sobre una secuencia de datos contiguos, sin copiarlos. Es la forma idiomática de pasar trozos de texto o listas. El tipo &str es un slice de un String:
fn primera_palabra(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[..i]; // slice hasta el espacio
}
}
&s[..] // sin espacios: devuelve todo
}
fn main() {
let frase = String::from("hola mundo");
let palabra = primera_palabra(&frase);
println!("{palabra}"); // hola
}El ejemplo demuestra algo poderoso: la función devuelve una referencia que vive tanto como la entrada. El compilador garantiza que palabra nunca pueda apuntar a memoria ya liberada, aunque frase se modifique después.
Sobre arrays y vectores también puedes crear slices con rangos:
let numeros = [10, 20, 30, 40, 50];
let parte = &numeros[1..3]; // [20, 30]
let todo = &numeros[..]; // [10, 20, 30, 40, 50]Structs y enums
Los struct agrupan campos, y con impl se les añaden métodos. El &self es un préstamo inmutable del struct (equivale a this):
struct Usuario {
nombre: String,
edad: u8,
activo: bool,
}
impl Usuario {
// Constructor convencional
fn nuevo(nombre: String, edad: u8) -> Self {
Usuario {
nombre,
edad,
activo: true,
}
}
fn saludar(&self) -> String {
format!("Hola, soy {}", self.nombre)
}
}
fn main() {
let ana = Usuario::nuevo(String::from("Ana"), 22);
println!("{}", ana.saludar());
}Los enum en Rust son mucho más potentes que en otros lenguajes: cada variante puede llevar datos. Son la base del pattern matching:
enum Estado {
Activo,
Pausado { motivo: String }, // variante con campos
Cancelado(u32), // variante con un valor
}
fn main() {
let estado = Estado::Pausado {
motivo: String::from("vacaciones"),
};
}Match y pattern matching
El match es el switch definitivo: cada brazo compara con un patrón y puede extraer datos de la variante. Es exhaustivo: el compilador obliga a cubrir todas las posibilidades:
fn describir(estado: Estado) -> String {
match estado {
Estado::Activo => String::from("en marcha"),
Estado::Pausado { motivo } => format!("pausado por {motivo}"),
Estado::Cancelado(codigo) => format!("cancelado (código {codigo})"),
}
}También funciona sobre números y otros valores, con patrones de rango y comodín _:
fn main() {
let numero = 3;
match numero {
1 => println!("uno"),
2 | 3 => println!("dos o tres"),
4..=10 => println!("entre cuatro y diez"),
_ => println!("otro"),
}
}El tipo Option<T> representa un valor que puede existir o no — es la alternativa segura a null. Se combina con match para forzarte a tratar ambos casos:
fn main() {
let valor: Option<i32> = Some(5);
match valor {
Some(n) => println!("Hay un valor: {n}"),
None => println!("No hay valor"),
}
}null en Rust. Donde otros lenguajes usan null (y explotan con NullPointerException), Rust usa Option y te obliga a decidir qué hacer con el caso vacío. Es imposible olvidarse del caso None.Errores
Rust no tiene excepciones. Los errores recuperables usan Result<T, E>: Ok(T) en éxito o Err(E) en fallo. Se maneja con match o con el operador ?, que propaga el error hacia arriba:
use std::fs::File;
use std::io::{self, ErrorKind};
// Con match, decidiendo caso a caso
fn abrir_o_crear() -> Result<File, io::Error> {
match File::open("hola.txt") {
Ok(archivo) => Ok(archivo),
Err(error) => match error.kind() {
ErrorKind::NotFound => File::create("hola.txt"),
otro => Err(otro),
},
}
}El operador ? es azúcar sintáctico: si el Result es Ok, extrae el valor; si es Err, devuelve el error de la función actual. Es la forma idiomática de escribir código limpio sin anidar matches:
use std::fs;
use std::io;
fn leer_contenido(ruta: &str) -> Result<String, io::Error> {
let contenido = fs::read_to_string(ruta)?; // propaga el error si falla
Ok(contenido)
}Para prototipos o valores que "no pueden fallar", existe unwrap() (pánico si es Err) y expect("mensaje"), que añade contexto al pánico. Úsalos con moderación: en código de producción prefiere ?:
fn main() {
// Pánico si el archivo no existe, con mensaje claro
let contenido = fs::read_to_string("hola.txt")
.expect("no se pudo leer hola.txt");
println!("{contenido}");
}Option y Result son la vía normal para errores esperables como archivos que no existen o JSON mal formado.Traits
Los traits son el equivalente a interfaces: definen comportamiento que un tipo debe implementar. Un trait se declara con trait y se implementa para un tipo con impl Trait for Tipo:
trait Sonido {
fn sonido(&self) -> String;
}
struct Perro;
struct Gato;
impl Sonido for Perro {
fn sonido(&self) -> String {
String::from("guau")
}
}
impl Sonido for Gato {
fn sonido(&self) -> String {
String::from("miau")
}
}
// Función genérica que acepta cualquier tipo que implemente Sonido
fn imprimir_sonido(animal: &impl Sonido) {
println!("{}", animal.sonido());
}
fn main() {
imprimir_sonido(&Perro); // guau
imprimir_sonido(&Gato); // miau
}Muchos traits se pueden derivar automáticamente con #[derive(...)]. Los más útiles: Debug (impresión de depuración) y Clone (copia explícita):
#[derive(Debug, Clone)]
struct Producto {
nombre: String,
precio: f64,
}
fn main() {
let p1 = Producto {
nombre: String::from("Teclado"),
precio: 89.99,
};
let p2 = p1.clone(); // copia explícita
println!("{:?}", p1); // salida de depuración
println!("{:?}", p2);
}Proyecto CLI
Construyamos una herramienta de línea de comandos que reciba argumentos.env::args() devuelve el programa y sus argumentos:
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
let comando = args.get(1).map(|s| s.as_str()).unwrap_or("help");
match comando {
"hola" => {
let nombre = args.get(2).map(|s| s.as_str()).unwrap_or("mundo");
println!("Hola, {nombre}!");
}
"suma" => {
let a: i32 = args.get(2).map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
let b: i32 = args.get(3).map(|s| s.parse().unwrap_or(0)).unwrap_or(0);
println!("{a} + {b} = {}", a + b);
}
_ => println!("Comandos: hola [nombre] | suma a b"),
}
}Compila con cargo build y ejecuta con cargo run, pasando argumentos después de --:
# Compilar en modo debug
cargo build
# Compilar optimizado (modo release)
cargo build --release
# Ejecutar pasando argumentos
cargo run -- hola Ana
# Salida: Hola, Ana!
cargo run -- suma 4 7
# Salida: 4 + 7 = 11Cargo también gestiona tests con cargo test y dependencias declaradas en Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
clap = "4"Para CLIs reales, la crate clap es el estándar de facto: parsea argumentos, genera la ayuda y valida tipos automáticamente. Para serializar JSON, serde es imprescindible.
Ejercicios
Con ownership, borrowing, enums, traits y Result ya tienes el núcleo de Rust. El compilador será tu mejor maestro durante las primeras semanas: cada error te enseña algo. Los siguientes pasos naturales son la crate clap para CLIs, serde para serialización, tokio para async y actix-web o axum para servidores web. Rust brilla donde el rendimiento y la fiabilidad no son negociables: sistemas de red, bases de datos, compiladores y WebAssembly.