Si vienes de otros lenguajes con herencia clásica, los traits de Rust chocan al principio. No hay clases padre ni jerarquías rígidas: hay comportamientos que un tipo puede implementar, y ya está. Esa sencillez es justo lo que hace que el código de Rust sea tan predecible una vez le coges el truco.
El problema es que muchos tutoriales te enseñan la sintaxis de trait e impl y ahí lo dejan, sin decirte cuáles son los traits que realmente te vas a encontrar leyendo cualquier crate mínimamente serio. Así que vamos a repasar los que de verdad importan, con ejemplos que puedes copiar y adaptar.
Display y Debug: dos formas de mostrar lo mismo
Rust separa desde el principio lo que le enseñas al usuario de lo que necesitas tú para depurar. Display es para humanos que usan tu programa; Debug es para ti, cuando algo no funciona y necesitas ver por dentro.
use std::fmt;
struct Usuario {
nombre: String,
edad: u32,
}
impl fmt::Display for Usuario {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ({} años)", self.nombre, self.edad)
}
}
fn main() {
let u = Usuario { nombre: "Marta".into(), edad: 34 };
println!("{}", u); // Marta (34 años)
println!("{:?}", u); // error de compilación: falta Debug
}
Fíjate en el último println!: si no derivas Debug con #[derive(Debug)], el compilador te lo va a recordar sin piedad. Y ahí está la gracia: Debug casi nunca lo escribes a mano, se deriva automáticamente. Display, en cambio, sí que lo implementas tú, porque decidir cómo se ve tu tipo de cara al usuario es una decisión de diseño, no algo mecánico.
From e Into: conversiones que no fallan nunca
Cuando conviertes un tipo en otro sin riesgo de error, Rust te da From e Into. Lo curioso es que solo necesitas implementar uno de los dos: si defines From<A> for B, el compilador te regala Into<B> for A sin que muevas un dedo.
struct IdPedido(u32);
impl From<IdPedido> for String {
fn from(id: IdPedido) -> String {
format!("PED-{:06}", id.0)
}
}
fn main() {
let id = IdPedido(42);
let texto: String = id.into();
println!("{}", texto); // PED-000042
}
Esto parece un detalle menor hasta que empiezas a manejar errores. El operador ? usa exactamente este mecanismo para convertir automáticamente un error de bajo nivel (por ejemplo, un io::Error) en el tipo de error unificado de tu aplicación, siempre que exista un From que lo permita. Es la razón por la que puedes propagar errores de crates distintos sin escribir un match en cada función.
TryFrom y TryInto: cuando la conversión puede salir mal
No todas las conversiones son seguras. Convertir un i32 en una edad válida puede fallar si te llega un número negativo o disparatado, así que aquí entra TryFrom, que devuelve un Result en vez de asumir que todo va a ir bien.
use std::convert::TryFrom;
#[derive(Debug)]
struct Edad(u8);
impl TryFrom<i32> for Edad {
type Error = String;
fn try_from(valor: i32) -> Result<Self, Self::Error> {
if (0..=120).contains(&valor) {
Ok(Edad(valor as u8))
} else {
Err(format!("edad inválida: {valor}"))
}
}
}
fn main() {
match Edad::try_from(150) {
Ok(e) => println!("{:?}", e),
Err(e) => println!("fallo: {e}"),
}
}
Igual que antes, implementar TryFrom te da TryInto de regalo. La regla mental es simple: si la conversión siempre funciona, From/Into; si puede romperse, TryFrom/TryInto. Nada de usar as para forzar el cambio de tipo y cruzar los dedos.
Clone y Copy: dos maneras muy distintas de duplicar
Aquí es donde mucha gente se hace la picha un lío al empezar con el borrow checker. Copy es una duplicación implícita y barata, pensada para tipos pequeños que caben en pila, como enteros o booleanos. Clone es explícita y puede implicar trabajo de verdad, como reservar memoria nueva para un String o un Vec.
#[derive(Copy, Clone, Debug)]
struct Punto { x: i32, y: i32 }
#[derive(Clone, Debug)]
struct Usuario { nombre: String }
fn main() {
let a = Punto { x: 1, y: 2 };
let b = a; // copia, a sigue siendo válido
println!("{:?} {:?}", a, b);
let u1 = Usuario { nombre: "Ana".into() };
let u2 = u1.clone(); // clonación explícita
println!("{} {}", u1.nombre, u2.nombre);
}
Punto puede ser Copy porque solo contiene enteros. Usuario no, porque un String guarda datos en el heap y copiarlo a lo tonto sería carísimo. Rust te obliga a llamar a .clone() precisamente para que esa decisión de coste quede visible en el código, en vez de escondida en una asignación cualquiera.
Default: un punto de partida razonable
Default te da una forma estándar de crear un valor inicial sensato. Combinado con ..Default::default() te ahorra escribir configuraciones enteras cuando solo quieres cambiar un par de campos.
#[derive(Debug)]
struct Config {
host: String,
puerto: u16,
debug: bool,
}
impl Default for Config {
fn default() -> Self {
Self { host: "localhost".into(), puerto: 8080, debug: false }
}
}
fn main() {
let dev = Config { puerto: 3000, debug: true, ..Default::default() };
println!("{:?}", dev);
}
Nada del otro mundo, pero es uno de esos traits que, una vez lo incorporas a tus propios tipos, no vuelves a escribir constructores repetitivos.
Deref y DerefMut: por qué Box<T> se comporta como T
Estos dos traits son los que permiten que un smart pointer como Box<T>, Rc<T> o Arc<T> se comporte, casi siempre, como si fuera el valor que envuelve. Gracias a la coerción de deref, puedes pasar un &String donde se espera un &str sin conversión manual.
use std::ops::Deref;
struct MiTexto(String);
impl Deref for MiTexto {
type Target = String;
fn deref(&self) -> &String { &self.0 }
}
fn imprimir(texto: &str) {
println!("{texto}");
}
fn main() {
let t = MiTexto("hola".into());
imprimir(&t); // funciona por deref coercion
println!("{}", t.len()); // len() viene de String, no de MiTexto
}
Es magia controlada: no estás heredando métodos, estás dejando que el compilador siga la cadena de referencias hasta encontrar el tipo que sí los tiene.
Iterator: la razón por la que casi no escribes bucles for con índices
Iterator es probablemente el trait que más veces vas a tocar sin darte cuenta. En vez de llevar la cuenta de posiciones a mano, describes qué quieres hacer con cada elemento y encadenas operaciones.
fn main() {
let numeros = vec![1, 2, 3, 4, 5, 6];
let resultado: Vec<i32> = numeros
.iter()
.filter(|&&n| n % 2 == 0)
.map(|&n| n * 10)
.take(2)
.collect();
println!("{:?}", resultado); // [20, 40]
}
Lo interesante es que esta cadena no crea vectores intermedios en cada paso: el compilador la compila a algo tan eficiente como el bucle equivalente escrito a mano. Es abstracción sin coste, que es más o menos el lema no oficial de Rust.
PartialEq, Eq, PartialOrd y Ord: comparar sin ambigüedad
Estos cuatro traits son los que hacen funcionar ==, <, > y el método .sort(). PartialEq te da igualdad básica; Eq garantiza que esa igualdad es coherente en todos los casos (por eso f64 no lo implementa, ya que NaN != NaN); PartialOrd permite comparar aunque algún par de valores no tenga un orden claro, y Ord exige que todos los valores sí lo tengan.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Puntuacion(u32);
fn main() {
let mut lista = vec![Puntuacion(70), Puntuacion(90), Puntuacion(80)];
lista.sort();
println!("{:?}", lista); // [Puntuacion(70), Puntuacion(80), Puntuacion(90)]
}
Con #[derive] te ahorras escribir la lógica de comparación campo por campo, que Rust hace en el orden en que declaraste los campos del struct.
AsRef y AsMut: aceptar varios tipos sin copiar nada
Cuando escribes una función genérica y quieres aceptar tanto un String como un &str sin forzar al que la llama a elegir, AsRef es tu aliado. Te da una referencia barata al tipo interno sin tomar posesión.
fn imprimir<T: AsRef<str>>(texto: T) {
println!("{}", texto.as_ref());
}
fn main() {
imprimir("literal");
imprimir(String::from("propietario"));
}
Lo verás mucho en firmas de funciones de la librería estándar, como en std::fs::File::open, que acepta cualquier cosa que implemente AsRef<Path>.
Fn, FnMut y FnOnce: los tres sabores de las closures
Aquí es donde muchos se atascan sin necesidad. La diferencia entre estos tres traits depende únicamente de qué hace la closure con lo que captura del entorno:
- Fn: puede llamarse varias veces sin tocar lo que capturó.
- FnMut: puede llamarse varias veces y sí puede mutar lo capturado.
- FnOnce: solo puede llamarse una vez, porque consume algo que capturó.
fn llamar_varias_veces<F: FnMut()>(mut f: F) {
f();
f();
}
fn main() {
let mut contador = 0;
let incrementar = || {
contador += 1;
println!("van {contador}");
};
llamar_varias_veces(incrementar);
}
Hay una jerarquía entre ellos: toda closure Fn también es FnMut, y toda FnMut también es FnOnce. Cuando diseñes una función que reciba una closure como parámetro, pide siempre la cota más laxa que necesites. Si con FnOnce te vale, no exijas Fn: así el que te llama tiene más margen y no le obligas a que su closure sea reutilizable si no lo necesita.
Lo que merece la pena recordar
No hace falta memorizar la firma exacta de cada trait. Con tener claro para qué sirve cada uno, ya lees código ajeno con mucha más soltura: Display/Debug para representar valores, From/Into y TryFrom/TryInto para conversiones seguras o falibles, Clone/Copy para saber qué te está costando duplicar algo, Default para arrancar con valores razonables, Deref para que los smart pointers se comporten con naturalidad, Iterator para procesar secuencias sin bucles manuales, la familia PartialEq/Ord para comparar y ordenar, AsRef/AsMut para aceptar tipos flexibles sin copiar, y Fn/FnMut/FnOnce para saber qué exigirle a una closure.
La próxima vez que abras el código de un crate y veas un impl From<...> o un #[derive(PartialOrd)], ya sabrás exactamente qué está pasando ahí debajo.
Imagen: Pexels / Markus Spiske
