Salta ai contenuti

Trait avanzati in Rust

Nella pagina sui trait hai imparato a definire un trait e a implementarlo. Nelle pagine sui generici e sui trait comuni hai usato trait come Display, From e Add. Per la maggior parte del codice di tutti i giorni basta. Ma quando leggi la libreria standard, o un crate famoso, vedrai caratteristiche dei trait che all’inizio sembrano un po’ strane: type Item, Add<Rhs = Self>, trait Pretty: Display, impl<T: Display> ... for T.

Questa pagina spiega queste caratteristiche una alla volta. Nessuna è magia. Ognuna risolve un problema piccolo e concreto, e quando conosci il problema la sintassi ha senso. Vai con calma: va benissimo leggere una sezione, provare il codice e tornare più tardi.

Hai già incontrato un tipo associato: Iterator ha type Item. Un tipo associato è un tipo segnaposto dentro un trait. Ogni implementazione lo riempie una volta sola.

Ecco un piccolo trait Container. Una playlist contiene String e una lista di punteggi contiene u32, quindi ognuna sceglie il suo Item:

use std::fmt::Debug;
trait Container {
type Item;
fn get(&self, index: usize) -> Option<&Self::Item>;
fn size(&self) -> usize;
fn first(&self) -> Option<&Self::Item> {
self.get(0)
}
}
struct Playlist {
songs: Vec<String>,
}
struct Scores {
values: Vec<u32>,
}
impl Container for Playlist {
type Item = String;
fn get(&self, index: usize) -> Option<&String> {
self.songs.get(index)
}
fn size(&self) -> usize {
self.songs.len()
}
}
impl Container for Scores {
type Item = u32;
fn get(&self, index: usize) -> Option<&u32> {
self.values.get(index)
}
fn size(&self) -> usize {
self.values.len()
}
}
// Funziona con qualsiasi Container i cui elementi si possono stampare con {:?}
fn describe<C: Container>(c: &C)
where
C::Item: Debug,
{
println!("{} elementi, il primo è {:?}", c.size(), c.first());
}
fn main() {
let playlist = Playlist { songs: vec![String::from("Intro"), String::from("Outro")] };
let scores = Scores { values: vec![90, 72, 85] };
describe(&playlist);
describe(&scores);
}

Output:

2 elementi, il primo è Some("Intro")
3 elementi, il primo è Some(90)
▶ Provalo tu

Dentro il trait, Self::Item vuol dire «il tipo che ha scelto questa implementazione». C::Item in describe vuol dire la stessa cosa per il tipo C. (La riga where è solo un posto più ordinato per scrivere il vincolo: ne riparliamo alla fine della pagina.)

Perché non un parametro generico? Potresti scrivere trait Container<T>. Ma allora un tipo potrebbe implementarlo tante volte, Container<String> e Container<u32>, e chi chiama dovrebbe ogni volta dire quale intende. Per una playlist non ha senso: contiene canzoni, punto.

È proprio per questo che Iterator usa type Item. Un iteratore su un Vec<i32> produce i32 e nient’altro, quindi for x in v.iter() non ha mai bisogno di un’annotazione di tipo. Una semplice regola pratica:

  • Una sola risposta naturale per tipo → tipo associato (Iterator::Item, Add::Output).
  • Tante risposte valide per tipo → parametro generico (From<T>: una String si può costruire da un &str, da un char, da un Box<str>…).

Nella pagina sui trait comuni hai fatto funzionare + per un Point implementando Add. Ecco come è definito davvero Add nella libreria standard:

trait Add<Rhs = Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}

Qui si incontrano due caratteristiche. Output è un tipo associato (quello che produce il +). E Rhs = Self è un parametro di tipo predefinito: «il lato destro è dello stesso tipo del sinistro, a meno che tu non dica altrimenti». Quando scrivi impl Add for Point, ottieni il valore predefinito. Quando vuoi sommare un tipo diverso, scrivi impl Add<AltroTipo> for Point.

use std::ops::Add;
#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Centimeters(f64);
// Usa il predefinito Rhs = Self, quindi questo è Meters + Meters
impl Add for Meters {
type Output = Meters;
fn add(self, other: Meters) -> Meters {
Meters(self.0 + other.0)
}
}
// Sovrascrive il predefinito: Meters + Centimeters
impl Add<Centimeters> for Meters {
type Output = Meters;
fn add(self, other: Centimeters) -> Meters {
Meters(self.0 + other.0 / 100.0)
}
}
fn main() {
let a = Meters(2.0);
let b = Meters(3.5);
let c = Centimeters(50.0);
println!("{:?}", a + b);
println!("{:?}", a + c);
}

