Perché un metodo non dovrebbe generare più tipi di eccezioni controllate?


47

Usiamo SonarQube per analizzare il nostro codice Java e ha questa regola (impostata su critica):

I metodi pubblici dovrebbero generare al massimo un'eccezione controllata

L'uso delle eccezioni verificate forza i chiamanti del metodo a gestire gli errori, propagandoli o gestendoli. Ciò rende tali eccezioni completamente parte dell'API del metodo.

Per mantenere ragionevole la complessità dei chiamanti, i metodi non dovrebbero generare più di un tipo di eccezione verificata. "

Un altro bit in Sonar ha questo :

I metodi pubblici dovrebbero generare al massimo un'eccezione controllata

L'uso delle eccezioni verificate forza i chiamanti del metodo a gestire gli errori, propagandoli o gestendoli. Ciò rende tali eccezioni completamente parte dell'API del metodo.

Per mantenere ragionevole la complessità per i chiamanti, i metodi non devono generare più di un tipo di eccezione verificata.

Il seguente codice:

public void delete() throws IOException, SQLException {      // Non-Compliant
  /* ... */
}

dovrebbe essere rifattorizzato in:

public void delete() throws SomeApplicationLevelException {  // Compliant
    /* ... */
}

I metodi di sostituzione non sono controllati da questa regola e possono generare diverse eccezioni verificate.

Non ho mai trovato questa regola / raccomandazione nelle mie letture sulla gestione delle eccezioni e ho cercato di trovare alcuni standard, discussioni ecc. Sull'argomento. L'unica cosa che ho trovato è questa da CodeRach: quante eccezioni dovrebbe generare al massimo un metodo?

È uno standard ben accettato?


7
Che cosa si pensa? La spiegazione che hai citato da SonarQube sembra ragionevole; hai qualche motivo per dubitarne?
Robert Harvey,

3
Ho scritto un sacco di codice che genera più di un'eccezione e ho fatto uso di molte librerie che generano anche più di un'eccezione. Inoltre, nei libri / articoli sulla gestione delle eccezioni, l'argomento di limitare il numero di eccezioni emesse non viene di solito sollevato. Ma molti degli esempi mostrano il lancio / cattura di multipli che danno un'approvazione implicita alla pratica. Quindi, ho trovato la regola sorprendente e volevo fare ulteriori ricerche sulle migliori pratiche / filosofia della gestione delle eccezioni rispetto solo agli esempi di base.
sdoca,

Risposte:


32

Consideriamo la situazione in cui si dispone del codice fornito di:

public void delete() throws IOException, SQLException {      // Non-Compliant
  /* ... */
}

Il pericolo qui è che il codice che scrivi per chiamare delete()sarà simile a:

try {
  foo.delete()
} catch (Exception e) {
  /* ... */
}

Anche questo è male. E verrà catturato con un'altra regola che contrassegna la cattura della classe Exception di base.

La chiave è non scrivere codice che ti fa venir voglia di scrivere codice errato altrove.

La regola che stai incontrando è piuttosto comune. Checkstyle lo ha nelle sue regole di progettazione:

ThrowsCount

Limita genera istruzioni per un conteggio specificato (1 per impostazione predefinita).

Razionale: le eccezioni fanno parte dell'interfaccia di un metodo. Dichiarare un metodo per generare troppe eccezioni con radici diverse rende onerosa la gestione delle eccezioni e porta a pratiche di programmazione scadenti come la scrittura di codice come catch (Exception ex). Questo controllo costringe gli sviluppatori a inserire le eccezioni in una gerarchia tale che, nel caso più semplice, un chiamante deve controllare solo un tipo di eccezione, ma eventuali sottoclassi possono essere individuate in modo specifico se necessario.

Questo descrive esattamente il problema e qual è il problema e perché non dovresti farlo. È uno standard ben accettato che molti strumenti di analisi statica identificheranno e contrassegneranno.

