Javier Valencia Javier Valencia
Ilustración retrofuturista de estética synthwave: un círculo, un cuadrado y un triángulo de neón, cada uno con la misma clavija, encajan en un zócalo común con tres huecos idénticos, bajo el rótulo <T> y con un sol a rayas a la izquierda

Traits y genéricos en Rust: contratos explícitos, despacho estático y dinámico

Javier Valencia · · 8 min de lectura · 2 visitas · Desarrollo
rust go traits genericos programacion desarrollo tutorial

En Ownership en Rust y Borrowing en Rust conté cómo decide Rust quién es dueño de cada valor y quién puede tocarlo mientras tanto. Por el camino aparecieron, sin presentarse, unas cuantas palabras raras: Copy, Clone, Drop, Send. Todas son traits, y hoy toca explicar qué son.

Durante años usé Go para todo lo que no hacía con Rails, y la pieza equivalente allí es la interfaz: un conjunto de métodos que un tipo cumple sin declararlo. En Rust el contrato se firma con todas las letras, y esa diferencia arrastra todo lo demás: los genéricos, cuándo se paga una llamada indirecta y qué puedes implementar y dónde. Todo está compilado con Rust 1.98 y la edición 2024, y los errores son la salida real de cargo, recortada donde sobra.

Un trait es un contrato que se firma

Un trait declara métodos que un tipo se compromete a tener. Puede traer además métodos por defecto, con cuerpo, que el tipo hereda si no los reescribe:

trait Resumen {
    fn titulo(&self) -> &str;

    // Método por defecto: quien implemente el trait lo hereda gratis.
    fn resumen(&self) -> String {
        format!("{} (leer más)", self.titulo())
    }
}

struct Post {
    titulo: String,
    palabras: usize,
}

struct Pagina {
    titulo: String,
}

impl Resumen for Post {
    fn titulo(&self) -> &str {
        &self.titulo
    }

    fn resumen(&self) -> String {
        format!("{}, {} min de lectura", self.titulo, self.palabras / 200)
    }
}

impl Resumen for Pagina {
    fn titulo(&self) -> &str {
        &self.titulo
    }
}

Un Post de 2.400 palabras imprime «Traits y genéricos en Rust, 12 min de lectura»; una Pagina se conforma con el resumen por defecto, «Sobre mí (leer más)». En Go la misma idea se escribe sin impl:

type Resumen interface {
	Resumen() string
}

type Post struct{ Titulo string }

// Ningún "implements": basta con tener el método.
func (p Post) Resumen() string { return "post: " + p.Titulo }

var _ Resumen = Post{} // comprobación opcional en compilación

La satisfacción implícita de Go tiene una ventaja real: tu tipo cumple interfaces definidas después, en otro paquete, sin enterarse. La de Rust tiene otras. La intención queda escrita: un método que se llama igual por casualidad no convierte a nadie en Resumen. Hay métodos por defecto, que en Go no existen y se suplen con funciones sueltas o con embedding. Y, como el impl es un bloque aparte del tipo, puedes implementar tu trait para tipos que no son tuyos, incluso String o Vec<T>. Go no te deja añadir métodos a un tipo de otro paquete: cannot define new methods on non-local type time.Duration.

Genéricos con trait bounds

Una función genérica en Rust no puede hacer nada con un T que el trait no prometa. Ni siquiera imprimirlo:

fn etiqueta<T>(valor: T) -> String {
    format!("[{valor}]")
}
error[E0277]: `T` doesn't implement `std::fmt::Display`
 --> src/main.rs:2:15
  |
2 |     format!("[{valor}]")
  |               ^^^^^^^ `T` cannot be formatted with the default formatter
  |
help: consider restricting type parameter `T` with trait `Display`
  |
