Trait Avanzati
Abbiamo già visto i trait in “Trait: Definire il Comportamento Condiviso con i Trait” nel Capitolo 10, ma non abbiamo trattato i dettagli più avanzati. Ora che sai di più su Rust, possiamo mettere le mani in pasta in certi dettagli più complessi.
Definire Trait con Type Associati
I type associati collegano un type segnaposto con un trait in modo che le definizioni dei metodi del trait possano usare questi segnaposto nelle loro firme. Chi implementa il trait specificherà il type concreto da usare per quella particolare implementazione. In questo modo possiamo definire un trait che usa qualche type senza dover sapere esattamente quali siano fino a quando il trait non verrà implementato.
Abbiamo detto che molte delle funzionalità avanzate di questo capitolo sono usate raramente. I type associati stanno a metà: si usano meno rispetto ad altre funzionalità spiegate nel resto del libro, ma più frequentemente di altre funzionalità in questo capitolo.
Un esempio di trait con un type associato è il trait Iterator
della
libreria standard. Il type associato si chiama Item
e rappresenta il type
dei valori su cui il type che implementa Iterator
itera. La definizione del
trait Iterator
è mostrata nel Listato 20-13.
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
Iterator
con type associato Item
Il type Item
è un segnaposto, e la definizione del metodo next
mostra che
restituirà valori di type Option<Self::Item>
. Chi implementa il trait
Iterator
specifica il type concreto per Item
, e il metodo next
ritorna
un Option
che contiene un valore di quel type.
I type associati potrebbero sembrare simili ai generici, visto che anche
questi ultimi permettono di definire una funzione senza specificare i type.
Per capirne la differenza, vediamo un’implementazione di Iterator
su un type
chiamato Contatore
che specifica Item
come u32
:
struct Contatore {
conteggio: u32,
}
impl Contatore {
fn new() -> Contatore {
Contatore { conteggio: 0 }
}
}
impl Iterator for Contatore {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
// --taglio--
if self.conteggio < 5 {
self.conteggio += 1;
Some(self.conteggio)
} else {
None
}
}
}
Questa sintassi ricorda i type generici. Allora perché non definire Iterator
usando solo generici, come mostra il Listato 20-14?
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
Iterator
usando i genericiLa differenza è che usando i generici, come nel Listato 20-14, dobbiamo annotare
i type in ogni implementazione; siccome potremmo anche implementare
Iterator<String> for Contatore
o qualsiasi altro type, potremmo avere più
implementazioni di Iterator
per Contatore
. In altre parole, quando un
trait ha un parametro generico, può essere implementato per un type più
volte, cambiando i type concreti dei parametri generici ogni volta. Quando
usiamo il metodo next
su Contatore
, dovremmo fornire annotazioni di type
per indicare quale implementazione di Iterator
vogliamo usare.
Con i type associati non serve annotare i type perché non possiamo
implementare un trait più volte su uno stesso type. Nel Listato 20-13, con i
type associati, scegliamo il type di Item
una sola volta perché c’è una
sola impl Iterator for Contatore
. Non serve indicare che vogliamo un iteratore
di u32
ogni volta che chiamiamo next
su Contatore
.
I type associati diventano parte del contratto del trait: chi implementa il trait deve fornire un type per sostituire il segnaposto. Spesso i type associati hanno nomi che descrivono come saranno usati, ed è buona prassi documentarli nelle API.
Usare Parametri Generici di Default e Sovrascrivere gli Operatori
Quando usiamo type generici, possiamo specificare un type concreto di
default per il type generico. Questo elimina la necessità per chi implementa
il trait di specificare un type concreto se il type di default va bene. Si
specifica un type di default dichiarando il generico con la sintassi
<TypeSegnaposto=TypeConcreto>
.
Un ottimo esempio di questa tecnica è la sovrascrittura degli operatori, dove
personalizzi il comportamento di un operatore (come +
) in situazioni
particolari.
Rust non permette di creare operatori propri o sovrascrivere operatori
arbitrari. Ma puoi sovrascrivere le operazioni e i trait corrispondenti
elencati in std::ops
implementando i trait associati all’operatore. Per
esempio, nel Listato 20-15 sovrascriviamo l’operatore +
per sommare due
istanze di Punto
. Lo facciamo implementando il trait Add
per la struct
Punto
.
use std::ops::Add; #[derive(Debug, Copy, Clone, PartialEq)] struct Punto { x: i32, y: i32, } impl Add for Punto { type Output = Punto; fn add(self, altro: Punto) -> Punto { Punto { x: self.x + altro.x, y: self.y + altro.y, } } } fn main() { assert_eq!( Punto { x: 1, y: 0 } + Punto { x: 2, y: 3 }, Punto { x: 3, y: 3 } ); }
Add
per sovrascrivere l’operatore +
per le istanze di Punto
Il metodo add
somma i valori x
di due istanze Punto
e i valori y
di due
istanze Punto
per creare un nuovo Punto
. Il trait Add
ha un type
associato chiamato Output
che determina il type restituito dal metodo add
.
Il type generico di default in questo codice si trova all’interno del trait
Add
. Ecco la sua definizione:
#![allow(unused)] fn main() { trait Add<Rhs=Self> { type Output; fn add(self, rhs: Rhs) -> Self::Output; } }
Questo codice dovrebbe sembrarti familiare: un trait con un metodo e un type
associato. La novità è Rhs=Self
: questa sintassi si chiama default type
parameters ( type di default dei parametri). Il parametro generico Rhs
(abbreviazione di right-hand side, lato destro) definisce il type del
parametro rhs
nel metodo add
. Se non specifichiamo un type concreto per
Rhs
nell’implementazione di Add
, il type di Rhs
di default sarà Self
,
il type su cui stiamo implementando Add
.
Quando abbiamo implementato Add
per Punto
, abbiamo usato il default per
Rhs
perché volevamo sommare due Punto
. Passiamo ora a un esempio di
implementazione del trait Add
in cui vogliamo personalizzare il type Rhs
invece di usare il default.
Abbiamo due struct, Millimetri
e Metri
, che contengono valori in unità
diverse. Questo tipo di “incapsulamento sottile” attorno a un type esistente è
chiamato newtype pattern, di cui parleremo più avanti nella sezione
“Implementare Trait Esterni con il Modello Newtype”. Vogliamo sommare valori in millimetri a valori in metri e far sì che l’implementazione di Add
si occupi di fare la conversione corretta. Possiamo implementare Add
per Millimetri
con Metri
come Rhs
, come mostrato nel Listato 20-16.
use std::ops::Add;
struct Millimetri(u32);
struct Metri(u32);
impl Add<Metri> for Millimetri {
type Output = Millimetri;
fn add(self, altro: Metri) -> Millimetri {
Millimetri(self.0 + (altro.0 * 1000))
}
}
Add
su Millimetri
per sommare Millimetri
con Metri
Per sommare Millimetri
e Metri
, specifichiamo impl Add<Metri>
per
impostare il valore del parametro di type Rhs
invece di usare il default
Self
.
Userai i type di default per i parametri in due modi principali:
- Per estendere un type senza rompere il codice esistente
- Per permettere personalizzazioni in casi specifici che la maggior parte degli utenti non userà
Il trait Add
della libreria standard è un esempio del secondo punto: di
solito si sommano due type uguali, ma il trait Add
permette di
personalizzare questo comportamento. L’uso di un type di default come
parametro nella definizione di Add
significa che non devi specificare quel
parametro extra la maggior parte delle volte, riducendo il codice ripetitivo e
facilitandone l’uso.
Il primo punto è simile ma all’opposto: se vuoi aggiungere un type come parametro a un trait esistente, puoi dargli un default per permettere l’estensione della funzionalità del trait senza rompere il codice esistente.
Disambiguare Tra Metodi Con lo Stesso Nome
Nulla in Rust vieta ad un trait di avere metodi che hanno lo stesso nome di metodi in un altro trait. E Rust non ti impedisce di implementare entrambi i trait su di un type. È possibile anche definire un metodo sul type con lo stesso nome di un metodo del trait.
Quando chiami metodi con lo stesso nome devi indicare a Rust quale vuoi usare.
Considera il codice nel Listato 20-17, dove sono definiti due trait, Pilota
e Mago
, entrambi con un metodo chiamato vola
. Entrambi i trait sono
implementati su un type Umano
, che ha anche un metodo vola
definito
direttamente.
trait Pilota { fn vola(&self); } trait Mago { fn vola(&self); } struct Umano; impl Pilota for Umano { fn vola(&self) { println!("Qui parla il capitano."); } } impl Mago for Umano { fn vola(&self) { println!("Sali!"); } } impl Umano { fn vola(&self) { println!("*sbatte furiosamente le braccia*"); } } fn main() {}
vola
implementati sul type Umano
, e un metodo vola
definito direttamente su Umano
Quando chiamiamo vola
su un’istanza Umano
, come impostazione predefinita il
compilatore chiama il metodo definito direttamente sul type, come mostrato nel
Listato 20-18.
trait Pilota { fn vola(&self); } trait Mago { fn vola(&self); } struct Umano; impl Pilota for Umano { fn vola(&self) { println!("Qui parla il capitano."); } } impl Mago for Umano { fn vola(&self) { println!("Sali!"); } } impl Umano { fn vola(&self) { println!("*sbatte furiosamente le braccia*"); } } fn main() { let persona = Umano; persona.fly(); }
vola
su un’istanza di Umano
Questo codice stampa *sbatte furiosamente le braccia*
, mostrando che Rust
chiama il metodo vola
definito direttamente su Umano
.
Per chiamare i metodi vola
dal trait Pilota
o Mago
serve una sintassi
più esplicita per indicare quale metodo si intende. Il Listato 20-19 mostra
questa sintassi.
trait Pilota { fn vola(&self); } trait Mago { fn vola(&self); } struct Umano; impl Pilota for Umano { fn vola(&self) { println!("Qui parla il capitano."); } } impl Mago for Umano { fn vola(&self) { println!("Sali!"); } } impl Umano { fn vola(&self) { println!("*sbatte furiosamente le braccia*"); } } fn main() { let persona = Umano; Pilota::vola(&persona); Mago::vola(&persona); persona.vola(); }
vola
di quale trait si vuole chiamareSpecificare il nome del trait prima del metodo chiarisce a Rust quale
implementazione di vola
vogliamo chiamare. Possiamo anche scrivere
Umano::vola(&persona)
, che è equivalente a person.vola()
, ma è più verboso
se non serve disambiguare.
Questo codice stampa:
$ cargo run
Compiling esempio-trait v0.1.0 (file:///progetti/esempio-trait)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.07s
Running `target/debug/esempio-trait`
Qui parla il capitano.
Sali!
*sbatte furiosamente le braccia*
Poiché il metodo vola
prende self
come parametro, se avessimo due type che
implementano entrambi un trait, Rust potrebbe inferire quale implementazione
del trait usare in base al type di self
.
Tuttavia, le funzioni associate che non sono metodi non hanno self
. Quando ci
sono più type o trait che definiscono funzioni con lo stesso nome, Rust non
sa sempre quale type intendi, a meno che non si usi la sintassi completamente
qualificata. Per esempio, nel Listato 20-20 creiamo un trait per un rifugio
per animali che vuole chiamare tutti i cuccioli di cane Rex. Realizziamo un
trait Animale
con una funzione associata chiamata nomignolo
. Il trait
Animale
è implementato per la struct Cane
, sulla quale definiamo una
funzione associata nomignolo
.
trait Animale { fn nomignolo() -> String; } struct Cane; impl Cane { fn nomignolo() -> String { String::from("Rex") } } impl Animale for Cane { fn nomignolo() -> String { String::from("cucciolo") } } fn main() { println!("Un piccolo di cane è detto {}", Cane::nomignolo()); }
Implementiamo il codice che chiama tutti i cuccioli Rex nella funzione associata
nomignolo
definita direttamente in Cane
. Il type Cane
implementa anche
il trait Animale
, che descrive caratteristiche comuni a tutti gli animali. I
piccoli di cane sono chiamati cuccioli, e questo è espresso nell’implementazione
del trait Animale
per Cane
nella funzione nomignolo
associata al trait
Animale
.
In main
, chiamando Cane::nomignolo
chiamiamo la funzione associata definita
direttamente su Cane
. Questo codice stampa:
$ cargo run
Compiling esempio-trait v0.1.0 (file:///progetti/esempio-trait)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.10s
Running `target/debug/esempio-trait`
Un piccolo di cane è detto Rex
Questo output non è quello voluto: vogliamo chiamare la funzione nomignolo
del
trait Animale
implementato su Cane
così che il codice stampi Un piccolo di cane è detto cucciolo
. La tecnica di specificare il nome del trait come
nel Listato 20-19 non aiuta; se cambiamo main
con il codice del Listato 20-21,
otterremo un errore di compilazione.
trait Animale {
fn nomignolo() -> String;
}
struct Cane;
impl Cane {
fn nomignolo() -> String {
String::from("Rex")
}
}
impl Animale for Cane {
fn nomignolo() -> String {
String::from("cucciolo")
}
}
fn main() {
println!("Un piccolo di cane è detto {}", Animale::nomignolo());
}
nomignolo
del trait Animale
, ma Rust non sa quale implementazione usarePoiché Animale::nomignolo
non ha il parametro self
, e potrebbero esserci
altri type che implementano il trait Animale
, Rust non riesce a inferire
quale implementazione di Animale::nomignolo
usare. Otterremo questo errore del
compilatore:
$ cargo run
Compiling esempio-trait v0.1.0 (file:///progetti/esempio-trait)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
--> src/main.rs:21:47
|
2 | fn nomignolo() -> String;
| ------------------------- `Animale::nomignolo` defined here
...
21 | println!("Un piccolo di cane è detto {}", Animale::nomignolo());
| ^^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
|
help: use the fully-qualified path to the only available implementation
|
21 | println!("Un piccolo di cane è detto {}", <Cane as Animale>::nomignolo());
| ++++++++ +
For more information about this error, try `rustc --explain E0790`.
error: could not compile `esempio-trait` (bin "esempio-trait") due to 1 previous error
Per disambiguare e indicare a Rust che vogliamo usare l’implementazione del
trait Animale
per Cane
anziché quella per un altro type, usiamo la
sintassi completamente qualificata. Il Listato 20-22 mostra come.
trait Animale { fn nomignolo() -> String; } struct Cane; impl Cane { fn nomignolo() -> String { String::from("Rex") } } impl Animale for Cane { fn nomignolo() -> String { String::from("cucciolo") } } fn main() { println!("Un piccolo di cane è detto {}", <Cane as Animale>::nomignolo()); }
nomignolo
del trait Animale
implementato su Cane
Forniamo un’annotazione di type tra parentesi angolari < >
per dire a Rust
che vogliamo chiamare il metodo nomignolo
del trait Animale
implementato
su Cane
, trattando il type Cane
come un type Animale
per questa
chiamata. Ora il codice stampa quello che vogliamo:
$ cargo run
Compiling esempio-trait v0.1.0 (file:///progetti/esempio-trait)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.11s
Running `target/debug/esempio-trait`
Un piccolo di cane è detto cucciolo
In generale la sintassi completamente qualificata è:
<Tipo as Trait>::funzione(ricevente_se_metodo, prossimo_argomento,...);
Per funzioni associate che non sono metodi, non c’è un parametro self
, solo la
lista degli altri argomenti. Puoi usare la sintassi completamente qualificata
ovunque chiami funzioni o metodi. Tuttavia, puoi omettere parti che Rust può
dedurre dal contesto. Devi usarla solo quando ci sono più implementazioni con lo
stesso nome e Rust ha bisogno di aiuto per distinguere quale usare.
Usare Supertrait
A volte puoi scrivere un trait che dipende da un altro trait: per un type che implementa il primo trait, richiedi che implementi anche il secondo trait. Lo fai perché la definizione del trait può usare gli elementi associati del secondo. Il trait da cui il trait che implementi dipende viene chiamato supertrait del tuo trait.
Per esempio, supponiamo di voler creare un trait StampaContorno
con un
metodo stampa_contorno
che stampa un valore delimitato da un contorno di
asterischi. Data una struct Punto
che implementa il trait Display
della
libreria standard per mostrare (x, y)
, quando chiami stampa_contorno
su
un’istanza di Punto
con x=1
e y=3
, dovrebbe stampare:
**********
* *
* (1, 3) *
* *
**********
Nell’implementazione di stampa_contorno
vogliamo usare la funzionalità del
trait Display
. Quindi il trait StampaContorno
dovrebbe funzionare solo
per type che implementano anche Display
. Lo specifichiamo nella definizione
con StampaContorno: Display
. Questo è simile ad aggiungere un vincolo di
trait al trait in questione. Il Listato 20-23 mostra un implementazione del
trait StampaContorno
.
use std::fmt; trait StampaContorno: fmt::Display { fn stampa_contorno(&self) { let output = self.to_string(); let len = output.len(); println!("{}", "*".repeat(len + 4)); println!("*{}*", " ".repeat(len + 2)); println!("* {output} *"); println!("*{}*", " ".repeat(len + 2)); println!("{}", "*".repeat(len + 4)); } } fn main() {}
StampaContorno
che richiede la funzionalità di Display
Dichiarando che StampaContorno
richiede il trait Display
, possiamo usare
la funzione to_string
che è implementata automaticamente su tutti i type che
implementano Display
. Se provassimo a usare to_string
senza specificare
Display
, otterremmo un errore perché il metodo to_string
non sarebbe trovato
per il type &Self
nello scope corrente.
Vediamo cosa succede se provassimo a implementare StampaContorno
su un type
che non implementa Display
come la struct Punto
:
use std::fmt;
trait StampaContorno: fmt::Display {
fn stampa_contorno(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {output} *");
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
struct Punto {
x: i32,
y: i32,
}
impl StampaContorno for Punto {}
fn main() {
let p = Punto { x: 1, y: 3 };
p.stampa_contorno();
}
Riceveremmo un errore che dice che Display
è richiesto ma non implementato:
$ cargo run
Compiling esempio-trait v0.1.0 (file:///progetti/esempio-trait)
error[E0277]: `Punto` doesn't implement `std::fmt::Display`
--> src/main.rs:21:25
|
21 | impl StampaContorno for Punto {}
| ^^^^^ `Punto` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Punto`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `StampaContorno`
--> src/main.rs:3:23
|
3 | trait StampaContorno: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `StampaContorno`
error[E0277]: `Punto` doesn't implement `std::fmt::Display`
--> src/main.rs:26:7
|
26 | p.stampa_contorno();
| ^^^^^^^^^^^^^^^ `Punto` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Punto`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `StampaContorno::stampa_contorno`
--> src/main.rs:3:23
|
3 | trait StampaContorno: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `StampaContorno::stampa_contorno`
4 | fn stampa_contorno(&self) {
| --------------- required by a bound in this associated function
For more information about this error, try `rustc --explain E0277`.
error: could not compile `esempio-trait` (bin "esempio-trait") due to 2 previous errors
Lo risolviamo implementando Display
su Punto
e soddisfiamo le necessità di
StampaContorno
:
trait StampaContorno: fmt::Display { fn stampa_contorno(&self) { let output = self.to_string(); let len = output.len(); println!("{}", "*".repeat(len + 4)); println!("*{}*", " ".repeat(len + 2)); println!("* {output} *"); println!("*{}*", " ".repeat(len + 2)); println!("{}", "*".repeat(len + 4)); } } struct Punto { x: i32, y: i32, } impl StampaContorno for Punto {} use std::fmt; impl fmt::Display for Punto { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "({}, {})", self.x, self.y) } } fn main() { let p = Punto { x: 1, y: 3 }; p.stampa_contorno(); }
A questo punto l’implementazione di StampaContorno
per Punto
si compila
correttamente e possiamo chiamare stampa_contorno
su un’istanza di Punto
per
stamparlo con un contorno di asterischi.
Implementare Trait Esterni con il Modello Newtype
In “Implementare un Trait su un Type” nel Capitolo 10 abbiamo parlato della orphan rule che dice che possiamo implementare un trait su un type solo se il trait o il type (o entrambi) sono locali al crate. Si può aggirare questa restrizione usando il modello newtype, che consiste nel creare un nuovo type come struct tupla. (Ne abbiamo già parlato in “Creare Type Diversi con Struct Tupla” del Capitolo 5.) La struct tupla avrà un solo campo e sarà un “incapsulamento sottile” attorno al type su cui vuoi implementare un trait. L’incapsulamento è locale al crate e puoi implementare il trait sull’incapsulatore. La parola newtype deriva dal linguaggio Haskell. Non c’è alcuna penalità in prestazioni nell’uso di questo modello, e il type dell’involucro viene eliso in fase di compilazione.
Per esempio, supponiamo di voler implementare Display
su Vec<T>
, cosa che la
orphan rule ci impedisce perché sia il trait Display
che il type
Vec<T>
sono definiti fuori dal nostro crate. Possiamo invece creare una
struct Capsula
che contiene un’istanza di Vec<T>
, poi implementare
Display
su Capsula
e usare il valore Vec<T>
all’interno, come mostrato nel
Listato 20-24.
use std::fmt; struct Capsula(Vec<String>); impl fmt::Display for Capsula { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "[{}]", self.0.join(", ")) } } fn main() { let w = Capsula(vec![String::from("hello"), String::from("world")]); println!("w = {w}"); }
Capsula
attorno a Vec<String>
per implementare Display
L’implementazione di Display
usa self.0
per accedere a Vec<T>
perché
Capsula
è una struct tupla e Vec<T>
è il campo all’indice 0 della tupla.
Così possiamo usare la funzionalità del trait Display
su Capsula
.
Lo svantaggio di questa tecnica è che Capsula
è un nuovo type e non ha i
metodi del valore che incapsula. Dovresti implementare manualmente tutti i
metodi di Vec<T>
in Capsula
, in modo che i metodi deleghino a self.0
, per
poter usare Capsula
come fosse effettivamente un Vec<T>
. Se volessimo che il
nuovo type abbia tutti i metodi del type interno, implementare il trait
Deref
su Capsula
per restituire il type interno potrebbe essere una
soluzione (abbiamo parlato dell’implementazione di Deref
in “Trattare i
Puntatori Intelligenti Come Normali Reference” in Chapter 15). Se invece non vogliamo che Capsula
abbia tutti i
metodi del type interno, ad esempio per restringerne le funzionalità, dovremmo
implementare i metodi che vogliamo manualmente.
Il modello newtype è utile anche quando non si tratta di trait. Passiamo ora a delle tecniche avanzate per interagire col sistema dei type di Rust.