Exploración Avanzada de Traits en Rust

RustRustBeginner
Practicar Ahora

This tutorial is from open-source community. Access the source code

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

Bienvenido a Advanced Traits. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, profundizaremos en los detalles más avanzados de los traits que se cubrieron anteriormente en "Traits: Defining Shared Behavior", ahora que tienes una mejor comprensión de Rust.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/integer_types("Integer Types") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/DataTypesGroup -.-> rust/type_casting("Type Conversion and Casting") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/AdvancedTopicsGroup -.-> rust/traits("Traits") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100448{{"Exploración Avanzada de Traits en Rust"}} rust/integer_types -.-> lab-100448{{"Exploración Avanzada de Traits en Rust"}} rust/string_type -.-> lab-100448{{"Exploración Avanzada de Traits en Rust"}} rust/type_casting -.-> lab-100448{{"Exploración Avanzada de Traits en Rust"}} rust/function_syntax -.-> lab-100448{{"Exploración Avanzada de Traits en Rust"}} rust/expressions_statements -.-> lab-100448{{"Exploración Avanzada de Traits en Rust"}} rust/method_syntax -.-> lab-100448{{"Exploración Avanzada de Traits en Rust"}} rust/traits -.-> lab-100448{{"Exploración Avanzada de Traits en Rust"}} rust/operator_overloading -.-> lab-100448{{"Exploración Avanzada de Traits en Rust"}} end

Advanced Traits

Primero cubrimos los traits en "Traits: Defining Shared Behavior", pero no discutimos los detalles más avanzados. Ahora que sabes más sobre Rust, podemos entrar en los detalles.

Tipos Asociados

Los tipos asociados conectan un tipo de marcador de posición con un trait de modo que las definiciones de métodos del trait pueden usar estos tipos de marcador de posición en sus firmas. El implementador de un trait especificará el tipo concrete que se usará en lugar del tipo de marcador de posición para una implementación particular. De esta manera, podemos definir un trait que use algunos tipos sin necesidad de conocer exactamente cuáles son esos tipos hasta que se implemente el trait.

Hemos descrito la mayoría de las características avanzadas de este capítulo como raras veces necesarias. Los tipos asociados se encuentran en un punto intermedio: se usan con menos frecuencia que las características explicadas en el resto del libro, pero más comúnmente que muchas de las otras características discutidas en este capítulo.

Un ejemplo de un trait con un tipo asociado es el trait Iterator que provee la biblioteca estándar. El tipo asociado se llama Item y representa el tipo de los valores sobre los que está iterando el tipo que implementa el trait Iterator. La definición del trait Iterator se muestra en la Lista 19-12.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

Lista 19-12: La definición del trait Iterator que tiene un tipo asociado Item

El tipo Item es un marcador de posición, y la definición del método next muestra que devolverá valores del tipo Option<Self::Item>. Los implementadores del trait Iterator especificarán el tipo concrete para Item, y el método next devolverá un Option que contiene un valor de ese tipo concrete.

Los tipos asociados pueden parecer un concepto similar a los genéricos, en el sentido de que estos últimos nos permiten definir una función sin especificar qué tipos puede manejar. Para examinar la diferencia entre estos dos conceptos, veremos una implementación del trait Iterator en un tipo llamado Counter que especifica que el tipo Item es u32:

Nombre de archivo: src/lib.rs

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        --snip--

Esta sintaxis parece comparable a la de los genéricos. Entonces, ¿por qué no simplemente definir el trait Iterator con genéricos, como se muestra en la Lista 19-13?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

Lista 19-13: Una definición hipotética del trait Iterator usando genéricos

La diferencia es que al usar genéricos, como en la Lista 19-13, debemos anotar los tipos en cada implementación; porque también podemos implementar Iterator<``String``> para Counter o cualquier otro tipo, podríamos tener múltiples implementaciones de Iterator para Counter. En otras palabras, cuando un trait tiene un parámetro genérico, se puede implementar para un tipo múltiples veces, cambiando los tipos concrete de los parámetros de tipo genérico cada vez. Cuando usamos el método next en Counter, tendríamos que proporcionar anotaciones de tipo para indicar qué implementación de Iterator queremos usar.