1 | fn etiqueta<T: std::fmt::Display>(valor: T) -> String {
  |              +++++++++++++++++++

E0277, trait bound not satisfied, es el error que más verás con genéricos, y aquí es instructivo dónde salta: en la definición, aunque nadie haya llamado a la función. A diferencia de las plantillas de C++, el cuerpo de una función genérica se comprueba contra sus restricciones, no contra los tipos con los que se use. Si compila, compila para cualquier T que las cumpla, y los errores de quien la use saltan en la llamada.

La restricción, el trait bound, admite tres sintaxis:

use std::fmt::{Debug, Display};

fn etiqueta<T: Display>(valor: T) -> String {
    format!("[{valor}]")
}

fn depurar<T, U>(clave: T, valor: U) -> String
where
    T: Display,
    U: Debug + Clone,
{
    format!("{clave} = {valor:?}")
}

fn etiqueta_corta(valor: impl Display) -> String {
    format!("[{valor}]")
}

struct Post {
    slug: &'static str,
    borrador: bool,
}

fn publicados(posts: &[Post]) -> impl Iterator<Item = &str> {
    posts.iter().filter(|p| !p.borrador).map(|p| p.slug)
}

T: Display va en línea; where es lo mismo, más legible cuando hay varios parámetros o varios bounds unidos con +; e impl Display en un argumento es azúcar para un genérico anónimo. En el retorno, impl Trait significa otra cosa: la función devuelve un único tipo concreto que el llamador no puede nombrar, solo usar como iterador. Es la forma normal de devolver una cadena de filter y map, cuyo tipo real nadie quiere escribir.

En Go, desde la 1.18, la restricción es una interfaz entre corchetes: func Etiqueta[T fmt.Stringer](v T) string. La idea es la misma. Lo que cambia es lo que ocurre al compilar.

Despacho estático o dinámico

Rust monomorfiza: por cada tipo concreto con el que llamas a una función genérica genera una copia especializada, como si la hubieras escrito a mano para ese tipo. El libro lo llama static dispatch, porque el compilador sabe en tiempo de compilación qué método se llama y puede integrarlo en línea.

Cuando necesitas mezclar tipos distintos en la misma colección, la monomorfización no basta: un Vec<T> tiene un solo T. Para eso existen los trait objects, dyn Trait:

// Estático: el compilador genera una copia por cada tipo concreto.
fn imprimir(item: &impl Resumen) {
    println!("{}", item.resumen());
}

fn main() {
    imprimir(&Post { titulo: "Traits en Rust".into() });

    // Dinámico: un mismo Vec con tipos distintos detrás de un puntero.
    let portada: Vec<Box<dyn Resumen>> = vec![
        Box::new(Post { titulo: "Ownership en Rust".into() }),
        Box::new(Pagina { titulo: "Sobre mí".into() }),
    ];
    for item in &portada {
        println!("{}", item.resumen()); // se resuelve en ejecución, vía vtable
    }

    println!("&Post: {} bytes", size_of::<&Post>());
    println!("&dyn Resumen: {} bytes", size_of::<&dyn Resumen>());
}
post: Traits en Rust
post: Ownership en Rust
página: Sobre mí
&Post: 8 bytes
&dyn Resumen: 16 bytes

Una referencia a un trait object es un puntero ancho: un puntero al dato y otro a la vtable, la tabla con las direcciones de los métodos de ese tipo. Cada llamada busca el método en la tabla en tiempo de ejecución, y el libro avisa de que eso impide al compilador integrar el código en línea y las optimizaciones que vienen detrás. Box<dyn Resumen> es lo mismo, pero dueño del valor en el montículo.

Esos 16 bytes le sonarán a quien venga de Go: un valor de interfaz también son dos palabras, un puntero al itab, con el tipo y sus métodos, y otro al dato, y unsafe.Sizeof de una interfaz da 16 en 64 bits. La diferencia es de valor por defecto. En Go, llamar a un método a través de una interfaz es despacho dinámico, salvo que el compilador logre desvirtualizar la llamada: lo hace en algunos casos en los que ve el tipo concreto y, desde Go 1.21, con PGO, que añade una llamada directa al tipo más habitual. Y sus genéricos no monomorfizan del todo: generan una implementación por GC shape y le pasan en cada llamada un diccionario con la información de tipos; todos los punteros comparten forma. En Rust el camino por defecto es el estático, y dyn es una decisión explícita que se ve en la firma.

La monomorfización tampoco es gratis. Cada combinación de tipos es código nuevo que LLVM tiene que optimizar, y el resultado ocupa sitio en el binario. Una función genérica grande usada con veinte tipos son, en principio, veinte funciones. Cuando eso pesa, dyn cambia algo de velocidad en ejecución por menos código. Lo razonable es empezar por genéricos y pasar a dyn cuando necesites colecciones heterogéneas, plugins o frenar el crecimiento del binario.

No todo trait puede ser dyn

Para construir la vtable, cada método tiene que tener una única dirección. Un método genérico tiene una por tipo, así que no cabe:

use std::io::{self, Write};

trait Exportable {
    fn exportar<W: Write>(&self, salida: W) -> io::Result<()>;
}
error[E0038]: the trait `Exportable` is not dyn compatible
  --> src/main.rs:16:27
   |
16 |     let cola: Vec<Box<dyn Exportable>> = vec![Box::new(Post)];
   |                           ^^^^^^^^^^ `Exportable` is not dyn compatible
   |
note: for a trait to be dyn compatible it needs to allow building a vtable
  --> src/main.rs:4:8
   |
 3 | trait Exportable {
   |       ---------- this trait is not dyn compatible...
 4 |     fn exportar<W: Write>(&self, salida: W) -> io::Result<()>;
   |        ^^^^^^^^ ...because method `exportar` has generic type parameters

Si has leído sobre Rust hace unos años, esto se llamaba object safety. La referencia del lenguaje y el compilador lo llaman ahora dyn compatibility, y la referencia mantiene una nota que recuerda el nombre antiguo. Las reglas impiden, entre otras cosas, métodos con parámetros de tipo, métodos que usen Self fuera del receptor, constantes asociadas o Self: Sized como supertrait. Hay dos salidas: cambiar el genérico por un trait object, fn exportar(&self, salida: &mut dyn Write), o añadir where Self: Sized al método, que lo saca de la vtable y deja el resto del trait usable como dyn.

Traits de la biblioteca estándar que conviene conocer

De Copy y Clone ya hablé en el post de ownership. Estos son los siguientes de la lista, casi todos en un ejemplo:

use std::fmt;

#[derive(Debug, Clone, PartialEq, Default)]
struct Slug(String);

impl From<&str> for Slug {
    fn from(titulo: &str) -> Self {
        let palabras: Vec<String> = titulo.split_whitespace().map(str::to_lowercase).collect();
        Slug(palabras.join("-"))
    }
}

impl fmt::Display for Slug {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "/post/{}", self.0)
    }
}

