Traits: Definiendo Comportamiento Compartido
Un trait define funcionalidad que un tipo particular tiene y puede compartir con otros tipos. Podemos usar traits para definir comportamiento compartido de una manera abstracta. Podemos usar trait bounds 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. En español también se les conoce como rasgos pero en el libro intentaremos mantener la palabra clave sin traducir, no obstante creamos esta encuesta para futuras revisiones.
Definiendo un Trait
El comportamiento de un tipo consiste en los métodos que podemos llamar en ese tipo. Diferentes tipos comparten el mismo comportamiento si podemos llamar los mismos métodos en todos esos tipos. Las definiciones de traits son una manera de agrupar firmas de métodos para definir un conjunto de comportamientos necesarios para lograr algún propósito.
Por ejemplo, digamos que tenemos múltiples structs que contienen varios tipos y
cantidades de texto: un struct NewsArticle
que contiene una historia de
noticias archivada en una ubicación 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 hacer una biblioteca de agregación de medios llamada aggregator
que
puede mostrar resúmenes de datos que podrían estar almacenados en una
instancia de NewsArticle
o Tweet
. Para hacer esto, necesitamos un resumen
de cada tipo, y solicitaremos ese resumen llamando un método summarize
en
una instancia. El listado 10-12 muestra la definición de un trait Summary
público que expresa este comportamiento.
pub trait Summary {
fn summarize(&self) -> String;
}
Aquí, declaramos un trait usando la palabra clave trait
y luego el nombre
del trait, que en este caso es Summary
. También hemos declarado el trait
como pub
para que los crates que dependen de este crate puedan hacer uso de
este trait también, como veremos en algunos ejemplos. Dentro de las llaves
curvas, 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 llaves curvas, usamos un punto y coma. Cada tipo que implementa este
trait debe proporcionar su propio comportamiento personalizado para el cuerpo
del método. El compilador hará cumplir que cualquier tipo que tenga el trait
Summary
tendrá el método summarize
definido con esta firma exactamente.
Un trait puede tener múltiples métodos en su cuerpo: las firmas de los métodos se enumeran una por línea y cada línea termina en un punto y coma.
Implementando un Trait en un Tipo
Ahora que hemos definido el trait Summary
, podemos implementarlo en los
tipos en nuestro agregador de medios. El listado 10-13 muestra una
implementación del trait Summary
en el struct NewsArticle
que usa el
encabezado, 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, asumiendo que el contenido del
tweet ya está limitado a 280 caracteres.
pub trait Summary {
fn summarize(&self) -> String;
}
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)
}
}
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 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 en particular.
Ahora que la biblioteca ha implementado el trait Summary
en NewsArticle
y
Tweet
, los usuarios de la biblioteca pueden llamar a los métodos de trait
en las instancias de NewsArticle
y Tweet
en la misma forma en que llamamos
a los métodos regulares. La única diferencia es que el usuario debe traer el
trait al scope, así como los tipos. Aquí hay un ejemplo de cómo un crate
binario podría usar nuestra biblioteca de 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 New article available! horse_ebooks: of course, as you probably already know, people
.
Otros crates que dependen de nuestro crate aggregator
pueden usar el trait
Summary
en el ámbito para implementar Summary
en sus propios tipos. Una
restricción a tener en cuenta es que podemos implementar un trait en un tipo
solo si ambos el trait o el tipo, o ambos, son local a nuestro crate. Por
ejemplo, podemos implementar traits de la biblioteca estándar como Display
en un tipo personalizado como Tweet
como parte de nuestra funcionalidad de
crate aggregator
, porque el tipo Tweet
es local a nuestro crate
aggregator
. También podemos implementar Summary
en Vec<T>
en nuestro
crate aggregator
, porque el trait Summary
es local a nuestro crate
aggregator
.
Pero no podemos implementar traits externos en tipos externos. Por ejemplo,
digamos que queremos implementar Display
en Vec<T>
como parte de nuestra
funcionalidad de crate aggregator
. Esto no es posible porque tanto Display
como Vec<T>
están definidos en la biblioteca estándar y no son locales a
nuestro crate aggregator
. La restricción de implementar un trait en un tipo
solo si uno de ellos es local a nuestro crate es parte de una propiedad
llamada coherencia, y más específicamente la regla huérfana, así llamada
porque el tipo padre no está presente. Esta regla asegura que el código de
otras personas no pueda romper su código y viceversa. Sin la regla, dos crates
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 en un trait en lugar de requerir implementaciones para todos los métodos en cada tipo. Luego, a medida que implementamos el trait en un tipo particular, podemos mantener o anular el comportamiento predeterminado para cada método.
En el listado 10-14, especificamos un string predeterminado para el método
summarize
del trait Summary
en lugar de solo definir la firma del método,
como hicimos en el listado 10-12.
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {}
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)
}
}
Para usar una implementación predeterminada para resumir instancias de
NewsArticle
, especificamos un bloque impl
vacío con impl Summary for NewsArticle {}
.
Aunque ya no estamos definiendo el método summarize
en NewsArticle
directamente, 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:
use aggregator::{self, NewsArticle, Summary};
fn main() {
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! (Read more...)
.
Crear una implementación predeterminada no requiere que cambiemos nada sobre
la implementación de Summary
en Tweet
en el listado 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 otros métodos en el mismo
trait, incluso si esos métodos no tienen una implementación predeterminada.
De esta manera, un trait puede proporcionar una gran cantidad de
funcionalidad útil y solo requiere 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 tenga una implementación predeterminada que
llame al método summarize_author
:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
Para usar esta version de Summary
, solo necesitamos definir summarize_author
cuando implementamos el trait en un tipo:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
Después de definir summarize_author
, podemos llamar a summarize
en
instancias de la estructura 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 requerirnos
escribir más código.
use aggregator::{self, 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: (Read more from @horse_ebooks...)
.
Ten en cuenta que no es posible llamar a la implementación predeterminada desde una implementación primordial de ese mismo método.
Traits como parametros
Ahora que sabes cómo definir y implementar traits, podemos explorar cómo usar
traits para definir funciones que aceptan muchos tipos diferentes. Usaremos el
trait Summary
que implementamos en los tipos NewsArticle
y Tweet
en el
listado 10-13 para definir una función notify
que llama 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 trait Summary {
fn summarize(&self) -> String;
}
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)
}
}
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
En lugar de un tipo concreto para el parámetro item
, especificamos el
parámetro impl
y el nombre del trait. Cualquier tipo que implemente el trait
Summary
puede ser pasado al parámetro item
en la función notify
. El
código que llama a la función notify
con cualquier otro tipo, como un String
o un i32
, no compilará porque esos tipos no implementan Summary
.
Sintaxis de trait bound
La sintaxis impl Trait
funciona para casos sencillos, pero en realidad es
azúcar sintáctico para una forma más larga conocida como trait bound; se ve
así:
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
Esta forma más larga es equivalente al ejemplo en la sección anterior pero más detallado. Colocamos los límites de los traits con la declaración del parámetro generic después de dos puntos y dentro de corchetes angulares.
La sintaxis impl Trait
es conveniente y hace que el código sea más conciso en
casos simples, mientras que la sintaxis de trait bound más completa puede
expresar más complejidad en otros casos. Por ejemplo, podemos tener dos
parámetros que implementan Summary
. Hacerlo con la sintaxis impl Trait
se
ve así:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
Usando impl Trait
es apropiado si queremos que esta función permita que
item1
y item2
tengan tipos diferentes (siempre que ambos tipos implementen
Summary
). Sin embargo, si queremos forzar que ambos parámetros tengan el
mismo tipo, debemos usar un trait bound, como esto:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
El tipo generic T
especificado como el tipo de los parámetros item1
e
item2
restringe la función de tal manera que el tipo concreto del valor
pasado como argumento para item1
e item2
debe ser el mismo.
Especificando múltiples trait bounds con la sintaxis +
También podemos especificar más de un trait bound. Digamos que queremos que
notify
use la representación de cadena de un tipo que implementa Summary
en el cuerpo de la función. Para hacer esto, necesitamos que el parámetro
item
implemente tanto Display
como Summary
. Podemos hacerlo usando la
sintaxis +
:
pub fn notify(item: &(impl Summary + Display)) {
La sintaxis +
también es válida con los trait bounds en tipos generics:
pub fn notify<T: Summary + Display>(item: &T) {
Con los dos trait bounds especificados, el cuerpo de notify
puede llamar a
summarize
y usar {}
para formatear item
.
Trait bounds más claros con cláusulas where
Usar demasiados trait bounds tiene sus inconvenientes. Cada generic tiene sus
propios trait bounds, por lo que las funciones con múltiples parámetros de tipo
generic pueden contener mucha información de trait bound 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 los trait bounds dentro de una cláusula where
después de la
firma de la función. Así que 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 esta:
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
unimplemented!()
}
La firma de esta función está menos desordenada: el nombre de la función, la lista de parámetros y el tipo de retorno están muy juntos, similar a una función sin muchos trait bounds.
Devolviendo tipos que implementan traits
También podemos usar la sintaxis impl Trait
en el tipo de retorno de una
función para devolver un valor de algún tipo que implementa un trait, como se
muestra aquí:
pub trait Summary {
fn summarize(&self) -> String;
}
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)
}
}
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 concreto. 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 que es una implementación de un trait
especialmente útil en el contexto de los closures y los iteradores, que
cubriremos en el Capítulo 13. Los closures y los iteradores crean tipos que
solo el compilador conoce o tipos que son muy largos de especificar. La sintaxis
impl Trait
te permite especificar de manera concisa que una función devuelve
algún tipo que implementa el trait Iterator
sin necesidad de escribir un tipo
muy largo.
Sin embargo, no puedes usar impl Trait
si la función devuelve más de un tipo.
Por ejemplo, este código que devuelve un NewsArticle
o un Tweet
con el tipo
de retorno especificado como impl Summary
no compilaría:
pub trait Summary {
fn summarize(&self) -> String;
}
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)
}
}
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,
}
}
}
Volviendo un NewsArticle
o un Tweet
no está permitido debido a las
restricciones en torno a cómo se implementa la sintaxis impl Trait
en el
compilador. Cubriremos cómo escribir una función con este comportamiento en la
sección “Usando objetos trait que permiten valores de diferentes
tipos” del Capítulo 18.
Usando trait bounds para implementar métodos condicionalmente
Al usar un trait bound con un bloque impl
que usa parámetros de tipo generic,
podemos implementar métodos condicionalmente para tipos que implementan los
traits especificados. Por ejemplo, el tipo Pair<T>
en el listado 10-15 siempre
implementa la función new
para devolver una nueva instancia de Pair<T>
(recuerda de la sección “Definiendo métodos” del
Capítulo 5 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 permite la comparación y el trait Display
que permite la
impresión.
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);
}
}
}
También podemos implementar condicionalmente un trait para cualquier tipo que
implemente otro trait. Implementaciones de un trait en cualquier tipo que
satisfaga los trait bounds se llaman implementaciones blanket y son usados
extensivamente 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 ve similar a este
código:
impl<T: Display> ToString for T {
// --snip--
}
Debido a que la biblioteca estándar tiene esta implementación, 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 esta manera porque los enteros implementan
Display
:
#![allow(unused)] fn main() { let s = 3.to_string(); }
Las implementaciones generales aparecen en la documentación del trait en la sección “Implementors”.
Traits y trait bounds nos permiten usar genéricos para reducir la duplicación de código, pero también para especificar a el compilador que queremos que un tipo generic tenga un comportamiento particular. El compilador puede usar la información de los trait bounds para verificar que todos los tipos concretos que usamos con nuestro código proporcionan el comportamiento correcto. En lenguajes de tipado dinámico, obtendríamos un error en tiempo de ejecución si llamamos a un método en un tipo que no define el método. Pero Rust mueve estos errores al tiempo de compilación, por lo que estamos obligados a corregir los problemas antes de que nuestro código pueda ejecutarse. Además, no tenemos que escribir código que verifique el comportamiento en tiempo de ejecución porque ya hemos verificado en tiempo de compilación. Hacerlo mejora el rendimiento sin tener que renunciar a la flexibilidad de los generics.