Ir al contenido

7. Traits y genéricos

Ejemplo completo: examples/l07_traits_generics.rscargo run --example l07_traits_generics.

trait Shape {
fn area(&self) -> f64;
fn name(&self) -> String { // método por defecto
String::from("shape")
}
}
struct Circle { radius: f64 }
struct Square { side: f64 }
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
fn name(&self) -> String {
format!("circle r={}", self.radius)
}
}
impl Shape for Square {
fn area(&self) -> f64 {
self.side * self.side
}
// conserva el name() por defecto
}
Rust C# Java
trait Shape { … } interface IShape { … } interface Shape { … }
método por defecto en el trait método de interfaz predeterminado (C# 8) método default (Java 8)
impl Shape for Circle { … } — un bloque aparte class Circle : IShape class Circle implements Shape
sin herencia entre structs herencia de clases herencia de clases

La implementación vive en su propio bloque impl, no en la declaración del tipo. Ese detalle importa: permite implementar un trait para un tipo a posteriori, incluso para un tipo que no escribiste tú (consulta métodos de extensión más abajo).

Omitir un método obligatorio es un error de compilación:

error[E0046]: not all trait items implemented, missing: `area`
--> e07_missing_method.rs:12:1
|
2 | fn area(&self) -> f64;
| ---------------------- `area` from trait
...
12 | impl Shape for Square {
| ^^^^^^^^^^^^^^^^^^^^^ missing `area` in implementation

Una función genérica debe decir qué traits implementa su parámetro de tipo — el equivalente de where T : IShape o de <T extends Shape>:

fn total_area<T: Shape>(shapes: &[T]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
let circles = [Circle { radius: 1.0 }, Circle { radius: 2.0 }];
println!("total circle area = {:.2}", total_area(&circles)); // 15.71

Sin la restricción (bound), Rust no supone nada sobre T:

error[E0599]: no method named `area` found for reference `&T` in the current scope
--> e07_missing_bound.rs:6:29
|
6 | shapes.iter().map(|s| s.area()).sum()
| ^^^^ method not found in `&T`
|
= help: items from traits can only be used if the type parameter is bounded by the trait
help: the following trait defines an item `area`, perhaps you need to restrict type parameter `T` with it:
|
5 | fn total_area<T: Shape>(shapes: &[T]) -> f64 {
| +++++++

Otras dos formas de escribir la misma idea:

// `impl Trait` en posición de argumento: atajo para un parámetro genérico
fn describe(shape: &impl Shape) -> String {
format!("{} has area {:.2}", shape.name(), shape.area())
}
// cláusula `where`: más legible con varias restricciones
fn print_all<T>(items: &[T])
where
T: fmt::Display + PartialOrd,
{
// …
}
3 9 4 (max 9)
pear apple fig (max pear)

total_area::<Circle> y total_area::<Square> se compilan como dos funciones distintas, cada una de las cuales llama directamente a area y puede insertarse en línea (inlining). Esto se llama monomorfización (monomorphization).

Cuando necesitas una colección de tipos distintos, usa un objeto trait (trait object), dyn Shape, detrás de un puntero como Box o &:

fn largest(shapes: &[Box<dyn Shape>]) -> Option<&dyn Shape> {
shapes
.iter()
.map(|s| s.as_ref())
.max_by(|a, b| a.area().total_cmp(&b.area()))
}
let mixed: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.5 }), Box::new(Square { side: 2.0 })];
// largest: circle r=1.5 (7.07)
Genéricos T: Shape Objeto trait dyn Shape
Se resuelve en tiempo de compilación en tiempo de ejecución, mediante una vtable
Tipos mezclados en un mismo Vec no
Coste ninguno en tiempo de ejecución, binario más grande una llamada indirecta, como una llamada a través de una interfaz
Analogía en C# genéricos sobre struct (especializados por el JIT) llamada a través de IShape
Analogía en Java — (los genéricos se borran y quedan en casts) llamada a través de Shape

Usa genéricos por defecto; recurre a dyn Trait cuando de verdad necesites valores heterogéneos o quieras ocultar el tipo concreto. No todos los traits se pueden usar como dyn: un trait con métodos genéricos, por ejemplo, no es dyn-compatible (antes llamado «object-safe»).

Gran parte de lo que C# pone en System.Object o en los operadores es un trait en Rust:

Trait de Rust C# Java Normalmente
Debug visualización en el depurador #[derive(Debug)]
Display ToString() toString() implementado a mano
Clone ICloneable clone() derivado
PartialEq / Eq Equals / == equals derivado
Hash GetHashCode hashCode derivado
PartialOrd / Ord IComparable<T> Comparable<T> derivado
Default constructor sin parámetros constructor sin argumentos derivado
From / Into operadores de conversión fábrica estática implementado a mano
Add, Mul, … operator + implementado a mano
#[derive(Debug, Default, PartialEq)]
struct Celsius(f64);
struct Fahrenheit(f64);
impl fmt::Display for Celsius {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:.1}°C", self.0)
}
}
impl From<Fahrenheit> for Celsius {
fn from(f: Fahrenheit) -> Self {
Celsius((f.0 - 32.0) * 5.0 / 9.0)
}
}
let body = Celsius::from(Fahrenheit(98.6));
let also: Celsius = Fahrenheit(212.0).into(); // Into viene gratis con From
println!("{body} / {also} / default {}", Celsius::default());
// 37.0°C / 100.0°C / default 0.0°C

