Traits: Definiendo Comportamiento Compartido

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 Traits: Definiendo Comportamiento Compartido. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, exploraremos los traits como una forma de definir el comportamiento compartido en un tipo y de especificar los límites de traits para tipos genéricos.

Traits: Definiendo Comportamiento Compartido

Un trait define la funcionalidad que tiene un tipo particular y que puede compartir con otros tipos. Podemos usar traits para definir el comportamiento compartido de manera abstracta. Podemos usar límites de traits para especificar que un tipo genérico puede ser cualquier tipo que tenga cierto comportamiento.

Nota: Los traits son similares a una característica a menudo llamada interfaces en otros lenguajes, aunque con algunas diferencias.

Definiendo un Trait

El comportamiento de un tipo está compuesto por los métodos que podemos llamar en ese tipo. Diferentes tipos comparten el mismo comportamiento si podemos llamar a los mismos métodos en todos ellos. Las definiciones de traits son una forma de agrupar firmas de métodos para definir un conjunto de comportamientos necesarios para cumplir algún propósito.

Por ejemplo, digamos que tenemos múltiples structs que almacenan diferentes tipos y cantidades de texto: un struct NewsArticle que almacena una noticia en un lugar particular y un Tweet que puede tener, como máximo, 280 caracteres, junto con metadatos que indican si es un nuevo tweet, un retweet o una respuesta a otro tweet.

Queremos crear una caja de código de biblioteca de agregador de medios llamada aggregator que pueda mostrar resúmenes de datos que pueden estar almacenados en una instancia de NewsArticle o Tweet. Para hacer esto, necesitamos un resumen de cada tipo y solicitaremos ese resumen llamando al método summarize en una instancia. La Lista 10-12 muestra la definición de un trait público Summary que expresa este comportamiento.

Nombre de archivo: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String;
}

Lista 10-12: Un trait Summary que consta del comportamiento proporcionado por un método summarize

Aquí, declaramos un trait usando la palabra clave trait y luego el nombre del trait, que en este caso es Summary. También declaramos el trait como pub para que las cajas de código que dependen de esta caja de código puedan también utilizar este trait, como veremos en algunos ejemplos. Dentro de las llaves, declaramos las firmas de los métodos que describen los comportamientos de los tipos que implementan este trait, que en este caso es fn summarize(&self) -> String.

Después de la firma del método, en lugar de proporcionar una implementación dentro de las llaves, usamos un punto y coma. Cada tipo que implemente este trait debe proporcionar su propio comportamiento personalizado para el cuerpo del método. El compilador exigirá que cualquier tipo que tenga el trait Summary tendrá el método summarize definido con exactamente esta firma.

Un trait puede tener múltiples métodos en su cuerpo: las firmas de los métodos se listan una por línea y cada línea termina en un punto y coma.

Implementando un Trait en un Tipo

Ahora que hemos definido las firmas deseadas de los métodos del trait Summary, podemos implementarlo en los tipos de nuestro agregador de medios. La Lista 10-13 muestra una implementación del trait Summary en el struct NewsArticle que utiliza el titular, el autor y la ubicación para crear el valor de retorno de summarize. Para el struct Tweet, definimos summarize como el nombre de usuario seguido del texto completo del tweet, suponiendo que el contenido del tweet ya está limitado a 280 caracteres.

Nombre de archivo: src/lib.rs

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!(
            "{}, by {} ({})",
            self.headline,
            self.author,
            self.location
        )
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Lista 10-13: Implementando el trait Summary en los tipos NewsArticle y Tweet

Implementar un trait en un tipo es similar a implementar métodos regulares. La diferencia es que después de impl, ponemos el nombre del trait que queremos implementar, luego usamos la palabra clave for, y luego especificamos el nombre del tipo para el que queremos implementar el trait. Dentro del bloque impl, ponemos las firmas de los métodos que la definición del trait ha definido. En lugar de agregar un punto y coma después de cada firma, usamos llaves y llenamos el cuerpo del método con el comportamiento específico que queremos que los métodos del trait tengan para el tipo particular.