E mentre si può farlo secondo il disegno la lingua, e ci possono essere momenti in cui è la cosa giusta da fare, è qualcosa che si dovrebbe vedere e andare immediatamente "um, perché sto facendo questo?" Può essere accettabile per il codice interno in cui tutti sono abbastanza disciplinati da non farlo mai catch (Exception e) {}, ma il più delle volte ho visto persone tagliare angoli soprattutto in situazioni interne.

Non indurre le persone che usano la tua classe a voler scrivere codice errato.


Vorrei sottolineare che l'importanza di questo è ridotta con Java SE 7 e versioni successive perché una singola istruzione catch può catturare più eccezioni ( Cattura di più tipi di eccezioni e Rrowrow Eccezioni con il controllo del tipo migliorato da Oracle).

Con Java 6 e precedenti, avresti un codice simile a:

public void delete() throws IOException, SQLException {
  /* ... */
}

e

try {
  foo.delete()
} catch (IOException ex) {
     logger.log(ex);
     throw ex;
} catch (SQLException ex) {
     logger.log(ex);
     throw ex;
}

o

try {
    foo.delete()
} catch (Exception ex) {
    logger.log(ex);
    throw ex;
}

Nessuna di queste opzioni con Java 6 è l'ideale. Il primo approccio viola DRY . Più blocchi fanno la stessa cosa, ancora e ancora - una volta per ogni eccezione. Vuoi registrare l'eccezione e riproporla? Ok. Le stesse righe di codice per ogni eccezione.

La seconda opzione è peggiore per diversi motivi. Innanzitutto, significa che stai rilevando tutte le eccezioni. Il puntatore null viene catturato lì (e non dovrebbe). Inoltre, stai ricodificando un Exceptionche significa che la firma del metodo sarebbe il deleteSomething() throws Exceptionche crea un pasticcio più in alto nello stack come le persone che usano il tuo codice sono ora costrette a fare catch(Exception e).

Con Java 7, questo non è così importante perché puoi invece fare:

catch (IOException|SQLException ex) {
    logger.log(ex);
    throw ex;
}

Inoltre, il tipo di controllo, se si fa prendere il tipo di eccezioni essere gettato:

public void rethrowException(String exceptionName)
throws IOException, SQLException {
    try {
        foo.delete();
    } catch (Exception e) {
        throw e;
    }
}

Il verificatore di tipi riconoscerà che epossono essere solo di tipi IOExceptiono SQLException. Non sono ancora troppo entusiasta dell'uso di questo stile, ma non sta causando un codice errato come lo era in Java 6 (dove ti costringerebbe ad avere la firma del metodo come la superclasse estesa dalle eccezioni).

Nonostante tutte queste modifiche, molti strumenti di analisi statica (Sonar, PMD, Checkstyle) stanno ancora applicando le guide di stile Java 6. Non è una brutta cosa. Tendo a concordare con un avvertimento che devono ancora essere applicati, ma potresti cambiare la priorità in maggiore o minore in base a come il tuo team ha la priorità.

Se le eccezioni devono essere controllati o incontrollato ... che è una questione di g r all'e una t dibattito che si può facilmente trovare innumerevoli post del blog che occupano ciascun lato della discussione. Tuttavia, se si lavora con eccezioni verificate, probabilmente si dovrebbe evitare di lanciare più tipi, almeno in Java 6.


Accettarlo come risposta in quanto risponde alla mia domanda sul fatto che sia uno standard ben accettato. Tuttavia, sto ancora leggendo le varie discussioni pro / contro a partire dal link Panzercrisis fornito nella sua risposta per determinare quale sarà il mio standard.
sdoca,

"Il verificatore di tipi riconoscerà che e può essere solo di tipi IOException o SQLException.": Cosa significa? Cosa succede quando viene generata un'eccezione di un altro tipo foo.delete()? È ancora catturato e riproposto?
Giorgio,

