Codice pulito e arte del trattamento delle eccezioni
Pubblicato: 2022-03-11Le eccezioni sono vecchie quanto la programmazione stessa. Ai tempi in cui la programmazione veniva eseguita nell'hardware o tramite linguaggi di programmazione di basso livello, le eccezioni venivano utilizzate per alterare il flusso del programma ed evitare guasti hardware. Oggi Wikipedia definisce le eccezioni come:
condizioni anomale o eccezionali che richiedono un'elaborazione speciale, che spesso modificano il normale flusso di esecuzione del programma...
E che gestirli richiede:
costrutti di linguaggi di programmazione specializzati o meccanismi hardware del computer.
Pertanto, le eccezioni richiedono un trattamento speciale e un'eccezione non gestita può causare un comportamento imprevisto. I risultati sono spesso spettacolari. Nel 1996, il famoso fallimento del lancio del razzo Ariane 5 è stato attribuito a un'eccezione di overflow non gestita. I peggiori bug del software della cronologia contengono altri bug che potrebbero essere attribuiti a eccezioni non gestite o gestite in modo errato.
Nel tempo, questi errori, e innumerevoli altri (forse non così drammatici, ma comunque catastrofici per le persone coinvolte) hanno contribuito a creare l'impressione che le eccezioni siano cattive .
Ma le eccezioni sono un elemento fondamentale della programmazione moderna; esistono per migliorare il nostro software. Invece di temere le eccezioni, dovremmo abbracciarle e imparare a trarne vantaggio. In questo articolo, discuteremo come gestire le eccezioni in modo elegante e usarle per scrivere codice pulito che sia più gestibile.
Gestione delle eccezioni: è una buona cosa
Con l'avvento della programmazione orientata agli oggetti (OOP), il supporto delle eccezioni è diventato un elemento cruciale dei moderni linguaggi di programmazione. Al giorno d'oggi, nella maggior parte delle lingue è integrato un solido sistema di gestione delle eccezioni. Ad esempio, Ruby prevede il seguente pattern tipico:
begin do_something_that_might_not_work! rescue SpecificError => e do_some_specific_error_clean_up retry if some_condition_met? ensure this_will_always_be_executed end
Non c'è niente di sbagliato nel codice precedente. Ma l'uso eccessivo di questi modelli causerà odori di codice e non sarà necessariamente vantaggioso. Allo stesso modo, il loro uso improprio può effettivamente danneggiare molto la tua base di codice, rendendola fragile o offuscando la causa degli errori.
Lo stigma che circonda le eccezioni spesso fa sentire i programmatori perplessi. È un dato di fatto che le eccezioni non possono essere evitate, ma spesso ci viene insegnato che devono essere affrontate in modo rapido e deciso. Come vedremo, questo non è necessariamente vero. Piuttosto, dovremmo imparare l'arte di gestire le eccezioni con grazia, rendendole armoniose con il resto del nostro codice.
Di seguito sono riportate alcune pratiche consigliate che ti aiuteranno ad abbracciare le eccezioni e a farne uso e le loro capacità per mantenere il tuo codice manutenibile , estensibile e leggibile :
- manutenibilità : ci consente di trovare e correggere facilmente nuovi bug, senza il timore di interrompere le funzionalità attuali, introdurre ulteriori bug o dover abbandonare del tutto il codice a causa della maggiore complessità nel tempo.
- estensibilità : ci consente di aggiungere facilmente alla nostra base di codice, implementando requisiti nuovi o modificati senza interrompere le funzionalità esistenti. L'estendibilità offre flessibilità e consente un elevato livello di riutilizzabilità per la nostra base di codice.
- leggibilità : ci consente di leggere facilmente il codice e scoprirne lo scopo senza perdere troppo tempo a scavare. Questo è fondamentale per scoprire in modo efficiente bug e codice non testato.
Questi elementi sono i fattori principali di ciò che potremmo chiamare pulizia o qualità , che non è una misura diretta in sé, ma è invece l'effetto combinato dei punti precedenti, come dimostrato in questo fumetto:
Detto questo, immergiamoci in queste pratiche e vediamo come ognuna di esse influisca su queste tre misure.
Nota: presenteremo esempi da Ruby, ma tutti i costrutti mostrati qui hanno equivalenti nei linguaggi OOP più comuni.
Crea sempre la tua gerarchia ApplicationError
La maggior parte delle lingue viene fornita con una varietà di classi di eccezione, organizzate in una gerarchia di ereditarietà, come qualsiasi altra classe OOP. Per preservare la leggibilità, la manutenibilità e l'estendibilità del nostro codice, è una buona idea creare il nostro sottoalbero di eccezioni specifiche dell'applicazione che estendono la classe di eccezione di base. Investire un po' di tempo nella strutturazione logica di questa gerarchia può essere estremamente vantaggioso. Per esempio:
class ApplicationError < StandardError; end # Validation Errors class ValidationError < ApplicationError; end class RequiredFieldError < ValidationError; end class UniqueFieldError < ValidationError; end # HTTP 4XX Response Errors class ResponseError < ApplicationError; end class BadRequestError < ResponseError; end class UnauthorizedError < ResponseError; end # ...
Avere un pacchetto di eccezioni estensibile e completo per la nostra applicazione rende molto più semplice la gestione di queste situazioni specifiche dell'applicazione. Ad esempio, possiamo decidere quali eccezioni gestire in modo più naturale. Questo non solo aumenta la leggibilità del nostro codice, ma aumenta anche la manutenibilità delle nostre applicazioni e librerie (gem).
Dal punto di vista della leggibilità, è molto più facile leggere:
rescue ValidationError => e
Che leggere:
rescue RequiredFieldError, UniqueFieldError, ... => e
Dal punto di vista della manutenibilità, ad esempio, stiamo implementando un'API JSON e abbiamo definito il nostro ClientError
con diversi sottotipi, da utilizzare quando un client invia una richiesta errata. Se uno di questi viene generato, l'applicazione dovrebbe rendere la rappresentazione JSON dell'errore nella sua risposta. Sarà più facile correggere o aggiungere logica a un singolo blocco che gestisce ClientError
s piuttosto che eseguire il ciclo su ogni possibile errore del client e implementare lo stesso codice del gestore per ciascuno. In termini di estensibilità, se in seguito dovremo implementare un altro tipo di errore del client, possiamo essere certi che sarà già gestito correttamente qui.
Inoltre, questo non ci impedisce di implementare una gestione speciale aggiuntiva per specifici errori del client in precedenza nello stack di chiamate o di alterare lo stesso oggetto eccezione lungo il percorso:
# app/controller/pseudo_controller.rb def authenticate_user! fail AuthenticationError if token_invalid? || token_expired? User.find_by(authentication_token: token) rescue AuthenticationError => e report_suspicious_activity if token_invalid? raise e end def show authenticate_user! show_private_stuff!(params[:id]) rescue ClientError => e render_error(e) end
Come puoi vedere, sollevare questa specifica eccezione non ci ha impedito di essere in grado di gestirla a diversi livelli, alterarla, sollevarla nuovamente e consentire al gestore della classe padre di risolverla.
Due cose da notare qui:
- Non tutte le lingue supportano la generazione di eccezioni dall'interno di un gestore di eccezioni.
- Nella maggior parte delle lingue, sollevare una nuova eccezione dall'interno di un gestore causerà la perdita per sempre dell'eccezione originale, quindi è meglio sollevare nuovamente lo stesso oggetto eccezione (come nell'esempio precedente) per evitare di perdere traccia della causa originale del errore. (A meno che tu non lo stia facendo intenzionalmente).
Non rescue Exception
Cioè, non tentare mai di implementare un gestore catch-all per il tipo di eccezione di base. Il salvataggio o la cattura di tutte le eccezioni all'ingrosso non è mai una buona idea in nessuna lingua, a livello globale a livello di applicazione di base o in un piccolo metodo nascosto utilizzato una sola volta. Non vogliamo salvare Exception
perché offuscherà qualsiasi cosa sia realmente accaduta, danneggiando sia la manutenibilità che l'estendibilità. Possiamo perdere un'enorme quantità di tempo a eseguire il debug di quale sia il problema reale, quando potrebbe essere semplice come un errore di sintassi:
# main.rb def bad_example i_might_raise_exception! rescue Exception nah_i_will_always_be_here_for_you end # elsewhere.rb def i_might_raise_exception! retrun do_a_lot_of_work! end
Potresti aver notato l'errore nell'esempio precedente; il return
è digitato in modo errato. Sebbene gli editor moderni forniscano una certa protezione contro questo tipo specifico di errore di sintassi, questo esempio illustra come rescue Exception
danneggia il nostro codice. In nessun momento viene affrontato il tipo effettivo dell'eccezione (in questo caso un NoMethodError
), né viene mai esposto allo sviluppatore, il che potrebbe farci perdere molto tempo a correre in tondo.
Non rescue
mai più eccezioni del necessario
Il punto precedente è un caso specifico di questa regola: dovremmo sempre fare attenzione a non generalizzare eccessivamente i nostri gestori di eccezioni. Le ragioni sono le stesse; ogni volta che salviamo più eccezioni di quanto dovremmo, finiamo per nascondere parti della logica dell'applicazione dai livelli più alti dell'applicazione, per non parlare della soppressione della capacità dello sviluppatore di gestire l'eccezione da solo. Ciò pregiudica gravemente l'estendibilità e la manutenibilità del codice.
Se proviamo a gestire diversi sottotipi di eccezioni nello stesso gestore, introduciamo blocchi di codice grasso che hanno troppe responsabilità. Ad esempio, se stiamo creando una libreria che utilizza un'API remota, la gestione di un MethodNotAllowedError
(HTTP 405) è in genere diversa dalla gestione di un UnauthorizedError
(HTTP 401), anche se sono entrambi ResponseError
s.
Come vedremo, spesso esiste una parte diversa dell'applicazione che sarebbe più adatta a gestire eccezioni specifiche in modo più ASCIUTTO.

