Trait: Definire Comportamenti Condivisi
Un trait definisce la funzionalità che un determinato tipo ha e che può condividere con altri tipi. Possiamo usare i trait per definire comportamenti condivisi in modo astratto. Possiamo usare i trait bounds per specificare che un tipo generico può essere qualsiasi tipo che ha certi comportamenti.
Nota: I trait sono simili a una funzione spesso chiamata interfacce in altri linguaggi, sebbene con alcune differenze.
Definire un Trait
Il comportamento di un tipo consiste nei metodi che possiamo chiamare su quel tipo. Tipi diversi condividono lo stesso comportamento se possiamo chiamare gli stessi metodi su tutti questi tipi. Le definizioni dei trait sono un modo per raggruppare insieme le firme dei metodi per definire un insieme di comportamenti necessari per raggiungere uno scopo.
Per esempio, diciamo che abbiamo più struct che contengono vari tipi e quantità di testo: una struct NewsArticle
che contiene una notizia archiviata in una posizione particolare e un Tweet
che può avere, al massimo, 280 caratteri assieme a metadati che indicano se era un nuovo tweet, un retweet, o una risposta a un altro tweet.
Vogliamo creare una crate libreria aggregatrice di media chiamata aggregator
che possa mostrare riassunti di dati che potrebbero essere archiviati in unistanza di
NewsArticleo
Tweet. Per fare questo, abbiamo bisogno di un sommario da ogni tipo, e richiederemo quel sommario chiamando un metodo
summarizesu un'istanza. Listing 10-12 mostra la definizione di un trait pubblico
Summary` che esprime questo comportamento.
Nome del file: src/lib.rs
pub trait Summary {
fn summarize(&self) -> String;
}
Qui dichiariamo un trait usando la parola chiave trait
e poi il nome del trait, che in questo caso è Summary
. Dichiariamo anche il trait come pub
affinché i crate che dipendono da questo crate possano utilizzare questo trait, come vedremo in alcuni esempi. Dentro le parentesi graffe, dichiariamo le firme dei metodi che descrivono i comportamenti dei tipi che implementano questo trait, che in questo caso è fn summarize(&self) -> String
.
Dopo la firma del metodo, invece di fornire una implementazione dentro parentesi graffe, usiamo un punto e virgola. Ogni tipo che implementa questo trait deve fornire il proprio comportamento personalizzato per il corpo del metodo. Il compilatore imporrà che qualsiasi tipo che ha il trait Summary
avrà il metodo summarize
definito con esattamente questa firma.
Un trait può avere più metodi nel suo corpo: le firme dei metodi sono elencate una per linea, e ogni linea termina in un punto e virgola.
Implementare un Trait su un Tipo
Ora che abbiamo definito le firme desiderate dei metodi del trait Summary
, possiamo implementarlo sui tipi nel nostro aggregatore di media. Listing 10-13 mostra un'implementazione del trait Summary
sulla struct NewsArticle
che utilizza il titolo, l'autore e la località per creare il valore di ritorno di summarize
. Per la struct Tweet
, definiamo summarize
come il nome utente seguito dall'intero testo del tweet, assumendo che il contenuto del tweet sia già limitato a 280 caratteri.
Nome del file: src/lib.rs
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)
}
}
Implementare un trait su un tipo è simile a implementare metodi regolari. La differenza è che dopo impl
, mettiamo il nome del trait che vogliamo implementare, usiamo la parola chiave for
, e poi specifichiamo il nome del tipo per cui vogliamo implementare il trait. All'interno del blocco impl
, mettiamo le firme dei metodi che la definizione del trait ha definito. Invece di aggiungere un punto e virgola dopo ogni firma, utilizziamo parentesi graffe e riempiamo il corpo del metodo con il comportamento specifico che vogliamo che i metodi del trait abbiano per il tipo particolare.
Ora che la libreria ha implementato il trait Summary
su NewsArticle
e Tweet
, gli utenti del crate possono chiamare i metodi del trait sulle istanze di NewsArticle
e Tweet
nello stesso modo in cui chiamiamo metodi regolari. L'unica differenza è che l'utente deve portare il trait nello scope così come i tipi. Ecco un esempio di come un crate binario potrebbe utilizzare il nostro crate di libreria 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());
}
Questo codice stampa 1 new tweet: horse_ebooks: of course, as you probably already know, people
.
Altri crate che dipendono dal crate aggregator
possono anche portare il trait Summary
nello scope per implementare Summary
sui propri tipi. Una limitazione da notare è che possiamo implementare un trait su un tipo solo se o il trait o il tipo, o entrambi, sono locali al nostro crate. Per esempio, possiamo implementare trait della libreria standard come Display
su un tipo personalizzato come Tweet
come parte della funzionalità del nostro crate aggregator
perché il tipo Tweet
è locale al nostro crate aggregator
. Possiamo anche implementare Summary
su Vec<T>
nel nostro crate aggregator
perché il trait Summary
è locale al nostro crate aggregator
.
Ma non possiamo implementare trait esterni su tipi esterni. Per esempio, non possiamo implementare il trait Display
su Vec<T>
nel nostro crate aggregator
perché sia Display
che Vec<T>
sono definiti nella libreria standard e non sono locali al nostro crate aggregator
. Questa restrizione è parte di una proprietà chiamata coesione, e più specificamente la regola dell'orfano, così chiamata perché il tipo padre non è presente. Questa regola garantisce che il codice di altre persone non possa rompere il tuo codice e viceversa. Senza la regola, due crate potrebbero implementare lo stesso trait per lo stesso tipo, e Rust non saprebbe quale implementazione usare.
Implementazioni Predefinite
A volte è utile avere un comportamento predefinito per alcuni o tutti i metodi in un trait invece di richiedere implementazioni per tutti i metodi su ogni tipo. Poi, mentre implementiamo il trait su un particolare tipo, possiamo mantenere o sovrascrivere il comportamento predefinito di ogni metodo.
Nel Listing 10-14, specifichiamo una stringa predefinita per il metodo summarize
del trait
Summary
invece di definire solo la firma del metodo, come abbiamo fatto nel Listing 10-12.
Nome del file: src/lib.rs
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)
}
}
Per utilizzare un’implementazione predefinita per riassumere le istanze di NewsArticle
,
specifichiamo un blocco impl
vuoto con impl Summary for NewsArticle {}
.
Anche se non stiamo più definendo direttamente il metodo summarize
su NewsArticle
,
abbiamo fornito un'implementazione predefinita e specificato che NewsArticle
implementa il trait Summary
. Di conseguenza, possiamo ancora chiamare il metodo summarize
su un'istanza di NewsArticle
, così:
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());
}
Questo codice stampa New article available! (Read more...)
.
Creare un'implementazione predefinita non richiede di cambiare nulla sull'implementazione di Summary
su Tweet
nel Listing 10-13. Il motivo è che la sintassi per sovrascrivere un’implementazione predefinita è la stessa della sintassi per implementare un metodo di trait che non ha un'implementazione predefinita.
Le implementazioni predefinite possono chiamare altri metodi nello stesso trait, anche se quegli altri metodi non hanno un'implementazione predefinita. In questo modo, un trait può fornire molta funzionalità utile e richiedere solo che gli implementatori specifichino una piccola parte di essa. Per esempio, potremmo definire il trait Summary
per avere un metodo summarize_author
la cui implementazione è richiesta, e poi definire un metodo summarize
che ha un'implementazione predefinita che chiama il metodo 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)
}
}
Per utilizzare questa versione di Summary
, dobbiamo solo definire summarize_author
quando implementiamo il trait su 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)
}
}
Dopo aver definito summarize_author
, possiamo chiamare summarize
sulle istanze della
struct Tweet
, e l'implementazione predefinita di summarize
chiamerà la definizione di summarize_author
che abbiamo fornito. Poiché abbiamo implementato summarize_author
, il trait Summary
ci ha dato il comportamento del metodo summarize
senza richiederci di scrivere altro codice. Ecco come appare:
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());
}
Questo codice stampa 1 new tweet: (Read more from @horse_ebooks...)
.
Nota che non è possibile chiamare l'implementazione predefinita da un'implementazione sovrascritta di quello stesso metodo.
Trait come Parametri
Ora che sai come definire e implementare i trait, possiamo esplorare come usare i trait per definire funzioni che accettano molti tipi diversi. Useremo il trait Summary
che abbiamo implementato sui tipi NewsArticle
e Tweet
nel Listing 10-13 per definire una funzione notify
che chiama il metodo summarize
sul suo parametro item
, che è di un tipo che implementa il trait Summary
. Per farlo, usiamo la sintassi impl Trait
, come questo:
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());
}
Invece di un tipo concreto per il parametro item
, specifichiamo la parola chiave impl
e il nome del trait. Questo parametro accetta qualsiasi tipo che implementi il trait specificato. Nel corpo di notify
, possiamo chiamare qualsiasi metodo su item
che proviene dal trait Summary
, come summarize
. Possiamo chiamare notify
e passare qualsiasi istanza di NewsArticle
o Tweet
. Il codice che chiama la funzione con qualsiasi altro tipo, come una String
o un i32
, non compila perché quei tipi non implementano Summary
.
Sintassi Trait Bound
La sintassi impl Trait
funziona per casi semplici ma è effettivamente una semplificazione sintattica per una forma più lunga conosciuta come trait bound; sembra così:
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
Questa forma più lunga è equivalente all'esempio nella sezione precedente ma è più verbosa. Inseriamo i bound trait con la dichiarazione del parametro di tipo generico dopo un due punti e all'interno di parentesi angolari.
La sintassi impl Trait
è conveniente e rende il codice più conciso in casi semplici, mentre la sintassi completa dei trait bound può esprimere maggiore complessità in altri casi. Per esempio, possiamo avere due parametri che implementano Summary
. Farlo con la sintassi impl Trait
sembra così:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
Usare impl Trait
è appropriato se vogliamo che questa funzione permetta a item1
e item2
di avere tipi diversi (purché entrambi i tipi implementino Summary
). Se vogliamo costringere entrambi i parametri ad avere lo stesso tipo, tuttavia, dobbiamo usare un trait bound, così:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
Il tipo generico T
specificato come tipo dei parametri item1
e item2
vincola la funzione in modo tale che il tipo concreto del valore passato come argomento per item1
e item2
debba essere lo stesso.
Specificare Più Trait Bound con la Sintassi +
Possiamo anche specificare più di un trait bound. Supponiamo di voler utilizzare la formattazione display oltre a summarize
su item
: specifichiamo nella definizione di notify
che item
deve implementare sia Display
che Summary
. Possiamo farlo usando la sintassi +
:
pub fn notify(item: &(impl Summary + Display)) {
La sintassi +
è valida anche con i trait bound sui tipi generici:
pub fn notify<T: Summary + Display>(item: &T) {
Con i due trait bound specificati, il corpo di notify
può chiamare summarize
e usare {}
per formattare item
.
Trait Bound più Chiari con Le Clausole where
Utilizzare troppi trait bound ha aspetti negativi. Ogni generico ha i suoi trait bound, quindi le funzioni con più parametri di tipo generico possono contenere molte informazioni sui trait bound tra il nome della funzione e la sua lista di parametri, rendendo difficile la lettura della firma della funzione. Per questo motivo, Rust ha una sintassi alternativa per specificare i trait bound all'interno di una clausola where
dopo la firma della funzione. Quindi, invece di scrivere questo:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
possiamo usare una clausola where
, così:
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
unimplemented!()
}
La firma di questa funzione è meno affollata: il nome della funzione, la lista dei parametri e il tipo di ritorno sono vicini tra loro, simili a una funzione senza numerosi trait bound.
Restituire Tipi che Implementano Trait
Possiamo anche usare la sintassi impl Trait
nella posizione di ritorno per restituire un valore di un tipo che implementa un trait, come mostrato qui:
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,
}
}
Usando impl Summary
come tipo di ritorno, specifichiamo che la funzione
returns_summarizable
restituisce un tipo che implementa il trait Summary
senza nominare il tipo concreto. In questo caso, returns_summarizable
restituisce un Tweet
, ma il codice che chiama questa funzione non ha bisogno di saperlo.
La capacità di specificare un tipo di ritorno solo tramite il trait che implementa è particolarmente utile nel contesto delle closure e degli iteratori, che copriamo nel Capitolo 13. Le closure e gli iteratori creano tipi che solo il compilatore conosce o tipi che sono molto lunghi da specificare. La sintassi impl Trait
ti permette di specificare concisamente che una funzione restituisce un tipo che implementa il trait Iterator
senza bisogno di scrivere un tipo molto lungo.
Tuttavia, puoi usare impl Trait
solo se stai restituendo un singolo tipo. Ad esempio, questo codice che restituisce un NewsArticle
o un Tweet
con il tipo di ritorno specificato come impl Summary
non funzionerebbe:
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,
}
}
}
Restituire sia un NewsArticle
che un Tweet
non è consentito a causa delle restrizioni su come la sintassi impl Trait
è implementata nel compilatore. Copriremo come scrivere una funzione con questo comportamento nella sezione “Usare Oggetti Trait che Consentono Valori di Diversi Tipi” del Capitolo 17.
Utilizzare i Trait Bound per Implementare Condizionalmente Met# Definizioni di Metodi
Utilizzando un trait bound
con un blocco impl
che usa parametri di tipo generico,
possiamo implementare metodi condizionalmente per tipi che implementano i trait
specificati.
Ad esempio, il tipo Pair<T>
in Listing 10-15 implementa sempre la funzione new
per restituire una nuova istanza di Pair<T>
(ricorda dalla sezione “Defining Methods” del Capitolo 5 che Self
è un alias di tipo per il tipo del blocco impl
, che in questo caso è Pair<T>
). Ma nel prossimo blocco impl
, Pair<T>
implementa il metodo cmp_display
solo se il suo tipo interno T
implementa il PartialOrd
trait che abilita il confronto e il Display
trait che abilita la stampa.
Filename: 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);
}
}
}
Possiamo anche implementare condizionatamente un trait per qualsiasi tipo che ne implementi un altro. Le implementazioni di un trait su qualsiasi tipo che soddisfi i trait bounds sono chiamate implementazioni globali e sono ampiamente utilizzate nella libreria standard di Rust. Ad esempio, la libreria standard implementa il ToString
trait su qualsiasi tipo che implementi il Display
trait. Il blocco impl
nella libreria standard è simile a questo codice:
impl<T: Display> ToString for T {
// --snip--
}
Poiché la libreria standard ha questa implementazione globale, possiamo chiamare il metodo to_string
definito dal ToString
trait su qualsiasi tipo che implementi il Display
trait. Ad esempio, possiamo trasformare gli interi nei loro corrispondenti valori String
in questo modo perché gli interi implementano Display
:
#![allow(unused)] fn main() { let s = 3.to_string(); }
Le implementazioni globali compaiono nella documentazione del trait nella sezione "Implementors".
I trait e i trait bounds ci permettono di scrivere codice che usa parametri di tipo generico per ridurre la duplicazione ma specificare anche al compilatore che vogliamo che il tipo generico abbia un particolare comportamento. Il compilatore può quindi utilizzare le informazioni del trait bound per verificare che tutti i tipi concreti utilizzati con il nostro codice forniscano il comportamento corretto. Nei linguaggi dinamicamente tipizzati, otterremmo un errore a runtime se chiamassimo un metodo su un tipo che non ha definito il metodo. Ma Rust sposta questi errori al tempo di compilazione, quindi siamo costretti a risolvere i problemi prima che il nostro codice possa essere eseguito. Inoltre, non dobbiamo scrivere codice che controlla il comportamento a tempo di esecuzione perché l'abbiamo già verificato a tempo di compilazione. Fare così migliora le prestazioni senza dover rinunciare alla flessibilità dei generici.