Programación · n.º 12 entre los lenguajes más usados

Aprende Rust desde cero

Tan rápido como C++ pero sin fallos de memoria: el compilador te protege.

  • 15 lecciones
  • 19 ejercicios interactivos
  • 24 conceptos clave
  • 18 % lo usa*

Qué es Rust y para qué sirve

Rust se compila a código máquina como C y C++, pero su compilador comprueba las reglas de propiedad de la memoria y rechaza el código que podría provocar errores como usar memoria ya liberada o carreras de datos entre hilos. Por eso lo adoptan proyectos de sistemas, navegadores y herramientas de línea de comandos.

  • Programas de sistemas y herramientas de terminal
  • Servicios web rápidos
  • WebAssembly y dispositivos

Para empezar: Prueba los ejemplos en el Rust Playground oficial o instala Rust con rustup y crea un proyecto con cargo new.

Nivel 1 Básico

Lección 1 · Básico

Tu primer programa con Cargo

Cargo es la herramienta oficial de Rust: crea proyectos, descarga dependencias, compila y ejecuta. cargo new hola crea la carpeta con src/main.rs y cargo run lo compila y ejecuta.

println! lleva una exclamación porque es una macro, no una función. Las llaves {} dentro del texto se sustituyen por los valores que pases después.

Ejemplo práctico
// src/main.rs
fn main() {
    println!("¡Hola, mundo!");
    println!("2 + 3 = {}", 2 + 3);
}

// cargo new hola && cd hola && cargo run

Ejercicio

¿Por qué println! lleva exclamación?

Ver la solución

Respuesta: Porque es una macro. En Rust, el ! indica que se llama a una macro, que genera código al compilar.

Lección 2 · Básico

Variables inmutables y mut

En Rust las variables son inmutables por defecto: una vez asignadas no cambian. Si necesitas cambiarlas, decláralas con let mut.

El compilador deduce los tipos, pero puedes indicarlos: i32 (entero), f64 (decimal), bool, char y &str o String para texto.

Ejemplo práctico
fn main() {
    let nombre = "Ana";        // &str
    let mut edad: u32 = 30;
    edad += 1;

    let precio: f64 = 19.99;
    println!("{nombre} tiene {edad} años y paga {:.2}", precio * 3.0);

    // nombre = "Eva";  // error: cannot assign twice to immutable variable
}

Ejercicio

Completa para que la variable pueda cambiar.

let  contador = 0;
contador += 1;
Ver la solución

Respuesta: mut. Sin mut, el compilador rechaza cualquier reasignación.

Lección 3 · Básico

Control de flujo

if no lleva paréntesis y es una expresión: puede devolver un valor. for recorre rangos (1..=5 incluye el 5) y colecciones, while repite mientras se cumpla algo y loop repite hasta un break.

Las condiciones tienen que ser bool: Rust no convierte números en verdadero o falso.

Ejemplo práctico
fn main() {
    let nota = 7;
    let resultado = if nota >= 5 { "aprobado" } else { "suspenso" };
    println!("{resultado}");

    for i in 1..=3 {
        println!("vuelta {i}");
    }
}

Ejercicio

¿Qué números recorre for i in 1..=3?

Ver la solución

Respuesta: 1, 2 y 3. ..= incluye el último valor; 1..3 iría de 1 a 2.

Encuentra los errores

Este programa debería mostrar tres vueltas y «Terminado», pero no compila. Tiene 2 errores.

