Apri un file che non esiste in Python e open() lancia un FileNotFoundError che risale fuori dalla tua funzione. Fai la stessa cosa in Go e os.ReadFile ti restituisce un valore error accanto a uno slice di byte nil. Stesso fallimento, due forme di codice completamente diverse attorno ad esso. La differenza non è estetica. Decide se chi chiama la funzione può ignorare il fallimento per errore, e se il compilatore se ne accorge quando succede.
Cosa fanno davvero in modo diverso le eccezioni e i valori di errore?
Un’eccezione è un flusso di controllo che salta. Qualcosa fallisce, la funzione lancia, e l’esecuzione lascia lo stack delle chiamate corrente per finire nel primo blocco catch o except corrispondente, non importa quanti livelli più in alto si trovi. La firma della funzione non dà nessun indizio che questo possa accadere. loadConfig(path: string): Config in TypeScript sembra esattamente una funzione che non può fallire, finché non lancia un’eccezione per un file mancante.
Un valore di errore è un dato. La funzione lo restituisce insieme al risultato richiesto, oppure al suo posto. La coppia (Config, error) di Go, il Result<Config, ConfigError> di Rust, e una lookup che restituisce null quando non trova nulla sono la stessa idea: il fallimento fa parte del tipo di ritorno, quindi chi chiama la funzione lo vede direttamente nel punto della chiamata. In Rust il sistema dei tipi ti obbliga a gestirlo prima di toccare il risultato. In Go lo fa la convenzione.
Nessuno dei due approcci è nuovo, e nessuno ha vinto definitivamente. C restituiva valori sentinella come -1 e NULL; le eccezioni sono arrivate dopo, per spostare la gestione degli errori fuori dalla logica principale. Python, Java, C# e JavaScript usano le eccezioni di default. Go e Rust usano i valori di errore di default. La maggior parte dei linguaggi che adottano un approccio di default ti lascia comunque usare l’altro quando è più adatto.
Come Go restituisce gli errori come valori?
Go non ha eccezioni per i fallimenti ordinari. Una funzione che può fallire restituisce un error come ultimo valore, e chi la chiama lo controlla immediatamente:
| |
%w avvolge l’errore originale invece di scartarlo, così errors.Is e errors.As più in alto nello stack possono controllare cosa è andato storto senza confrontare stringhe nel messaggio.
Il punto debole: niente ti impedisce di scrivere data, _ := os.ReadFile(path) e buttare via l’errore. Il compilatore lo accetta senza problemi. Un linter come errcheck individua gli errori non controllati, ma solo se è configurato nella tua CI.
Come fanno le eccezioni di Python a saltare a un handler?
Lo stesso caricatore di configurazione in Python non ha controlli di errore in linea dopo ogni chiamata. Il controllo avviene attorno alla chiamata, non al suo interno:
| |
raise ... from e mantiene il traceback originale collegato al nuovo ConfigError, allo stesso modo in cui %w in Go mantiene raggiungibile l’errore originale.
La differenza che conta è la firma. load_config non dice mai che può lanciare un’eccezione. Chi la chiama tre livelli più sopra, se ha dimenticato il try, lo scopre quando il programma va in crash con un traceback in produzione, perché nel linguaggio niente controlla il punto della chiamata.
Come fa Result in Rust a obbligarti a gestire l’errore?
Rust mantiene l’idea dell’errore-come-valore e aggiunge un sistema di tipi che non ti lascia toccare il valore di successo senza riconoscere il caso di fallimento:
| |
Serve serde (con la feature derive) e serde_json nel Cargo.toml.
Result<T, E> è un enum con due varianti, Ok(T) e Err(E), e non puoi ottenere una Config senza gestirle entrambe. L’operatore ? è una scorciatoia per quel match: se fs::read_to_string restituisce Err, load_config ritorna subito con quell’errore, mappato in ConfigError::Io. Altrimenti estrae il valore da Ok e continua.
Puoi comunque saltare tutto questo con .unwrap(), che va in panic su Err. È la versione Rust di un’eccezione non gestita. Trattalo come qualcosa da mettere in discussione in code review, non come una scorciatoia.
Perché fetch() non lancia un’eccezione su un 404?
La divisione non corre solo tra i linguaggi: una singola API può usare entrambi i modelli per fallimenti diversi. fetch() rigetta la sua promise su un fallimento di rete (il DNS non si risolve, la connessione viene rifiutata) ma si risolve normalmente su un 404 o un 500. Lo stato HTTP è un valore sulla response che hai già: controlli response.ok, non catturi nulla. Gestione degli Errori con la Fetch API in JavaScript spiega perché questo sorprende chi si aspetta che un errore HTTP lanci un’eccezione, e come avvolgere fetch() per riottenere un comportamento in stile eccezione se lo vuoi.
Quando usare le eccezioni, e quando restituire errori?
Fatti una domanda sul fallimento specifico: chi chiama immediatamente deve agire su di esso, o dovrebbe propagarsi fino a un confine che lo gestisce?
Un cache miss o un errore di validazione sull’input dell’utente è un esito che il tuo codice dovrebbe aspettarsi e su cui ramificare lì per lì. Un valore di errore è adatto. Chi chiama non può svolgere il suo compito senza reagire, quindi un tipo di ritorno che rende visibile il fallimento è un aiuto, non una formalità.
Una connessione al database che cade a metà di una transazione, o un’asserzione sullo stato del programma che risulta falsa, sono casi diversi. Niente di quello che fa chi chiama immediatamente lo risolve. Lasciarlo propagare come eccezione o panic fino a un unico punto che registra il problema e chiude in modo ordinato è meno codice, e codice più onesto, che farlo attraversare manualmente ogni singolo frame.
I linguaggi che adottano un modello di default usano già l’altro dove serve. Go va in panic su un indice di slice fuori dai limiti, perché quello è un bug, non una condizione che chi chiama dovrebbe gestire. Rust va in panic su un overflow di interi nelle build di debug. Il dict.get(key, default) di Python restituisce un valore invece di lanciare KeyError, per il caso in cui una chiave mancante sia prevista invece che eccezionale.
Cosa va storto con ciascuna strategia nella pratica?
Errori ingoiati. Un except: vuoto che non fa nulla, o un if err != nil { } in Go con un corpo vuoto, cancella il fallimento. Il sintomo si manifesta più tardi come dati mancanti o una schermata vuota, senza nessun errore vicino alla causa reale. È il flusso difficile da tracciare descritto in Cos’è lo Spaghetti Code e Come Rifattorizzarlo.
Panic nella goroutine sbagliata. recover() funziona solo dentro la goroutine che è andata in panic. Un recover differito in main non ti salva da un panic in una goroutine worker; l’intero programma va giù. Goroutine e Channel in Go: Guida con Esempi mostra perché gli errori dal lavoro concorrente dovrebbero tornare su un channel, come valori.
.unwrap() e un catch generico invece di una decisione. Entrambi ti permettono di smettere di pensare al caso di fallimento senza farlo scomparire davvero. Scrivi un test che forzi il percorso di errore. È molto più facile quando la funzione restituisce un errore che puoi costruire tu stesso rispetto a quando lo lancia da dentro una libreria che non controlli, ed è il motivo per cui i percorsi di errore hanno test dedicati in Test Unitari vs Test di Integrazione vs Test End-to-End.
Come scegliere funzione per funzione, non per linguaggio?
La prossima volta che stai per scrivere throw o return err, guarda la funzione che hai davanti. Se chi la chiama deve reagire immediatamente, metti il fallimento nel tipo di ritorno: (T, error) in Go, Result in Rust, None o un default in Python. Se non può succedere niente di utile finché il fallimento non arriva a un handler di livello superiore, lancia un’eccezione o un panic e gestiscilo una volta sola, a quel confine. Poi aggiungi un test per il percorso di errore, qualunque cosa tu abbia scelto.