fn main() {
    let a = Slug::from("Traits y genéricos en Rust");
    let b: Slug = "traits y GENÉRICOS en rust".into(); // Into sale gratis de From
    println!("{a}"); // Display, escrito a mano
    println!("{b:?}"); // Debug, derivado
    println!("{}", a == b); // PartialEq, derivado
    println!("{:?}", Slug::default()); // Default, derivado
}
/post/traits-y-genéricos-en-rust
Slug("traits-y-genéricos-en-rust")
true
Slug("")
  • Display y Debug: {} es para personas y se escribe a mano; {:?} es para programadores y casi siempre se deriva.
  • #[derive] genera la implementación de Debug, Clone, PartialEq, Default y compañía campo a campo, siempre que todos los campos los implementen.
  • From e Into: implementas From<&str> for Slug y obtienes Into<Slug> for &str gratis, porque la biblioteca estándar tiene un blanket impl, una implementación para todo tipo que cumpla una condición. Por eso la documentación pide implementar siempre From. Lo mismo pasa con ToString: cualquier tipo con Display tiene .to_string().

El otro trait que hay que conocer es Iterator. Basta con implementar next para recibir gratis los métodos por defecto: map, filter, skip, sum, collect y decenas más. Las páginas de la portada de este blog, de cinco en cinco:

struct Paginas {
    actual: usize,
    total: usize,
}

impl Iterator for Paginas {
    type Item = usize;

