panic!
o non panic!
Quindi, come si decide quando chiamare panic!
e quando restituire Result
?
Quando il codice va in panic, non c’è modo di tornare indietro. Si potrebbe
chiamare panic!
per qualsiasi situazione di errore, indipendentemente dal
fatto che ci sia o meno una possibile soluzione, ma in tal caso si sta prendendo
la decisione che una situazione non è reversibile per conto del codice
chiamante. Quando si sceglie di restituire un valore Result
, si forniscono al
codice chiamante delle opzioni. Il codice chiamante potrebbe scegliere di
tentare di rispondere in un modo appropriato alla situazione, oppure potrebbe
decidere che un valore Err
in questo caso è irreversibile, quindi può chiamare
panic!
e trasformare il tuo errore reversibile in un errore irreversibile.
Pertanto, restituire Result
è una buona scelta predefinita quando si definisce
una funzione che potrebbe fallire.
In situazioni come codice di esempio, prototipale e test, è più appropriato
scrivere codice che vada in panic invece di restituire un Result
. Esploriamo
il motivo, poi analizziamo le situazioni in cui il compilatore non può dire che
il fallimento è impossibile, ma tu, in quanto essere umano, sì. Il capitolo si
concluderà con alcune linee guida generali su come decidere se andare in panic
quando scrivi codice per una libreria.
Codice di Esempio, Prototipale e Test
Quando si scrive un esempio per illustrare un concetto, includere anche un
codice robusto per la gestione degli errori può rendere l’esempio meno chiaro.
Negli esempi, è sufficientemente chiaro che una chiamata a un metodo come
unwrap
che potrebbe andare in panico è intesa come segnaposto per il modo in
cui si desidera che l’applicazione gestisca gli errori, che può differire in
base al comportamento del resto del codice.
Allo stesso modo, i metodi unwrap
ed expect
sono molto utili durante la fase
di prototipazione, prima di decidere come gestire gli errori. Lasciano chiari
punti nel codice per quando si è pronti a riscriverlo per renderlo più robusto.
Se una chiamata a un metodo fallisce in un test, si desidera che l’intero test
fallisca, anche se quel metodo non è la funzionalità in fase di test. Poiché
panic!
è il modo in cui un test viene contrassegnato come fallito, chiamare
unwrap
o expect
è esattamente ciò che dovrebbe accadere.
Quando Hai Più Informazioni Del Compilatore
Sarebbe anche appropriato chiamare expect
quando si dispone di un’altra logica
che garantisce che Result
abbia un valore Ok
, ma la logica non è qualcosa
che il compilatore capisce. Si avrà comunque un valore Result
che bisogna
gestire: qualsiasi operazione si stia chiamando ha comunque la possibilità di
fallire, anche se è logicamente impossibile nella propria situazione specifica.
Se puoi assicurarti, ispezionando manualmente il codice, che non avrai mai una
variante Err
, è perfettamente accettabile chiamare expect
e documentare il
motivo per cui pensi di non avere mai una variante Err
nel testo
dell’argomento. Ecco un esempio:
fn main() { use std::net::IpAddr; let home: IpAddr = "127.0.0.1" .parse() .expect("Indirizzo IP definito dovrebbe essere valido"); }
Stiamo creando un’istanza di IpAddr
analizzando una stringa scritta
direttamente. Possiamo vedere che 127.0.0.1
è un indirizzo IP valido, quindi è
accettabile usare expect
qui. Tuttavia, avere una stringa valida specificata
nel codice non cambia il type di ritorno del metodo parse
: otteniamo
comunque un valore Result
e il compilatore ci farà comunque gestire Result
come se la variante Err
fosse una possibilità perché il compilatore non è
abbastanza intelligente da vedere che questa stringa è sempre un indirizzo IP
valido. Se la stringa dell’indirizzo IP provenisse da un utente anziché essere
scritta direttamente nel programma e quindi avesse una possibilità di errore,
vorremmo sicuramente gestire Result
in un modo più robusto. Menzionare il
presupposto che questo indirizzo IP sia definito ci indurrà a modificare
expect
con un codice di gestione degli errori migliore se, in futuro,
dovessimo ottenere l’indirizzo IP da un’altra fonte.
Linee Guida Per la Gestione degli Errori
È consigliabile mandare in panic il codice quando è possibile che il codice possa finire in uno stato non valido. In questo contesto, uno stato non valido si verifica quando un presupposto, garanzia o contratto non sono rispettati, ad esempio quando vengono passati al codice valori non validi, valori contraddittori o valori mancanti, più almeno una delle seguenti cose:
- Lo stato non valido è qualcosa di inaspettato, al contrario di qualcosa che probabilmente accadrà occasionalmente, come un utente che inserisce dati nel formato sbagliato.
- Il codice da questo punto in poi deve fare affidamento sul fatto di non trovarsi in questo stato non valido, piuttosto che verificare la presenza del problema a ogni passaggio.
- Non esiste un buon modo per codificare queste informazioni nei type utilizzati. Faremo un esempio di ciò che intendiamo in “Codifica di Stati e Comportamenti Come Type” nel Capitolo 18.
Se qualcuno chiama il tuo codice e passa valori che non hanno senso, è meglio
restituire un errore, se possibile, in modo che l’utente della libreria possa
decidere cosa fare in quel caso. Tuttavia, nei casi in cui continuare potrebbe
essere insicuro o dannoso, la scelta migliore potrebbe essere quella di chiamare
panic!
e avvisare la persona che utilizza la tua libreria del bug nel suo
codice in modo che possa correggerlo durante lo sviluppo. Allo stesso modo,
panic!
è spesso appropriato se stai chiamando codice esterno fuori dal tuo
controllo e restituisce uno stato non valido che non hai modo di correggere.
Tuttavia, quando è previsto un errore, è più appropriato restituire un Result
piuttosto che effettuare una chiamata panic!
. Esempi includono un parser che
riceve dati non validi o una richiesta HTTP che restituisce uno stato che indica
il raggiungimento di un limite di connessioni. In questi casi, restituire un
Result
indica che un errore è una possibilità prevista che il codice chiamante
deve decidere come gestire.
Quando il codice esegue un’operazione che potrebbe mettere a rischio un utente
se viene chiamata utilizzando valori non validi, il codice dovrebbe prima
verificare che i valori siano validi e generare un errore di panic se i valori
non sono validi. Questo avviene principalmente per motivi di sicurezza: tentare
di operare su dati non validi può esporre il codice a vulnerabilità. Questo è il
motivo principale per cui la libreria standard chiamerà panic!
se si tenta un
accesso alla memoria fuori dai limiti: tentare di accedere a memoria che non
appartiene alla struttura dati corrente è un problema di sicurezza comune. Le
funzioni spesso hanno dei contracts (contratti): il loro comportamento è
garantito solo se gli input soddisfano determinati requisiti. Andare in panic
quando il contratto viene violato ha senso perché una violazione del contratto
indica sempre un bug lato chiamante, e non è un tipo di errore che si desidera
che il codice chiamante debba gestire esplicitamente. In effetti, non esiste un
modo ragionevole per il codice chiamante di recuperare; i programmatori che
chiamano il codice devono correggerlo. I contratti per una funzione, soprattutto
quando una violazione causerà un panic, dovrebbero essere spiegati nella
documentazione API della funzione.
Tuttavia, avere molti controlli di errore in tutte le funzioni sarebbe prolisso
e fastidioso. Fortunatamente, è possibile utilizzare il sistema dei type di
Rust (e quindi il controllo dei type effettuato dal compilatore) per eseguire
molti dei controlli al tuo posto. Se la tua funzione ha un type particolare
come parametro, potete procedere con la logica del codice sapendo che il
compilatore ha già verificato la presenza di un valore valido. Ad esempio, se
avete un type anziché un’Option
, il tuo programma si aspetta di avere
qualcosa anziché niente. Il codice non dovrà quindi gestire due casi per le
varianti Some
e None
: gestirà solo il caso che ha sicuramente un valore. Il
codice che tenta di non passare nulla alla funzione non verrà nemmeno compilato,
quindi la funzione non dovrà verificare quel caso in fase di esecuzione. Un
altro esempio è l’utilizzo di un type integer senza segno come u32
, che
garantisce che il parametro non sia mai negativo.
Type Personalizzati per la Convalida
Sviluppiamo ulteriormente l’idea di utilizzare il sistema dei type di Rust per garantire un valore valido e proviamo a creare un type personalizzato per la convalida. Riprendiamo il gioco di indovinelli del Capitolo 2 in cui il nostro codice chiedeva all’utente di indovinare un numero compreso tra 1 e 100. Non abbiamo mai verificato che la risposta dell’utente fosse compresa tra quei numeri prima di confrontarla con il nostro numero segreto; abbiamo solo verificato che la risposta fosse positiva. In questo caso, le conseguenze non sono state poi così gravi: il nostro output “Troppo alto” o “Troppo basso” sarebbe stato comunque corretto. Ma sarebbe stato un utile miglioramento guidare l’utente verso risposte valide e avere un comportamento diverso quando l’utente ipotizza un numero fuori dall’intervallo rispetto a quando l’utente digita, ad esempio, delle lettere.
Un modo per farlo sarebbe analizzare l’ipotesi come i32
invece che solo come
u32
per consentire numeri potenzialmente negativi, e quindi aggiungere un
controllo per verificare che il numero sia compreso nell’intervallo, in questo
modo:
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
println!("Indovina il numero!");
let numero_segreto = rand::thread_rng().gen_range(1..=100);
loop {
// --taglio--
println!("Inserisci la tua ipotesi.");
let mut ipotesi = String::new();
io::stdin()
.read_line(&mut ipotesi)
.expect("Errore di lettura");
let ipotesi: i32 = match ipotesi.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
if ipotesi < 1 || ipotesi > 100 {
println!("Il numero segreto è compreso tra 1 e 100.");
continue;
}
match ipotesi.cmp(&numero_segreto) {
// --taglio--
Ordering::Less => println!("Troppo piccolo!"),
Ordering::Greater => println!("Troppo grande!"),
Ordering::Equal => {
println!("Hai indovinato!");
break;
}
}
}
}
L’espressione if
verifica se il nostro valore è fuori dall’intervallo, informa
l’utente del problema e chiama continue
per avviare l’iterazione successiva
del ciclo e richiedere un’altra ipotesi. Dopo l’espressione if
, possiamo
procedere con i confronti tra ipotesi
e il numero segreto, sapendo che
ipotesi
è compreso tra 1 e 100.
Tuttavia, questa non è una soluzione ideale: se fosse assolutamente fondamentale che il programma operasse solo su valori compresi tra 1 e 100, e avesse molte funzioni con questo requisito, avere un controllo di questo tipo in ogni funzione sarebbe ripetitivo (e potrebbe influire sulle prestazioni).
Invece, possiamo creare un nuovo type in un modulo dedicato e inserire le
validazioni in una funzione per creare un’istanza del type, anziché ripetere
le validazioni ovunque. In questo modo, le funzioni possono utilizzare il nuovo
type nelle loro firme in tutta sicurezza e utilizzare con sicurezza i valori
che ricevono. Il Listato 9-13 mostra un modo per definire un type Ipotesi
che creerà un’istanza di Ipotesi
solo se la funzione new
riceve un valore
compreso tra 1 e 100.
#![allow(unused)] fn main() { pub struct Ipotesi { valore: i32, } impl Ipotesi { pub fn new(valore: i32) -> Ipotesi { if valore < 1 || valore > 100 { panic!("L'ipotesi deve essere compresa tra 1 e 100, valore ottenuto: {valore}."); } Ipotesi { valore } } pub fn valore(&self) -> i32 { self.valore } } }
Ipotesi
che continuerà solo con valori compresi tra 1 e 100Nota che questo codice in src/gioco_indovinello.rs dipende dall’aggiunta di
una dichiarazione di modulo mod gioco_indovinello;
in src/lib.rs che non
abbiamo mostrato qui. All’interno del file di questo nuovo modulo, definiamo una
struct denominata Ipotesi
che ha un campo denominato valore
di type
i32
. È qui che verrà memorizzato il numero.
Quindi implementiamo una funzione associata denominata new
su Ipotesi
che
crea istanze di valori Ipotesi
. La funzione new
è definita per avere un
parametro denominato valore
di type i32
e restituire un Ipotesi
. Il
codice nel corpo della funzione new
verifica valore
per assicurarsi che sia
compreso tra 1 e 100. Se valore
non supera questo test, effettuiamo una
chiamata panic!
, che avviserà il programmatore che sta scrivendo il codice
chiamante che ha un bug da correggere, perché creare un Ipotesi
con un
valore
al di fuori di questo intervallo violerebbe il contratto su cui si basa
Ipotesi::new
. Le condizioni in cui Ipotesi::new
potrebbe generare un errore
di panic dovrebbero essere discusse nella documentazione dell’API pubblica;
tratteremo le convenzioni di documentazione che indicano la possibilità di un
errore panic!
nella documentazione delle API che creerai nel Capitolo 14. Se
valore
supera il test, creiamo un nuovo Ipotesi
con il suo campo valore
impostato sul parametro valore
e restituiamo Ipotesi
.
Successivamente, implementiamo un metodo chiamato valore
che prende in
prestito (borrow) self
, non ha altri parametri e restituisce un i32
.
Questo tipo di metodo è talvolta chiamato getter perché il suo scopo è
ottenere alcuni dati dai suoi campi e restituirli. È necessario dichiarare
questo metodo come public perché il campo valore
della struttura Ipotesi
è
privato. È importante che il campo valore
sia privato, in modo che il codice
che utilizza la struttura Ipotesi
non possa impostare valore
direttamente:
il codice esterno al modulo gioco_indovinello
deve utilizzare la funzione
Ipotesi::new
per creare un’istanza di Ipotesi
, garantendo così che Ipotesi
non possa avere un valore
che non sia stato verificato dalle condizioni della
funzione Ipotesi::new
.
Una funzione che ha un parametro o restituisce solo numeri compresi tra 1 e 100
potrebbe quindi dichiarare nella sua definizione che accetta o restituisce un
Ipotesi
anziché un i32
e non avrebbe bisogno di effettuare ulteriori
controlli nel suo corpo.
Riepilogo
Le funzionalità di gestione degli errori di Rust sono progettate per aiutarti a
scrivere codice più robusto. La macro panic!
segnala che il programma si trova
in uno stato che non può gestire e ti consente di dire al processo di
interrompersi invece di provare a procedere con valori non validi o errati.
L’enum Result
utilizza il sistema dei type di Rust per indicare che le
operazioni potrebbero fallire in un modo “recuperabile”. Puoi usare Result
anche per indicare al codice chiamante che deve gestire potenziali successi o
fallimenti. L’utilizzo di panic!
e Result
nelle situazioni appropriate
renderà il tuo codice più affidabile di fronte a inevitabili problemi.
Ora che hai visto i modi utili in cui la libreria standard utilizza i type
generici con le enum Option
e Result
, nel prossimo capitolo ne parleremo
in maniera approfondita e di come puoi usarli nel tuo codice.