En C#, un método de extensión añade Shout() a string. En Rust, defines un trait y lo implementas para el tipo existente:

trait Shout {
fn shout(&self) -> String;
}
impl Shout for str {
fn shout(&self) -> String {
format!("{}!", self.to_uppercase())
}
}
println!("{}", "hello".shout()); // HELLO!

Como con el using de C# para los métodos de extensión, el trait debe estar en el ámbito (use) allí donde lo llamas.

Puedes implementar tu trait para cualquier tipo, o cualquier trait para tu tipo — pero no el trait de otro para el tipo de otro:

impl fmt::Display for Vec<i32> { /* … */ }
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
--> e07_orphan.rs:3:1
|
3 | impl fmt::Display for Vec<i32> {
| ^^^^^^^^^^^^^^^^^^^^^^--------
| |
| `Vec` is not defined in the current crate
|
= note: impl doesn't have any local type before any uncovered type parameters
= note: for more information see https://doc.rust-lang.org/reference/items/implementations.html#orphan-rules
= note: define and implement a trait or new type instead

Esto garantiza que dos crates nunca puedan proporcionar implementaciones en conflicto. La solución habitual es el newtype de la lección 5: envolver el tipo ajeno en tu propio struct.

  • Los traits son interfaces con métodos por defecto, implementadas en bloques impl aparte.
  • El código genérico debe declarar lo que necesita mediante restricciones (T: Shape, impl Shape, where).
  • Los genéricos se resuelven en tiempo de compilación; dyn Trait ofrece polimorfismo en tiempo de ejecución cuando necesitas tipos mezclados.
  • Display, Clone, PartialEq, Default y From sustituyen a ToString, ICloneable, Equals, los constructores y las conversiones.
  • Implementar un trait para un tipo existente sustituye a los métodos de extensión, dentro de los límites de la regla del huérfano.
  1. Define un trait Priced con fn price(&self) -> f64 y un método por defecto fn price_with_tax(&self, rate: f64) -> f64. Impleméntalo para Book { title: String, price: f64 } y escribe fn cheapest<T: Priced>(items: &[T]) -> Option<&T>.
Solución
trait Priced {
fn price(&self) -> f64;
fn price_with_tax(&self, rate: f64) -> f64 {
self.price() * (1.0 + rate)
}
}
struct Book {
title: String,
price: f64,
}
impl Priced for Book {
fn price(&self) -> f64 {
self.price
}
}
fn cheapest<T: Priced>(items: &[T]) -> Option<&T> {
items.iter().min_by(|a, b| a.price().total_cmp(&b.price()))
}
let books = [
Book { title: "Rust".into(), price: 40.0 },
Book { title: "C#".into(), price: 35.0 },
];
assert_eq!(cheapest(&books).map(|b| b.title.as_str()), Some("C#"));
assert_eq!(books[0].price_with_tax(0.25), 50.0);

Se usa total_cmp porque f64 no tiene un orden total (NaN), así que min_by_key no se puede usar directamente con flotantes.

  1. Añade struct Coffee { size_ml: u32 } con un precio de 0.01 por ml. Escribe fn total(items: &[Box<dyn Priced>]) -> f64 sobre un vector que mezcle libros y cafés. ¿Por qué cheapest del ejercicio 1 no puede recibir ese mismo vector como &[T] con T = Book?
Solución
struct Coffee {
size_ml: u32,
}
impl Priced for Coffee {
fn price(&self) -> f64 {
self.size_ml as f64 * 0.01
}
}
fn total(items: &[Box<dyn Priced>]) -> f64 {
items.iter().map(|i| i.price()).sum()
}
let basket: Vec<Box<dyn Priced>> = vec![
Box::new(Book { title: "Rust".into(), price: 40.0 }),
Box::new(Coffee { size_ml: 250 }),
];
assert_eq!(total(&basket), 42.5);

Un &[T] genérico exige que todos los elementos sean del mismo tipo concreto T. Un Vec<Box<dyn Priced>> contiene tipos distintos detrás de un mismo objeto trait, que es justo para lo que sirve el despacho dinámico. Para reutilizar cheapest con la cesta, T tendría que ser Box<dyn Priced> — lo cual funciona en cuanto reenvías el trait a la caja:

impl Priced for Box<dyn Priced> {
fn price(&self) -> f64 {
(**self).price()
}
}
let cheapest_item = cheapest(&basket).map(|i| i.price());
assert_eq!(cheapest_item, Some(2.5));
  1. impl fmt::Display for Vec<i32> se rechaza (E0117). Imprime una lista de puntuaciones con la forma "3 scores: 12, 7, 30" usando en su lugar un newtype.
Solución
use std::fmt;
struct Scores(Vec<i32>);
impl fmt::Display for Scores {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let list: Vec<String> = self.0.iter().map(|s| s.to_string()).collect();
write!(f, "{} scores: {}", self.0.len(), list.join(", "))
}
}
assert_eq!(Scores(vec![12, 7, 30]).to_string(), "3 scores: 12, 7, 30");

Scores es un tipo local, así que está permitido implementar para él el trait ajeno Display. Todo tipo que implementa Display obtiene también to_string().