Traits: Definire un Comportamento Condiviso
Un trait definisce la funzionalità che un particolare tipo possiede e può condividere con altri tipi. Possiamo utilizzare i traits per definire un comportamento condiviso in modo astratto. Possiamo usare i vincoli dei trait per specificare che un tipo generico può essere qualsiasi tipo che ha un certo comportamento.
Nota: I traits sono simili a una funzionalità spesso chiamata interfacce in altri linguaggi, anche se 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 quei tipi. Le definizioni dei traits sono un modo per raggruppare insieme le firme dei metodi per definire un insieme di comportamenti necessari a raggiungere un certo scopo.
Per esempio, supponiamo di avere diverse structs che contengono vari tipi e
quantità di testo: una struct NewsArticle
che contiene una notizia archiviata in un
particolare luogo e un Tweet
che può avere, al massimo, 280 caratteri insieme a metadati che indicano se era un nuovo tweet, un retweet, o una risposta
a un altro tweet.
Vogliamo creare una libreria crate di aggregazione di media chiamata aggregator
che può
mostrare sommari dei dati che potrebbero essere memorizzati in un'istanza NewsArticle
o Tweet
. Per fare questo, abbiamo bisogno di un sommario da ciascun tipo, e chiederemo tale sommario chiamando un metodo summarize
su un'istanza. Il Listing 10-12 mostra la definizione di un trait pubblico Summary
che esprime questo comportamento.
File: src/lib.rs
pub trait Summary {
fn summarize(&self) -> String;
}
Qui, dichiariamo un trait utilizzando la parola chiave trait
e poi il nome del trait,
che in questo caso è Summary
. Dichiariamo anche il trait come pub
in modo che
anche i crates che dipendono da questo crate possano utilizzare questo trait, come vedremo
in alcuni esempi. All'interno delle 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 un'implementazione tra parentesi
graffe, usiamo un punto e virgola. Ogni tipo che implementa questo trait deve fornire
il proprio comportamento personalizzato per il blocco del metodo. Il compilatore imporrà
che qualsiasi tipo che abbia il trait Summary
avrà il metodo summarize
definito con questa firma esatta.
Un trait può avere più metodi nel suo corpo: le firme dei metodi sono elencate una per riga, e ogni riga termina con 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. Il Listing 10-13 mostra
un'implementazione del trait Summary
sulla struct NewsArticle
che utilizza
il titolo, l'autore e la localizzazione 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.
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,
poi 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, usiamo le 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 su istanze di
NewsArticle
e Tweet
nello stesso modo in cui chiamiamo i metodi regolari. L'unica
differenza è che l'utente deve portare il trait a Scope così come i
tipi. Ecco un esempio di come un crate binario potrebbe utilizzare la nostra 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 crates che dipendono dal crate aggregator
possono anche portare il trait Summary
a Scope per implementare Summary
sui propri tipi. Una restrizione 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 i traits della libreria standard come
Display
su un tipo personalizzato come Tweet
come parte della nostra
funzionalità del 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>
all'interno del nostro crate aggregator
perché
Display
e Vec<T>
sono entrambi definiti nella libreria standard e non sono
locali al nostro crate aggregator
. Questa restrizione fa parte di una proprietà chiamata
coerenza, e più specificamente la regola dell'orfano, così chiamata perché il
tipo padre non è presente. Questa regola assicura che il codice degli altri non possa
rompere il tuo codice e viceversa. Senza la regola, due crates potrebbero implementare
lo stesso trait per lo stesso tipo, e Rust non saprebbe quale implementazione
utilizzare.
Implementazioni Predefinite
A volte è utile avere un comportamento predefinito per alcuni o per tutti i metodi in un trait invece di richiedere implementazioni per tutti i metodi su ogni tipo. Poi, mentre implementiamo il trait su un tipo particolare, possiamo mantenere o sovrascrivere il comportamento predefinito di ciascun 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.
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 una implementazione predefinita per riassumere le istanze di NewsArticle
, specifichiamo un blocco impl
vuoto con impl Summary for NewsArticle {}
.
Anche se non definiamo più il metodo summarize
su NewsArticle
direttamente, abbiamo fornito un'implementazione predefinita e specificato che
NewsArticle
implementa il trait Summary
. Di conseguenza, possiamo continuare a chiamare
il metodo summarize
su un'istanza di NewsArticle
, in questo modo:
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 ci 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 sintassi per implementare un metodo del 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, per 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
su 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 fornito il comportamento del
metodo summarize
senza richiederci di scrivere più 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 di override di quello stesso metodo.
Traits come Parametri
Ora che sai come definire e implementare i traits, possiamo esplorare come
utilizzare i traits 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 fare questo, utilizziamo la sintassi impl Trait
, come questa:
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 implementa il
trait specificato. Nel Blocco di notify
, possiamo chiamare qualsiasi metodo su item
che provenga 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 sarà compilato
perché quei tipi non implementano Summary
.
Sintassi del Vincolo di Trait
La sintassi impl Trait
funziona per casi semplici ma è in realtà una scorciatoia
sintattica per una forma più lunga conosciuta come vincolo di trait; si presenta 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. Poniamo i vincoli di trait con la dichiarazione del tipo generico di parametro dopo un due punti e all'interno di parentesi angolari.
La sintassi impl Trait
è comoda e rende il codice più conciso in casi semplici,
mentre la sintassi più completa del vincolo di trait può esprimere più
complessità in altri casi. Per esempio, possiamo avere due parametri che implementano
Summary
. Farlo con la sintassi impl Trait
appare 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 (a condizione che entrambi i tipi implementino Summary
). Se
vogliamo forzare entrambi i parametri ad avere lo stesso tipo, tuttavia, dobbiamo usare un
vincolo di trait, come questo:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
Il tipo generico T
specificato come tipo dei parametri item1
e item2
limita la funzione in modo tale che il tipo concreto del valore passato come argomento
per item1
e item2
deve essere lo stesso.
Specificare Vincoli di Trait Multipli con la Sintassi +
Possiamo anche specificare più di un vincolo di trait. Supponiamo di voler utilizzare
la formattazione del display così come 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 vincoli di trait su tipi generici:
pub fn notify<T: Summary + Display>(item: &T) {
Con i due vincoli di trait specificati, il blocco di notify
può chiamare
summarize
e utilizzare {}
per formattare item
.
Vincoli di Trait Più Chiari con le Clauses where
Utilizzare troppi vincoli di trait ha i suoi svantaggi. Ogni generico ha i propri
vincoli di trait, quindi le funzioni con più parametri di tipo generico possono
contenere molte informazioni sui vincoli di trait tra il nome della funzione e la lista
dei suoi parametri, rendendo la firma della funzione difficile da leggere. Per questo motivo, Rust
ha una sintassi alternativa per specificare i vincoli di trait all'interno di una clause
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 clause where
, come questa:
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
unimplemented!()
}
La firma di questa funzione è meno ingombra: il nome della funzione, la lista dei parametri, e il tipo di ritorno sono vicini, simili a una funzione senza molti vincoli di trait.
Restituire Tipi che Implementano Traits
Possiamo anche utilizzare la sintassi impl Trait
nella posizione di ritorno per restituire
un valore di qualche 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,
}
}
Utilizzando impl Summary
per il tipo di ritorno, specifichiamo che la
funzione returns_summarizable
restituisce un qualche 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 closures e degli iteratori, che trattiamo
nel Capitolo 13. Le closures 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
concettualmente che una funzione restituisce qualche tipo che implementa il trait Iterator
senza dover scrivere un tipo molto lungo.
Tuttavia, puoi utilizzare impl Trait
solo se stai restituendo un singolo tipo. Per
esempio, questo codice che restituisce o 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 o un NewsArticle
o un Tweet
non è permesso a causa delle restrizioni su come
la sintassi impl Trait
è implementata nel compilatore. Tratteremo come scrivere una funzione
con questo comportamento nella sezione “Usare Oggetti Trait che Consentono Valori di Tipi Diversi” del Capitolo 17.
Usare Vincoli di Trait per Implementare Condizionatamente Metodi
Utilizzando un bound di trait con un blocco impl
che utilizza parametri di tipo generici, possiamo implementare metodi in modo condizionale per i tipi che implementano i trait specificati. Ad esempio, il tipo Pair<T>
nel Listing 10-15 implementa sempre la funzione new
per restituire una nuova istanza di Pair<T>
(ricorda dalla sezione “Definire i Metodi” del Capitolo 5 che Self
è un alias di tipo per il tipo del blocco impl
, che in questo caso è Pair<T>
). Ma nel successivo blocco impl
, Pair<T>
implementa il metodo cmp_display
solo se il suo tipo interno T
implementa il trait PartialOrd
che abilita il confronto e il trait Display
che abilita la stampa.
Nome file: 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 condizionalmente un trait per qualsiasi tipo che implementa un altro trait. Le implementazioni di un trait su qualsiasi tipo che soddisfa i bound del trait sono chiamate implementazioni blanket e sono usate ampiamente nella libreria standard di Rust. Ad esempio, la libreria standard implementa il trait ToString
su qualsiasi tipo che implementa il trait Display
. Il blocco impl
nella libreria standard appare simile a questo codice:
impl<T: Display> ToString for T {
// --snip--
}
Poiché la libreria standard ha questa implementazione blanket, possiamo chiamare il metodo to_string
definito dal trait ToString
su qualsiasi tipo che implementa il trait Display
. Ad esempio, possiamo trasformare gli interi nei loro valori String
corrispondenti in questo modo perché gli interi implementano Display
:
#![allow(unused)] fn main() { let s = 3.to_string(); }
Le implementazioni blanket appaiono nella documentazione per il trait nella sezione “Implementatori”.
I trait e i bound di trait ci permettono di scrivere codice che utilizza parametri di tipo generici per ridurre la duplicazione ma anche di specificare al compilatore che vogliamo che il tipo generico abbia un comportamento particolare. Il compilatore può quindi utilizzare le informazioni sul bound di trait per verificare che tutti i tipi concreti utilizzati con il nostro codice forniscano il comportamento corretto. Nei linguaggi tipizzati dinamicamente, riceveremmo un errore a runtime se chiamassimo un metodo su un tipo che non definisce il metodo. Ma Rust sposta questi errori al tempo di compilazione, quindi siamo costretti a risolvere i problemi prima ancora che il nostro codice possa essere eseguito. Inoltre, non dobbiamo scrivere codice che controlli il comportamento a runtime perché abbiamo già controllato al tempo di compilazione. Fare ciò migliora le prestazioni senza dover rinunciare alla flessibilità dei generici.