Con los tipos asociados, no necesitamos anotar los tipos porque no podemos implementar un trait para un tipo múltiples veces. En la Lista 19-12 con la definición que usa tipos asociados, podemos elegir cuál será el tipo de Item solo una vez porque solo puede haber una impl Iterator for Counter. No tenemos que especificar que queremos un iterador de valores de u32 en todos los lugares donde llamamos a next en Counter.

Los tipos asociados también se convierten en parte del contrato del trait: los implementadores del trait deben proporcionar un tipo para sustituir el marcador de posición del tipo asociado. Los tipos asociados a menudo tienen un nombre que describe cómo se usará el tipo, y documentar el tipo asociado en la documentación de la API es una buena práctica.

Parámetros de Tipo Genéricos Predeterminados y Sobrecarga de Operadores

Cuando usamos parámetros de tipo genéricos, podemos especificar un tipo concrete predeterminado para el tipo genérico. Esto elimina la necesidad de que los implementadores del trait especifiquen un tipo concrete si el tipo predeterminado funciona. Se especifica un tipo predeterminado al declarar un tipo genérico con la sintaxis <TipoMarcador=TipoConcreto>.

Un gran ejemplo de una situación en la que esta técnica es útil es con la sobrecarga de operadores, en la que se personaliza el comportamiento de un operador (como +) en situaciones particulares.

Rust no te permite crear tus propios operadores o sobrecargar operadores arbitrarios. Pero puedes sobrecargar las operaciones y los traits correspondientes enumerados en std::ops al implementar los traits asociados al operador. Por ejemplo, en la Lista 19-14 sobrecargamos el operador + para sumar dos instancias de Point. Hacemos esto al implementar el trait Add en una struct Point.

Nombre de archivo: src/main.rs

use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}

Lista 19-14: Implementando el trait Add para sobrecargar el operador + para instancias de Point

El método add suma los valores de x de dos instancias de Point y los valores de y de dos instancias de Point para crear un nuevo Point. El trait Add tiene un tipo asociado llamado Output que determina el tipo devuelto por el método add.

El tipo genérico predeterminado en este código está dentro del trait Add. Aquí está su definición:

trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}

Este código debería parecer generalmente familiar: un trait con un método y un tipo asociado. La parte nueva es Rhs=Self: esta sintaxis se llama parámetros de tipo predeterminados. El parámetro de tipo genérico Rhs (abreviatura de "lado derecho") define el tipo del parámetro rhs en el método add. Si no especificamos un tipo concrete para Rhs cuando implementamos el trait Add, el tipo de Rhs tendrá como valor predeterminado Self, que será el tipo en el que estamos implementando Add.

Cuando implementamos Add para Point, usamos el valor predeterminado para Rhs porque queríamos sumar dos instancias de Point. Veamos un ejemplo de implementación del trait Add donde queremos personalizar el tipo Rhs en lugar de usar el valor predeterminado.

Tenemos dos structs, Millimeters y Meters, que almacenan valores en diferentes unidades. Este envoltorio delgado de un tipo existente en otra struct se conoce como el patrón newtype, que describiremos con más detalle en "Usando el Patrón newtype para Implementar Traits Externos en Tipos Externos". Queremos sumar valores en milímetros a valores en metros y que la implementación de Add haga la conversión correctamente. Podemos implementar Add para Millimeters con Meters como Rhs, como se muestra en la Lista 19-15.

Nombre de archivo: src/lib.rs

use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}

Lista 19-15: Implementando el trait Add en Millimeters para sumar Millimeters y Meters

Para sumar Millimeters y Meters, especificamos impl Add<Meters> para establecer el valor del parámetro de tipo Rhs en lugar de usar el valor predeterminado de Self.

Usarás los parámetros de tipo predeterminados de dos maneras principales:

  1. Para extender un tipo sin romper el código existente
  2. Para permitir la personalización en casos específicos que la mayoría de los usuarios no necesitarán

