Definire il Comportamento Condiviso con i Trait
Un tratto (trait) definisce la funzionalità che un particolare type ha e può condividere con altri type. Possiamo usare i trait per definire il comportamento condiviso in modo astratto. Possiamo usare i vincoli del tratto (trait bound) per specificare che un type generico può essere qualsiasi type che abbia un determinato comportamento.
Nota: i trait sono simili a una funzionalità spesso chiamata interfacce (interfaces) in altri linguaggi, sebbene con alcune differenze.
Definire un Trait
Il comportamento di un type consiste nei metodi che possiamo chiamare su quel type. Type diversi condividono lo stesso comportamento se possiamo chiamare gli stessi metodi su tutti quei type. Le definizioni dei trait sono un modo per raggruppare le firme dei metodi per definire un insieme di comportamenti necessari per raggiungere un determinato scopo.
Ad esempio, supponiamo di avere più struct che contengono vari tipi e quantità
di testo: una struttura Articolo
che contiene una notizia archiviata in una
posizione specifica e una PostSocial
che può contenere, al massimo, 280
caratteri insieme a metadati che indicano se si tratta di un nuovo post, una
ripubblicazione o una risposta a un altro post.
Vogliamo creare una libreria di aggregazione multimediale denominata
aggregatore
in grado di visualizzare riepiloghi dei dati che potrebbero essere
memorizzati in un’istanza di Articolo
o PostSocial
. Per fare ciò, abbiamo
bisogno di un riepilogo per ciascun type e richiederemo tale riepilogo
chiamando un metodo riassunto
su un’istanza. Il Listato 10-12 mostra la
definizione di un trait pubblico Sommario
che esprime questo comportamento.
pub trait Sommario {
fn riassunto(&self) -> String;
}
Sommario
che consiste nel comportamento fornito da un metodo riassunto
Qui, dichiariamo un trait usando la parola chiave trait
e poi il nome del
trait, che in questo caso è Sommario
. Dichiariamo anche il trait come
pub
in modo che anche i crate 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 type che implementano questo trait, che in questo caso è fn riassunto(&self) -> String
.
Dopo la firma del metodo, invece di fornire un’implementazione tra parentesi
graffe, utilizziamo un punto e virgola. Ogni type che implementa questo
trait deve fornire il proprio comportamento personalizzato per il corpo del
metodo. Il compilatore imporrà che qualsiasi type che abbia il trait
Sommario
abbia il metodo riassunto
definito esattamente con questa firma.
Una 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 Type
Ora che abbiamo definito le firme desiderate dei metodi del trait Sommario
,
possiamo implementarlo sui type nel nostro aggregatore multimediale. Il
Listato 10-13 mostra un’implementazione del trait Sommario
sulla struct
Articolo
che utilizza il titolo, l’autore e la posizione per creare il valore
di ritorno di riassunto
. Per la struct PostSocial
, definiamo riassunto
come il nome utente seguito dall’intero testo del post, supponendo che il
contenuto del post sia già limitato a 280 caratteri.
pub trait Sommario {
fn riassunto(&self) -> String;
}
pub struct Articolo {
pub titolo: String,
pub posizione: String,
pub autore: String,
pub contenuto: String,
}
impl Sommario for Articolo {
fn riassunto(&self) -> String {
format!("{}, di {} ({})", self.titolo, self.autore, self.posizione)
}
}
pub struct PostSocial {
pub nomeutente: String,
pub contenuto: String,
pub risposta: bool,
pub repost: bool,
}
impl Sommario for PostSocial {
fn riassunto(&self) -> String {
format!("{}: {}", self.nomeutente, self.contenuto)
}
}
Sommario
sui type Articolo
e PostSocial
Implementare un trait su un type è simile a come normalmente sono
implementati i metodi. La differenza è che dopo impl
, inseriamo il nome del
trait che vogliamo implementare, poi utilizziamo la parola chiave for
e
infine specifichiamo il nome del type per cui vogliamo implementare il
trait. All’interno del blocco impl
, inseriamo le firme dei metodi definite
dalla definizione del trait. Invece di aggiungere un punto e virgola dopo ogni
firma, utilizziamo le parentesi graffe e riempiamo il corpo del metodo con il
comportamento specifico che vogliamo che i metodi del trait abbiano per quel
particolare type.
Ora che la libreria ha implementato il trait Sommario
su Articolo
e
PostSocial
, gli utenti del crate possono chiamare i metodi del trait sulle
istanze di Articolo
e PostSocial
nello stesso modo in cui chiamiamo i metodi
normali. L’unica differenza è che l’utente deve includere il trait nello
scope oltre ai type. Ecco un esempio di come un crate binario potrebbe
utilizzare il nostro crate libreria aggregatore
:
use aggregatore::{PostSocial, Sommario};
fn main() {
let post = PostSocial {
nomeutente: String::from("horse_ebooks"),
contenuto: String::from(
"ovviamente, come probabilmente già sapete, gente",
),
risposta: false,
repost: false,
};
println!("1 nuovo post: {}", post.riassunto());
}
Questo codice stampa 1 nuovo post: horse_ebooks: ovviamente, come probabilmente già sapete, gente
.
Anche altri crate che dipendono dal crate aggregatore
possono includere il
trait Sommario
nello scope per implementare Sommario
sui propri type.
Una restrizione da notare è che possiamo implementare un trait su un type
solo se il trait o il type, o entrambi, sono locali al nostro crate. Ad
esempio, possiamo implementare trait della libreria standard come Display
su
un type personalizzato come PostSocial
come parte della funzionalità del
nostro crate aggregatore
, perché il type PostSocial
è locale al nostro
crate aggregatore
. Possiamo anche implementare Sommario
su Vec<T>
nel
nostro crate aggregatore
, perché il trait Sommario
è locale al nostro
crate aggregatore
.
Ma non possiamo implementare trait esterni su type esterni. Ad esempio, non
possiamo implementare il trait Display
su Vec<T>
all’interno del nostro
crate aggregatore
perché Display
e Vec<T>
sono entrambi definiti nella
libreria standard e non sono locali al nostro crate aggregatore
. Questa
restrizione fa parte di una proprietà chiamata coerenza (coherence), e più
specificamente della regola dell’orfano (orphan rule), così chiamata perché
il type genitore non è presente. Questa regola garantisce che il codice di
altri non possa rompere il tuo codice e viceversa. Senza questa regola, due
crate potrebbero implementare lo stesso trait per lo stesso type e Rust
non saprebbe quale implementazione utilizzare.
Usare le 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 type. Quindi, quando implementiamo il trait su un type particolare, possiamo mantenere o sovrascrivere il comportamento predefinito di ciascun metodo.
Nel Listato 10-14, specifichiamo una stringa predefinita per il metodo
riassunto
del trait Sommario
invece di definire solo la firma del metodo,
come abbiamo fatto nel Listato 10-12.
pub trait Sommario {
fn riassunto(&self) -> String {
String::from("(Leggi di più...)")
}
}
pub struct Articolo {
pub titolo: String,
pub posizione: String,
pub autore: String,
pub contenuto: String,
}
impl Sommario for Articolo {}
pub struct PostSocial {
pub nomeutente: String,
pub contenuto: String,
pub risposta: bool,
pub repost: bool,
}
impl Sommario for PostSocial {
fn riassunto(&self) -> String {
format!("{}: {}", self.nomeutente, self.contenuto)
}
}
Sommario
con un’implementazione predefinita del metodo riassunto
Per utilizzare un’implementazione predefinita per riassumere le istanze di
Articolo
, specifichiamo un blocco impl
vuoto con impl Sommario for Articolo {}
.
Anche se non definiamo più il metodo riassunto
su Articolo
direttamente,
abbiamo fornito un’implementazione predefinita e specificato che Articolo
implementa il trait Sommario
. Di conseguenza, possiamo comunque chiamare il
metodo riassunto
su un’istanza di Articolo
, in questo modo:
fn main() {
let articolo = Articolo {
titolo: String::from("I Penguins vincono la Stanley Cup!"),
posizione: String::from("Pittsburgh, PA, USA"),
autore: String::from("Iceburgh"),
contenuto: String::from(
"I Pittsburgh Penguins sono ancora una volta\
la migliore squadra di hockey nella NHL.",
),
};
println!("Nuovo articolo disponibile! {}", articolo.riassunto());
}
Questo codice stampa Nuovo articolo disponibile! (Leggi di più...)
.
La creazione di un’implementazione predefinita non richiede alcuna modifica
all’implementazione di Sommario
su PostSocial
nel Listato 10-13. Il motivo è
che la sintassi per sovrascrivere un’implementazione predefinita è la stessa
della sintassi per implementare un metodo di un trait che non ha
un’implementazione predefinita.
Le implementazioni predefinite possono chiamare altri metodi nello stesso
trait, anche se questi non hanno un’implementazione predefinita. In questo
modo, un trait può fornire molte funzionalità utili e richiedere agli
implementatori di specificarne solo una piccola parte. Ad esempio, potremmo
definire il trait Sommario
in modo che abbia un metodo riassunto_autore
la
cui implementazione è richiesta, e quindi definire un metodo riassunto
con
un’implementazione predefinita che chiama il metodo riassunto_autore
:
pub trait Sommario {
fn riassunto_autore(&self) -> String;
fn riassunto(&self) -> String {
format!("(Leggi di più da {}...)", self.riassunto_autore())
}
}
pub struct PostSocial {
pub nomeutente: String,
pub contenuto: String,
pub risposta: bool,
pub repost: bool,
}
impl Sommario for PostSocial {
fn riassunto_autore(&self) -> String {
format!("@{}", self.nomeutente)
}
}
Per utilizzare questa versione di Sommario
, dobbiamo definire
riassunto_autore
solo quando implementiamo il trait su un type:
pub trait Sommario {
fn riassunto_autore(&self) -> String;
fn riassunto(&self) -> String {
format!("(Leggi di più da {}...)", self.riassunto_autore())
}
}
pub struct PostSocial {
pub nomeutente: String,
pub contenuto: String,
pub risposta: bool,
pub repost: bool,
}
impl Sommario for PostSocial {
fn riassunto_autore(&self) -> String {
format!("@{}", self.nomeutente)
}
}
Dopo aver definito riassunto_autore
, possiamo chiamare riassunto
sulle
istanze della struct PostSocial
e l’implementazione predefinita di
riassunto
chiamerà la definizione di riassunto_autore
che abbiamo fornito.
Poiché abbiamo implementato riassunto_autore
, il trait Sommario
ci ha
fornito il comportamento del metodo riassunto
senza richiedere ulteriore
codice. Ecco come appare:
use aggregatore::{self, PostSocial, Sommario};
fn main() {
let post = PostSocial {
nomeutente: String::from("horse_ebooks"),
contenuto: String::from(
"ovviamente, come probabilmente già sapete, gente",
),
risposta: false,
repost: false,
};
println!("1 nuovo post: {}", post.riassunto());
}
Questo codice stampa 1 nuovo post: (Leggi di più su @horse_ebooks...)
.
Nota che non è possibile chiamare l’implementazione predefinita da una implementazione sovrascritta dello stesso metodo.
Usare i Trait come Parametri
Ora che sai come definire e implementare i trait, possiamo esplorare come
usarli per definire funzioni che accettano molti type diversi. Useremo il
trait Sommario
che abbiamo implementato sui type Articolo
e PostSocial
nel Listato 10-13 per definire una funzione notifica
che chiama il metodo
riassunto
sul suo parametro elemento
, che è di un type che implementa il
trait Sommario
. Per fare ciò, utilizziamo la sintassi impl Trait
, in
questo modo:
pub trait Sommario {
fn riassunto(&self) -> String;
}
pub struct Articolo {
pub titolo: String,
pub posizione: String,
pub autore: String,
pub contenuto: String,
}
impl Sommario for Articolo {
fn riassunto(&self) -> String {
format!("{}, di {} ({})", self.titolo, self.autore, self.posizione)
}
}
pub struct PostSocial {
pub nomeutente: String,
pub contenuto: String,
pub risposta: bool,
pub repost: bool,
}
impl Sommario for PostSocial {
fn riassunto(&self) -> String {
format!("{}: {}", self.nomeutente, self.contenuto)
}
}
pub fn notifica(elemento: &impl Sommario) {
println!("Ultime notizie! {}", elemento.riassunto());
}
Invece di un type concreto per il parametro elemento
, specifichiamo la
parola chiave impl
e il nome del trait. Questo parametro accetta qualsiasi
type che implementi il trait specificato. Nel corpo di notifica
, possiamo
chiamare qualsiasi metodo su elemento
che provenga dal trait Sommario
,
come riassunto
. Possiamo chiamare notifica
e passare qualsiasi istanza di
Articolo
o PostSocial
. Il codice che chiama la funzione con qualsiasi altro
type, come String
o i32
, non verrà compilato perché questi type non
implementano Sommario
.
Sintassi del Vincolo di Trait
La sintassi impl Trait
funziona per i casi più semplici, ma in realtà è solo
una sintassi semplificata di una forma più lunga nota come vincolo del tratto
(trait bound); si presenta così:
pub fn notifica<T: Sommario>(elemento: &T) {
println!("Ultime notizie! {}", elemento.riassunto());
}
Questa forma più lunga è equivalente all’esempio della sezione precedente, ma è più dettagliata. Posizioniamo il vincolo di trait con la dichiarazione del parametro di type generico dopo i due punti e tra parentesi angolari.
La sintassi impl Trait
è comoda e consente di scrivere codice più conciso nei
casi semplici, mentre la sintassi più completa del vincolo di trait può
esprimere una maggiore complessità in altri casi. Ad esempio, possiamo avere due
parametri che implementano Sommario
. Con la sintassi impl Trait
, ciò si
ottiene in questo modo:
pub fn notifica(elemento1: &impl Sommario, elemento2: &impl Sommario) {
L’utilizzo di impl Trait
è appropriato se vogliamo che questa funzione
consenta a elemento1
e elemento2
di avere type diversi (purché entrambi i
type implementino Sommario
). Tuttavia, se vogliamo forzare entrambi i
parametri ad avere lo stesso type, dobbiamo usare un vincolo di trait, in
questo modo:
pub fn notifica<T: Sommario>(elemento1: &T, elemento2: &T) {
Il type generico T
specificato come type dei parametri elemento1
e
elemento2
vincola la funzione in modo che il type concreto del valore
passato come argomento per elemento1
e elemento2
debba essere lo stesso.
Specificare più Vincoli di Trait con la Sintassi +
Possiamo anche specificare più di un vincolo di trait. Supponiamo di voler che
notifica
usi sia la formattazione di visualizzazione, fornita dal trait
Display
, sia che usi riassunto
su elemento
: specifichiamo nella
definizione di notifica
che elemento
deve implementare sia Display
che
Sommario
. Possiamo farlo utilizzando la sintassi +
:
pub fn notifica(elemento: &(impl Sommario + Display)) {
La sintassi +
è valida anche con i vincoli di trait sui type generici:
pub fn notifica<T: Sommario + Display>(elemento: &T) {
Con i due vincoli di trait specificati, il corpo di notifica
può chiamare
riassunto
e utilizzare {}
per formattare elemento
.
Specificare i Vincoli di Trait con le Clausole where
L’utilizzo di troppi vincoli di trait ha i suoi svantaggi. Ogni generico ha i
suoi vincoli di trait, quindi le funzioni con più parametri di type generico
possono contenere molte informazioni sui vincoli di trait tra il nome della
funzione e il suo elenco di 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 clausola where
dopo la
firma della funzione. Quindi, invece di scrivere:
fn una_funzione<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
possiamo usare una clausola where
, in questo modo:
fn una_funzione<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
unimplemented!()
}
La firma di questa funzione è meno confusionaria: il nome della funzione, l’elenco dei parametri e il type di ritorno sono vicini, come in una funzione senza molti vincoli di trait.
Restituire Type che Implementano Trait
Possiamo anche usare la sintassi impl Trait
nella posizione di ritorno per
restituire un valore di un type che implementa un trait, come mostrato qui:
pub trait Sommario {
fn riassunto(&self) -> String;
}
pub struct Articolo {
pub titolo: String,
pub posizione: String,
pub autore: String,
pub contenuto: String,
}
impl Sommario for Articolo {
fn riassunto(&self) -> String {
format!("{}, di {} ({})", self.titolo, self.autore, self.posizione)
}
}
pub struct PostSocial {
pub nomeutente: String,
pub contenuto: String,
pub risposta: bool,
pub repost: bool,
}
impl Sommario for PostSocial {
fn riassunto(&self) -> String {
format!("{}: {}", self.nomeutente, self.contenuto)
}
}
fn riassumibile() -> impl Sommario {
PostSocial {
nomeutente: String::from("horse_ebooks"),
contenuto: String::from(
"ovviamente, come probabilmente già sapete, gente",
),
risposta: false,
repost: false,
}
}
Utilizzando impl Sommario
come type di ritorno, specifichiamo che la
funzione riassumibile
restituisce un type che implementa il trait
Sommario
senza nominare il type concreto. In questo caso, riassumibile
restituisce un PostSocial
, ma il codice che chiama questa funzione non ha
bisogno di saperlo.
La possibilità di specificare un type di ritorno solo tramite il trait che
implementa è particolarmente utile nel contesto di chiusure (closure) e
iteratori, che tratteremo nel Capitolo 13. Chiusure e iteratori creano type
che solo il compilatore conosce o type che sono molto lunghi da specificare.
La sintassi impl Trait
consente di specificare in modo conciso che una
funzione restituisca un type che implementa il trait Iterator
senza dover
scrivere un type molto lungo.
Tuttavia, è possibile utilizzare impl Trait
solo se si restituisce un singolo
type. Ad esempio, questo codice che restituisce un Articolo
o un
PostSocial
con il type di ritorno specificato come impl Sommario
non
funzionerebbe:
pub trait Sommario {
fn riassunto(&self) -> String;
}
pub struct Articolo {
pub titolo: String,
pub posizione: String,
pub autore: String,
pub contenuto: String,
}
impl Sommario for Articolo {
fn riassunto(&self) -> String {
format!("{}, di {} ({})", self.titolo, self.autore, self.posizione)
}
}
pub struct PostSocial {
pub nomeutente: String,
pub contenuto: String,
pub risposta: bool,
pub repost: bool,
}
impl Sommario for PostSocial {
fn riassunto(&self) -> String {
format!("{}: {}", self.nomeutente, self.contenuto)
}
}
fn riassumibile(switch: bool) -> impl Sommario {
if switch {
Articolo {
titolo: String::from(
"I Penguins vincono la Stanley Cup!",
),
posizione: String::from("Pittsburgh, PA, USA"),
autore: String::from("Iceburgh"),
contenuto: String::from(
"I Pittsburgh Penguins sono ancora una volta la migliore squadra di hockey nella NHL.",
),
}
} else {
PostSocial {
nomeutente: String::from("horse_ebooks"),
contenuto: String::from(
"ovviamente, come probabilmente già sapete, gente",
),
risposta: false,
riposta: false,
}
}
}
Restituire un Articolo
o un PostSocial
non è consentito a causa di
restrizioni relative all’implementazione della sintassi impl Trait
nel
compilatore. Spiegheremo come scrivere una funzione con questo comportamento
nella sezione “Usare gli Oggetti Trait per Astrarre Comportamenti
Condivisi” del Capitolo 18.
Utilizzare Vincoli di Trait per Implementare Metodi in Modo Condizionale
Utilizzando un vincolo di trait con un blocco impl
che utilizza parametri di
type generico, possiamo implementare metodi in modo condizionale per i type
che implementano i trait specificati. Ad esempio, il type Coppia<T>
nel
Listato 10-15 implementa sempre la funzione new
per restituire una nuova
istanza di Coppia<T>
(abbiamo menzionato nella sezione “Metodi” del Capitolo 5 che Self
è un alias di type per il type del
blocco impl
, che in questo caso è Coppia<T>
). Ma nel blocco impl
successivo, Coppia<T>
implementa il metodo mostra_comparazione
solo se il
suo type interno T
implementa il trait PartialOrd
che abilita il
confronto e il trait Display
che abilita la stampa.
use std::fmt::Display;
struct Coppia<T> {
x: T,
y: T,
}
impl<T> Coppia<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Coppia<T> {
fn mostra_comparazione(&self) {
if self.x >= self.y {
println!("Il membro più grande è x = {}", self.x);
} else {
println!("Il membro più grande è y = {}", self.y);
}
}
}
Possiamo anche implementare in modo condizionale un trait per qualsiasi type
che implementa un altro trait. Le implementazioni di un trait su qualsiasi
type che soddisfi i vincoli di trait sono chiamate implementazioni
generali (blanket implementations) e sono ampiamente utilizzate nella
libreria standard di Rust. Ad esempio, la libreria standard implementa il
trait ToString
su qualsiasi type che implementi il trait Display
. Il
blocco impl
nella libreria standard è simile a questo codice:
impl<T: Display> ToString for T {
// --taglio--
}
Poiché la libreria standard ha questa implementazione generale, possiamo
chiamare il metodo to_string
definito dal trait ToString
su qualsiasi
type che implementi il trait Display
. Ad esempio, possiamo trasformare gli
integer nei loro corrispondenti valori String
in questo modo, perché gli
integer implementano Display
:
#![allow(unused)] fn main() { let s = 3.to_string(); }
Le implementazioni generali compaiono nella documentazione per il trait in questione nella sezione “Implementatori” (Implementors).
I trait e i vincoli dei trait ci consentono di scrivere codice che utilizza parametri di type generico per ridurre le duplicazioni, ma anche di specificare al compilatore che desideriamo che il type generico abbia un comportamento particolare. Il compilatore può quindi utilizzare le informazioni sui vincoli di trait per verificare che tutti i type concreti utilizzati nel nostro codice forniscano il comportamento corretto. Nei linguaggi a tipizzazione dinamica, otterremmo un errore durante l’esecuzione se chiamassimo un metodo su un type che non lo definisce. Ma Rust sposta questi errori in fase di compilazione, quindi siamo costretti a correggere i problemi prima ancora che il nostro codice possa essere eseguito. Inoltre, non dobbiamo scrivere codice che verifichi il comportamento durante l’esecuzione, perché lo abbiamo già verificato in fase di compilazione. Ciò migliora le prestazioni senza dover rinunciare alla flessibilità dei type generici.