Gestire gli errori in Rust con anyhow e thiserror
Nelle pagine su Result e sugli errori personalizzati hai scritto i tipi di errore a mano: un’enum, un impl Display, magari un impl From per far funzionare ?. È il modo giusto per imparare. Ma in un progetto vero finisci per scrivere lo stesso codice ripetitivo ancora e ancora.
Due piccoli crate, entrambi dello stesso autore, eliminano quel codice ripetitivo. Risolvono due problemi diversi:
thiserrorti aiuta a definire i tipi di errore. L’enum la scrivi ancora tu, ma un#[derive]scrive per teDisplay,ErroreFrom.anyhowti aiuta a passare gli errori verso l’alto. Ti dà un unico tipo di errore che può contenere qualsiasi errore, più modi semplici per aggiungere un messaggio come «durante il caricamento del file di configurazione».
Librerie e applicazioni
Sezione intitolata “Librerie e applicazioni”Quale scegliere dipende da chi gestisce l’errore.
Una libreria è codice che altre persone chiamano. Chi la chiama potrebbe voler reagire in modo diverso a ogni fallimento: riprovare dopo un timeout, mostrare un errore nel modulo quando l’input è sbagliato. Quindi una libreria dovrebbe restituire un’enum precisa su cui chi chiama può fare match. Questo è thiserror.
Un’applicazione (il tuo main.rs) di solito è l’ultima fermata. La maggior parte degli errori finisce come messaggio per una persona, e magari un codice di uscita. Raramente ci fai match; vuoi che siano facili da passare in alto con ? e facili da leggere. Questo è anyhow.
Una buona regola pratica: thiserror per il codice che chiamano gli altri, anyhow per la parte più alta del tuo programma. Molti progetti li usano entrambi, come vedrai alla fine di questa pagina.
Configurazione
Sezione intitolata “Configurazione”cargo add thiserror anyhow[dependencies]anyhow = "1"thiserror = "2"Non conosci ancora Cargo.toml? Vedi Cargo e crate. Entrambi i crate sono disponibili nel Rust Playground, quindi ogni pulsante «Provalo tu» di questa pagina funziona.
thiserror: derivare Error e scrivere i messaggi
Sezione intitolata “thiserror: derivare Error e scrivere i messaggi”Metti #[derive(Debug, Error)] sulla tua enum, poi dai a ogni variante un messaggio #[error("...")]. Il messaggio diventa il testo Display della variante. Dentro il messaggio, {0} indica il primo campo della tupla e {balance} un campo con nome:
use thiserror::Error;
#[derive(Debug, Error)]enum BankError { #[error("conto `{0}` non trovato")] NotFound(String), #[error("fondi insufficienti: il saldo è {balance}, hai provato a prelevare {amount}")] InsufficientFunds { balance: u32, amount: u32 }, #[error("l'importo deve essere maggiore di zero")] ZeroAmount,}
fn main() { let errors = vec![ BankError::NotFound(String::from("alice")), BankError::InsufficientFunds { balance: 50, amount: 80 }, BankError::ZeroAmount, ];
for e in &errors { println!("{e}"); } println!("{:?}", errors[1]);}Output:
conto `alice` non trovatofondi insufficienti: il saldo è 50, hai provato a prelevare 80l'importo deve essere maggiore di zeroInsufficientFunds { balance: 50, amount: 80 }È esattamente l’enum della pagina sugli errori personalizzati, ma l’intero blocco impl Display è sparito. A runtime non c’è niente di nascosto: thiserror scrive solo il codice che avresti scritto tu a mano. Chi chiama vede un’enum normale.
thiserror: #[from] e #[source]
Sezione intitolata “thiserror: #[from] e #[source]”Spesso gli errori hanno una causa: «la porta non è valida» perché «c’è una cifra non valida nella stringa». Il trait standard Error ha un metodo .source() per questo, e thiserror lo compila per te.
#[from] fa due cose. Segna il campo come sorgente (source) e scrive un impl From, così ? converte automaticamente l’errore interno nella tua variante:
use std::error::Error; // porta in scope il metodo .source()use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]enum ConfigError { #[error("manca `=` nella riga {0:?}")] NoEquals(String), #[error("la porta non è un numero valido")] BadPort(#[from] ParseIntError),}
fn parse_port(line: &str) -> Result<u16, ConfigError> { let (_, value) = line .split_once('=') .ok_or_else(|| ConfigError::NoEquals(line.to_string()))?; let port = value.trim().parse::<u16>()?; // ParseIntError diventa ConfigError::BadPort Ok(port)}
fn main() { for line in ["port = 8080", "port 8080", "port = ottanta"] { match parse_port(line) { Ok(port) => println!("ok: {port}"), Err(e) => { println!("errore: {e}"); if let Some(cause) = e.source() { println!(" causato da: {cause}"); } } } }}Output:
ok: 8080errore: manca `=` nella riga "port 8080"errore: la porta non è un numero valido causato da: invalid digit found in stringLa causa, invalid digit found in string («cifra non valida nella stringa»), è il messaggio della libreria standard e resta in inglese.
A volte vuoi una sorgente ma non un From automatico, perché devi aggiungere dati in più, come un numero di riga. Allora usa #[source] sul campo e costruisci tu l’errore con .map_err(). (Un campo che si chiama semplicemente source viene trattato automaticamente come sorgente.) thiserror funziona anche sulle struct, non solo sulle enum:
use std::error::Error;use std::num::ParseIntError;
#[derive(Debug, thiserror::Error)]#[error("la riga {line} non contiene un numero")]struct LineError { line: usize, #[source] cause: ParseIntError,}
fn sum_lines(text: &str) -> Result<i32, LineError> { let mut total = 0; for (i, row) in text.lines().enumerate() { let n: i32 = row .trim() .parse() .map_err(|cause| LineError { line: i + 1, cause })?; total += n; } Ok(total)}
fn main() { println!("totale: {}", sum_lines("10\n20\n30").unwrap());
let err = sum_lines("10\nventi\n30").unwrap_err(); println!("{err}"); println!("causato da: {}", err.source().unwrap());}Output:
totale: 60la riga 2 non contiene un numerocausato da: invalid digit found in stringanyhow: un solo tipo Result per tutto
Sezione intitolata “anyhow: un solo tipo Result per tutto”anyhow::Result<T> è un’abbreviazione di Result<T, anyhow::Error>. Un anyhow::Error può contenere qualsiasi tipo di errore che implementa il trait standard Error. Quindi ? funziona con tutti, anche quando una funzione incontra diversi tipi di errore:
use anyhow::Result;
// Due tipi di errore diversi: ParseIntError e ParseFloatError.// anyhow::Result li accetta entrambi tramite `?`.fn total_price(quantity: &str, price: &str) -> Result<f64> { let quantity: u32 = quantity.trim().parse()?; let price: f64 = price.trim().parse()?; Ok(quantity as f64 * price)}
fn main() { for (quantity, price) in [("3", "2.50"), ("tre", "2.50"), ("3", "economico")] { match total_price(quantity, price) { Ok(total) => println!("totale: {total}"), Err(e) => println!("errore: {e}"), } }}Output:
totale: 7.5errore: invalid digit found in stringerrore: invalid float literalSenza anyhow ti servirebbe un’enum tua con due varianti, oppure un Box<dyn Error>. anyhow è come un Box<dyn Error> più comodo. Ma nota il problema: «invalid digit found in string» non dice quale valore era sbagliato. È a questo che serve il contesto.
anyhow: aggiungere contesto e stampare la catena
Sezione intitolata “anyhow: aggiungere contesto e stampare la catena”Importa il trait Context, e ogni Result riceve due metodi nuovi:
.context("messaggio")avvolge l’errore con un messaggio fisso..with_context(|| format!(...))prende una closure. Costruisce il messaggio solo se c’è davvero un errore, quindi usalo quando il messaggio ha bisogno diformat!.
Ogni strato di contesto aggiunge un anello a una catena di errori. Il modo in cui stampi l’errore decide quanta parte della catena vedi:
use anyhow::{Context, Result};
fn parse_port(text: &str) -> Result<u16> { let port: u16 = text .parse() .with_context(|| format!("`{text}` non è una porta valida"))?; Ok(port)}
fn load_settings(port_text: &str) -> Result<u16> { let port = parse_port(port_text).context("impossibile caricare le impostazioni")?; Ok(port)}
fn main() { let err = load_settings("99999").unwrap_err();
println!("{err}"); // solo il messaggio più esterno println!("{err:#}"); // tutta la catena su una riga
for (i, cause) in err.chain().enumerate() { println!("{i}: {cause}"); }}Output:
impossibile caricare le impostazioniimpossibile caricare le impostazioni: `99999` non è una porta valida: number too large to fit in target type0: impossibile caricare le impostazioni1: `99999` non è una porta valida2: number too large to fit in target typeL’ultimo anello, number too large to fit in target type («numero troppo grande per il tipo di destinazione»), viene dalla libreria standard. Il terzo formato è {:?}. Stampa un resoconto su più righe, ed è quello che vedi quando main restituisce un errore:
println!("{err:?}");impossibile caricare le impostazioni
Caused by: 0: `99999` non è una porta valida 1: number too large to fit in target typeCaused by: («causato da») è scritto da anyhow, sempre in inglese. Se è impostata la variabile d’ambiente RUST_BACKTRACE=1, {:?} aggiunge anche un backtrace dello stack che mostra dove è stato creato l’errore. Vedi Ambiente e processi.
Un uso tipico nella realtà è la lettura dei file, dove il messaggio del sistema operativo («No such file or directory») non dice mai di quale file si tratta:
let text = std::fs::read_to_string(path) .with_context(|| format!("impossibile leggere il file di configurazione {path}"))?;anyhow: bail!, ensure! e anyhow!
Sezione intitolata “anyhow: bail!, ensure! e anyhow!”A volte non c’è un errore di fondo. Sei tu a decidere che qualcosa non va. anyhow ha tre macro per questo:
bail!("...")restituisce subito un errore. È un’abbreviazione direturn Err(anyhow!("...")).ensure!(condizione, "...")restituisce un errore se la condizione è falsa.anyhow!("...")crea un valore di errore senza restituirlo.
Tutte e tre accettano messaggi nello stile di format!.
use anyhow::{anyhow, bail, ensure, Result};
fn withdraw(balance: u32, amount: u32) -> Result<u32> { ensure!(amount > 0, "l'importo deve essere maggiore di zero"); if amount > balance { bail!("fondi insufficienti: il saldo è {balance}, hai provato a prelevare {amount}"); } Ok(balance - amount)}
fn find_user(id: u32) -> Result<&'static str> { match id { 1 => Ok("Ferris"), _ => Err(anyhow!("nessun utente con id {id}")), }}
fn main() { println!("{:?}", withdraw(100, 30).ok()); for (balance, amount) in [(100, 0), (100, 250)] { if let Err(e) = withdraw(balance, amount) { println!("errore: {e}"); } } println!("{}", find_user(1).unwrap()); println!("errore: {}", find_user(7).unwrap_err());}Output:
Some(70)errore: l'importo deve essere maggiore di zeroerrore: fondi insufficienti: il saldo è 100, hai provato a prelevare 250Ferriserrore: nessun utente con id 7Restituire anyhow::Result da main
Sezione intitolata “Restituire anyhow::Result da main”Il posto in cui vedi anyhow più spesso è questo: main restituisce anyhow::Result<()>, e usi ? dappertutto. Se un errore arriva fino a main, Rust lo stampa con {:?} su stderr ed esce con il codice 1:
use anyhow::{Context, Result};
fn main() -> Result<()> { let text = "quarantadue"; let n: i32 = text .parse() .with_context(|| format!("impossibile leggere {text:?} come numero"))?; println!("{n}"); Ok(())}Su stdout non viene stampato niente. Su stderr vedi:
Error: impossibile leggere "quarantadue" come numero
Caused by: invalid digit found in stringUsarli insieme
Sezione intitolata “Usarli insieme”Ecco la forma tipica di un progetto vero. Il modulo inventory fa la parte della libreria: restituisce un InventoryError preciso, creato con thiserror. Il codice dell’applicazione usa anyhow, aggiunge contesto e guarda dentro l’errore solo dove gli interessa davvero. downcast_ref chiede a un anyhow::Error «sei per caso di questo tipo?» e funziona anche attraverso il contesto aggiunto:
// ----- parte "libreria": errori precisi con thiserror -----mod inventory { use thiserror::Error;
#[derive(Debug, Error)] pub enum InventoryError { #[error("articolo sconosciuto `{0}`")] UnknownItem(String), #[error("ne restano solo {available}, impossibile prenderne {wanted}")] NotEnough { available: u32, wanted: u32 }, }
pub struct Store { pub apples: u32, }
impl Store { pub fn take(&mut self, item: &str, wanted: u32) -> Result<(), InventoryError> { if item != "mela" { return Err(InventoryError::UnknownItem(item.to_string())); } if wanted > self.apples { return Err(InventoryError::NotEnough { available: self.apples, wanted }); } self.apples -= wanted; Ok(()) } }}
// ----- parte "applicazione": anyhow + contesto -----use anyhow::{Context, Result};use inventory::{InventoryError, Store};
fn process_order(store: &mut Store, id: u32, item: &str, amount: u32) -> Result<()> { store .take(item, amount) .with_context(|| format!("ordine #{id} fallito"))?; println!("ordine #{id} completato"); Ok(())}
fn main() { let mut store = Store { apples: 5 }; let orders = [(1, "mela", 2), (2, "prugna", 1), (3, "mela", 9)];
for (id, item, amount) in orders { if let Err(e) = process_order(&mut store, id, item, amount) { println!("errore: {e:#}"); // Possiamo ancora guardare dentro per trovare il tipo thiserror originale: if let Some(InventoryError::NotEnough { available, .. }) = e.downcast_ref::<InventoryError>() { println!(" suggerimento: prova a ordinarne {available} o meno"); } } } println!("mele rimaste: {}", store.apples);}Output:
ordine #1 completatoerrore: ordine #2 fallito: articolo sconosciuto `prugna`errore: ordine #3 fallito: ne restano solo 3, impossibile prenderne 9 suggerimento: prova a ordinarne 3 o menomele rimaste: 3Errori comuni
Sezione intitolata “Errori comuni”Dimenticare di importare Context. .context() è un metodo del trait anyhow::Context, e i metodi di un trait funzionano solo quando il trait è in scope:
use anyhow::Result;
fn parse(text: &str) -> Result<i32> { let n: i32 = text.parse().context("not a number")?; Ok(n)}error[E0599]: no method named `context` found for enum `Result<T, E>` in the current scope --> src/main.rs:4:31 |4 | let n: i32 = text.parse().context("not a number")?; | ^^^^^^^ | = help: items from traits can only be used if the trait is in scopehelp: trait `Context` which provides `context` is implemented but not in scope; perhaps you want to import it |1 + use anyhow::Context; |Il compilatore dice (in inglese) che non trova nessun metodo context e suggerisce di importare il trait Context. La correzione: use anyhow::{Context, Result};.
Ripetere il messaggio della sorgente nel tuo messaggio. Con #[from], l’errore interno è già la sorgente. Se metti anche {0} nel messaggio, un resoconto completo lo stampa due volte:
#[derive(Debug, thiserror::Error)]enum AppError { #[error("porta non valida: {0}")] // contiene il testo della sorgente... BadPort(#[from] ParseIntError), // ...e anche questa è la sorgente}Stampato con {:#} tramite anyhow, dà porta non valida: invalid digit found in string: invalid digit found in string. La correzione: o scrivi #[error("porta non valida")] e lasci che la catena mostri la causa, oppure tieni {0} e non segni il campo come sorgente. Scegline una.
Stampare con {} e perdere la causa. println!("{e}") su un anyhow::Error mostra solo il messaggio più esterno, come «impossibile caricare le impostazioni». Quando stampi un errore per una persona, usa {:#} (una riga) o {:?} (resoconto completo).
Usare anyhow nell’API pubblica di una libreria. Se la tua libreria restituisce anyhow::Result, chi la chiama non può fare match sui tuoi errori. Dovrebbe indovinare il tipo con downcast_ref. Dai alle funzioni pubbliche un’enum thiserror, e tieni anyhow per applicazioni, test ed esempi.
Riepilogo
Sezione intitolata “Riepilogo”- thiserror serve a definire gli errori:
#[derive(Error)]più un messaggio#[error("...")]per ogni variante sostituisce gliimpl DisplayedErrorscritti a mano. #[from]fa sì che?converta un errore interno nella tua variante e lo registra come sorgente.#[source]registra la sorgente senza la conversione.- anyhow serve a passare gli errori verso l’alto:
anyhow::Result<T>accetta qualsiasi errore tramite?. .context()e.with_context()aggiungono alla catena di errori uno strato leggibile per le persone. Stampa la catena con{:#}(una riga) o{:?}(resoconto).bail!restituisce subito un errore,ensure!ne restituisce uno se una condizione è falsa, eanyhow!ne crea uno.- Configurazione comune: thiserror nel codice di libreria, anyhow in
main.
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. Stai scrivendo una libreria, e chi la chiama deve reagire in modo diverso a un timeout e a un input sbagliato. Quale crate è più adatto?
2. Cosa fa #[from] sul campo di una variante?
3. Quale formato stampa un anyhow::Error con tutta la sua catena su una riga?
4. Quando conviene usare .with_context(|| ...) invece di .context(...)?
5. Cosa fa ensure!(amount > 0, "l'importo deve essere positivo") quando amount vale 0?
Punteggio: 0 / 5
Per approfondire
Sezione intitolata “Per approfondire”- Documentazione di anyhow — docs.rs (in inglese): l’API completa, compresi
Context,chain()e il downcasting. - Documentazione di thiserror — docs.rs (in inglese): tutti gli attributi, come
#[error(transparent)]e i campi per il backtrace. - The Context trait — anyhow (in inglese): come funzionano
contextewith_context, con esempi. - Error Handling — The Rust Book (in inglese): le basi su cui si appoggiano entrambi i crate.
- std::error::Error (in inglese): il trait standard dietro
.source()e le catene di errori.