El trait Add de la biblioteca estándar es un ejemplo del segundo propósito: por lo general, sumarás dos tipos similares, pero el trait Add proporciona la capacidad de personalizar más allá de eso. Usar un parámetro de tipo predeterminado en la definición del trait Add significa que no tienes que especificar el parámetro extra la mayor parte del tiempo. En otras palabras, no se necesita un poco de código de implementación repetitivo, lo que hace que sea más fácil usar el trait.

El primer propósito es similar al segundo pero al revés: si quieres agregar un parámetro de tipo a un trait existente, puedes darle un valor predeterminado para permitir la extensión de la funcionalidad del trait sin romper el código de implementación existente.

Desambiguación entre Métodos con el Mismo Nombre

En Rust, nada impide que un trait tenga un método con el mismo nombre que un método de otro trait, ni tampoco impide que implementes ambos traits en un mismo tipo. También es posible implementar un método directamente en el tipo con el mismo nombre que los métodos de los traits.

Cuando llamas a métodos con el mismo nombre, necesitarás decirle a Rust cuál quieres usar. Considera el código de la Lista 19-16 donde hemos definido dos traits, Pilot y Wizard, que ambos tienen un método llamado fly. Luego implementamos ambos traits en un tipo Human que ya tiene un método llamado fly implementado en él. Cada método fly hace algo diferente.

Nombre de archivo: src/main.rs

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

Lista 19-16: Se definen dos traits para tener un método fly y se implementan en el tipo Human, y se implementa un método fly en Human directamente.

Cuando llamamos a fly en una instancia de Human, el compilador por defecto llama al método que está directamente implementado en el tipo, como se muestra en la Lista 19-17.

Nombre de archivo: src/main.rs

fn main() {
    let person = Human;
    person.fly();
}

Lista 19-17: Llamando a fly en una instancia de Human

Ejecutando este código imprimirá *waving arms furiously*, lo que muestra que Rust llamó al método fly implementado directamente en Human.

Para llamar a los métodos fly del trait Pilot o del trait Wizard, necesitamos usar una sintaxis más explícita para especificar qué método fly queremos decir. La Lista 19-18 demuestra esta sintaxis.

Nombre de archivo: src/main.rs

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

Lista 19-18: Especificando qué método fly de qué trait queremos llamar

Especificar el nombre del trait antes del nombre del método aclara a Rust qué implementación de fly queremos llamar. También podríamos escribir Human::fly(&person), lo que es equivalente a person.fly() que usamos en la Lista 19-18, pero esto es un poco más largo de escribir si no necesitamos desambiguar.

Ejecutando este código imprime lo siguiente:

This is your captain speaking.
Up!
*waving arms furiously*

Debido a que el método fly toma un parámetro self, si tuviéramos dos tipos que ambos implementan un trait, Rust podría determinar qué implementación de un trait usar basado en el tipo de self.

Sin embargo, las funciones asociadas que no son métodos no tienen un parámetro self. Cuando hay múltiples tipos o traits que definen funciones no métodos con el mismo nombre de función, Rust no siempre sabe qué tipo quieres decir a menos que uses la sintaxis completamente cualificada. Por ejemplo, en la Lista 19-19 creamos un trait para un refugio de animales que quiere llamar a todos los cachorros Spot. Creamos un trait Animal con una función asociada no método baby_name. El trait Animal se implementa para la struct Dog, en la que también proporcionamos una función asociada no método baby_name directamente.

Nombre de archivo: src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}

Lista 19-19: Un trait con una función asociada y un tipo con una función asociada del mismo nombre que también implementa el trait

Implementamos el código para llamar a todos los cachorros Spot en la función asociada baby_name que está definida en Dog. El tipo Dog también implementa el trait Animal, que describe las características que todos los animales tienen. Los cachorros se llaman cachorros, y eso se expresa en la implementación del trait Animal en Dog en la función baby_name asociada al trait Animal.