Output:

Meters(5.5)
Meters(2.5)
▶ Provalo tu

Nota che Add usa entrambi gli strumenti: un parametro generico per il lato destro (tante risposte valide: puoi sommare Meters o Centimeters) e un tipo associato per il risultato (una risposta per ogni coppia). I parametri di tipo predefiniti servono soprattutto quando progetti un trait: tengono corto il caso comune senza bloccare quello raro.

A volte il tuo trait ha senso solo se il tipo sa già fare qualcos’altro. Un supertrait dice «per implementare il mio trait, devi implementare anche quello». Si scrive con i due punti, come un vincolo:

use std::fmt;
// Tutto ciò che è Pretty deve essere anche Display
trait Pretty: fmt::Display {
fn pretty(&self) -> String {
format!("*** {} ***", self)
}
}
struct Temperature {
celsius: f64,
}
impl fmt::Display for Temperature {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:.1}°C", self.celsius)
}
}
impl Pretty for Temperature {}
fn main() {
let t = Temperature { celsius: 21.456 };
println!("{}", t);
println!("{}", t.pretty());
}

Output:

21.5°C
*** 21.5°C ***
▶ Provalo tu

Il metodo predefinito pretty può usare {} su self perché il supertrait promette che Display c’è. Senza : fmt::Display, Rust rifiuterebbe quel format!, dato che non potrebbe sapere se il tipo è stampabile. impl Pretty for Temperature {} è vuoto: dice solo «sì, usa il metodo predefinito».

Esempi reali dalla libreria standard: Copy: Clone (ogni tipo Copy deve essere anche Clone) ed Eq: PartialEq.

Un’implementazione blanket («a coperta») implementa un trait per ogni tipo che soddisfa una condizione, in un colpo solo. La sintassi è impl<T: QualcheVincolo> MioTrait for T:

use std::fmt::Display;
trait Shout {
fn shout(&self) -> String;
}
// Ogni tipo che implementa Display riceve Shout gratis
impl<T: Display> Shout for T {
fn shout(&self) -> String {
format!("{}!", self.to_string().to_uppercase())
}
}
fn main() {
println!("{}", "ciao".shout());
println!("{}", 42.shout());
println!("{}", 'a'.shout());
println!("{}", String::from("rust mi piace").shout());
}

Output:

CIAO!
42!
A!
RUST MI PIACE!
▶ Provalo tu

Non abbiamo mai scritto impl Shout for i32 o impl Shout for char. L’impl blanket li ha coperti tutti, compresi i tipi Display che ancora non esistono.

Hai usato un impl blanket tante volte senza saperlo. La libreria standard ha (più o meno) impl<T: Display> ToString for T. Ecco perché ogni tipo con Display ha un metodo .to_string(). Lo stesso trucco ti regala Into quando implementi From.

Un limite: quando esiste un impl blanket, non puoi aggiungere un secondo impl più specifico per un tipo che è già coperto (per esempio un impl Shout for i32 speciale). Rust avrebbe due risposte e rifiuta con un errore di «implementazioni in conflitto» (conflicting implementations).

Ecco una regola che sorprende tutti, almeno una volta. Prova a dare a Vec<String> un bel Display:

use std::fmt;
impl fmt::Display for Vec<String> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.join(", "))
}
}
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
--> src/main.rs:3:1
|
3 | impl fmt::Display for Vec<String> {
| ^^^^^^^^^^^^^^^^^^^^^^-----------
| |
| `Vec` is not defined in the current crate
|
= note: define and implement a trait or new type instead

Il compilatore dice (in inglese) che per i tipi definiti fuori dal tuo crate puoi implementare solo trait definiti nel tuo crate, e che Vec non è definito qui. È la regola dell’orfano (orphan rule): puoi implementare un trait per un tipo solo se il trait o il tipo è definito nel tuo crate. Display appartiene alla libreria standard, e anche Vec, quindi nessuno dei due è tuo.

Perché esiste questa regola? Immagina che due crate da cui dipendi abbiano scritto entrambi il loro impl Display for Vec<String>. Quale dovrebbe usare Rust? Non c’è una risposta buona, quindi Rust rende la situazione impossibile.

La soluzione è il pattern newtype: avvolgi il tipo esterno in una piccola struct tua. Ora il tipo è tuo, e puoi implementarci quello che vuoi:

use std::fmt;
// Un involucro locale attorno a un tipo esterno
struct Shopping(Vec<String>);
impl fmt::Display for Shopping {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let list = Shopping(vec![String::from("latte"), String::from("uova"), String::from("pane")]);
println!("Ti serve: {}", list);
println!("Sono {} cose", list.0.len());
}

Output:

Ti serve: [latte, uova, pane]
Sono 3 cose
▶ Provalo tu

self.0 raggiunge il Vec che sta dentro. Un newtype non ha costi a runtime: in memoria, Shopping è esattamente un Vec<String>. Lo svantaggio è che i metodi di Vec non stanno direttamente su Shopping, quindi scrivi list.0.len() (oppure aggiungi i metodi che ti servono). I newtype servono a molto più che aggirare la regola dell’orfano; vedi i design pattern.

Sintassi completamente qualificata: quando i nomi si scontrano

Sezione intitolata “Sintassi completamente qualificata: quando i nomi si scontrano”

Due trait possono avere metodi con lo stesso nome, e anche il tipo stesso può averne uno. Quale chiama robot.greet()?

trait English {
fn greet(&self) -> String;
fn language() -> String;
}
trait Spanish {
fn greet(&self) -> String;
fn language() -> String;
}
struct Robot;
impl Robot {
fn greet(&self) -> String {
String::from("BEEP BOOP")
}
}
impl English for Robot {
fn greet(&self) -> String { String::from("Hello!") }
fn language() -> String { String::from("inglese") }
}
impl Spanish for Robot {
fn greet(&self) -> String { String::from("¡Hola!") }
fn language() -> String { String::from("spagnolo") }
}
fn main() {
let r = Robot;
println!("{}", r.greet()); // vince il metodo del tipo stesso
println!("{}", English::greet(&r)); // scegli un trait per nome
println!("{}", Spanish::greet(&r));
// Qui non c'è `self`, quindi Rust ha bisogno del tipo E del trait:
println!("{}", <Robot as English>::language());
println!("{}", <Robot as Spanish>::language());
}

Output:

BEEP BOOP
Hello!
¡Hola!
inglese
spagnolo
▶ Provalo tu

Tre livelli, dal più corto al più esplicito:

  • r.greet(): viene scelto per primo il metodo proprio del tipo (inerente).
  • English::greet(&r): «la versione di English». Rust ricava il tipo da &r.
  • <Robot as English>::language(): la sintassi completamente qualificata (fully qualified syntax). Ti serve per le funzioni senza self, perché non c’è un valore da cui Rust possa capire il tipo. English::language() da solo fallisce con error[E0790]: cannot call associated function on trait without specifying the corresponding impl type (non puoi chiamare una funzione associata su un trait senza indicare il tipo dell’implementazione).

Non ti servirà spesso. Quando ti serve, di solito l’errore del compilatore ti suggerisce la correzione esatta.

impl Trait negli argomenti e nei valori di ritorno, più where

Sezione intitolata “impl Trait negli argomenti e nei valori di ritorno, più where”

Puoi scrivere impl Trait in due posti, e in ognuno vuol dire una cosa leggermente diversa:

  • Come argomento, fn f(x: impl Trait): è chi chiama a scegliere il tipo. È un modo corto di scrivere una funzione generica fn f<T: Trait>(x: T).
  • Come valore di ritorno, fn f() -> impl Trait: è la funzione a scegliere un tipo concreto e a tenerlo nascosto. Chi chiama sa solo che «è qualcosa che implementa Trait».
// Sceglie chi chiama: va bene qualsiasi iteratore di u32
fn print_all(items: impl Iterator<Item = u32>) {
let all: Vec<u32> = items.collect();
println!("{:?}", all);
}
// Sceglie la funzione: il tipo vero è un lungo Filter<...> che non scriviamo mai
fn evens_up_to(limit: u32) -> impl Iterator<Item = u32> {
(0..=limit).filter(|n| n % 2 == 0)
}
fn main() {
print_all(vec![3, 1, 4].into_iter());
print_all(evens_up_to(10));
}

Output:

[3, 1, 4]
[0, 2, 4, 6, 8, 10]
▶ Provalo tu

impl Trait come valore di ritorno è comodissimo con closure e iteratori. Il tipo vero del risultato di evens_up_to contiene il tipo della closure, e le closure non hanno un nome che potresti scrivere. impl Iterator ti permette di saltarlo.

Quando una funzione ha diversi parametri generici con diversi vincoli ciascuno, le parentesi angolari diventano affollate. Una clausola where sposta i vincoli dopo la firma, così la forma della funzione è più facile da leggere:

use std::fmt::{Debug, Display};
// Affollato: fn report<T: Display, U: Debug + PartialOrd>(label: T, a: U, b: U) -> String
// Più ordinato:
fn report<T, U>(label: T, a: U, b: U) -> String
where
T: Display,
U: Debug + PartialOrd,
{
let bigger = if a > b { a } else { b };
format!("{}: il maggiore è {:?}", label, bigger)
}
fn main() {
println!("{}", report("punteggi", 7, 12));
println!("{}", report(String::from("nomi"), "ann", "bob"));
}

Output:

punteggi: il maggiore è 12
nomi: il maggiore è "bob"
▶ Provalo tu

Per il compilatore le due forme significano esattamente la stessa cosa. where è anche il posto naturale per i vincoli sui tipi associati, come C::Item: Debug nel primo esempio di questa pagina.

Implementare un trait dimenticando il suo supertrait. Se togli il blocco impl fmt::Display for Temperature dall’esempio sui supertrait, ottieni:

error[E0277]: `Temperature` doesn't implement `std::fmt::Display`
--> src/main.rs:13:17
|
13 | impl Pretty for Temperature {}
| ^^^^^^^^^^^ unsatisfied trait bound
|
note: required by a bound in `Pretty`
--> src/main.rs:3:15
|
3 | trait Pretty: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `Pretty`

Il messaggio dice che Temperature non implementa Display, richiesto da un vincolo di Pretty. La soluzione è implementare anche il supertrait (qui, Display). La nota punta proprio al : fmt::Display che lo richiede.

Restituire due tipi diversi da -> impl Trait. impl Trait nasconde un solo tipo. Non può essere «a volte questo, a volte quello»:

fn numbers(evens: bool) -> impl Iterator<Item = u32> {
if evens {
(0..10).filter(|n| n % 2 == 0)
} else {
vec![1, 3, 5].into_iter()
}
}
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:5:9
|
2 | / if evens {
3 | | (0..10).filter(|n| n % 2 == 0)
| | ------------------------------
| | | |
| | | the expected closure
| | expected because of this
4 | | } else {
5 | | vec![1, 3, 5].into_iter()
| | ^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Filter<Range<{integer}>, {closure@...}>`, found `IntoIter<{integer}>`
6 | | }
| |_____- `if` and `else` have incompatible types
|
= note: expected struct `Filter<std::ops::Range<{integer}>, {closure@src/main.rs:3:24: 3:27}>`
found struct `std::vec::IntoIter<{integer}>`
help: you could change the return type to be a boxed trait object
|
1 - fn numbers(evens: bool) -> impl Iterator<Item = u32> {
1 + fn numbers(evens: bool) -> Box<dyn Iterator<Item = u32>> {
|
help: if you change the return type to expect trait objects, box the returned expressions
|
3 ~ Box::new((0..10).filter(|n| n % 2 == 0))
4 | } else {
5 ~ Box::new(vec![1, 3, 5].into_iter())
|

Il compilatore spiega che i rami if ed else hanno tipi incompatibili, e suggerisce di restituire un trait object in un Box. Quando il tipo cambia davvero a runtime, restituisci invece un Box<dyn Trait>, come propone il compilatore. Vedi Trait object.

  • Un tipo associato (type Item) dà una sola risposta per ogni implementazione. Usa un parametro generico quando un tipo può implementare il trait in tanti modi.
  • I parametri di tipo predefiniti come Add<Rhs = Self> tengono corto il caso comune; impl Add<Altro> for T li sovrascrive.
  • Un supertrait (trait Pretty: Display) richiede un altro trait e permette ai metodi predefiniti di usarlo.
  • Un impl blanket (impl<T: Display> MioTrait for T) copre in un colpo tutti i tipi che corrispondono: è così che funziona .to_string().
  • La regola dell’orfano ti impedisce di implementare un trait esterno per un tipo esterno. Avvolgi il tipo in un newtype per aggirarla.
  • Usa la sintassi completamente qualificata <Tipo as Trait>::funzione() quando i nomi si scontrano; impl Trait come valore di ritorno nasconde un solo tipo concreto; where rende leggibili i vincoli lunghi.

Verifica veloce

1. Perché Iterator usa type Item invece di Iterator<T>?

2. Cosa vuol dire Rhs = Self in trait Add<Rhs = Self>?

3. Vuoi impl Display for Vec<String>. Rust rifiuta. Qual è la soluzione abituale?

4. Quale riga chiama la versione English di una funzione senza parametro self?

5. In fn evens() -> impl Iterator<Item = u32>, chi decide il tipo concreto dell'iteratore?

Punteggio: 0 / 5