Ver la solución
  • Línea 2: En Rust las variables son inmutables por defecto: para poder hacer contador += 1 hay que declararla con mut. Corrección: let mut contador = 0;
  • Línea 7: = asigna; para comparar se usa ==. Rust da un error porque una asignación no es un booleano. Corrección: if contador == 3 {

Lección 4 · Básico

Vectores y texto

Vec<T> es una lista que crece; la macro vec! la crea con valores. Recorre sus elementos con for sobre una referencia (&notas) para no consumir el vector.

Hay dos tipos de texto: &str es una referencia a un texto que no es tuyo (como un literal) y String es un texto propio que puede crecer.

Ejemplo práctico
fn main() {
    let mut notas = vec![7, 8, 9];
    notas.push(10);
    let suma: i32 = notas.iter().sum();
    println!("Media: {}", suma as f64 / notas.len() as f64);   // 8.5

    let mut saludo = String::from("Hola");
    saludo.push_str(", Rust");
    println!("{saludo}");
}

Ejercicio

¿Qué tipo de texto puedes ampliar con push_str?

Ver la solución

Respuesta: String. String es dueño de su contenido y puede crecer; &str solo apunta a un texto existente.

Escribe el código

Escribe fn suma_pares(numeros: &[i32]) -> i32: devuelve la suma de los números pares del slice. Recórrelo con un bucle for.

Tab escribe una sangría; para salir del editor con el teclado, pulsa Esc y luego Tab. Ctrl + Intro corrige.

Ver la solución
fn suma_pares(numeros: &[i32]) -> i32 {
    let mut suma = 0;
    for &n in numeros {
        if n % 2 == 0 {
            suma += n;
        }
    }
    suma
}

En Rust la última expresión de una función, sin punto y coma, es el valor que devuelve: por eso basta con escribir suma al final.

Nivel 2 Intermedio

Lección 5 · Intermedio

Ownership y préstamos

Cada valor tiene un único dueño. Al asignar un String a otra variable o pasarlo a una función, la propiedad se mueve y la variable original ya no se puede usar.

Para usar un valor sin quedártelo, lo pides prestado con una referencia &. Puede haber muchas referencias de solo lectura o una sola &mut que modifica, pero no las dos a la vez: así Rust evita errores de memoria al compilar.

Ejemplo práctico
fn longitud(texto: &String) -> usize {   // préstamo de solo lectura
    texto.len()
}

fn anadir_punto(texto: &mut String) {    // préstamo para modificar
    texto.push('.');
}

fn main() {
    let mut frase = String::from("Hola");
    println!("{}", longitud(&frase));    // 4
    anadir_punto(&mut frase);
    println!("{frase}");                 // Hola.

    let otra = frase;                    // la propiedad se mueve
    // println!("{frase}");              // error: borrow of moved value
    println!("{otra}");
}

Ejercicio

Tras let otra = frase; (con frase un String), ¿qué pasa al usar frase?

Ver la solución

Respuesta: Error de compilación: el valor se ha movido. La propiedad pasó a otra; el compilador impide usar frase después.

Encuentra los errores

Este programa no compila: saludo se usa después de haberse movido. Marca las 2 líneas que hay que cambiar para prestar el texto en vez de moverlo.

Ver la solución
  • Línea 1: Si la función recibe un String, se queda con él (lo «mueve»). Para solo leerlo, recibe una referencia: &str. Corrección: fn longitud(texto: &str) -> usize {
  • Línea 7: Al llamarla, presta el texto con & para que saludo siga siendo válido en la línea siguiente. Corrección: let n = longitud(&saludo);

Lección 6 · Intermedio

Structs e impl

Un struct agrupa datos con nombre. En un bloque impl escribes sus métodos: &self para leer, &mut self para modificar.

Una función asociada sin self, como new, se llama con Tipo::new() y suele servir de constructor.

Ejemplo práctico
struct Cuenta {
    titular: String,
    saldo: f64,
}

impl Cuenta {
    fn new(titular: &str) -> Self {
        Cuenta { titular: titular.to_string(), saldo: 0.0 }
    }
    fn ingresar(&mut self, cantidad: f64) {
        self.saldo += cantidad;
    }
}

fn main() {
    let mut c = Cuenta::new("Ana");
    c.ingresar(50.0);
    println!("{}: {}", c.titular, c.saldo);   // Ana: 50
}

Ejercicio

Completa el parámetro de un método que modifica el struct.

fn ingresar(& self, cantidad: f64) { … }
Ver la solución

Respuesta: mut. &mut self presta el objeto para modificarlo.

Lección 7 · Intermedio

Enums, match y Option

Un enum define un tipo que es una de varias variantes, y cada una puede llevar datos. match decide qué hacer en cada caso y el compilador exige que cubras todos.

Rust no tiene null: un valor que puede faltar es un Option<T>, que vale Some(valor) o None, y tienes que tratar los dos casos.

Ejemplo práctico
enum Pago {
    Efectivo,
    Tarjeta { ultimos4: String },
}

fn describir(p: &Pago) -> String {
    match p {
        Pago::Efectivo => "Pago en efectivo".to_string(),
        Pago::Tarjeta { ultimos4 } => format!("Tarjeta ****{ultimos4}"),
    }
}

fn main() {
    println!("{}", describir(&Pago::Tarjeta { ultimos4: "1234".into() }));
    let precios = vec![3.5, 12.0];
    match precios.first() {
        Some(p) => println!("Primero: {p}"),
        None => println!("Lista vacía"),
    }
}

Ejercicio

¿Cómo representa Rust un valor que puede faltar?

Ver la solución

Respuesta: Option<T>. Option<T> obliga a tratar el caso None, así que no hay errores por valores nulos olvidados.

Escribe el código

Escribe fn dividir(a: f64, b: f64) -> Option<f64>: devuelve None si b es 0 y, si no, Some con el resultado de la división.

Tab escribe una sangría; para salir del editor con el teclado, pulsa Esc y luego Tab. Ctrl + Intro corrige.

Ver la solución
fn dividir(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None
    } else {
        Some(a / b)
    }
}

Option obliga a quien llama a tratar el caso sin resultado (con match, if let o unwrap_or), así que no hay forma de olvidarse de la división entre cero.

Nivel 3 Avanzado

Lección 8 · Avanzado

Errores con Result y el operador ?

Las operaciones que pueden fallar devuelven Result<T, E>: Ok(valor) o Err(error). No hay excepciones; el tipo te obliga a decidir qué hacer con el error.

Dentro de una función que también devuelve Result, el operador ? devuelve el error hacia arriba automáticamente si algo falla.

Ejemplo práctico
use std::fs;
use std::num::ParseIntError;

fn leer_numero(texto: &str) -> Result<i32, ParseIntError> {
    let n: i32 = texto.trim().parse()?;   // si falla, devuelve el Err
    Ok(n * 2)
}

fn main() {
    println!("{:?}", leer_numero("21"));    // Ok(42)
    println!("{:?}", leer_numero("hola"));  // Err(ParseIntError { kind: InvalidDigit })

    match fs::read_to_string("notas.txt") {
        Ok(texto) => println!("{texto}"),
        Err(e) => println!("No se pudo leer: {e}"),
    }
}

Ejercicio

Completa el operador que propaga el error.

let n: i32 = texto.parse();
Ver la solución

Respuesta: ?. ? desempaqueta el Ok o devuelve el Err a quien llamó a la función.

Lección 9 · Avanzado

Traits y genéricos

Un trait define un comportamiento que varios tipos pueden implementar, parecido a una interfaz. Con genéricos y límites de trait (T: Display) escribes funciones que aceptan cualquier tipo que cumpla ese comportamiento.

Muchas capacidades básicas son traits de la biblioteca estándar: Display (mostrar), Clone (copiar) o Debug (mostrar para depurar, con {:?}).

Ejemplo práctico
use std::fmt::Display;

trait Area {
    fn area(&self) -> f64;
}

struct Cuadrado(f64);
impl Area for Cuadrado {
    fn area(&self) -> f64 { self.0 * self.0 }
}

fn mostrar<T: Display>(etiqueta: &str, valor: T) {
    println!("{etiqueta}: {valor}");
}

fn main() {
    mostrar("Área", Cuadrado(3.0).area());   // Área: 9
    mostrar("Nombre", "Ana");
}

Ejercicio

¿A qué se parece un trait en otros lenguajes?

Ver la solución

Respuesta: A una interfaz. Un trait declara métodos que cada tipo implementa a su manera, como una interfaz.

Lección 10 · Avanzado

Concurrencia sin miedo

std::thread::spawn lanza un hilo; move le pasa la propiedad de los datos que usa, y join() espera a que termine.

Para compartir datos entre hilos se usan tipos como Arc (contador de referencias seguro entre hilos) y Mutex (acceso de uno en uno). Si intentas compartir algo de forma insegura, el programa no compila.

Ejemplo práctico
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let contador = Arc::new(Mutex::new(0));
    let mut hilos = vec![];

    for _ in 0..4 {
        let c = Arc::clone(&contador);
        hilos.push(thread::spawn(move || {
            *c.lock().unwrap() += 1;
        }));
    }
    for h in hilos {
        h.join().unwrap();
    }
    println!("Total: {}", *contador.lock().unwrap());   // Total: 4
}