Ahora que la biblioteca ha implementado el trait Summary en NewsArticle y Tweet, los usuarios de la caja de código pueden llamar a los métodos del trait en instancias de NewsArticle y Tweet de la misma manera que llamamos a métodos regulares. La única diferencia es que el usuario debe traer el trait al ámbito, así como los tipos. Aquí hay un ejemplo de cómo una caja de código binaria podría usar nuestra caja de código de biblioteca aggregator:

use aggregator::{Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}

Este código imprime 1 new tweet: horse_ebooks: of course, as you probably already know, people.

Otras cajas de código que dependen de la caja de código aggregator también pueden traer el trait Summary al ámbito para implementar Summary en sus propios tipos. Una restricción a tener en cuenta es que solo podemos implementar un trait en un tipo si el trait o el tipo, o ambos, son locales a nuestra caja de código. Por ejemplo, podemos implementar traits de la biblioteca estándar como Display en un tipo personalizado como Tweet como parte de la funcionalidad de nuestra caja de código aggregator porque el tipo Tweet es local a nuestra caja de código aggregator. También podemos implementar Summary en Vec<T> en nuestra caja de código aggregator porque el trait Summary es local a nuestra caja de código aggregator.

Pero no podemos implementar traits externos en tipos externos. Por ejemplo, no podemos implementar el trait Display en Vec<T> dentro de nuestra caja de código aggregator porque Display y Vec<T> están ambos definidos en la biblioteca estándar y no son locales a nuestra caja de código aggregator. Esta restricción es parte de una propiedad llamada coherencia, y más específicamente la regla de los huérfanos, así llamada porque el tipo padre no está presente. Esta regla asegura que el código de otras personas no pueda romper tu código y viceversa. Sin la regla, dos cajas de código podrían implementar el mismo trait para el mismo tipo, y Rust no sabría qué implementación usar.

Implementaciones Predeterminadas

A veces es útil tener un comportamiento predeterminado para algunos o todos los métodos de un trait en lugar de requerir implementaciones para todos los métodos en cada tipo. Entonces, cuando implementamos el trait en un tipo particular, podemos mantener o anular el comportamiento predeterminado de cada método.

En la Lista 10-14, especificamos una cadena predeterminada para el método summarize del trait Summary en lugar de solo definir la firma del método, como hicimos en la Lista 10-12.

Nombre de archivo: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Leer más...)")
    }
}

Lista 10-14: Definiendo un trait Summary con una implementación predeterminada del método summarize

Para usar la implementación predeterminada para resumir instancias de NewsArticle, especificamos un bloque impl vacío con impl Summary for NewsArticle {}.

Aunque ya no estamos definiendo directamente el método summarize en NewsArticle, hemos proporcionado una implementación predeterminada y especificado que NewsArticle implementa el trait Summary. Como resultado, todavía podemos llamar al método summarize en una instancia de NewsArticle, como esto:

let article = NewsArticle {
    headline: String::from(
        "Penguins win the Stanley Cup Championship!"
    ),
    location: String::from("Pittsburgh, PA, USA"),
    author: String::from("Iceburgh"),
    content: String::from(
        "The Pittsburgh Penguins once again are the best \
         hockey team in the NHL.",
    ),
};

println!("New article available! {}", article.summarize());

Este código imprime New article available! (Leer más...).

Crear una implementación predeterminada no requiere que cambiemos nada en la implementación de Summary en Tweet en la Lista 10-13. La razón es que la sintaxis para anular una implementación predeterminada es la misma que la sintaxis para implementar un método de trait que no tiene una implementación predeterminada.

Las implementaciones predeterminadas pueden llamar a otros métodos en el mismo trait, incluso si esos otros métodos no tienen una implementación predeterminada. De esta manera, un trait puede proporcionar mucha funcionalidad útil y solo requerir que los implementadores especifiquen una pequeña parte de ella. Por ejemplo, podríamos definir el trait Summary para tener un método summarize_author cuya implementación es requerida, y luego definir un método summarize que tiene una implementación predeterminada que llama al método summarize_author:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!(
            "(Leer más de {}...)",
            self.summarize_author()
        )
    }
}

