Preguntas de entrevista de Rust
Estas son preguntas que salen una y otra vez en entrevistas de Rust, desde puestos junior hasta senior. Cada respuesta es lo bastante corta como para decirla en voz alta, y enlaza a la lección que explica el tema a fondo.
No te las aprendas de memoria palabra por palabra. Los entrevistadores lo notan, y casi siempre hacen una pregunta de seguimiento para ver si de verdad lo entiendes. Usa esta página para comprobar lo que sabes y luego ve a la lección enlazada en todo lo que no tengas claro. Si puedes explicar una respuesta con tus propias palabras y escribir un ejemplo pequeño, estás listo.
Lo básico
Sección titulada «Lo básico»P: ¿Qué es Cargo?
Sección titulada «P: ¿Qué es Cargo?»Cargo es la herramienta oficial de compilación y el gestor de paquetes de Rust. Viene incluido con Rust. Con una sola herramienta puedes:
- crear un proyecto (
cargo new), - compilarlo y ejecutarlo (
cargo build,cargo run), - ejecutar los tests (
cargo test), - añadir bibliotecas, llamadas crates (paquetes), desde crates.io (
cargo add serde).
La configuración y las dependencias de un proyecto están en Cargo.toml. Las versiones exactas quedan fijadas en Cargo.lock, así que las compilaciones se pueden repetir igual. Un buen punto extra: otros lenguajes suelen necesitar varias herramientas para todo esto, pero en Rust todo el mundo usa Cargo, así que cualquier proyecto te resulta familiar.
Aprende más: Cargo y crates, Empezar con Rust.
P: ¿Qué diferencia hay entre shadowing y mut?
Sección titulada «P: ¿Qué diferencia hay entre shadowing y mut?»mut te deja cambiar el valor de la misma variable. El tipo tiene que seguir siendo el mismo.
El shadowing (sombreado) consiste en volver a escribir let con el mismo nombre. Así se crea una variable nueva que oculta a la anterior. La nueva incluso puede tener otro tipo.
fn main() { let spaces = " "; let spaces = spaces.len(); // shadowing: una variable nueva, ahora es un número println!("{spaces}");
let mut count = 1; count += 1; // mut: la misma variable recibe un valor nuevo println!("{count}");}Salida:
32El shadowing viene muy bien para transformar un valor paso a paso (por ejemplo, convertir texto en un número) sin inventar nombres como input_str e input_num. Y la variable final sigue siendo inmutable.
Aprende más: Variables.
P: ¿Qué diferencia hay entre una macro y una función?
Sección titulada «P: ¿Qué diferencia hay entre una macro y una función?»Una función se llama en tiempo de ejecución con un número fijo de argumentos con tipo. Una macro se ejecuta en tiempo de compilación: recibe código y escribe más código antes de que el compilador lo revise. Las macros se reconocen por el !, como println!, vec! y format!.
Las macros pueden hacer cosas que las funciones no pueden, como aceptar un número variable de argumentos (println!("{} {}", a, b)) o comprobar una cadena de formato al compilar. Además, las macros de Rust trabajan con la estructura del código, no con texto plano, así que evitan los errores clásicos de las macros de C:
macro_rules! square { ($x:expr) => { $x * $x };}
fn main() { // En C, una macro basada en texto daría 2 + 1 * 2 + 1 = 5. println!("{}", square!(2 + 1));}Salida:
9La contrapartida, siendo sinceros: las macros son más difíciles de leer, escribir y depurar. Usa una función cuando una función sirva.
Aprende más: Macros.
P: ¿Qué significa “zero-cost abstractions”?
Sección titulada «P: ¿Qué significa “zero-cost abstractions”?»Las zero-cost abstractions (abstracciones de coste cero) significan que las funciones de alto nivel del lenguaje no deberían hacer tu programa más lento que el código de bajo nivel que escribirías a mano. “No pagas por lo que no usas, y lo que sí usas no lo podrías escribir mejor tú mismo”.
Por ejemplo, una cadena de iteradores como .iter().filter(...).map(...).sum() normalmente se compila al mismo código máquina que un bucle escrito a mano. Los genéricos son otro ejemplo: el compilador crea una copia especializada para cada tipo que usas (esto se llama monomorfización), así que no hay ninguna búsqueda en tiempo de ejecución.
Sé honesto con lo del “coste”: es coste cero en tiempo de ejecución. Sigues pagando en tiempo de compilación, y los genéricos pueden hacer que el ejecutable sea más grande.
Aprende más: Iteradores, Genéricos.
P: ¿Los iteradores de Rust son perezosos?
Sección titulada «P: ¿Los iteradores de Rust son perezosos?»Sí. Métodos como .map() y .filter() no hacen ningún trabajo por sí solos. Construyen una descripción del trabajo. No pasa nada hasta que algo consume el iterador, como .collect(), .sum() o un bucle for.
fn main() { let numbers = vec![1, 2, 3]; let doubled = numbers.iter().map(|n| { println!("duplicando {n}"); n * 2 });
println!("Todavía no se ha ejecutado nada."); let result: Vec<i32> = doubled.collect(); println!("{:?}", result);}Salida:
Todavía no se ha ejecutado nada.duplicando 1duplicando 2duplicando 3[2, 4, 6]Por eso también el compilador te avisa si escribes numbers.iter().map(...); y nunca usas el resultado: no haría nada.
Aprende más: Iteradores.
Ownership y borrowing
Sección titulada «Ownership y borrowing»P: ¿Cuáles son las reglas de ownership?
Sección titulada «P: ¿Cuáles son las reglas de ownership?»Hay tres:
- Cada valor en Rust tiene un owner (dueño), que es una variable.
- Solo puede haber un dueño a la vez.
- Cuando el dueño sale de su ámbito (scope), el valor se libera (se hace
dropy su memoria queda libre).
Estas reglas de ownership (propiedad) permiten a Rust liberar la memoria justo en el momento correcto sin un recolector de basura, y sin que tengas que llamar a free a mano. El compilador las comprueba, así que errores como “usar después de liberar” o “liberar dos veces” no pueden ocurrir en código seguro.
Aprende más: Ownership, Ámbito.
P: ¿Qué diferencia hay entre move, copy y clone?
Sección titulada «P: ¿Qué diferencia hay entre move, copy y clone?»- Move (mover): al asignar o pasar un valor, se transfiere el ownership. La variable antigua ya no se puede usar. Es lo que pasa por defecto con tipos como
StringyVec. No se copia nada, salvo un pequeño puntero, la longitud y la capacidad. - Copy (copiar): con tipos pequeños y simples como
i32,f64,boolychar, la asignación hace una copia barata bit a bit, y las dos variables siguen siendo usables. - Clone (clonar):
.clone()es una copia profunda explícita. En unString, copia todo el texto a memoria nueva. Puede ser costoso, y por eso Rust te obliga a escribirlo.
fn main() { let x = 5; let y = x; // Copy: x sigue siendo usable println!("{x} {y}");
let a = String::from("hi"); let b = a.clone(); // Clone: una copia completa del texto let c = a; // Move: `a` ya no se puede usar después de esta línea println!("{b} {c}");}Salida:
5 5hi hiAprende más: Ownership.
P: ¿Qué es el trait Copy y qué tipos lo tienen?
Sección titulada «P: ¿Qué es el trait Copy y qué tipos lo tienen?»Copy es un trait (rasgo) marcador que dice “este tipo se puede duplicar simplemente copiando sus bits”. Los tipos que son Copy se copian en vez de moverse al asignarlos.
Los enteros, los flotantes, bool, char, las referencias compartidas (&T) y las tuplas o arrays formados solo por tipos Copy son todos Copy. String, Vec y Box no lo son, porque son dueños de memoria que está en otro sitio, y copiar el puntero daría dos dueños.
Puedes derivarlo para tus propios tipos, pero solo si todos los campos son Copy:
#[derive(Clone, Copy)]struct User { name: String, age: u32,}error[E0204]: the trait `Copy` cannot be implemented for this type --> src/main.rs:2:8 |1 | #[derive(Clone, Copy)] | ---- in this derive macro expansion2 | struct User { | ^^^^3 | name: String, | ------------ this field does not implement `Copy`El compilador te dice que Copy no se puede implementar porque el campo name (un String) no es Copy.
Copy siempre exige también Clone. Además, un tipo que implementa Drop no puede ser Copy.
Aprende más: Traits comunes, Ownership.
P: ¿Qué diferencia hay entre String y &str?
Sección titulada «P: ¿Qué diferencia hay entre String y &str?»Stringes un texto con dueño que puede crecer y se guarda en el heap. Puedes modificarlo, por ejemplo conpush_str.&str(un “string slice”, o porción de cadena) es una vista prestada de algún texto. Es solo un puntero y una longitud. Los literales de cadena como"hello"son&str.
Una buena regla: las funciones que solo leen texto deberían recibir &str. Así aceptan los dos, porque un &String se convierte en &str automáticamente.
fn greet(name: &str) { println!("¡Hola, {name}!");}
fn main() { let owned: String = String::from("Ana"); let literal: &str = "Ben";
greet(&owned); // &String funciona donde se espera &str greet(literal);
let mut full = owned; full.push_str(" Smith"); // solo un String puede crecer greet(&full);}Salida:
¡Hola, Ana!¡Hola, Ben!¡Hola, Ana Smith!P: ¿Cuáles son las reglas de borrowing?
Sección titulada «P: ¿Cuáles son las reglas de borrowing?»En cualquier momento, para un valor dado, puedes tener o bien:
- cualquier número de referencias compartidas (
&T), o bien - exactamente una referencia mutable (
&mut T),
pero no las dos cosas a la vez. Y toda referencia debe apuntar siempre a datos válidos.
¿Por qué? Si alguien pudiera cambiar los datos mientras otra parte los está leyendo, tendrías errores como un vector que mueve su memoria mientras tú guardas un puntero dentro de él. Con hilos, eso es exactamente una carrera de datos (data race). Rust lo impide al compilar. Desde la edición 2018, el compilador es listo para saber cuándo termina un borrow (préstamo); esto se llama non-lexical lifetimes: un préstamo solo dura hasta su último uso, no hasta el final del bloque.
Aprende más: Borrowing.
P: ¿Qué son los lifetimes?
Sección titulada «P: ¿Qué son los lifetimes?»Un lifetime (tiempo de vida) es el nombre que usa el compilador para “cuánto tiempo es válida una referencia”. Cada referencia tiene uno. Casi siempre el compilador los deduce solo (esto se llama lifetime elision), así que nunca los ves.
Solo los escribes cuando el compilador no puede saber cómo se relaciona la referencia de salida con las de entrada, como en una función que devuelve una de dos cadenas prestadas:
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() >= b.len() { a } else { b }}
fn main() { let first = String::from("borrow checker"); let second = String::from("lifetimes"); println!("{}", longest(&first, &second));}Salida:
borrow checkerUn punto importante para decir en una entrevista: las anotaciones de lifetime no cambian cuánto vive nada. Solo describen relaciones para que el compilador pueda comprobar que ninguna referencia vive más que los datos a los que apunta.
Aprende más: Lifetimes.
Tipos y traits
Sección titulada «Tipos y traits»P: Rust no tiene null. ¿Qué usa en su lugar?
Sección titulada «P: Rust no tiene null. ¿Qué usa en su lugar?»Option<T>, un enum (enumeración) con dos variantes: Some(value) o None. Como es un tipo distinto de T, el compilador te obliga a manejar el caso None antes de poder usar el valor. No puedes olvidarte de comprobar el null, porque el código no compila.
fn find_user(id: u32) -> Option<&'static str> { match id { 1 => Some("Ana"), 2 => Some("Ben"), _ => None, }}
fn main() { for id in [1, 3] { match find_user(id) { Some(name) => println!("Encontrado: {name}"), None => println!("No hay ningún usuario con id {id}"), } }}Salida:
Encontrado: AnaNo hay ningún usuario con id 3Un detalle extra que queda muy bien: Option<&T> y Option<Box<T>> no ocupan memoria extra comparados con un puntero normal. Rust usa internamente el patrón de bits del “null” para representar None, así que aquí la seguridad no cuesta nada.
P: ¿Qué es un trait? ¿Es lo mismo que una interfaz?
Sección titulada «P: ¿Qué es un trait? ¿Es lo mismo que una interfaz?»Un trait define un conjunto de métodos que un tipo puede implementar. Se parece a una interfaz de Java, C# o Go, y es una buena forma de empezar tu respuesta. Luego menciona las diferencias:
- Los traits pueden tener implementaciones de métodos por defecto.
- Puedes implementar un trait para un tipo después de que el tipo se haya escrito, incluso para tipos que no escribiste tú, como implementar tu propio trait para
i32. (La “orphan rule”, o regla del huérfano, dice que el trait o el tipo tiene que ser tuyo). - Los traits se usan como restricciones de genéricos (
T: Display), que se resuelven al compilar, y también como trait objects (dyn Display) en tiempo de ejecución. - Rust no tiene herencia de clases. Los traits junto con la composición la sustituyen.
Aprende más: Traits, Traits comunes.
P: ¿Qué diferencia hay entre genéricos y trait objects?
Sección titulada «P: ¿Qué diferencia hay entre genéricos y trait objects?»Los dos permiten que una función trabaje con muchos tipos. La diferencia es cuándo se decide el tipo:
- Los genéricos (
fn f<T: Speak>(x: &T)) usan despacho estático. El compilador crea una copia aparte de la función para cada tipo. Las llamadas son tan rápidas como las de una función normal, pero el programa puede crecer. - Los trait objects (
&dyn Speak,Box<dyn Speak>) usan despacho dinámico. Hay una sola copia de la función, y el método correcto se busca en tiempo de ejecución en una tabla (una “vtable”). Es un poquito más lento, pero te deja mezclar tipos distintos en una misma colección.
trait Speak { fn speak(&self) -> String;}
struct Dog;struct Cat;
impl Speak for Dog { fn speak(&self) -> String { String::from("Guau") }}
impl Speak for Cat { fn speak(&self) -> String { String::from("Miau") }}
fn speak_static<T: Speak>(animal: &T) -> String { animal.speak()}
fn speak_dynamic(animal: &dyn Speak) -> String { animal.speak()}
fn main() { println!("{}", speak_static(&Dog)); println!("{}", speak_dynamic(&Cat));
// Solo los trait objects pueden mezclar tipos en un mismo Vec: let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)]; for animal in &animals { println!("{}", animal.speak()); }}Salida:
GuauMiauGuauMiauRegla práctica: empieza con genéricos. Usa trait objects cuando necesites una colección de tipos distintos, o quieras reducir el tiempo de compilación y el tamaño del ejecutable.
Aprende más: Genéricos, Trait objects.
P: ¿Qué diferencia hay entre Box<dyn Trait> e impl Trait?
Sección titulada «P: ¿Qué diferencia hay entre Box<dyn Trait> e impl Trait?»Los dos te permiten decir “algún tipo que implementa este trait” sin nombrarlo. La diferencia está en cuántos tipos concretos se permiten:
impl Traitsignifica un tipo concreto que el compilador conoce, pero que no te obliga a escribir. Es despacho estático (static dispatch): sin reserva en el heap, sin vtable, igual de rápido que si escribieras el tipo tú mismo. Como argumento (fn f(x: impl Display)) es solo una forma más corta de escribir un genérico. Como tipo de retorno, es la manera de devolver cosas con tipos largos o imposibles de nombrar, como closures y cadenas de iteradores.Box<dyn Trait>es un trait object en el heap. El tipo real puede ser distinto cada vez, se decide en tiempo de ejecución, y el método se encuentra a través de una vtable. Eso cuesta una reserva de memoria y una llamada indirecta, pero es la única forma de devolver uno de varios tipos, o de guardarlos en un mismoVec.
use std::fmt::Display;
// impl Trait: un único tipo oculto, fijado al compilar.fn evens(limit: u32) -> impl Iterator<Item = u32> { (0..limit).filter(|n| n % 2 == 0)}
// Box<dyn Trait>: el tipo real puede cambiar en tiempo de ejecución.fn label(as_number: bool) -> Box<dyn Display> { if as_number { Box::new(42) } else { Box::new("cuarenta y dos") }}
fn main() { let v: Vec<u32> = evens(10).collect(); println!("{v:?}"); println!("{}", label(true)); println!("{}", label(false));}Salida:
[0, 2, 4, 6, 8]42cuarenta y dosUn buen detalle para mencionar: si cambias label para que devuelva impl Display y quitas los Box, no compila. El if devuelve un i32 y el else devuelve un &str, y impl Trait tiene que ser un único tipo. Regla práctica: usa impl Trait por defecto, y pasa a Box<dyn Trait> cuando de verdad necesites elegir entre tipos en tiempo de ejecución.
Aprende más: Trait objects, Genéricos.
P: ¿Por qué match tiene que ser exhaustivo?
Sección titulada «P: ¿Por qué match tiene que ser exhaustivo?»Un match tiene que cubrir todos los valores posibles, o el código no compila. Esto significa que, cuando añades una variante nueva a un enum, el compilador te muestra todos los sitios que hay que actualizar. En otros lenguajes, un case olvidado en un switch simplemente no hace nada, sin avisar.
Puedes usar _ para capturar todo lo demás, pero con cuidado: ese comodín también oculta las variantes nuevas que añadas más adelante, así que pierdes esa red de seguridad.
Aprende más: Match, Pattern matching, Enums.
Manejo de errores
Sección titulada «Manejo de errores»P: ¿Cómo maneja Rust los errores sin excepciones?
Sección titulada «P: ¿Cómo maneja Rust los errores sin excepciones?»Los errores recuperables son valores normales de tipo Result<T, E>: o bien Ok(value), o bien Err(error). Una función que puede fallar lo indica en su tipo de retorno, y quien la llama tiene que encargarse. Nada salta de forma invisible hacia arriba en la pila, como hace una excepción.
Las ventajas: puedes ver en la firma de una función que podría fallar, y el compilador te avisa si ignoras un Result. El precio es escribir un poco más, algo que el operador ? elimina casi del todo.
Aprende más: Result.
P: ¿Cuándo deberías usar panic! en vez de devolver un Result?
Sección titulada «P: ¿Cuándo deberías usar panic! en vez de devolver un Result?»- Devuelve un
Resultcuando el fallo es esperable y quien llama podría manejarlo razonablemente: un archivo que no existe, una entrada incorrecta del usuario, un timeout de red. - Haz panic cuando algo es un bug, es decir, cuando el programa llegó a un estado que “debería ser imposible” y seguir sería un error. Acceder más allá del final de un vector provoca un panic precisamente por esto.
Los panics también están bien en tests, ejemplos y prototipos rápidos. En una biblioteca, evita hacer panic con entradas que controla quien la usa. Devuelve un error y deja que decida.
P: ¿Qué diferencia hay entre unwrap, expect y ??
Sección titulada «P: ¿Qué diferencia hay entre unwrap, expect y ??»unwrap()te da el valor que hay dentro deOk/Some, o hace panic con un mensaje genérico.expect("msg")hace lo mismo, pero el panic usa tu mensaje. Úsalo para explicar por qué crees que no puede fallar.?no hace panic. AnteErr/None, devuelve el error desde la función actual, para que quien la llamó lo maneje. La propia función tiene que devolver unResulto unOption.
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> { let n: i32 = text.parse()?; // si hay error, se devuelve a quien llamó Ok(n * 2)}
fn main() { println!("{:?}", double("21")); println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("un número escrito en el código siempre debería poder convertirse"); println!("{n}");}Salida:
Ok(42)Err(ParseIntError { kind: InvalidDigit })7En código de producción, prefiere ?. Usa expect cuando un fallo sería un bug, y deja unwrap para los tests y los experimentos rápidos.
P: ¿Cómo creas tus propios tipos de error?
Sección titulada «P: ¿Cómo creas tus propios tipos de error?»Normalmente con un enum que enumera las formas en que tu código puede fallar, más implementaciones de std::fmt::Display y std::error::Error. Si implementas From para otros tipos de error, ? puede convertirlos automáticamente.
En la práctica, muchos proyectos usan crates de ayuda: thiserror para definir tipos de error en bibliotecas, y anyhow para aplicaciones que solo necesitan pasar los errores hacia arriba con contexto. Para programas rápidos, Box<dyn Error> puede contener cualquier error.
Aprende más: Errores personalizados.
P: ¿Cuándo usarías anyhow y cuándo thiserror?
Sección titulada «P: ¿Cuándo usarías anyhow y cuándo thiserror?»Resuelven dos mitades del mismo problema, así que la respuesta sincera es “muchas veces, los dos”:
thiserrores para bibliotecas, o para cualquier código cuyos llamadores necesiten reaccionar a errores concretos. Escribes tu propio enum de errores y#[derive(Error)]rellena el código repetitivo deDisplayyError. Quien llama recibe un tipo de verdad sobre el que puede hacermatch, comoConfigError::MissingField.anyhowes para aplicaciones, donde la mayoría de los errores solo hay que informarlos, no manejarlos uno por uno. Todas las funciones devuelvenanyhow::Result<T>,?acepta casi cualquier tipo de error, y.context("al leer la configuración")añade encima una explicación fácil de entender. La contrapartida: quien llama ya no puede hacermatchsobre el error con facilidad.
Una configuración habitual es thiserror dentro de los crates de biblioteca y anyhow en main y en el resto del binario. Ninguno de los dos crates es magia. Ambos solo quitan código repetitivo sobre el trait estándar std::error::Error, así que siempre puedes escribir lo mismo a mano.
Aprende más: anyhow y thiserror, Errores personalizados.
Memoria y smart pointers
Sección titulada «Memoria y smart pointers»P: ¿Rust tiene recolector de basura?
Sección titulada «P: ¿Rust tiene recolector de basura?»No. La memoria se libera cuando su dueño sale del ámbito, en un punto que el compilador conoce de antemano (esto se llama RAII, y el método de limpieza es Drop). Eso te da un rendimiento predecible, sin pausas del recolector de basura.
Cuando de verdad necesitas ownership compartido, lo eliges tú de forma explícita con conteo de referencias (Rc o Arc), que libera el valor cuando desaparece el último dueño.
Aprende más: Ownership, Smart pointers.
P: ¿Cuándo usarías Box, Rc, Arc y RefCell?
Sección titulada «P: ¿Cuándo usarías Box, Rc, Arc y RefCell?»Box<T>: un solo dueño, el valor está en el heap. Úsalo para tipos recursivos (como un árbol o una lista enlazada), valores grandes o trait objects (Box<dyn Trait>).Rc<T>: varios dueños en un solo hilo. Cuenta las referencias y libera el valor cuando el contador llega a cero. Los datos son de solo lectura.Arc<T>: comoRc, pero el contador es atómico, así que es seguro compartirlo entre hilos. Es un poco más lento queRc.RefCell<T>: te deja modificar datos a través de una referencia compartida (“mutabilidad interior”). Las reglas de borrowing se comprueban en tiempo de ejecución en vez de al compilar, y romperlas provoca un panic.
Se pueden combinar. Rc<RefCell<T>> es “compartido y modificable, en un solo hilo”. Arc<Mutex<T>> es la versión segura para hilos.
use std::cell::RefCell;use std::rc::Rc;
fn main() { let shared = Rc::new(RefCell::new(vec![1, 2])); let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow()); println!("dueños: {}", Rc::strong_count(&shared));}Salida:
[1, 2, 3]dueños: 2Aprende más: Smart pointers.
P: ¿Se puede tener una fuga de memoria en Rust seguro?
Sección titulada «P: ¿Se puede tener una fuga de memoria en Rust seguro?»Sí, y es honesto reconocerlo. Rust garantiza la seguridad de memoria (nada de usar memoria ya liberada, nada de punteros colgantes, nada de carreras de datos), pero una fuga de memoria no se considera insegura. Dos formas habituales:
- Un ciclo de
Rc: dos valores que se apuntan entre sí conRcnunca llegan a un contador de cero. La solución es que una de las dos direcciones sea una referenciaWeak. - Funciones como
Box::leakystd::mem::forget, que provocan la fuga a propósito.
En el código del día a día las fugas son raras, porque el ownership libera casi todo automáticamente.
Aprende más: Smart pointers.
P: ¿Qué es unsafe y cuándo se usa?
Sección titulada «P: ¿Qué es unsafe y cuándo se usa?»unsafe marca un bloque (o una función) donde puedes hacer cinco cosas extra que el compilador no puede comprobar, como desreferenciar punteros crudos, llamar a funciones unsafe (incluido código C a través de FFI) o acceder a variables estáticas mutables. Dentro de ese bloque, tú prometes que se cumplen las reglas.
Importante: unsafe no desactiva el borrow checker ni las demás comprobaciones. Solo desbloquea esas pocas operaciones extra.
Se usa para comunicarse con bibliotecas de C, escribir estructuras de datos de bajo nivel y exprimir el rendimiento cuando el compilador no puede demostrar que algo es seguro. La propia biblioteca estándar usa unsafe dentro de APIs seguras como Vec. Buenas prácticas: mantén los bloques unsafe pequeños, envuélvelos en una API segura y escribe un comentario // SAFETY: que explique por qué es correcto. La mayoría del código de aplicaciones nunca lo necesita.
Aprende más: Unsafe Rust.
Concurrencia y async
Sección titulada «Concurrencia y async»P: ¿Qué significan Send y Sync?
Sección titulada «P: ¿Qué significan Send y Sync?»Son traits marcadores que usa el compilador para que los hilos sean seguros:
Send: un valor de este tipo se puede mover a otro hilo.Sync: un valor de este tipo se puede compartir por referencia entre hilos (&TesSend).
La mayoría de los tipos son ambas cosas, y el compilador los implementa automáticamente. La excepción famosa es Rc, cuyo contador no es seguro entre hilos. Intenta enviar uno a un hilo y no compilará:
use std::rc::Rc;use std::thread;
fn main() { let data = Rc::new(5); let handle = thread::spawn(move || { println!("{data}"); }); handle.join().unwrap();}error[E0277]: `Rc<i32>` cannot be sent between threads safely ... = help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`El mensaje dice que Rc<i32> no se puede enviar entre hilos de forma segura, porque no implementa Send.
Cambia Rc por Arc y funciona. Esto es buena parte de lo que la gente quiere decir con la “fearless concurrency” (concurrencia sin miedo) de Rust: los errores de seguridad entre hilos son errores de compilación.
Aprende más: Concurrencia.
P: ¿Cómo compartes datos modificables entre hilos?
Sección titulada «P: ¿Cómo compartes datos modificables entre hilos?»La respuesta clásica es Arc<Mutex<T>>. Arc permite que varios hilos sean dueños del valor. Mutex se asegura de que solo un hilo a la vez pueda modificarlo: llamas a .lock() y obtienes un guard que se desbloquea solo cuando sale del ámbito.
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new();
for _ in 0..4 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { *counter.lock().unwrap() += 1; } })); }
for handle in handles { handle.join().unwrap(); }
println!("Total: {}", *counter.lock().unwrap());}Salida:
Total: 4000Menciona también la alternativa: en vez de compartir memoria, enviar mensajes entre hilos con canales (std::sync::mpsc). Y para contadores sencillos, los tipos atómicos como AtomicUsize evitan el lock.
Aprende más: Concurrencia.
P: ¿En qué se diferencian los canales del estado compartido con Mutex?
Sección titulada «P: ¿En qué se diferencian los canales del estado compartido con Mutex?»Son dos estilos de repartir trabajo entre hilos:
- Los canales (channels,
std::sync::mpsc) pasan mensajes. Un valor se mueve al canal y sale por el otro lado, así que solo un hilo es su dueño en cada momento. Encajan en pipelines y grupos de trabajadores: “los trabajadores producen resultados y un hilo los recoge”. Suelen ser más fáciles de razonar, porque nunca hay dos hilos tocando los mismos datos. - El estado compartido (
Arc<Mutex<T>>) significa que varios hilos usan los mismos datos, por turnos, a través de un candado (lock). Encaja cuando muchos hilos necesitan leer y actualizar una misma estructura, como una caché o una tabla de contadores. Los riesgos son los deadlocks (bloqueos mutuos) y la lentitud cuando los hilos se pasan el rato esperando el candado.
Aquí tienes el contador de la respuesta anterior, reescrito con un canal. Cada trabajador cuenta por su cuenta, sin candado, y envía su resultado:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for _ in 0..4 { let tx = tx.clone(); thread::spawn(move || { let mut count = 0; for _ in 0..1000 { count += 1; } tx.send(count).unwrap(); }); } drop(tx); // suelta el emisor original, o rx.iter() nunca termina
let total: i32 = rx.iter().sum(); println!("Total: {total}");}Salida:
Total: 4000La línea drop(tx) es un detalle estupendo para mencionar: rx.iter() sigue esperando hasta que desaparecen todos los emisores, así que si la olvidas, el programa se queda colgado. En los dos estilos, Rust comprueba la seguridad entre hilos al compilar. La elección depende de qué diseño es más claro para tu problema, y muchos programas usan los dos.
Aprende más: Canales (channels), Concurrencia.
P: ¿Rust evita todos los errores de concurrencia?
Sección titulada «P: ¿Rust evita todos los errores de concurrencia?»No, y decirlo demuestra que lo entiendes de verdad. Rust seguro evita las carreras de datos (dos hilos tocando la misma memoria a la vez, con al menos uno escribiendo). No evita:
- Los deadlocks (bloqueos mutuos), por ejemplo dos hilos esperando cada uno un lock que tiene el otro.
- Las condiciones de carrera en tu lógica, por ejemplo comprobar un valor y luego actuar según él cuando otro hilo ya lo ha cambiado.
Así que Rust elimina toda una categoría de errores difíciles, pero aun así tienes que diseñar bien cómo usas los locks.
Aprende más: Concurrencia.
P: ¿Qué diferencia hay entre async y los hilos?
Sección titulada «P: ¿Qué diferencia hay entre async y los hilos?»- Los hilos los gestiona el sistema operativo. Cada uno tiene su propia pila, y el sistema operativo va alternando entre ellos. Son ideales para trabajo que usa mucho la CPU. Pero miles de hilos consumen mucha memoria.
- Las tareas async son mucho más ligeras. Una
async fndevuelve unFuture, y un runtime (normalmente Tokio) ejecuta muchos futures en unos pocos hilos. Cuando una tarea está esperando (a la red, a un temporizador, a un archivo),.awaitdeja que se ejecuten otras tareas. Es ideal para programas que esperan mucho, como servidores web que atienden miles de conexiones.
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String { sleep(Duration::from_millis(ms)).await; // simulamos que esperamos a la red format!("{name} terminó")}
#[tokio::main]async fn main() { // Las dos se ejecutan a la vez, así que esto tarda unos 200 ms, no 300 ms. let (a, b) = tokio::join!(fetch("primera", 200), fetch("segunda", 100)); println!("{a}"); println!("{b}");}Salida:
primera terminósegunda terminóDos detalles honestos que les gustan a los entrevistadores: los futures de Rust son perezosos y no hacen nada hasta que se les hace .await, y la biblioteca estándar no incluye un runtime, así que eliges tú uno. Además, el código bloqueante (como un cálculo largo o std::thread::sleep) dentro de una tarea async bloquea las demás tareas de ese hilo.
Aprende más: Async/Await, Concurrencia.
Consejos para responder en una entrevista
Sección titulada «Consejos para responder en una entrevista»- Empieza simple y luego profundiza. Da primero una respuesta de una frase (“
Rces un puntero con conteo de referencias para compartir ownership en un solo hilo”). Después añade detalles si ves que les interesa. - Explica el porqué. Las reglas de Rust existen para evitar errores concretos. Decir “las reglas de borrowing evitan las carreras de datos y los punteros colgantes” es más sólido que solo recitar las reglas.
- Usa un ejemplo pequeño. Escribir tres líneas de código, o describirlas, a menudo explica más que un discurso largo.
- Sé honesto con las contrapartidas. Rust tiene costes: compilaciones más lentas, una curva de aprendizaje y más código para manejar errores. Reconocerlo demuestra experiencia, no debilidad.
- Di “no lo sé” cuando no lo sepas, y luego razona en voz alta. “No he usado
Pindirectamente, pero sé que tiene que ver con futures que no se deben mover en memoria” es una respuesta estupenda. - Ten una historia preparada. Los entrevistadores suelen preguntar: “Cuéntame alguna vez que el borrow checker te frenó”. Piensa en un ejemplo real de tu propio código y en lo que aprendiste de él.
- Practica escribiendo código. La página de Ejercicios y los proyectos del Juego de adivinanzas y la Lista de tareas son un buen calentamiento.
Comprueba lo que aprendiste
Sección titulada «Comprueba lo que aprendiste»Comprueba lo aprendido
1. ¿Qué smart pointer usarías para compartir datos de solo lectura entre hilos?
2. ¿Qué hace el operador ? cuando se encuentra un Err?
3. ¿Cuál de estas cosas evita Rust seguro al compilar?
4. ¿Las anotaciones de lifetime cambian cuánto vive un valor?
Puntuación: 0 / 4
Para leer más
Sección titulada «Para leer más»- The Rust Programming Language (el Rust Book) (en inglés), también disponible en su traducción al español de la comunidad: el libro oficial. La mayoría de las respuestas de esta página salen directamente de sus capítulos sobre ownership, traits, errores y concurrencia.
- Rust by Example (en inglés): ejemplos cortos que puedes ejecutar, muy útiles para repasar la sintaxis justo antes de una entrevista.
- The Rust Reference (en inglés): las reglas precisas del lenguaje, para cuando el entrevistador pregunte “pero ¿exactamente cuándo pasa eso?”.
- The Rustonomicon (en inglés): la guía de Rust
unsafe, con más contexto sobreSend,Syncy la disposición en memoria para preguntas de nivel senior. - Asynchronous Programming in Rust (en inglés): cómo encajan los futures,
async/.awaity los runtimes, en el libro oficial de async del proyecto Rust.