Ejercicio

¿Qué hace Mutex?

Ver la solución

Respuesta: Permite que solo un hilo a la vez acceda al dato. Un Mutex (exclusión mutua) bloquea el dato mientras un hilo lo usa.

Lección 11 · Avanzado

Iteradores y closures

Un iterador recorre una colección elemento a elemento. Encadenando adaptadores como filter (quedarse con algunos), map (transformar) y sum o collect (juntar el resultado) se escriben transformaciones de datos claras y, a menudo, tan rápidas como un bucle.

Los adaptadores reciben closures, funciones anónimas que se escriben con barras: |x| x * 2. Los iteradores son perezosos: no hacen nada hasta que algo, como collect, pide los resultados.

Ejemplo práctico
fn main() {
    let precios = vec![12.0, 40.0, 8.0, 25.0];

    let caros: Vec<f64> = precios.iter().copied().filter(|&p| p > 20.0).collect();
    println!("{:?}", caros);                      // [40.0, 25.0]

    let total: f64 = precios.iter().map(|p| p * 1.21).sum();
    println!("{:.2}", total);                     // 102.85

    let nombres = ["ana", "luis"];
    let mayus: Vec<String> = nombres.iter().map(|n| n.to_uppercase()).collect();
    println!("{}", mayus.join(", "));             // ANA, LUIS
}

