Trait Avanzati
Abbiamo già visto i trait nella sezione “Definire il Comportamento Condiviso con i Trait” del 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 ItemIl 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 PuntoIl 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 (parametri per type di default). 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 MetriPer 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, in cui è già definito un metodo vola. Ogni
metodo vola fa qualcosa di diverso.
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 UmanoQuando 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.vola(); }
vola su un’istanza di UmanoQuesto 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 CaneForniamo 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 le 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 le funzionalità di DisplayDichiarando 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 {}
| ^^^^^ unsatisfied trait bound
|
help: the trait `std::fmt::Display` is not implemented for `Punto`
--> src/main.rs:16:1
|
16 | struct Punto {
| ^^^^^^^^^^^^
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();
| ^^^^^^^^^^^^^^^ unsatisfied trait bound
|
help: the trait `std::fmt::Display` is not implemented for `Punto`
--> src/main.rs:16:1
|
16 | struct Punto {
| ^^^^^^^^^^^^
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
Nella sezione “Implementare un Trait su un Type” del 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 DisplayL’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.