    fn next(&mut self) -> Option<Self::Item> {
        if self.actual == self.total {
            return None;
        }
        self.actual += 1;
        Some(self.actual)
    }
}

fn main() {
    let total = 23_usize.div_ceil(5); // 23 posts, 5 por página
    let urls: Vec<String> = Paginas { actual: 0, total }
        .skip(1)
        .map(|n| format!("/?page={n}"))
        .collect();
    println!("{urls:?}"); // ["/?page=2", "/?page=3", "/?page=4", "/?page=5"]
}

next devuelve un Option, que es Some(valor) mientras queda algo y None al final; le dedico el próximo post.

Tipos asociados frente a parámetros genéricos

Fíjate en type Item = usize. Item es un tipo asociado: lo elige cada implementación, una sola vez. Podría haberse diseñado como Iterator<T>, y el libro explica por qué no: con un parámetro genérico, un mismo tipo podría implementar Iterator<usize> e Iterator<String> a la vez, y cada llamada a next necesitaría una anotación para saber cuál usar. Con el tipo asociado solo puede haber un impl Iterator for Paginas, y el compilador sabe qué sale de él.

From<T>, en cambio, es genérico a propósito: String implementa From<&str>, From<char>, From<Box<str>> y varios más, porque tiene sentido construir un String desde muchos orígenes. La regla práctica: parámetro genérico si un tipo puede implementar el trait varias veces con sentido; tipo asociado si para cada tipo hay una única respuesta natural.

Coherencia, huérfanos y newtype

Para que a == b o x.to_string() signifiquen siempre lo mismo, Rust exige coherencia: para cada par de trait y tipo hay, como mucho, una implementación en todo el programa. La herramienta que la garantiza entre crates es la regla del huérfano (orphan rule): solo puedes implementar un trait si el trait o el tipo son de tu crate. Implementar uno ajeno para un tipo ajeno no se puede:

use std::fmt;

impl fmt::Display for Vec<String> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "#{}", self.join(" #"))
    }
}
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
 --> src/main.rs:3:1
  |
3 | impl fmt::Display for Vec<String> {
  | ^^^^^^^^^^^^^^^^^^^^^^-----------
  |                       |
  |                       `Vec` is not defined in the current crate
  |
  = note: define and implement a trait or new type instead

La razón, según la referencia, es que si se permitiera, dos crates podrían implementar el mismo trait para el mismo tipo de formas incompatibles, y actualizar una dependencia podría romper la compilación de un programa que no ha cambiado.

La nota del final propone la salida estándar, el patrón newtype: un struct tupla con un único campo, que es tuyo y por tanto admite el impl:

struct Tags(Vec<String>);

impl fmt::Display for Tags {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "#{}", self.0.join(" #"))
    }
}

Con rust, go y traits dentro imprime #rust #go #traits. El envoltorio no cuesta nada en ejecución, porque el compilador lo elimina; el precio es que Tags no tiene los métodos de Vec, y hay que delegarlos o implementar Deref. El Slug(String) de antes era otro newtype, en ese caso para que un slug y un título no se confundan aunque ambos sean texto.

Lo curioso es que Go llega al mismo sitio por otro camino. No permite métodos en tipos de otro paquete, así que para dar un String() a un []string también defines type Tags []string. La diferencia es que en Go el problema solo afecta a los métodos; en Rust afecta a los traits, que son los que deciden si {} o == funcionan con tu tipo.

Lo que viene

Resumiendo: un trait es un contrato explícito; los genéricos se restringen con bounds que se comprueban en la definición; Rust monomorfiza por defecto y solo despacha en ejecución cuando escribes dyn; y la regla del huérfano mantiene la coherencia, con el newtype como salida. Frente a Go, ganas control sobre cuándo pagas una indirección y pierdes la comodidad de cumplir interfaces sin decirlo.

En los ejemplos de hoy se ha colado dos veces un tipo que no he explicado: el Option que devuelve next y el io::Result de exportar. Son enums, y junto con el pattern matching y el operador ? forman la forma en que Rust maneja la ausencia y los errores. De eso va Option y Result en Rust, el lunes.