Escribe el código

Escribe fn cuadrados_pares(v: &[i32]) -> Vec<i32>: devuelve los cuadrados de los números pares de v, sin bucles: con iter(), filter, map y collect.

Tab escribe una sangría; para salir del editor con el teclado, pulsa Esc y luego Tab. Ctrl + Intro corrige.

Ver la solución
fn cuadrados_pares(v: &[i32]) -> Vec<i32> {
    v.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).collect()
}

iter() da referencias (&i32), por eso en el filter aparece &&n: el filtro recibe una referencia a cada referencia. Escribir |n| *n % 2 == 0 también vale.

Lección 12 · Avanzado

HashMap y la API entry

HashMap<K, V> guarda pares clave-valor y encuentra un valor por su clave al instante. get devuelve un Option (la clave puede no existir) e insert añade o sustituye.

La API entry resuelve en una línea el caso típico de «si la clave no existe, créala con un valor inicial; después, modifícala»: *mapa.entry(clave).or_insert(0) += 1 cuenta apariciones.

Ejemplo práctico
use std::collections::HashMap;

fn main() {
    let texto = "el gato y el perro y el pez";
    let mut cuenta: HashMap<&str, i32> = HashMap::new();

    for palabra in texto.split_whitespace() {
        *cuenta.entry(palabra).or_insert(0) += 1;
    }

    println!("{:?}", cuenta.get("el"));    // Some(3)
    println!("{:?}", cuenta.get("lobo"));  // None

    let mut ordenadas: Vec<_> = cuenta.into_iter().collect();
    ordenadas.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
    println!("{:?}", &ordenadas[..2]);      // [("el", 3), ("y", 2)]
}

Ejercicio

Completa para sumar 1 a la cuenta de palabra, creándola con 0 si no existe.

*cuenta.entry(palabra).(0) += 1;
Ver la solución

Respuesta: or_insert. entry(clave) apunta a la posición de esa clave y or_insert(0) la crea con 0 si no estaba; después se le suma 1.

Lección 13 · Avanzado

Tiempos de vida: 'a sin miedo

Rust comprueba que ninguna referencia viva más que el dato al que apunta. Casi siempre lo deduce solo, pero cuando una función recibe varias referencias y devuelve una, necesita que le digas con cuál está relacionada la que devuelve.

Eso es una anotación de tiempo de vida: fn mas_largo<'a>(a: &'a str, b: &'a str) -> &'a str significa «el resultado es válido mientras lo sean a y b». No cambia cuánto dura nada; solo describe la relación para que el compilador la compruebe.

Ejemplo práctico
fn mas_largo<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    let ciudad = String::from("Valencia");
    let resultado;
    {
        let otra = String::from("Ávila");
        resultado = mas_largo(&ciudad, &otra);
        println!("{}", resultado);   // Valencia
    }
    // println!("{}", resultado);   // error: «otra» ya no existe aquí
}

