Checked vs Unchecked vs No Exception ... Una buona pratica di convinzioni contrarie


10

Esistono molti requisiti necessari affinché un sistema trasmetta e gestisca correttamente le eccezioni. Ci sono anche molte opzioni tra cui scegliere una lingua per implementare il concetto.

Requisiti per le eccezioni (in nessun ordine particolare):

  1. Documentazione : una lingua dovrebbe avere un mezzo per documentare le eccezioni che un'API può generare. Idealmente, questo supporto di documentazione dovrebbe essere utilizzabile dalla macchina per consentire a compilatori e IDE di fornire supporto al programmatore.

  2. Trasmissione di situazioni eccezionali : questa è ovvia per consentire a una funzione di trasmettere situazioni che impediscono alla funzionalità chiamata di eseguire l'azione prevista. Secondo me ci sono tre grandi categorie di tali situazioni:

    2.1 Bug nel codice che rendono alcuni dati non validi.

    2.2 Problemi di configurazione o altre risorse esterne.

    2.3 Risorse intrinsecamente inaffidabili (rete, file system, database, utenti finali ecc.). Questi sono un caso un po 'complicato poiché la loro natura inaffidabile dovrebbe farci aspettare i loro sporadici fallimenti. In questo caso queste situazioni devono essere considerate eccezionali?

  3. Fornire informazioni sufficienti affinché il codice possa gestirlo : le eccezioni dovrebbero fornire informazioni sufficienti al chiamante in modo che possa reagire e possibilmente gestire la situazione. le informazioni dovrebbero anche essere sufficienti affinché, quando registrate, queste eccezioni fornissero un contesto sufficiente a un programmatore per identificare e isolare le dichiarazioni offensive e fornire una soluzione.

  4. Fornire fiducia al programmatore sullo stato corrente dello stato di esecuzione del suo codice : le capacità di gestione delle eccezioni di un sistema software dovrebbero essere sufficientemente presenti da fornire le garanzie necessarie evitando il programmatore in modo da poter rimanere concentrato sul compito di mano.

Per coprire questi sono stati implementati i seguenti metodi in varie lingue:

  1. Eccezioni verificate Fornire un ottimo modo per documentare le eccezioni e teoricamente, se implementato correttamente, dovrebbe fornire un'ampia rassicurazione sul fatto che tutto va bene. Tuttavia, il costo è tale che molti ritengono più produttivo bypassare semplicemente ingoiando le eccezioni o rilanciandole come eccezioni non controllate. Quando usato eccezioni controllate in modo inappropriato praticamente perde tutta la sua utilità. Inoltre, le eccezioni verificate rendono difficile la creazione di un'API stabile nel tempo. Le implementazioni di un sistema generico all'interno di un dominio specifico porteranno il carico di una situazione eccezionale che sarebbe difficile da mantenere usando solo le eccezioni verificate.

  2. Eccezioni non selezionate : molto più versatili delle eccezioni verificate, non riescono a documentare correttamente le possibili situazioni eccezionali di una determinata implementazione. Si basano sulla documentazione ad hoc, se non del tutto. Questo crea situazioni in cui la natura inaffidabile di un supporto è mascherata da un'API che dà l'impressione di affidabilità. Anche quando vengono lanciate queste eccezioni perdono il loro significato mentre risalgono attraverso gli strati di astrazione. Poiché sono scarsamente documentati, un programmatore non può indirizzarli in modo specifico e spesso ha bisogno di lanciare una rete molto più ampia del necessario per garantire che i sistemi secondari, in caso di guasto, non abbattano l'intero sistema. Il che ci riporta al problema della deglutizione verificate le eccezioni fornite.

  3. Tipi di restituzione multistato Qui è fare affidamento su un insieme disgiunto, tupla o altri concetti simili per restituire il risultato previsto o un oggetto che rappresenta l'eccezione. Qui nessuno svolgimento dello stack, nessun taglio del codice, tutto viene eseguito normalmente ma il valore restituito deve essere convalidato per errore prima di continuare. In realtà non ho ancora lavorato su questo, quindi non posso commentare per esperienza. Riconosco che risolve alcuni problemi, eccezioni che aggirano il flusso normale, ma soffrirà comunque degli stessi problemi delle eccezioni verificate, essendo noiosi e costantemente "in faccia".

Quindi la domanda è:

Qual è la tua esperienza in materia e quale, secondo te, è il miglior candidato per creare un buon sistema di gestione delle eccezioni per una lingua?


EDIT: Pochi minuti dopo aver scritto questa domanda mi sono imbattuto in questo post , spettrale!


2
"soffrirà più o meno degli stessi problemi delle eccezioni verificate come noiose e costantemente in faccia": Non proprio: con un adeguato supporto linguistico devi solo programmare il "percorso di successo", con i macchinari linguistici sottostanti che si occupano della propagazione errori.
Giorgio,

"Una lingua dovrebbe avere un mezzo per documentare le eccezioni che un'API può lanciare." - weeeel. In C ++ "abbiamo" imparato che questo non funziona davvero. Tutto quello che può davvero utilmente fare è quello di indicare se un'API può lanciare qualsiasi eccezione. (In verità, tagliare una lunga storia, ma penso che guardare la noexceptstoria in C ++ possa produrre ottime intuizioni per EH anche in C # e Java.)
Martin Ba

Risposte:


10

All'inizio del C ++ abbiamo scoperto che senza una sorta di programmazione generica, i linguaggi fortemente tipizzati erano estremamente ingombranti. Abbiamo anche scoperto che le eccezioni controllate e la programmazione generica non funzionavano bene insieme e che le eccezioni controllate erano essenzialmente abbandonate.

I tipi di restituzione multiset sono eccezionali, ma non sostituiscono le eccezioni. Senza eccezioni, il codice è pieno di rumore di controllo degli errori.

L'altro problema con le eccezioni verificate è che una modifica delle eccezioni generata da una funzione di basso livello forza una cascata di modifiche in tutti i chiamanti, i loro chiamanti e così via. L'unico modo per impedirlo è che ogni livello di codice rilevi eventuali eccezioni generate da livelli inferiori e le racchiuda in una nuova eccezione. Ancora una volta, si finisce con un codice molto rumoroso.


2
I generici aiutano a risolvere un'intera classe di errori che sono principalmente dovuti a una limitazione del supporto del linguaggio al paradigma OO. tuttavia, le alternative sembrano essere o avere un codice che per lo più controlla errori o che funziona sperando che nulla vada mai storto. O hai situazioni eccezionali costantemente in faccia o vivi in ​​una terra da sogno di soffici coniglietti bianchi che diventano davvero brutti quando fai cadere un grosso lupo cattivo nel mezzo!
Newtopian,

3
+1 per il problema a cascata. Qualsiasi sistema / architettura che renda difficile il cambiamento porta solo a patch di scimmie e sistemi disordinati, non importa quanto gli autori pensino che fossero ben progettati.
Matthieu M.,

2
@Newtopian: i modelli eseguono operazioni che non possono essere eseguite con un rigoroso orientamento agli oggetti, come ad esempio la sicurezza di tipo statico per i contenitori generici.
David Thornley,

2
Vorrei vedere un sistema di eccezioni con un concetto di "eccezioni controllate", ma molto diverso da quello di Java. Checked-ness non dovrebbe essere un attributo di un tipo di eccezione , ma piuttosto lanciare siti, siti di cattura e istanze di eccezione; se un metodo viene pubblicizzato come lancio di un'eccezione controllata, ciò dovrebbe avere due effetti: (1) la funzione dovrebbe gestire un "lancio" dell'eccezione controllata facendo qualcosa di speciale al ritorno (ad esempio impostando la bandiera carry, ecc. a seconda del piattaforma esatta) per il quale è necessario preparare il codice chiamante.
supercat

7
"Senza eccezioni, il codice è pieno di rumore di controllo degli errori.": Non ne sono sicuro: in Haskell puoi usare le monadi per questo e tutto il rumore di controllo degli errori è sparito. Il rumore introdotto dai "tipi di ritorno a più stati" è più una limitazione del linguaggio di programmazione che della soluzione in sé.
Giorgio,

9

Per molto tempo le lingue OO, l'uso delle eccezioni è stato di fatto lo standard per comunicare errori. Ma i linguaggi di programmazione funzionale offrono la possibilità di un approccio diverso, ad esempio l'uso di monadi (che non ho usato), o la più leggera "Programmazione orientata alle ferrovie", come descritto da Scott Wlaschin.