Para usar esta versión de Summary, solo necesitamos definir summarize_author cuando implementamos el trait en un tipo:

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Después de definir summarize_author, podemos llamar a summarize en instancias del struct Tweet, y la implementación predeterminada de summarize llamará a la definición de summarize_author que hemos proporcionado. Debido a que hemos implementado summarize_author, el trait Summary nos ha dado el comportamiento del método summarize sin que tengamos que escribir más código. Aquí está cómo se ve:

let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from(
        "of course, as you probably already know, people",
    ),
    reply: false,
    retweet: false,
};

println!("1 new tweet: {}", tweet.summarize());

Este código imprime 1 new tweet: (Leer más de @horse_ebooks...).

Tenga en cuenta que no es posible llamar a la implementación predeterminada desde una implementación que anula ese mismo método.

Traits como Parámetros

Ahora que sabes cómo definir e implementar traits, podemos explorar cómo usar traits para definir funciones que acepten muchos tipos diferentes. Usaremos el trait Summary que implementamos en los tipos NewsArticle y Tweet en la Lista 10-13 para definir una función notify que llame al método summarize en su parámetro item, que es de algún tipo que implementa el trait Summary. Para hacer esto, usamos la sintaxis impl Trait, como esto:

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

En lugar de un tipo concrete para el parámetro item, especificamos la palabra clave impl y el nombre del trait. Este parámetro acepta cualquier tipo que implemente el trait especificado. En el cuerpo de notify, podemos llamar a cualquier método en item que provenga del trait Summary, como summarize. Podemos llamar a notify y pasar cualquier instancia de NewsArticle o Tweet. El código que llama a la función con cualquier otro tipo, como una String o un i32, no se compilará porque esos tipos no implementan Summary.

Sintaxis de Límite de Trait

La sintaxis impl Trait funciona para casos sencillos, pero en realidad es azúcar sintáctico para una forma más larga conocida como límite de trait; se ve así:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

Esta forma más larga es equivalente al ejemplo de la sección anterior, pero es más verbosa. Colocamos los límites de trait con la declaración del parámetro de tipo genérico después de dos puntos y dentro de corchetes angulares.

La sintaxis impl Trait es conveniente y produce un código más conciso en casos simples, mientras que la sintaxis más completa de límite de trait puede expresar más complejidad en otros casos. Por ejemplo, podemos tener dos parámetros que implementen Summary. Hacer esto con la sintaxis impl Trait se ve así:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

Usar impl Trait es adecuado si queremos que esta función permita que item1 e item2 tengan diferentes tipos (siempre y cuando ambos tipos implementen Summary). Sin embargo, si queremos forzar a que ambos parámetros tengan el mismo tipo, entonces debemos usar un límite de trait, como esto:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

El tipo genérico T especificado como el tipo de los parámetros item1 e item2 restringe la función de modo que el tipo concrete del valor pasado como argumento para item1 e item2 debe ser el mismo.

Especificando Varios Límites de Trait con la Sintaxis +

También podemos especificar más de un límite de trait. Digamos que queremos que notify use la formateación de visualización así como summarize en item: especificamos en la definición de notify que item debe implementar tanto Display como Summary. Lo podemos hacer usando la sintaxis +:

pub fn notify(item: &(impl Summary + Display)) {

La sintaxis + también es válida con límites de trait en tipos genéricos:

pub fn notify<T: Summary + Display>(item: &T) {

Con los dos límites de trait especificados, el cuerpo de notify puede llamar a summarize y usar {} para formatear item.

Límites de Trait más Claros con Clausulas where

Usar demasiados límites de trait tiene sus inconvenientes. Cada tipo genérico tiene sus propios límites de trait, por lo que las funciones con múltiples parámetros de tipo genérico pueden contener mucha información de límites de trait entre el nombre de la función y su lista de parámetros, lo que hace que la firma de la función sea difícil de leer. Por esta razón, Rust tiene una sintaxis alternativa para especificar límites de trait dentro de una cláusula where después de la firma de la función. Entonces, en lugar de escribir esto:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

podemos usar una cláusula where, como esto:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{

La firma de esta función es menos desordenada: el nombre de la función, la lista de parámetros y el tipo de retorno están juntos, similar a una función sin muchos límites de trait.

Devolviendo Tipos que Implementan Traits

También podemos usar la sintaxis impl Trait en la posición de retorno para devolver un valor de algún tipo que implemente un trait, como se muestra aquí:

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    }
}

Al usar impl Summary para el tipo de retorno, especificamos que la función returns_summarizable devuelve algún tipo que implementa el trait Summary sin nombrar el tipo concrete. En este caso, returns_summarizable devuelve un Tweet, pero el código que llama a esta función no necesita saber eso.

La capacidad de especificar un tipo de retorno solo por el trait que implementa es especialmente útil en el contexto de closures e iteradores, que cubrimos en el Capítulo 13. Los closures e iteradores crean tipos que solo el compilador conoce o tipos que son muy largos de especificar. La sintaxis impl Trait te permite especificar concisamente que una función devuelve algún tipo que implementa el trait Iterator sin necesidad de escribir un tipo muy largo.

Sin embargo, solo puedes usar impl Trait si estás devolviendo un solo tipo. Por ejemplo, este código que devuelve ya sea un NewsArticle o un Tweet con el tipo de retorno especificado como impl Summary no funcionaría:

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        Tweet {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            retweet: false,
        }
    }
}

Devolver ya sea un NewsArticle o un Tweet no está permitido debido a las restricciones sobre cómo se implementa la sintaxis impl Trait en el compilador. Cubriremos cómo escribir una función con este comportamiento en "Usando Objetos de Trait que Permiten Valores de Diferentes Tipos".

Usando Límites de Trait para Implementar Métodos Condicionalmente

Al usar un límite de trait con un bloque impl que utiliza parámetros de tipo genéricos, podemos implementar métodos condicionalmente para los tipos que implementan los traits especificados. Por ejemplo, el tipo Pair<T> en la Lista 10-15 siempre implementa la función new para devolver una nueva instancia de Pair<T> (recuerde de "Definiendo Métodos" que Self es un alias de tipo para el tipo del bloque impl, que en este caso es Pair<T>). Pero en el siguiente bloque impl, Pair<T> solo implementa el método cmp_display si su tipo interno T implementa el trait PartialOrd que habilita la comparación y el trait Display que habilita la impresión.

Nombre de archivo: src/lib.rs

use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

Lista 10-15: Implementando métodos condicionalmente en un tipo genérico dependiendo de los límites de trait

También podemos implementar un trait condicionalmente para cualquier tipo que implemente otro trait. Las implementaciones de un trait en cualquier tipo que satisfaga los límites de trait se llaman implementaciones generalizadas y se utilizan ampliamente en la biblioteca estándar de Rust. Por ejemplo, la biblioteca estándar implementa el trait ToString en cualquier tipo que implemente el trait Display. El bloque impl en la biblioteca estándar se parece a este código:

impl<T: Display> ToString for T {
    --snip--
}

Debido a que la biblioteca estándar tiene esta implementación generalizada, podemos llamar al método to_string definido por el trait ToString en cualquier tipo que implemente el trait Display. Por ejemplo, podemos convertir enteros en sus valores String correspondientes de la siguiente manera porque los enteros implementan Display:

let s = 3.to_string();

Las implementaciones generalizadas aparecen en la documentación del trait en la sección "Implementadores".

Los traits y los límites de trait nos permiten escribir código que utiliza parámetros de tipo genéricos para reducir la duplicación, pero también especificar al compilador que queremos que el tipo genérico tenga un comportamiento particular. El compilador puede entonces utilizar la información de los límites de trait para comprobar que todos los tipos concretos utilizados con nuestro código proporcionan el comportamiento correcto. En los lenguajes de tipado dinámico, obtendríamos un error en tiempo de ejecución si llamáramos a un método en un tipo que no definiera el método. Pero Rust mueve estos errores a tiempo de compilación para que sepamos corregir los problemas antes de que nuestro código pueda ejecutarse. Además, no tenemos que escribir código que compruebe el comportamiento en tiempo de ejecución porque ya lo hemos comprobado en tiempo de compilación. Hacer esto mejora el rendimiento sin tener que renunciar a la flexibilidad de los genéricos.

Resumen

¡Felicitaciones! Has completado el laboratorio de Traits: Definiendo Comportamiento Compartido. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.