wandres.dev
TRAITS · comportamiento compartido

Definir e implementar un trait: capacidades, no jerarquías

Un trait declara un contrato de comportamiento —un conjunto de firmas de método— que muchos tipos sin parentesco pueden cumplir. Cómo se define con trait, cómo se implementa con impl para un tipo, y por qué pensar en capacidades sustituye a la herencia.

⏱ 18 min

Ya has derivado traits como Debug y Clone, y los has consumido sin nombrarlos: cada + invoca a Add, cada bucle for a IntoIterator. Ahora inviertes el papel. Vas a declarar una capacidad nueva y a enseñar a tus tipos a cumplirla. Un trait es un contrato de comportamiento desacoplado de todo tipo concreto, y definirlo e implementarlo es el gesto fundacional del que cuelga toda la abstracción de Rust.

🎯 Al terminar esta lección sabrás
  • Definir un trait con trait como un conjunto de firmas de método.
  • Implementar un trait para un tipo con impl Trait for Tipo.
  • Distinguir los receptores &self, &mut self y self.
  • Razonar en términos de capacidades en lugar de jerarquías de herencia.

El trait como contrato

Un trait declara qué sabe hacer un tipo, nunca cómo. Su cuerpo es una lista de firmas terminadas en punto y coma:

trait Resumible {
    fn resumen(&self) -> String;
}

Esto no es un tipo que puedas instanciar ni un valor que guardes en una variable; es un contrato. Nombra una capacidad —“saber resumirse”— mediante una única obligación: todo tipo que quiera ser Resumible deberá aportar un método resumen que tome &self y devuelva un String. El punto y coma en lugar de un cuerpo es deliberado: el trait enuncia la obligación, no su cumplimiento. Es, literalmente, una promesa que otros tipos firmarán.

Un contrato puede declarar varias obligaciones a la vez, y sus métodos reciben parámetros como cualquier función:

trait Reproducible {
    fn duracion_segundos(&self) -> u32;
    fn reproducir(&self, volumen: u8);
    fn titulo(&self) -> String;
}

Cada firma es una casilla que el implementador tendrá que rellenar. Cuantas más obligaciones declare el trait, más exige de quien lo firme; en la próxima lección veremos cómo aliviar esa carga dando a algunos métodos un cuerpo por defecto que se hereda gratis.

Implementar el contrato

Un bloque impl Trait for Tipo es la firma de ese contrato: aporta los cuerpos que el trait dejó pendientes.

struct Articulo { titulo: String, cuerpo: String }
struct Tweet { autor: String, texto: String }

impl Resumible for Articulo {
    fn resumen(&self) -> String {
        format!("{} ({} caracteres)", self.titulo, self.cuerpo.len())
    }
}

impl Resumible for Tweet {
    fn resumen(&self) -> String {
        format!("@{}: {}", self.autor, self.texto)
    }
}

Dos structs sin ancestro común, sin relación alguna entre sí, adquieren la misma capacidad. No hay una superclase de la que ambos “hereden”: cada uno declara por separado que sabe cumplir el contrato, y el compilador registra ese hecho. Una vez firmado, el método se invoca como cualquier otro:

fn main() {
    let a = Articulo { titulo: String::from("Rust 2024"), cuerpo: String::from("...") };
    let t = Tweet { autor: String::from("ada"), texto: String::from("hola mundo") };
    println!("{}", a.resumen());   // Rust 2024 (3 caracteres)
    println!("{}", t.resumen());   // @ada: hola mundo
}
ℹ️
El trait debe estar en el ámbito

Un método de trait solo es visible si su trait lo es. Si Resumible vive en otro módulo, tendrás que traerlo con use ruta::Resumible; aunque nunca escribas su nombre de forma explícita: sin ese use, a.resumen() falla con “no method named resumen”. Rust separa la identidad del método (su nombre) de la capacidad que lo autoriza (el trait), y exige que la capacidad esté a la vista.

Los receptores: &self, &mut self, self

La primera palabra de cada firma no es un detalle sintáctico: declara la relación de propiedad entre el método y el valor. Es lo primero que un lector debe interpretar.

trait Documento {
    fn titulo(&self) -> &str;             // lee: préstamo compartido
    fn renombrar(&mut self, nuevo: String); // muta: préstamo exclusivo
    fn consumir(self) -> String;          // destruye: toma posesión
}

&self toma el valor por referencia compartida: el método solo lee, y puedes llamarlo cuantas veces quieras. &mut self pide un préstamo exclusivo: el método muta el estado y nadie más puede tocar el valor mientras dura. self —sin referencia— consume el valor: transfiere su propiedad al método, que puede desmantelarlo y devolver sus piezas; tras la llamada, el original ya no existe. Toda la disciplina de ownership de los niveles anteriores reaparece aquí, elevada al diseño de contratos: elegir el receptor es decidir qué le permites hacer al método con el dato.

Un tipo cumple el contrato aportando los tres cuerpos, cada uno respetando su receptor:

struct Nota { titulo: String }

impl Documento for Nota {
    fn titulo(&self) -> &str { &self.titulo }
    fn renombrar(&mut self, nuevo: String) { self.titulo = nuevo; }
    fn consumir(self) -> String { self.titulo }  // devuelve la pieza y disuelve la Nota
}

