Salta ai contenuti

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.

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? true
900 piedi sono 274.3 metri
Aereo al sicuro? false
▶ Provalo tu

Se 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 incorrect

Il 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).

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().to("[email protected]").build());
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=true
Pronta: [email protected] subject=(nessun oggetto) body="" urgent=false
Errore: un'email ha bisogno di un destinatario
Errore: 'ana.example.com' non è un indirizzo email
▶ Provalo tu

Ogni 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).

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 }
▶ Provalo tu

..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 stato
impl<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 URL
impl 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 header
▶ Provalo tu

Ora 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 blocco
dentro il blocco
pulizia blocco
inizio lavoro rischioso
lavoro finito
pulizia lavoro rischioso
Ok(())
inizio lavoro rischioso
pulizia lavoro rischioso
Err("qualcosa è andato storto")
▶ Provalo tu

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».

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" }
▶ Provalo tu

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.

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/i
carrello con 2 articolo/i
spedito, tracciamento TRACK-42
spedito, tracciamento TRACK-42
annullato
▶ Provalo tu

Un 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.

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 distrutta
lavoro con `let _`
lavoro con `let _g`
guardia distrutta
▶ Provalo tu

Con 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.

  • 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 un Result. Default + .. è la scelta più leggera quando non c’è niente da controllare.
  • Typestate mette lo stato nel tipo (Request<NoUrl> contro Request<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

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