En main, llamamos a la función Dog::baby_name, que llama directamente a la función asociada definida en Dog. Este código imprime lo siguiente:

A baby dog is called a Spot

Esta salida no es lo que queremos. Queremos llamar a la función baby_name que es parte del trait Animal que implementamos en Dog para que el código imprima A baby dog is called a puppy. La técnica de especificar el nombre del trait que usamos en la Lista 19-18 no ayuda aquí; si cambiamos main al código de la Lista 19-20, obtendremos un error de compilación.

Nombre de archivo: src/main.rs

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}

Lista 19-20: Intentando llamar a la función baby_name del trait Animal, pero Rust no sabe qué implementación usar

Debido a que Animal::baby_name no tiene un parámetro self, y podría haber otros tipos que implementen el trait Animal, Rust no puede determinar qué implementación de Animal::baby_name queremos. Obtendremos este error del compilador:

error[E0283]: type annotations needed
  --> src/main.rs:20:43
   |
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^ cannot infer
type
   |
   = note: cannot satisfy `_: Animal`

Para desambiguar y decirle a Rust que queremos usar la implementación de Animal para Dog en lugar de la implementación de Animal para algún otro tipo, necesitamos usar la sintaxis completamente cualificada. La Lista 19-21 demuestra cómo usar la sintaxis completamente cualificada.

Nombre de archivo: src/main.rs

fn main() {
    println!(
        "A baby dog is called a {}",
        <Dog as Animal>::baby_name()
    );
}

Lista 19-21: Usando la sintaxis completamente cualificada para especificar que queremos llamar a la función baby_name del trait Animal como implementado en Dog

Estamos proporcionando a Rust una anotación de tipo dentro de los corchetes angulares, lo que indica que queremos llamar al método baby_name del trait Animal como implementado en Dog al decir que queremos tratar el tipo Dog como un Animal para esta llamada de función. Este código ahora imprimirá lo que queremos:

A baby dog is called a puppy

En general, la sintaxis completamente cualificada se define como sigue:

<Type as Trait>::function(receiver_if_method, next_arg,...);

Para funciones asociadas que no son métodos, no habría un receiver: solo habría la lista de otros argumentos. Podrías usar la sintaxis completamente cualificada en todos los lugares donde llamas a funciones o métodos. Sin embargo, se te permite omitir cualquier parte de esta sintaxis que Rust pueda deducir de otras informaciones en el programa. Solo necesitas usar esta sintaxis más verbosa en casos donde hay múltiples implementaciones que usan el mismo nombre y Rust necesita ayuda para identificar qué implementación quieres llamar.

Usando Supertraits

A veces, es posible que escribas una definición de trait que depende de otro trait: para que un tipo implemente el primer trait, quieres exigir que ese tipo también implemente el segundo trait. Lo harías para que la definición de tu trait pueda aprovechar los elementos asociados del segundo trait. El trait en el que se basa la definición de tu trait se llama supertrait de tu trait.

Por ejemplo, digamos que queremos crear un trait OutlinePrint con un método outline_print que imprimirá un valor dado con un formato que lo rodee de asteriscos. Es decir, dado una struct Point que implementa el trait Display de la biblioteca estándar para obtener (x, y), cuando llamamos a outline_print en una instancia de Point que tiene 1 para x y 3 para y, debería imprimir lo siguiente:

**********
*        *
* (1, 3) *
*        *
**********

En la implementación del método outline_print, queremos usar la funcionalidad del trait Display. Por lo tanto, necesitamos especificar que el trait OutlinePrint solo funcionará para tipos que también implementen Display y proporcionen la funcionalidad que OutlinePrint necesita. Lo podemos hacer en la definición del trait especificando OutlinePrint: Display. Esta técnica es similar a agregar un límite de trait al trait. La Lista 19-22 muestra una implementación del trait OutlinePrint.

Nombre de archivo: src/main.rs

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {} *", output);
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

Lista 19-22: Implementando el trait OutlinePrint que requiere la funcionalidad de Display

