Design pattern in Rust
Un design pattern (schema di progettazione) è un modo collaudato di risolvere un problema che si ripresenta spesso. I pattern non sono librerie da installare. Sono più simili a ricette: «quando hai questo problema, dai al tuo codice questa forma».
Molti pattern di Java o C# in Rust hanno un aspetto diverso, oppure non servono proprio. Rust ha i suoi preferiti, costruiti sul suo forte sistema di tipi, sugli enum, sui trait e sull’ownership. L’idea di fondo della maggior parte è la stessa: lascia che sia il compilatore a scovare gli errori, così non arrivano mai ai tuoi utenti. Ogni sezione qui sotto parte dal problema e poi mostra il pattern che lo risolve.
Newtype: significati diversi, tipi diversi
Sezione intitolata “Newtype: significati diversi, tipi diversi”Il problema. Un semplice f64 può voler dire qualsiasi cosa: metri, piedi, secondi, euro. Se una funzione prende un f64 in metri e tu le passi dei piedi, il compilatore non può aiutarti: sono entrambi solo f64. (È proprio per una confusione di unità di misura di questo tipo che la NASA perse la sonda Mars Climate Orbiter nel 1999.)
Il pattern. Avvolgi il valore in una minuscola struct con un solo campo. Ora Meters e Feet sono tipi diversi, e Rust non ti lascia confonderli:
#[derive(Debug, Clone, Copy)]struct Meters(f64);
#[derive(Debug, Clone, Copy)]struct Feet(f64);
impl Feet { fn to_meters(self) -> Meters { Meters(self.0 * 0.3048) }}
fn safe_altitude(height: Meters) -> bool { height.0 >= 300.0}
fn main() { let tower = Meters(330.0); let plane = Feet(900.0);
println!("Torre al sicuro? {}", safe_altitude(tower)); println!("900 piedi sono {:.1} metri", plane.to_meters().0); println!("Aereo al sicuro? {}", safe_altitude(plane.to_meters()));}Output:
Torre al sicuro? true900 piedi sono 274.3 metriAereo al sicuro? falseSe dimentichi di convertire e scrivi safe_altitude(plane), il programma non compila nemmeno:
error[E0308]: mismatched types --> src/main.rs:13:34 |13 | println!("{}", safe_altitude(plane)); | ------------- ^^^^^ expected `Meters`, found `Feet` | | | arguments to this function are incorrectIl compilatore dice che i tipi non corrispondono: si aspettava Meters e ha trovato Feet. Un newtype non costa niente in esecuzione: in memoria Meters è esattamente un f64. Usalo per gli ID (UserId(u32) contro OrderId(u32)), le unità di misura e le stringhe già validate. Ti permette anche di aggirare la orphan rule (vedi Trait avanzati).
Builder: tante opzioni, un risultato chiaro
Sezione intitolata “Builder: tante opzioni, un risultato chiaro”Il problema. Alcune struct hanno tanti campi, e la maggior parte è facoltativa. Un costruttore come Email::new("[email protected]", "Hi", "", vec![], false) è difficile da leggere: quale false è quale? E se l’indirizzo manca o è sbagliato?
Il pattern. Una struct builder separata raccoglie le impostazioni con piccoli metodi dal nome chiaro. Ogni metodo restituisce il builder, così puoi concatenare le chiamate. Un build() finale controlla tutto e restituisce un Result:
struct Email { to: String, subject: String, body: String, urgent: bool,}
#[derive(Default)]struct EmailBuilder { to: Option<String>, subject: Option<String>, body: String, urgent: bool,}
impl EmailBuilder { fn new() -> Self { EmailBuilder::default() }
fn to(mut self, address: &str) -> Self { self.to = Some(address.to_string()); self }
fn subject(mut self, subject: &str) -> Self { self.subject = Some(subject.to_string()); self }
fn body(mut self, text: &str) -> Self { self.body = text.to_string(); self }
fn urgent(mut self) -> Self { self.urgent = true; self }
fn build(self) -> Result<Email, String> { let to = self.to.ok_or("un'email ha bisogno di un destinatario")?; if !to.contains('@') { return Err(format!("'{}' non è un indirizzo email", to)); } let subject = self.subject.unwrap_or_else(|| String::from("(nessun oggetto)")); Ok(Email { to, subject, body: self.body, urgent: self.urgent }) }}
fn show(result: Result<Email, String>) { match result { Ok(e) => println!("Pronta: to={} subject={} body={:?} urgent={}", e.to, e.subject, e.body, e.urgent), Err(msg) => println!("Errore: {}", msg), }}
fn main() { let email = EmailBuilder::new() .subject("Pranzo?") .body("Pizza alle 12?") .urgent() .build(); show(email);
show(EmailBuilder::new().subject("Ciao").build()); show(EmailBuilder::new().to("ana.example.com").build());}Output:
Pronta: [email protected] subject=Pranzo? body="Pizza alle 12?" urgent=truePronta: [email protected] subject=(nessun oggetto) body="" urgent=falseErrore: un'email ha bisogno di un destinatarioErrore: 'ana.example.com' non è un indirizzo emailOgni setter prende mut self (cioè prende l’ownership del builder), cambia un campo e restituisce il builder. È questo che fa funzionare la catena. Chi chiama imposta solo quello che gli interessa, i nomi spiegano ogni valore, e build() è l’unico posto in cui si controllano le regole. Al suo interno, ok_or trasforma il None del destinatario mancante in un Err, e ? restituisce subito quell’errore. Troverai dei builder in molti crate, per esempio std::thread::Builder e std::process::Command (vedi Ambiente e processi).
Default e struct update: l’opzione leggera
Sezione intitolata “Default e struct update: l’opzione leggera”Il problema. Un builder è un bel po’ di codice. Se la tua struct non ha regole da controllare, solo dei valori iniziali sensati, è uno spreco.
Il pattern. Implementa Default con buoni valori iniziali, poi usa la sintassi di struct update .. per cambiare solo i campi che ti interessano:
#[derive(Debug, Clone)]struct ServerConfig { host: String, port: u16, workers: u32, verbose: bool,}
impl Default for ServerConfig { fn default() -> Self { ServerConfig { host: String::from("127.0.0.1"), port: 8080, workers: 4, verbose: false, } }}
fn main() { let dev = ServerConfig { verbose: true, ..Default::default() }; let prod = ServerConfig { host: String::from("0.0.0.0"), workers: 16, ..ServerConfig::default() }; // Parti da un'altra configurazione invece che da quella predefinita: let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev); println!("{:?}", prod); println!("{:?}", staging);}Output:
ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }..qualcosa significa «prendi da qualcosa tutti i campi che non ho elencato». Perché prod.clone()? La struct update sposta i campi rimanenti, e host è una String. Senza il clone, prod sarebbe parzialmente spostato e non potresti più stamparlo. Una buona regola: usa Default + .. quando ogni combinazione di valori va bene, e un builder quando alcune combinazioni non sono valide.
Typestate: una macchina a stati controllata in compilazione
Sezione intitolata “Typestate: una macchina a stati controllata in compilazione”Il problema. Alcune cose devono succedere in un certo ordine. Una richiesta web ha bisogno di un URL prima di poter essere inviata. Con una struct normale controlleresti in esecuzione («l’URL è impostato? se no, restituisci un errore») e speri che ogni chiamante gestisca quell’errore.
Il pattern. Fai diventare lo stato parte del tipo. Una Request<NoUrl> semplicemente non ha un metodo send. Solo impostare l’URL la trasforma in una Request<HasUrl>, che invece ce l’ha:
// I due stati. Non contengono dati (o solo quelli che servono in quello stato).struct NoUrl;struct HasUrl(String);
struct Request<U> { url: U, headers: Vec<(String, String)>,}
impl Request<NoUrl> { fn new() -> Self { Request { url: NoUrl, headers: Vec::new() } }
// Cambia il tipo: Request<NoUrl> -> Request<HasUrl> fn url(self, url: &str) -> Request<HasUrl> { Request { url: HasUrl(url.to_string()), headers: self.headers } }}
// Disponibile in ogni statoimpl<U> Request<U> { fn header(mut self, key: &str, value: &str) -> Self { self.headers.push((key.to_string(), value.to_string())); self }}
// Disponibile solo dopo aver impostato un URLimpl Request<HasUrl> { fn send(self) -> String { format!("GET {} con {} header", self.url.0, self.headers.len()) }}
fn main() { let response = Request::new() .header("Accept", "text/html") .url("https://example.com") .header("User-Agent", "rust-tutorial") .send();
println!("{}", response);}Output:
GET https://example.com con 2 headerOra prova a inviare senza URL, con Request::new().send(), e il compilatore ti ferma:
error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope --> src/main.rs:22:35 |22 | let response = Request::new().send(); | ^^^^ method not found in `Request<NoUrl>` | = note: the method was found for `Request<HasUrl>`Il compilatore dice che Request<NoUrl> non ha nessun metodo send, e ti fa notare che esiste per Request<HasUrl>. Il bug non può esistere in un programma che compila. Non c’è nessun controllo in esecuzione e nessun costo in più: NoUrl occupa zero byte. Visto che url() prende self per valore, la vecchia Request<NoUrl> viene consumata, quindi nessuno può continuare a usare per sbaglio lo stato «prima». Qui i generici sono usati in modo furbo; vale la pena provare una piccola modifica nel Playground per sentire come funziona.
RAII e guardie Drop: una pulizia che non puoi dimenticare
Sezione intitolata “RAII e guardie Drop: una pulizia che non puoi dimenticare”Il problema. Le risorse vanno ripulite: chiudere il file, sbloccare il lock, cancellare la cartella temporanea, stampare «fatto». Se la pulizia è una funzione che devi ricordarti di chiamare, qualcuno se ne dimenticherà, soprattutto in caso di return anticipato o di errore.
Il pattern. Metti la pulizia in Drop. Rust chiama drop in automatico quando un valore esce dallo scope, su ogni strada d’uscita: fine normale, return anticipato, ?, persino un panic. Questo si chiama RAII («Resource Acquisition Is Initialization»: ottenere la risorsa e possedere un valore sono lo stesso passo). Un valore che serve solo a fare pulizia si chiama guardia (guard):
struct Guard { name: String,}
impl Guard { fn new(name: &str) -> Guard { println!("inizio {}", name); Guard { name: name.to_string() } }}
impl Drop for Guard { fn drop(&mut self) { println!("pulizia {}", self.name); }}
fn risky(fail: bool) -> Result<(), String> { let _guard = Guard::new("lavoro rischioso"); if fail { return Err(String::from("qualcosa è andato storto")); // la guardia fa comunque pulizia } println!("lavoro finito"); Ok(())}
fn main() { { let _block = Guard::new("blocco"); println!("dentro il blocco"); } // qui _block viene distrutto (drop)
println!("{:?}", risky(false)); println!("{:?}", risky(true));}Output:
inizio bloccodentro il bloccopulizia bloccoinizio lavoro rischiosolavoro finitopulizia lavoro rischiosoOk(())inizio lavoro rischiosopulizia lavoro rischiosoErr("qualcosa è andato storto")Usi già questo pattern in continuazione. Un File si chiude da solo quando viene distrutto. Un MutexGuard (ottenuto da mutex.lock(), vedi Concorrenza) sblocca il mutex quando viene distrutto. Un Box libera la sua memoria quando viene distrutto (Smart pointer). Ecco perché nel codice Rust non ci sono quasi mai chiamate a «close» o «free».
From e Into: argomenti di funzione più comodi
Sezione intitolata “From e Into: argomenti di funzione più comodi”Il problema. La tua funzione prende una String, quindi chi ha una &str deve scrivere ogni volta String::from("Ana") o "Ana".to_string(). Oppure la tua funzione prende un UserId, e chi la chiama ha un semplice u32. È poca cosa, ma aggiunge rumore ovunque.
Il pattern. Implementa From per i tuoi tipi e accetta impl Into<T> nelle tue funzioni. Così chi chiama può passare qualsiasi cosa si converta:
#[derive(Debug)]struct UserId(u32);
impl From<u32> for UserId { fn from(n: u32) -> Self { UserId(n) }}
#[derive(Debug)]struct User { id: UserId, name: String,}
impl User { fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self { User { id: id.into(), name: name.into() } }}
fn main() { let ana = User::new(1, "Ana"); // u32 e &str let ben = User::new(UserId(2), String::from("Ben")); // già i tipi giusti
println!("{:?}", ana); println!("{:?}", ben);}Output:
User { id: UserId(1), name: "Ana" }User { id: UserId(2), name: "Ben" }Implementa From, mai direttamente Into: Into lo ottieni gratis. Lo stesso trucco fa funzionare la gestione degli errori: ? chiama From::from sull’errore, quindi implementare From<io::Error> for MyError permette a ? di convertire gli errori per te (vedi Errori personalizzati). Usalo per conversioni che non possono fallire. Se una conversione può fallire, usa TryFrom, che restituisce un Result.
Macchine a stati con enum e stati impossibili
Sezione intitolata “Macchine a stati con enum e stati impossibili”Il problema. Un ordine passa da «nel carrello» a «pagato» a «spedito». Un primo tentativo spesso è così:
struct Order { paid: bool, shipped: bool, tracking_number: Option<String>,}Ma questo permette delle assurdità: shipped: true con paid: false, oppure un numero di tracciamento su un ordine non ancora spedito. Ogni funzione deve controllare queste combinazioni impossibili.
Il pattern. Rendi impossibile rappresentare gli stati non validi. Usa un enum con una variante per ogni stato, e dai a ogni variante solo i dati che quello stato ha davvero. Le transizioni sono metodi che prendono il vecchio stato e restituiscono quello nuovo:
#[derive(Debug)]enum Order { Cart { items: Vec<String> }, Paid { items: Vec<String>, amount: u32 }, Shipped { tracking: String }, Cancelled,}
impl Order { fn pay(self, amount: u32) -> Order { match self { Order::Cart { items } => Order::Paid { items, amount }, other => other, // si può pagare solo un carrello; tutto il resto resta com'è } }
fn ship(self, tracking: &str) -> Order { match self { Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() }, other => other, } }
fn cancel(self) -> Order { match self { Order::Shipped { .. } => self, // troppo tardi per annullare _ => Order::Cancelled, } }
fn status(&self) -> String { match self { Order::Cart { items } => format!("carrello con {} articolo/i", items.len()), Order::Paid { items, amount } => format!("pagati {} per {} articolo/i", amount, items.len()), Order::Shipped { tracking } => format!("spedito, tracciamento {}", tracking), Order::Cancelled => String::from("annullato"), } }}
fn main() { let order = Order::Cart { items: vec![String::from("libro"), String::from("penna")] }; println!("{}", order.status());
let order = order.ship("XYZ"); // non ancora pagato: non succede niente println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42"); println!("{}", order.status());
let order = order.cancel(); // già spedito println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel(); println!("{}", other.status());}Output:
carrello con 2 articolo/icarrello con 2 articolo/ispedito, tracciamento TRACK-42spedito, tracciamento TRACK-42annullatoUn ordine spedito non può essere senza numero di tracciamento: la variante Shipped ne ha sempre uno. Un carrello non può avere un numero di tracciamento: non c’è nessun campo per metterlo. E visto che match deve coprire ogni variante, se aggiungi un nuovo stato (per esempio Refunded) il compilatore ti indica ogni punto da aggiornare.
Enum o trait object? I linguaggi a oggetti usano spesso un’interfaccia «State» con una classe per ogni stato. In Rust potresti farlo con i trait object (Box<dyn State>), e il Rust Book mostra come. Ecco come scegliere:
- Enum: conosci tutti gli stati fin dall’inizio. Ottieni i controlli esaustivi di
match, e tutta la logica sta in un posto solo. In Rust è la scelta più comune. - Trait object: altre persone (o altri crate) devono poter aggiungere nuovi stati senza modificare il tuo codice.
- Typestate (visto prima): vuoi che un ordine di chiamate sbagliato sia un errore di compilazione, non solo qualcosa che viene ignorato in esecuzione. È più rigido, ma lo stato deve essere noto in fase di compilazione.
Errori comuni
Sezione intitolata “Errori comuni”Scrivere let _ = guard invece di let _guard = guard. Sembrano quasi uguali, ma si comportano in modo diverso. Un nome che comincia con un trattino basso (come _guard) tiene in vita il valore fino alla fine dello scope. Un semplice _ non è affatto una variabile, quindi il valore viene distrutto subito:
struct Guard;
impl Drop for Guard { fn drop(&mut self) { println!("guardia distrutta"); }}
fn main() { let _ = Guard; println!("lavoro con `let _`");
let _g = Guard; println!("lavoro con `let _g`");}Output:
guardia distruttalavoro con `let _`lavoro con `let _g`guardia distruttaCon let _, la pulizia è avvenuta prima del lavoro, che è esattamente il contrario di ciò che vuoi per un lock o un timer. Per le guardie, dai sempre un nome alla variabile.
Usare un pattern solo perché esiste. Un builder per una struct con due campi, o un typestate per qualcosa che nessuno usa mai male, aggiunge codice senza rendere niente più sicuro. Parti semplice. Ricorri a un pattern quando senti il problema che risolve.
Riepilogo
Sezione intitolata “Riepilogo”- Newtype (
struct Meters(f64)) dà tipi diversi agli stessi dati, così il compilatore scova le confusioni, senza alcun costo in esecuzione. - Builder offre una configurazione leggibile e concatenata, e un unico
build()che valida e restituisce unResult. Default +..è la scelta più leggera quando non c’è niente da controllare. - Typestate mette lo stato nel tipo (
Request<NoUrl>controRequest<HasUrl>), così chiamare le cose nell’ordine sbagliato non compila. - RAII / guardie Drop eseguono la pulizia in automatico su ogni strada d’uscita da uno scope.
- From/Into permettono alle funzioni di accettare molti tipi di input con
impl Into<T>. - Gli enum modellano le macchine a stati e rendono impossibile rappresentare gli stati non validi; usa i trait object solo quando altri devono aggiungere stati.
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. Cosa ti dà il newtype struct Meters(f64) rispetto a un semplice f64?
2. Nel pattern builder di questa pagina, cosa restituisce build()?
3. Nell'esempio typestate, perché non puoi chiamare send() su Request::new()?
4. Quando viene eseguita la pulizia di una guardia Drop?
5. Cosa fa let _ = Guard;?
Punteggio: 0 / 5
Per approfondire
Sezione intitolata “Per approfondire”- Rust Design Patterns (in inglese): il libro gratuito della community su idiomi, pattern e anti-pattern di Rust, compresi newtype, builder e guardie RAII.
- Implementing an Object-Oriented Design Pattern — The Rust Book (in inglese): il pattern State realizzato con i trait object, poi rifatto codificando gli stati come tipi.
- Rust API Guidelines (in inglese): i consigli ufficiali per progettare API comode e idiomatiche (conversioni, builder, nomi).
- RAII — Rust by Example (in inglese): come ownership e
Dropti danno una pulizia automatica. - The Typestate Pattern in Rust — Cliff L. Biffle (in inglese): una guida più approfondita e molto leggibile al typestate, con altri esempi.