È davvero una variante del tipo di risultato multistato.

  • Una funzione restituisce un successo o un errore. Non può restituire entrambi (come nel caso di una tupla).
  • Tutti i possibili errori sono stati brevemente documentati (almeno in F # con tipi di risultati come sindacati discriminati).
  • Il chiamante non può usare il risultato senza prendere in considerazione se il risultato è stato un successo o un fallimento.

Il tipo di risultato potrebbe essere dichiarato in questo modo

type Result<'TSuccess,'TFail> =
| Success of 'TSuccess
| Fail of 'TFail

Quindi il risultato di una funzione che restituisce questo tipo sarebbe Successo un Failtipo. Non può essere entrambi.

In linguaggi di programmazione più orientati all'imperativo, questo tipo di stile potrebbe richiedere una grande quantità di codice sul sito del chiamante. Ma la programmazione funzionale consente di costruire funzioni di associazione o operatori per collegare più funzioni in modo che il controllo degli errori non occupi metà del codice. Come esempio:

// Create an updateUser function that takes an id, and new state
// as input, and updates an existing user.
let updateUser id input =
    validateInput input
    >>= loadUser id
    >>= updateUser input
    >>= saveUser id
    >>= notifyAboutUserUpdated

La updateUserfunzione chiama ciascuna di queste funzioni in successione e ognuna potrebbe non riuscire. Se tutti hanno esito positivo, viene restituito il risultato dell'ultima funzione chiamata. Se una delle funzioni fallisce, il risultato di quella funzione sarà il risultato della updateUserfunzione complessiva . Tutto questo è gestito dall'operatore personalizzato >> =.

Nell'esempio sopra, i tipi di errore potrebbero essere

type UserValidationErrorType =
| InvalidEmail of string
| MissingFirstName of string
... etc

type DbErrorType =
| RecordNotFound of int
| ConcurrencyError of int

type UpdateUserErrorType =
| InvalidInput of UserValidationErrorType
| DbError of DbErrorType

Se il chiamante di updateUsernon gestisce esplicitamente tutti i possibili errori della funzione, il compilatore emetterà un avviso. Quindi hai tutto documentato.

In Haskell esiste una donotazione che può rendere il codice ancora più pulito.


2
Ottima risposta e riferimenti (programmazione orientata alla ferrovia), +1. Potresti voler menzionare la donotazione di Haskell , che rende il codice risultante ancora più pulito.
Giorgio,

1
@Giorgio - L'ho fatto ora, ma non ho lavorato con Haskell, solo F #, quindi non potevo davvero scrivere molto al riguardo. Ma puoi aggiungere alla risposta se vuoi.
Pete,

Grazie, ho scritto un piccolo esempio ma dato che non era abbastanza piccolo per essere aggiunto alla tua risposta, ho scritto una risposta completa (con alcune informazioni di base extra).
Giorgio,

2
Il Railway Oriented Programmingcomportamento è esattamente monadico.
Daenyth,

5

Trovo la risposta di Pete molto buona e vorrei aggiungere qualche considerazione e un esempio. Una discussione molto interessante sull'uso delle eccezioni rispetto alla restituzione di valori di errore speciali è disponibile in Programmazione in Standard ML, di Robert Harper , alla fine della Sezione 29.3, pagina 243, 244.

Il problema è implementare una funzione parziale che frestituisce un valore di qualche tipo t. Una soluzione è avere la funzione avere tipo

f : ... -> t

e genera un'eccezione quando non è possibile ottenere alcun risultato. La seconda soluzione è implementare una funzione con tipo

f : ... -> t option

e ritorno SOME val successo e NONEal fallimento.

Ecco il testo del libro, con un piccolo adattamento fatto da me stesso per rendere il testo più generale (il libro fa riferimento a un esempio particolare). Il testo modificato è scritto in corsivo .

Quali sono i compromessi tra le due soluzioni?

  1. La soluzione basata su tipi di opzioni rende esplicita fla possibilità di errore nel tipo di funzione . Questo costringe il programmatore a testare esplicitamente il fallimento usando un'analisi del caso sul risultato della chiamata. Il controllo del tipo garantirà che non si possa usare t optiondovet è previsto a. La soluzione basata su eccezioni non indica esplicitamente un errore nel suo tipo. Tuttavia, il programmatore è comunque costretto a gestire l'errore, altrimenti un errore di eccezione non rilevata verrebbe generato in fase di esecuzione, anziché in fase di compilazione.
  2. La soluzione basata sui tipi di opzione richiede un'analisi esplicita del caso sul risultato di ciascuna chiamata. Se "la maggior parte" dei risultati ha esito positivo, il controllo è ridondante e quindi eccessivamente costoso. La soluzione basata su eccezioni è priva di questo sovraccarico: è distorta dal caso "normale" di restituzione di un caso t, piuttosto che dal caso "fallimento" di non restituire affatto un risultato . L'implementazione delle eccezioni garantisce che l'uso di un gestore sia più efficiente di un'analisi del caso esplicita nel caso in cui il fallimento sia raro rispetto al successo.

[taglio] In generale, se l'efficienza è fondamentale, tendiamo a preferire le eccezioni se il fallimento è una rarità e preferiamo le opzioni se il fallimento è relativamente comune. Se, d'altra parte, il controllo statico è fondamentale, allora è vantaggioso utilizzare le opzioni poiché il controllo del tipo imporrà il requisito per cui il programmatore controlla l'errore, piuttosto che avere l'errore si presenti solo in fase di esecuzione.

Questo per quanto riguarda la scelta tra eccezioni e tipi di restituzione delle opzioni.

Per quanto riguarda l'idea che la rappresentazione di un errore nel tipo restituito porti a controlli degli errori sparsi in tutto il codice: non è necessario che ciò avvenga. Ecco un piccolo esempio in Haskell che illustra questo.

Supponiamo di voler analizzare due numeri e quindi dividere il primo per il secondo. Quindi potrebbe esserci un errore durante l'analisi di ciascun numero o durante la divisione (divisione per zero). Quindi dobbiamo verificare la presenza di un errore dopo ogni passaggio.

import Text.Read

parseInt :: String -> Maybe Int
parseInt s = readMaybe s :: Maybe Int

safeDiv :: Int -> Int -> Maybe Int
safeDiv n d = if d /= 0 then Just (n `div` d) else Nothing

toString :: Maybe Int -> String
toString (Just i) = show i
toString Nothing  = "error"

main = do
         -- Get two lines from the terminal.
         nStr <- getLine
         dStr <- getLine

         -- Parse each string and divide.
         let r = do n <- parseInt nStr
                    d <- parseInt dStr
                    safeDiv n d

         -- Print the result.
         putStrLn $ toString r

L'analisi e la divisione vengono eseguite nel let ...blocco. Nota che usando la Maybemonade e la donotazione, viene specificato solo il percorso di successo : la semantica della Maybemonade propaga implicitamente il valore di errore ( Nothing). Nessun sovraccarico per il programmatore.


2
Penso che in casi come questo in cui si desidera stampare una sorta di utile messaggio di errore, il Eithertipo sarebbe più adatto. Cosa fai se arrivi Nothingqui? Hai appena ricevuto il messaggio "errore". Non molto utile per il debug.
Sara

1

Sono diventato un grande fan di Checked Exceptions e vorrei condividere la mia regola generale su quando usarli.

Sono giunto alla conclusione che ci sono fondamentalmente 2 tipi di errori che il mio codice deve affrontare. Esistono errori verificabili prima dell'esecuzione del codice e non sono verificabili prima dell'esecuzione del codice. Un semplice esempio di errore verificabile prima dell'esecuzione del codice in una NullPointerException.

//... bad code below.  the runnable variable
// tries to call the run() method before the variable
// is instantiated.  Running the code below will cause
// a NullPointerException.
Runnable runnable = null;
runnable.run();

Un semplice test avrebbe potuto evitare l'errore come ...

Runnable runnable = null;
...
if (runnable != null)
{   runnable.run(); }

Ci sono momenti nel calcolo in cui è possibile eseguire 1 o più test prima di eseguire il codice per assicurarsi di essere al sicuro E SIAMO ANCORA OTTENUTI UN'ECCEZIONE. Ad esempio, è possibile testare un file system per assicurarsi che vi sia spazio su disco sufficiente sul disco rigido prima di scrivere i dati sull'unità. In un sistema operativo multiprocessore, come quelli utilizzati oggi, il processo potrebbe testare lo spazio su disco e il file system restituirà un valore che dice che c'è spazio sufficiente, quindi un cambio di contesto in un altro processo potrebbe scrivere i byte rimanenti disponibili per il sistema operativo sistema. Quando il contesto del sistema operativo torna al processo in esecuzione in cui si scrivono i contenuti sul disco, si verificherà un'eccezione semplicemente perché non c'è spazio su disco sufficiente sul file system.

Considero lo scenario sopra come un caso perfetto per un'eccezione controllata. È un'eccezione nel codice che ti costringe a gestire qualcosa di brutto anche se il tuo codice potrebbe essere scritto perfettamente. Se scegli di fare cose cattive come "ingoia l'eccezione", sei il cattivo programmatore. A proposito, ho trovato casi in cui è ragionevole ingoiare l'eccezione ma si prega di lasciare un commento nel codice sul motivo per cui l'eccezione è stata ingoiata. Il meccanismo di gestione delle eccezioni non è da biasimare. Scherzo comunemente che preferirei che il mio pacemaker fosse scritto con una lingua con Checked Exceptions.

Ci sono momenti in cui diventa difficile decidere se il codice è testabile o meno. Ad esempio, se stai scrivendo un interprete e viene generata una SyntaxException quando il codice non viene eseguito per qualche motivo sintattico, la SyntaxException dovrebbe essere un'eccezione controllata o (in Java) una RuntimeException? Risponderei se l'interprete controlla la sintassi del codice prima che il codice venga eseguito, quindi l'eccezione dovrebbe essere una RuntimeException. Se l'interprete esegue semplicemente il codice "hot" e rileva semplicemente un errore di sintassi, direi che l'eccezione dovrebbe essere un'eccezione controllata.

Devo ammettere che non sono sempre felice di dover catturare o lanciare un'eccezione controllata perché ci sono momenti in cui non sono sicuro di cosa fare. Le eccezioni controllate sono un modo per forzare un programmatore a tenere conto del potenziale problema che può verificarsi. Uno dei motivi per cui programma in Java è perché ha controllato le eccezioni.


1
Preferirei che il mio pacemaker cardiaco fosse scritto in una lingua che non aveva alcuna eccezione, e tutte le righe di codice gestivano gli errori attraverso i codici di ritorno. Quando si genera un'eccezione, si sta dicendo "è andato tutto storto" e l'unico modo sicuro per continuare l'elaborazione è arrestare e riavviare. Un programma che finisce così facilmente in uno stato non valido non è qualcosa che desideri per il software critico (e Java non ne autorizza esplicitamente l'uso per il software critico nell'EULA)
gbjbaanb,

L'uso dell'eccezione e il mancato controllo rispetto al codice di ritorno e il mancato controllo alla fine produce lo stesso arresto cardiaco.
Newtopian,

-1

Sono attualmente nel mezzo di un progetto / API basato su OOP piuttosto grande e ho usato questo layout delle eccezioni. Ma tutto dipende davvero da quanto in profondità vuoi andare con la gestione delle eccezioni e simili.

ExpectedException
- AuthorisedException
- EmptySetException
- NoRemainingException
- NoRowsException
- NotFoundException
- ValidationException

UnexpectedException
- ConnectivityException
- EnvironmentException
- ProgrammerException
- SQLException

ESEMPIO

   $valid_types = array('mysql', 'oracle', 'sqlite');
       if (!in_array($type, $valid_types)) {
           throw new ecProgrammerException(
        'The database type specified, %1$s, is invalid. Must be one of: %2$s.',
    $type,
    join(', ', $valid_types)
    );
}

11
Se l'eccezione è prevista, non è in realtà un'eccezione. "NoRowsException"? Mi sembra un flusso di controllo, e quindi un cattivo uso di un'eccezione.
Quentin-starin,

1
@qes: ha senso sollevare un'eccezione ogni volta che una funzione non è in grado di calcolare un valore, ad esempio double Math.sqrt (double v) o User findUser (long id). Ciò offre al chiamante la libertà di rilevare e gestire gli errori laddove opportuno, anziché verificare dopo ogni chiamata.
Kevin Cline,

1
Previsto = flusso di controllo = anti-pattern di eccezione. L'eccezione non deve essere utilizzata per il flusso di controllo. Se si prevede che genererà un errore per un input specifico, verrà passato una parte del valore restituito. Quindi abbiamo NANo NULL.
Eonil,

1
@Eonil ... o Opzione <T>
Maarten Bodewes,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.