Debido a que hemos especificado que OutlinePrint requiere el trait Display, podemos usar la función to_string que se implementa automáticamente para cualquier tipo que implemente Display. Si intentáramos usar to_string sin agregar dos puntos y especificar el trait Display después del nombre del trait, obtendríamos un error que dice que no se encontró ningún método llamado to_string para el tipo &Self en el ámbito actual.

Veamos qué pasa cuando intentamos implementar OutlinePrint en un tipo que no implementa Display, como la struct Point:

Nombre de archivo: src/main.rs

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

Obtenemos un error que dice que se requiere Display pero no está implementado:

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:6
   |
20 | impl OutlinePrint for Point {}
   |      ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for
pretty-print) instead
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

Para solucionar esto, implementamos Display en Point y cumplimos con la restricción que OutlinePrint requiere, así:

Nombre de archivo: src/main.rs

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

Luego, implementar el trait OutlinePrint en Point se compilará correctamente, y podemos llamar a outline_print en una instancia de Point para mostrarla dentro de un contorno de asteriscos.

Usando el Patrón newtype para Implementar Traits Externos

En "Implementando un Trait en un Tipo", mencionamos la regla de huérfano que establece que solo se nos permite implementar un trait en un tipo si el trait o el tipo, o ambos, son locales a nuestro crate. Es posible circunvenir esta restricción usando el patrón newtype, que implica crear un nuevo tipo en una struct tupla. (Cubrimos las structs tupla en "Usando Structs Tupla Sin Campos con Nombre para Crear Diferentes Tipos".) La struct tupla tendrá un solo campo y será un envoltorio delgado del tipo para el cual queremos implementar un trait. Luego, el tipo envoltorio es local a nuestro crate, y podemos implementar el trait en el envoltorio. Newtype es un término que proviene del lenguaje de programación Haskell. No hay penalización de rendimiento en tiempo de ejecución al usar este patrón, y el tipo envoltorio se omite en tiempo de compilación.

Como ejemplo, digamos que queremos implementar Display en Vec<T>, lo cual la regla de huérfano nos impide hacer directamente porque el trait Display y el tipo Vec<T> se definen fuera de nuestro crate. Podemos crear una struct Wrapper que contiene una instancia de Vec<T>; luego podemos implementar Display en Wrapper y usar el valor de Vec<T>, como se muestra en la Lista 19-23.

Nombre de archivo: src/main.rs

use std::fmt;

struct Wrapper(Vec<String>);

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

fn main() {
    let w = Wrapper(vec![
        String::from("hello"),
        String::from("world"),
    ]);
    println!("w = {w}");
}

Lista 19-23: Creando un tipo Wrapper alrededor de Vec<String> para implementar Display

La implementación de Display usa self.0 para acceder a la Vec<T> interna porque Wrapper es una struct tupla y Vec<T> es el elemento en el índice 0 de la tupla. Luego podemos usar la funcionalidad del tipo Display en Wrapper.

La desventaja de usar esta técnica es que Wrapper es un nuevo tipo, por lo que no tiene los métodos del valor que está conteniendo. Tendríamos que implementar todos los métodos de Vec<T> directamente en Wrapper de modo que los métodos deleguen en self.0, lo que nos permitiría tratar a Wrapper exactamente como una Vec<T>. Si quisiéramos que el nuevo tipo tuviera todos los métodos que tiene el tipo interno, implementar el trait Deref en Wrapper para devolver el tipo interno sería una solución (discutimos la implementación del trait Deref en "Tratando Punteros Inteligentes Como Referencias Normales con Deref"). Si no quisiéramos que el tipo Wrapper tuviera todos los métodos del tipo interno -por ejemplo, para restringir el comportamiento del tipo Wrapper- tendríamos que implementar solo los métodos que realmente queremos manualmente.

Este patrón newtype también es útil incluso cuando no se involucran traits. Cambiemos de enfoque y veamos algunas maneras avanzadas de interactuar con el sistema de tipos de Rust.

Resumen

¡Felicidades! Has completado el laboratorio de Traits Avanzados. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.