@Giorgio Sarà un errore di compilazione se viene generata deleteun'eccezione controllata diversa da IOException o SQLException in quell'esempio. Il punto chiave che stavo cercando di sottolineare è che un metodo che chiama rethrowException otterrà ancora il tipo di eccezione in Java 7. In Java 6, tutto ciò viene unito al Exceptiontipo comune che rende tristi l'analisi statica e altri programmatori.

Vedo. Mi sembra un po 'contorto. Troverei più intuitivo vietare catch (Exception e)e forzarlo a essere catch (IOException e)o catch (SQLException e)invece.
Giorgio,

@Giorgio È un passo avanti progressivo da Java 6 per cercare di semplificare la scrittura di un buon codice. Sfortunatamente, l'opzione di scrivere un codice errato rimarrà con noi per molto tempo. Ricorda che Java 7 può fare catch(IOException|SQLException ex)invece. Ma, se hai intenzione di riproporre l'eccezione, consentire al controllo del tipo di propagare il tipo effettivo dell'eccezione mentre semplifica il codice non è una cosa negativa.

22

Il motivo per cui, idealmente, si vorrebbe lanciare un solo tipo di eccezione è perché fare altrimenti probabilmente viola i principi di Responsabilità singola e Inversione di dipendenza . Facciamo un esempio per dimostrare.

Diciamo che abbiamo un metodo che recupera i dati dalla persistenza e che la persistenza è un insieme di file. Dato che abbiamo a che fare con i file, possiamo avere un FileNotFoundException:

public String getData(int id) throws FileNotFoundException

Ora abbiamo un cambiamento nei requisiti e i nostri dati provengono da un database. Invece di un FileNotFoundException(poiché non abbiamo a che fare con i file), ora lanciamo un SQLException:

public String getData(int id) throws SQLException

Ora dovremmo esaminare tutto il codice che utilizza il nostro metodo e modificare l'eccezione che dobbiamo verificare, altrimenti il ​​codice non verrà compilato. Se il nostro metodo viene chiamato in lungo e in largo, può essere molto cambiare / far cambiare gli altri. Ci vuole molto tempo e le persone non saranno felici.

L'inversione delle dipendenze afferma che non dovremmo davvero lanciare nessuna di queste eccezioni perché espongono i dettagli dell'implementazione interna che stiamo lavorando per incapsulare. Il codice chiamante deve sapere che tipo di persistenza stiamo usando, quando in realtà dovrebbe essere solo preoccupato se il record può essere recuperato. Invece dovremmo lanciare un'eccezione che trasmette l'errore allo stesso livello di astrazione che stiamo esponendo attraverso la nostra API:

public String getData(int id) throws InvalidRecordException

Ora, se cambiamo l'implementazione interna, possiamo semplicemente racchiudere quell'eccezione in una InvalidRecordExceptione passarla (o non racchiuderla e semplicemente lanciarne una nuova InvalidRecordException). Il codice esterno non conosce né si preoccupa del tipo di persistenza utilizzato. È tutto incapsulato.


Per quanto riguarda la responsabilità singola, dobbiamo pensare al codice che genera eccezioni multiple e non correlate. Diciamo che abbiamo il seguente metodo:

public Record parseFile(String filename) throws IOException, ParseException

Cosa possiamo dire di questo metodo? Possiamo solo dire dalla firma che apre un file e lo analizza. Quando vediamo una congiunzione, come "e" o "o" nella descrizione di un metodo, sappiamo che sta facendo più di una cosa; ha più di una responsabilità . I metodi con più di una responsabilità sono difficili da gestire in quanto possono cambiare se una qualsiasi delle responsabilità cambia. Invece, dovremmo rompere i metodi in modo che abbiano una sola responsabilità:

public String readFile(String filename) throws IOException
public Record parse(String data) throws ParseException

Abbiamo rimosso la responsabilità di leggere il file dalla responsabilità di analizzare i dati. Un effetto collaterale di questo è che ora possiamo trasmettere qualsiasi dato String ai dati di analisi da qualsiasi sorgente: in memoria, file, rete, ecc. Ora possiamo anche testare parsepiù facilmente perché non abbiamo bisogno di un file su disco eseguire test contro.