Quindi, definisci la singola responsabilità della tua classe o metodo e gestisci il minimo indispensabile di eccezioni che soddisfano questo requisito di responsabilità . Ad esempio, se un metodo è responsabile dell'ottenimento di informazioni sulle scorte da un'API remota, dovrebbe gestire le eccezioni che derivano dall'ottenere solo tali informazioni e lasciare la gestione degli altri errori a un metodo diverso progettato specificamente per queste responsabilità:
def get_info begin response = HTTP.get(STOCKS_URL + "#{@symbol}/info") fail AuthenticationError if response.code == 401 fail StockNotFoundError, @symbol if response.code == 404 return JSON.parse response.body rescue JSON::ParserError retry end end
Qui abbiamo definito il contratto per questo metodo per fornirci solo le informazioni sullo stock. Gestisce gli errori specifici dell'endpoint , ad esempio una risposta JSON incompleta o errata. Non gestisce il caso quando l'autenticazione non riesce o scade o se lo stock non esiste. Questi sono responsabilità di qualcun altro e vengono esplicitamente passati allo stack di chiamate dove dovrebbe esserci un posto migliore per gestire questi errori in modo ASCIUTTO.
Resisti alla tentazione di gestire immediatamente le eccezioni
Questo è il complemento all'ultimo punto. Un'eccezione può essere gestita in qualsiasi punto dello stack di chiamate e in qualsiasi punto della gerarchia delle classi, quindi sapere esattamente dove gestirla può essere sconcertante. Per risolvere questo enigma, molti sviluppatori scelgono di gestire qualsiasi eccezione non appena si presenta, ma investire tempo per riflettere su questo di solito si tradurrà nella ricerca di un posto più appropriato per gestire eccezioni specifiche.
Un modello comune che vediamo nelle applicazioni Rails (in particolare quelle che espongono API solo JSON) è il seguente metodo controller:
# app/controllers/client_controller.rb def create @client = Client.new(params[:client]) if @client.save render json: @client else render json: @client.errors end end
(Si noti che sebbene questo non sia tecnicamente un gestore di eccezioni, funzionalmente serve allo stesso scopo, poiché @client.save
restituisce false solo quando incontra un'eccezione.)
In questo caso, tuttavia, ripetere lo stesso gestore di errori in ogni azione del controller è l'opposto di DRY e danneggia la manutenibilità e l'estendibilità. Invece, possiamo sfruttare la natura speciale della propagazione delle eccezioni e gestirle solo una volta, nella classe controller genitore, ApplicationController
:
# app/controllers/client_controller.rb def create @client = Client.create!(params[:client]) render json: @client end
# app/controller/application_controller.rb rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity def render_unprocessable_entity(e) render \ json: { errors: e.record.errors }, status: 422 end
In questo modo, possiamo garantire che tutti gli errori ActiveRecord::RecordInvalid
vengano gestiti correttamente e in modo asciutto in un'unica posizione, a livello di ApplicationController
di base. Questo ci dà la libertà di giocherellare con loro se vogliamo gestire casi specifici al livello inferiore o semplicemente lasciarli propagare con grazia.
Non tutte le eccezioni devono essere gestite
Durante lo sviluppo di una gemma o di una libreria, molti sviluppatori cercheranno di incapsulare la funzionalità e non consentire che alcuna eccezione si propaghi fuori dalla libreria. Ma a volte, non è ovvio come gestire un'eccezione finché non viene implementata l'applicazione specifica.
Prendiamo ActiveRecord
come esempio della soluzione ideale. La libreria fornisce agli sviluppatori due approcci per la completezza. Il metodo save
gestisce le eccezioni senza propagarle, semplicemente restituendo false
, mentre save!
solleva un'eccezione quando fallisce. Ciò offre agli sviluppatori la possibilità di gestire casi di errore specifici in modo diverso o semplicemente di gestire qualsiasi errore in modo generale.
Ma cosa succede se non hai il tempo o le risorse per fornire un'implementazione così completa? In tal caso, in caso di incertezza, è meglio esporre l'eccezione e rilasciarla in natura.
Ecco perché: lavoriamo quasi sempre con requisiti in movimento e prendere la decisione che un'eccezione sarà sempre gestita in un modo specifico potrebbe effettivamente danneggiare la nostra implementazione, danneggiare l'estendibilità e la manutenibilità e potenzialmente aggiungere enormi debiti tecnici, specialmente durante lo sviluppo biblioteche.
Prendi l'esempio precedente di un consumatore API azionario che recupera i prezzi delle azioni. Abbiamo scelto di gestire la risposta incompleta e errata sul posto e abbiamo scelto di riprovare la stessa richiesta fino a quando non abbiamo ottenuto una risposta valida. Ma in seguito, i requisiti potrebbero cambiare, in modo tale che dobbiamo ricorrere ai dati storici sulle scorte salvati, invece di riprovare la richiesta.
A questo punto, saremo costretti a cambiare la libreria stessa, aggiornando come viene gestita questa eccezione, perché i progetti dipendenti non gestiranno questa eccezione. (Come potrebbero? Non è mai stato esposto a loro prima.) Dovremo anche informare i proprietari dei progetti che fanno affidamento sulla nostra biblioteca. Questo potrebbe diventare un incubo se ci sono molti progetti di questo tipo, poiché è probabile che siano stati costruiti sul presupposto che questo errore verrà gestito in un modo specifico.
Ora possiamo vedere dove stiamo andando con la gestione delle dipendenze. Le prospettive non sono buone. Questa situazione si verifica abbastanza spesso e, il più delle volte, degrada l'utilità, l'estendibilità e la flessibilità della libreria.
Quindi ecco la linea di fondo: se non è chiaro come deve essere gestita un'eccezione, lascia che si propaghi con grazia . Ci sono molti casi in cui esiste un posto chiaro per gestire l'eccezione internamente, ma ci sono molti altri casi in cui è meglio esporre l'eccezione. Quindi, prima di scegliere di gestire l'eccezione, pensaci due volte. Una buona regola pratica è insistere sulla gestione delle eccezioni solo quando si interagisce direttamente con l'utente finale.
Segui la convenzione
L'implementazione di Ruby, e ancor di più Rails, segue alcune convenzioni di denominazione, come la distinzione tra method_names
e method_names!
con un "botto". In Ruby, il botto indica che il metodo altererà l'oggetto che lo ha invocato, e in Rails, significa che il metodo solleverà un'eccezione se non riesce a eseguire il comportamento previsto. Cerca di rispettare la stessa convenzione, soprattutto se intendi rendere open source la tua libreria.
Se dovessimo scrivere un nuovo method!
con un botto in un'applicazione Rails, dobbiamo tenere conto di queste convenzioni. Non c'è nulla che ci obbliga a sollevare un'eccezione quando questo metodo fallisce, ma deviando dalla convenzione, questo metodo può indurre i programmatori a credere che gli sarà data la possibilità di gestire le eccezioni da soli, quando, in realtà, non lo faranno.
Un'altra convenzione di Ruby, attribuita a Jim Weirich, consiste nell'usare fail
per indicare il fallimento del metodo e nell'usare raise
solo se si sta rilanciando l'eccezione.
A parte, poiché uso le eccezioni per indicare i fallimenti, uso quasi sempre la parola chiave
fail
piuttosto che la parola chiaveraise
in Ruby. Fail e raise sono sinonimi, quindi non c'è differenza tranne che fail comunica più chiaramente che il metodo ha fallito. L'unica volta che uso raise è quando catturo un'eccezione e la rilancio, perché qui non sto fallendo, ma sollevando un'eccezione in modo esplicito e intenzionale. Questa è una questione stilistica che seguo, ma dubito che molte altre persone lo facciano.
Molte altre comunità linguistiche hanno adottato convenzioni come queste su come vengono trattate le eccezioni e ignorare queste convenzioni danneggerà la leggibilità e la manutenibilità del nostro codice.
Logger.log(tutto)
Questa pratica non si applica solo alle eccezioni, ovviamente, ma se c'è una cosa che dovrebbe essere sempre registrata, è un'eccezione.
La registrazione è estremamente importante (abbastanza importante da consentire a Ruby di spedire un registratore con la sua versione standard). È il diario delle nostre applicazioni e, ancor più importante di tenere traccia di come le nostre applicazioni hanno successo, è registrare come e quando falliscono.
Non mancano librerie di registrazione o servizi basati su log e modelli di progettazione. È fondamentale tenere traccia delle nostre eccezioni in modo da poter rivedere cosa è successo e indagare se qualcosa non va bene. Messaggi di registro corretti possono indirizzare gli sviluppatori direttamente alla causa di un problema, risparmiando loro tempo incommensurabile.
Quella fiducia nel codice pulito
Le eccezioni sono una parte fondamentale di ogni linguaggio di programmazione. Sono speciali ed estremamente potenti e dobbiamo sfruttare il loro potere per elevare la qualità del nostro codice invece di esaurirci combattendo con loro.
In questo articolo, abbiamo approfondito alcune buone pratiche per strutturare i nostri alberi delle eccezioni e come può essere utile per la leggibilità e la qualità strutturarli logicamente. Abbiamo esaminato diversi approcci per la gestione delle eccezioni, in un unico luogo o su più livelli.
Abbiamo visto che è brutto "prenderli tutti" e che va bene lasciarli fluttuare e gonfiarsi.
Abbiamo esaminato dove gestire le eccezioni in modo ASCIUTTO e abbiamo appreso che non siamo obbligati a gestirle quando o dove si verificano per la prima volta.
Abbiamo discusso di quando esattamente è una buona idea gestirli, quando è una cattiva idea e perché, in caso di dubbio, è una buona idea lasciarli propagare.
Infine, abbiamo discusso altri punti che possono aiutare a massimizzare l'utilità delle eccezioni, come seguire le convenzioni e registrare tutto.
Con queste linee guida di base, possiamo sentirci molto più a nostro agio e sicuri nell'affrontare casi di errore nel nostro codice e rendere le nostre eccezioni davvero eccezionali!
Un ringraziamento speciale ad Avdi Grimm e al suo fantastico discorso Exceptional Ruby, che ha aiutato molto nella realizzazione di questo articolo.