Ejercicio

¿Qué indica <'a> en fn mas_largo<'a>(a: &'a str, b: &'a str) -> &'a str?

Ver la solución

Respuesta: Que el resultado solo es válido mientras lo sean las dos referencias de entrada. La anotación relaciona las referencias: el compilador impedirá usar el resultado cuando alguna de las entradas ya no exista.

Lección 14 · Avanzado

Pruebas con cargo test

Rust trae las pruebas incorporadas: una función con el atributo #[test] es una prueba, y cargo test las ejecuta todas y resume cuáles fallan. Se suelen poner en un módulo tests al final del mismo archivo.

assert_eq!(obtenido, esperado) falla mostrando los dos valores, assert!(condición) comprueba que algo es cierto y #[should_panic] marca una prueba que debe provocar un pánico.

Ejemplo práctico
pub fn precio_final(precio: f64, descuento: u8) -> f64 {
    assert!(descuento <= 100, "descuento imposible");
    precio * (100 - descuento) as f64 / 100.0
}

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

    #[test]
    fn sin_descuento() {
        assert_eq!(precio_final(50.0, 0), 50.0);
    }

    #[test]
    fn con_descuento() {
        assert_eq!(precio_final(80.0, 25), 60.0);
    }

    #[test]
    #[should_panic]
    fn descuento_imposible() {
        precio_final(10.0, 120);
    }
}
// cargo test  →  test result: ok. 3 passed; 0 failed

Encuentra los errores

Estas pruebas deberían comprobar es_par, pero tienen 2 errores: una no se ejecutaría nunca y otra espera un resultado equivocado.

Ver la solución
  • Línea 9: Sin el atributo #[test], cargo test no la ejecuta: es una función normal (y el compilador avisa de que no se usa). Corrección: #[test] fn cero_es_par() {
  • Línea 15: 7 no es par, así que es_par(7) devuelve false: la prueba fallaría aunque la función esté bien. Corrección: assert_eq!(es_par(7), false);

Lección 15 · Avanzado

Módulos y visibilidad

Un proyecto grande se organiza en módulos con mod. Cada módulo puede estar en su propio archivo (mod facturas; busca facturas.rs) y todo lo que contiene es privado salvo lo que marques con pub.

use trae nombres de otro módulo para no escribir la ruta completa cada vez: use crate::facturas::calcular_total;. Las bibliotecas de crates.io se añaden con cargo add nombre y se usan igual.

Ejemplo práctico
mod facturas {
    pub struct Linea {
        pub concepto: String,
        pub importe: f64,
    }

    pub fn total(lineas: &[Linea]) -> f64 {
        lineas.iter().map(|l| l.importe).sum::<f64>() * (1.0 + IVA)
    }

    const IVA: f64 = 0.21;   // privado: solo se usa dentro del módulo
}

use facturas::{total, Linea};

fn main() {
    let lineas = vec![
        Linea { concepto: "Diseño".into(), importe: 300.0 },
        Linea { concepto: "Hosting".into(), importe: 100.0 },
    ];
    println!("{} líneas, total {:.2} €", lineas.len(), total(&lineas));  // 2 líneas, total 484.00 €
    // println!("{}", facturas::IVA);   // error: IVA es privado
}

Ejercicio

Dentro de mod facturas, la constante IVA no lleva pub. ¿Qué pasa si main intenta usar facturas::IVA?

Ver la solución

Respuesta: Error de compilación: es privada para quien está fuera del módulo. En Rust todo es privado por defecto; solo lo marcado con pub se puede usar desde fuera del módulo.

¿Has terminado? Sigue practicando

Repasa los conceptos en el diccionario, practica con estas herramientas o prueba otro lenguaje: las ideas que has aprendido (variables, condiciones, funciones, objetos…) te servirán en cualquiera.

Fuentes consultadas

* Porcentaje de personas que dijeron haber usado Rust en la Stack Overflow Developer Survey 2026: Technology (pregunta sobre lenguajes de programación, de scripts y de marcado; todas las respuestas). El progreso de las lecciones se guarda solo en este navegador.