A volte ci sono davvero due (o più) eccezioni che possiamo generare da un metodo, ma se ci atteniamo a SRP e DIP, i tempi in cui incontriamo questa situazione diventano più rari.


Sono totalmente d'accordo con il confezionamento di eccezioni di livello inferiore come nel tuo esempio. Lo facciamo regolarmente e lanciamo variazioni di MyAppExceptions. Uno degli esempi in cui sto generando più eccezioni è quando si tenta di aggiornare un record in un database. Questo metodo genera un RecordNotFoundException. Tuttavia, il record può essere aggiornato solo se si trova in un determinato stato, quindi il metodo genera anche InvalidRecordStateException. Penso che questo sia valido e fornisca al chiamante informazioni preziose.
sdoca,

@sdoca Se il tuo updatemetodo è atomico quanto puoi, e le eccezioni sono al giusto livello di astrazione, allora sì, sembra che devi lanciare due diversi tipi di eccezioni, poiché ci sono due casi eccezionali. Dovrebbe essere la misura di quante eccezioni possono essere lanciate, piuttosto che queste (a volte arbitrarie) regole di linter.
cbojar

2
Ma se ho un metodo che legge i dati da uno stream e li analizza man mano che procede, non posso suddividere queste due funzioni senza leggere l'intero stream in un buffer, il che potrebbe essere ingombrante. Inoltre, il codice che decide come gestire correttamente le eccezioni potrebbe essere separato dal codice che esegue la lettura e l'analisi. Quando scrivo il codice di lettura e analisi, non so come il codice che chiama il mio codice potrebbe voler gestire entrambi i tipi di eccezione, quindi devo lasciarli passare entrambi.
user3294068

+1: Mi piace molto questa risposta, soprattutto perché affronta il problema da un punto di vista della modellazione. Spesso non è necessario usare ancora un altro linguaggio (come catch (IOException | SQLException ex)) perché il vero problema è nel modello / design del programma.
Giorgio,

3

Ricordo di aver giocato un po 'con questo un po' quando giocavo con Java qualche tempo fa, ma non ero davvero consapevole della distinzione tra controllato e non controllato fino a quando non ho letto la tua domanda. Ho trovato questo articolo su Google abbastanza rapidamente, ed entra in alcune delle apparenti polemiche:

http://tutorials.jenkov.com/java-exception-handling/checked-or-unchecked-exceptions.html

Detto questo, uno dei problemi di cui parlava questo ragazzo con le eccezioni verificate è che (e l'ho incontrato personalmente fin dall'inizio con Java) se continui ad aggiungere un mucchio di eccezioni controllate alle throwsclausole nelle dichiarazioni del tuo metodo, non solo devi inserire un codice molto più esteso per supportarlo mentre passi a metodi di livello superiore, ma crea anche un pasticcio più grande e interrompe la compatibilità quando provi a introdurre più tipi di eccezione per metodi di livello inferiore. Se si aggiunge un tipo di eccezione verificato a un metodo di livello inferiore, è necessario eseguire nuovamente il codice e regolare diverse altre dichiarazioni di metodo.

Un punto di mitigazione menzionato nell'articolo - e all'autore non è piaciuto personalmente - è stato quello di creare un'eccezione della classe base, limitare le throwsclausole per usarla solo e quindi aumentare solo le sottoclassi di essa internamente. In questo modo è possibile creare nuovi tipi di eccezione verificati senza dover eseguire il back-through di tutto il codice.

L'autore di questo articolo potrebbe non essere piaciuto così tanto, ma ha perfettamente senso nella mia esperienza personale (soprattutto se puoi comunque cercare quali sono tutte le sottoclassi), e scommetto che è per questo che il consiglio che ti viene dato è limita tutto a un tipo di eccezione verificato ciascuno. Inoltre, i consigli che hai citato in realtà consentono più tipi di eccezioni verificate nei metodi non pubblici, il che ha perfettamente senso se questo è il loro motivo (o anche altrimenti). Se è solo un metodo privato o qualcosa di simile, non cambierai metà della tua base di codice quando cambi una piccola cosa.