El compilador vigila que la firma del impl coincida con la del trait, receptor incluido: si declararas renombrar con &self en lugar de &mut self, el tipo dejaría de cumplir el contrato y el código no compilaría. La capacidad no es solo “tener un método con ese nombre”, sino tenerlo con exactamente la forma que el trait pactó.

flowchart TB
t[trait Resumible declara la capacidad resumen] --> a[impl para Articulo]
t --> b[impl para Tweet]
t --> c[impl para Informe]
a --> u[el codigo que exige Resumible los trata a todos por igual]
b --> u
c --> u

Programación orientada a traits

En la orientación a objetos clásica, un tipo es lo que su cadena de herencia dicta: los datos y el comportamiento nacen soldados en la misma declaración de clase. Rust rompe esa soldadura. Un struct define solo datos; sus capacidades se declaran aparte, en bloques impl que pueden vivir en otro punto del archivo, en otro módulo o incluso —con límites— en otro crate. La pregunta deja de ser “¿de qué clase desciende este tipo?” y pasa a ser “¿qué traits implementa?”, es decir, “¿qué sabe hacer?”.

Este giro —de la identidad a la capacidad— es la programación orientada a traits. Compones comportamiento haciendo que un tipo firme varios contratos independientes (Debug + Clone + Resumible + Ord) en vez de encajarlo en una única jerarquía rígida. No hay problema del diamante, no hay clases base infladas, no hay super: solo un conjunto abierto de capacidades que cada tipo elige adquirir.

#[derive(Debug, Clone)]      // dos capacidades derivadas
struct Producto { nombre: String, precio: f64 }

impl Resumible for Producto { // una tercera, manual, en su propio bloque
    fn resumen(&self) -> String {
        format!("{} a {:.2}", self.nombre, self.precio)
    }
}

Producto compone ahora tres capacidades independientes —Debug, Clone y Resumible— sin descender de ninguna clase. Cada impl es un contrato firmado por separado; añadir uno nuevo nunca obliga a tocar los demás ni la definición del struct.

💡
Los impl pueden vivir separados del tipo

No hace falta declarar las capacidades de un tipo junto a su struct. Cada impl Trait for Tipo puede estar en otro punto del archivo o en otro módulo, y agrupar el código por capacidad —todos los impl de un trait juntos— a menudo se lee mejor que agrupar por tipo. Esa separación entre la definición de los datos y la de sus comportamientos es justo lo que la herencia de clases no ofrece, y es la semilla de la implementación retroactiva que verás en la última lección de este nivel.

Un trait no es una clase: es un predicado sobre tipos

La tentación de quien viene de Java es leer trait como “interfaz” y impl como “implements”. Funciona como intuición de partida, pero se queda corta y te ocultará la mitad del poder. Un trait, en su sentido más profundo, es un predicado sobre tipos: una propiedad que un tipo cumple o no cumple, y por tanto una forma de clasificar el universo de los tipos por lo que saben hacer. Resumible no es una caja de la que heredas; es el conjunto de todos los tipos que saben resumirse, definido por comprensión. Esta es exactamente la idea de las typeclasses de Haskell, y difiere de las interfaces de la OO en un punto decisivo: la declaración del tipo y la declaración de su capacidad están separadas. En Java, una clase lista sus interfaces en el momento de nacer y ahí queda cerrada; en Rust puedes tomar un tipo que ya existe —incluso uno ajeno— y, más tarde y en otro lugar, declarar que cumple un trait. Esa apertura, la implementación retroactiva, es lo que convierte a los traits en el mecanismo unificador de todo el lenguaje: son a la vez las interfaces que restringen genéricos, la sobrecarga de operadores, los marker traits como Send y Sync que gobiernan la concurrencia, y el vocabulario con el que el compilador razona. Y todo se resuelve en compilación, sin coste: cuando un genérico exige T: Resumible, el compilador no consulta una tabla de métodos en ejecución, sino que demuestra que tu tipo pertenece al conjunto y sustituye la llamada por la implementación concreta. Interioriza el trait como conjunto, como predicado, como capacidad —no como caja de la que se hereda— y el resto del nivel 13 al 18 dejará de sorprenderte.

📝
Lo esencial

Un trait declara un contrato: firmas de método sin cuerpo. impl Trait for Tipo lo cumple, y dos tipos sin relación pueden firmar el mismo contrato sin heredar de nadie. El receptor (&self, &mut self o self) fija qué puede hacer el método con el valor. Pensar en capacidades (“qué sabe hacer”) en lugar de en jerarquías (“qué es”) es la mentalidad orientada a traits, y con ella se compone comportamiento en vez de heredarlo.

⚔️ Declara y firma un contrato
  1. Define un trait Area con un método area(&self) -> f64 e impleméntalo para un struct Circulo { radio: f64 } y un struct Rectangulo { base: f64, altura: f64 }.
  2. Escribe una función libre que reciba una referencia a algo con Area y la imprima; comprueba que sirve para ambos tipos.
  3. Añade al trait un método escalar(&mut self, factor: f64) con receptor &mut self e impleméntalo; observa por qué necesita el préstamo exclusivo.
  4. Mueve el trait a un módulo geometria y comprueba que sin use geometria::Area; el código de llamada deja de compilar.
  5. Explica con tus palabras por qué Circulo y Rectangulo pueden compartir la capacidad sin compartir ningún ancestro común.