In gran parte hai chiesto se si tratta di uno standard accettato, ma tra le tue ricerche che hai citato, questo articolo ragionevolmente ponderato, e parlando solo per esperienza di programmazione personale, non sembra certamente distinguersi in alcun modo.


2
Perché non dichiarare semplicemente i tiri Throwablee finirlo, invece di inventare tutta la tua gerarchia?
Deduplicatore,

@Deduplicator Questo è il motivo per cui all'autore non è piaciuta neanche l'idea; ha solo pensato che potresti anche usare tutto senza controllo, se hai intenzione di farlo. Ma se chiunque usi l'API (possibilmente il tuo collega di lavoro) ha un elenco di tutte le eccezioni della sottoclasse che derivano dalla tua classe di base, posso vedere un po 'di beneficio nel far loro almeno sapere che tutte le eccezioni attese sono all'interno di una determinata serie di sottoclassi; quindi se si sentono come uno di loro è più "manipolabile" rispetto agli altri, non sarebbero così inclini a rinunciare a un gestore specifico per questo.
Panzercrisis,

La ragione per cui le eccezioni verificate in generale sono il cattivo karma è semplice: sono virali e infettano ogni utente. Specificare una specifica intenzionalmente più ampia possibile è solo un modo per dire che tutte le eccezioni sono deselezionate, evitando così il caos. Sì, documentare ciò che potresti voler gestire è una buona idea, per la documentazione : il solo sapere quali eccezioni potrebbero derivare da una funzione ha un valore strettamente limitato (a parte nessuna o forse una, ma Java non lo consente comunque) .
Deduplicatore,

1
@Deduplicator Non approvo l'idea e non la favorisco necessariamente. Sto solo parlando della direzione da cui potrebbe essere stato dato il consiglio del PO.
Panzercrisis,

1
Grazie per il link È stato un ottimo punto di partenza per leggere su questo argomento.
sdoca,

-1

Lanciare più eccezioni controllate ha senso quando ci sono più cose ragionevoli da fare.

Ad esempio, supponiamo che tu abbia un metodo

public void doSomething(Credentials cred, Work work) 
    throws CredentialsRequiredException, TryAgainLaterException{...}

ciò viola la regola delle eccezioni, ma ha senso.

Sfortunatamente, ciò che accade di solito sono metodi simili

void doSomething() 
    throws IOException, JAXBException,SQLException,MyException {...}

Qui ci sono poche possibilità per il chiamante di fare qualcosa di specifico in base al tipo di eccezione. Quindi, se vogliamo spingerlo a rendersi conto che questi metodi POSSONO, a volte, andranno male, lanciare solo SomethingMightGoWrongException è enogh e migliore.

Quindi regola al massimo un'eccezione controllata.

Ma se il tuo progetto utilizza la progettazione in cui vi sono più eccezioni verificate significative, questa regola non dovrebbe essere applicata.

Sidenote: Qualcosa in realtà può andare storto quasi ovunque, quindi si può pensare di usare? estende RuntimeException, ma esiste una differenza tra "tutti commettiamo errori" e "questo parla di un sistema esterno e a volte non funzionerà, gestirlo".


1
" Molteplici cose ragionevoli da fare" difficilmente rispettano srp - questo punto è stato esposto nella risposta precedente
moscerino del

Lo fa. È possibile RILEVARE un problema in una funzione (gestione di un aspetto) e un altro problema in un'altra funzione (anche gestione di un aspetto) chiamato da una funzione che gestisce una cosa, vale a dire chiamare queste due funzioni. E il chiamante può in uno strato di provare a catturare (in una funzione) MANEGGIARE un problema e passare un altro verso l'alto e gestirlo anche da solo.
user470365,
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.