Gestione degli errori per casi non eccezionali nel C ++ moderno

2

Per affinare le mie capacità - e per il piacere di farlo - sto scrivendo un piccolo gioco per i miei figli nel moderno C ++ (C ++ 11, C ++ 14 e la parte di C ++ 17 già supportata da Visual Studio ), che è una bella pausa dai miei soliti compiti di programmazione (aziendale).

Ho bisogno di un piccolo interprete per l'input dell'utente e ovviamente devo gestire input errati (pensate a "missile magico csat" invece di "lanciare missili magici"). Questo non è eccezionale, piuttosto è la norma che l'input dell'utente può essere per qualche motivo non ben formato.

Quindi sto cercando l'approccio raccomandato per gestirlo. Ho letto le Linee guida per C ++ o altre domande sul sito, e ho sperimentato vari approcci nel mio codice.

Alla fine ho deciso di cambiare tutti i tipi di ritorno rilevanti in qualcosa di simile

std::tuple<my_true_return_type, RetCode> my_function(...);

dove RetCode è un enum:

enum RetCode{
SUCCESS,
WRONG_NUMBER_OF_INPUTS,
....
};

e ogni chiamata di funzione è fatta come:

std::tie(result, error_code) = my_function(...);

Questo sembra più o meno in linea con le linee guida e posso essere piuttosto sistematico con esso.

Il mio unico problema con questa soluzione è che può capitare di essere in grado di rilevare un errore prima ancora di costruire my_true_return_type e in questo caso devo costruirlo in ogni caso, solo per scartarlo nel punto di chiamata.

Qualcosa di simile

if (an_erroneous_condition){
    // I have to default-construct a return type object, 
    // which I didn't really need 
    return std::make_tupe(my_true_return_type(), FAILURE); 
}

Potrei invece optare per un tipo di ritorno Nullable (come la Forse monad in Haskell), ma a un certo costo.

In tal caso, avrei un tipo di ritorno relativamente complesso:

std::tuple<SomeNullableType<my_true_return_type, ...>, RetCode>

e la complessità risultante nel punto di chiamata. Inoltre, non esiste ancora un tipo annullabile nella libreria standard AFAIK , o in Boost (lì è stata la proposta di Boost.Outcome ma è ancora in fase di evoluzione), quindi avrei bisogno di trovare un'altra soluzione, o rollare la mia che è anche divertente, ma non sembra strettamente necessario allo scopo di codificare un semplice gioco. E con ogni probabilità la mia soluzione sarebbe mezza cotta, non pronta per la produzione.

Se torno alla route di eccezioni ottengo un codice di ritorno semplificato e non devo costruire nessun oggetto di cui non ho bisogno, ma mi sembra che le eccezioni siano riservato per casi eccezionali e non per quello che mi aspetto essere molto comune.

Quindi la mia domanda è:

  • come gestire, secondo le migliori pratiche attuali e sfruttando appieno tutta la moderna tecnica C ++, errori che non sono situazioni eccezionali?
  • se il mio modo di procedere è ragionevole, cosa posso fare per garantire che il costo del tipo di rendimento "predefinito" "inutile" sia ridotto al minimo?
posta Francesco 15.08.2017 - 17:50
fonte

5 risposte

8

Boost :: variant e Boost :: facoltativo sarebbero le librerie che consiglio di guardare. Sono tremendamente efficaci nel minimizzare i costi extra di costruzione perché non inizializzano un membro a meno che non ci siano dati da inizializzare con.

Detto questo, guarderei questo da un punto di vista più alto. La gestione degli errori è spesso concisa ed è un reclamo importante tra gli utenti. Se non è conciso, è prolisso! (qualcuno prova a eseguire il debug di un errore del modello di boost lungo 100 righe?!)

Gli errori eccezionali sono gestiti in modo tardo perché sono eccezionali. Non ci si può aspettare che lo sviluppatore spieghi come gestire tutti gli errori eccezionali. Invece, vengono ricompensati per lanciare una vasta rete, catturare molti errori diversi e quindi provare a tornare a uno stato ragionevole ragionevole prima di continuare.

SE hai errori non eccezionali, significa che stai pensando a loro come parte del flusso del tuo programma. Dai loro il merito che meritano. Non chiedere semplicemente "come posso rilevare questi errori", ma chiedi "come vorrebbe l'utente gestire un errore come questo?"

Scrivo spesso parser in cui fornire un nome file e un numero di riga è la differenza tra un cliente odioso e uno riconoscente. Così mi assicuro che tutto il mio codice di gestione degli errori possa passare attraverso di essi nomi di file e numeri di linea, arrivando addirittura a fare una "pila di tracce" di oggetti manipolati nel momento in cui si è verificato l'errore.

Nel caso di input da parte dell'utente, l'utente probabilmente non vuole solo sapere che si è verificato un errore, ma avere un feedback che suggerisce ciò che il computer pensa l'utente ha voluto fare in primo luogo. Se hai digitato csat magic missile , l'utente potrebbe apprezzare il fatto di passare abbastanza informazioni per realizzare csat è la parola incriminata e che ha una distanza di hamming molto breve da cast , quindi è probabile che sia un errore di battitura. Ciò significa che vorrai essere in grado di trasferire i dati analizzati in modo incompleto in un algoritmo intelligente in grado di provare euristicamente a indovinare cosa stava pensando l'utente.

Una volta che inizi a pensare in questo modo, scoprirai che molti dei dettagli tecnici come costruttori e utilizzo della memoria si risolvono da soli. Ora, invece di gestire gli errori è "quella cosa che facciamo perché dobbiamo", è una parte vibrante del tuo programma.

    
risposta data 15.08.2017 - 19:50
fonte
2

Lo scopo di lanciare un'eccezione è di segnalare che una funzione non può restituire un risultato valido. In quanto tale, le eccezioni sono una parte essenziale del moderno C ++. Hanno costi e benefici sia nella complessità del codice che nella velocità di esecuzione che devono essere valutati per ogni situazione. Penso che le eccezioni siano un guadagno netto per il tuo programma a causa della semplicità del codice rispetto ad altre opzioni.

If I go back to the exception route I get a simplified return code and I don't have to construct any object which I don't need, but it seems to me that exceptions are to be reserved for exceptional cases and not for what I expect to be very common occur.

La prima metà di questa frase contiene ottimi motivi per utilizzare le eccezioni.

Per quanto riguarda la seconda metà, ho visto due ragioni per aver lanciato eccezioni solo in casi rari - veramente eccezionali:

  1. I compilatori ottimizzano per il percorso senza eccezioni, quindi lanciare eccezioni come parte della normale esecuzione può rallentare gravemente il tuo programma.

  2. La gestione delle eccezioni richiede molte infrastrutture: try{...}catch(...){...} . Questo può essere fastidioso per casi semplici. Vedi il saggio di Eric Lippert su eccezioni vexing .

Il primo punto non è rilevante nel tuo caso poiché stai aspettando l'input dell'utente. Non c'è nulla da fare per il programma finché l'utente non dà un comando valido, quindi non c'è bisogno di preoccuparsi delle prestazioni qui.

Il secondo punto, credo, è rilevante solo per le situazioni semplici in cui ti interessa solo se my_function(...) è riuscito o meno, non perché ha fallito. Ad esempio, ecco come chiedere ripetutamente all'utente l'input fino a quando non viene immesso un numero intero valido utilizzando l'% di lancio di eccezione% co_de:

int32 result;
while(true)
{
    try
    {
        result = int32.Parse(getUserInput());
        break;
    }
    catch{int32.ParseException)
    {
    }
}

Confronta questo con la versione int32.Parse() non di lancio:

int32 result;
while( ! int32.TryParse(getUserInput(), result));

Molto più bello! Questo è il motivo per cui l'eccezione generata da int32.TryParse() è chiamata "fastidio" nel saggio collegato.

Nel tuo caso, probabilmente vuoi che il programma aiuti l'utente a diagnosticare l'input problematico e suggerire correzioni. Questa è una procedura complessa che merita il proprio blocco di codice. Con le eccezioni, puoi lanciare diversi tipi di eccezioni (corrispondenti al tuo int32.Parse() s) e utilizzare diversi% -dei blocchi di% di gestione per gestire diversi tipi di errori. Avrai questa infrastruttura a prescindere dalla strategia di gestione degli errori che usi.

    
risposta data 16.08.2017 - 11:11
fonte
1

Nel mio progetto AvCpp (wrapper FFmpeg) seleziono la prossima strategia: usa OptionalErrorCode classe che avvolge std::error_code e usalo come ultimo argomento funzione / metodo opzionale (altra soluzione: usa due metodi di overload con e senza argomento di archiviazione degli errori: in tal caso, consiglio di utilizzare la memorizzazione degli errori come primo argomento).

La logica è semplice: se qualcosa fallisce e l'utente fornisce spazio per il codice di errore - il codice di errore memorizza in questa memoria. Altrimenti si verificano eccezioni. Su sistemi in cui l'eccezione è impossibile per alcune ragioni (nel mio caso ho solo 300 Kb per il codice ;-)) il lancio di eccezioni può essere sostituito da una sorta di panico: abort() / std::terminate() / solo reset del dispositivo o così via.

Nel mio caso, semplifica l'utilizzo dell'API, ma fornisce una reazione rapida in caso di errore se l'utente omette la gestione degli errori.

Inoltre, in tale strategia possiamo restituire l'oggetto in qualsiasi stato: garantiamo solo che l'oggetto sia valido solo se non ci sono errori. Altrimenti lo stato è non specifico.

Ovviamente, se fornisci l'archiviazione degli errori e ometti il controllo ... Se qualcuno vuole sterminare se stesso, lo fa in ogni caso.

Puoi vedere l'implementazione:

UPD: C ++ 20 può permetterci di utilizzare il binding della struttura come C99 in questo caso possiamo usare una sorta di argomento di denominazione che può migliorare la gestione degli errori sopra descritta:

struct SomeFunctionArgs {
  int arg0;
  int arg1;
  int arg2 = 31337;
  OptionalErrorCode ec = OptionaErrorCode::null();
};

// Function
SomeObject get_some_object(SomeFunctionArgs&& args) {
  ...
}

// Call
auto obj0 = get_some_object({.arg0 = 1, .arg1 = 2}); // throw exception if fail

std::error_code ec;
auto obj1 = get_some_object({.arg0 = 1, .arg1 = 2, .ec = ec}); // fill ec with error code, no throw. Optional arg2 is not touched.
    
risposta data 16.08.2017 - 05:48
fonte
1

C'è una certa religiosità nella questione dei codici di errore rispetto alle eccezioni, il che rende le best practice difficili da stabilire. Un caso può essere fatto per entrambi in questa domanda.

"Eccezionale" non significa "accade raramente" tanto quanto "al di fuori della sfera di competenza dell'algoritmo"; Direi che le espressioni non ben formate sono eccezionali per un parser. Inoltre, nelle Domande frequenti su C ++ answer che seguono quella collegata alla domanda, gli stati di Stroustrup:

In C++, exceptions is [sic] used to signal errors that cannot be handled locally[...]

che sembra descrivere il caso di errori di parsing non corretti (se includendo l'eccellente suggerimento di Cort) (anche se errori di analisi correggibili potrebbero causare un'eccezione, per far sì che il comando corretto venga verificato anziché eseguirlo ciecamente).

Gli argomenti comuni contro le eccezioni (di solito a supporto dei codici di errore) riguardano le prestazioni, la perdita di progettazione e la complessità (sulla falsariga di "spaghetti code", poiché il flusso di controllo può "invisibilmente" saltare sopra i blocchi di codice).

  • prestazioni: non riuscendo al primo errore di input, ci sarà al massimo un errore di analisi per riga di input. Poiché le eccezioni sono limitate dall'input dell'utente, che è più lento di ordini di grandezza rispetto al costo delle eccezioni, il costo delle prestazioni è trascurabile. Inoltre, le domande frequenti su C ++ collegate in questione includono la confutazione di Stroustrup:

    but exceptions are expensive!: Not really. Modern C++ implementations reduce the overhead of using exceptions to a few percent (say, 3%) and that's compared to no error handling. Writing code with error-return codes and tests is not free either. As a rule of thumb, exception handling is extremely cheap when you don't throw an exception. It costs nothing on some implementations. All the cost is incurred when you throw an exception: that is, "normal code" is faster than code using error-return codes and tests. You incur cost only when you have an error.

  • Perdita: la perdita si verifica quando qualcosa supera un confine ed è considerato dannoso quando rompe principi come Separazione delle preoccupazioni. La gestione della riga di comando fa parte del modulo di input dell'utente; finché l'eccezione di analisi viene gestita all'interno dello stesso modulo, non vi sono perdite tra i rischi. Inoltre, i codici di errore possono rappresentare lo stesso tipo di perdita (informazioni sulle modalità di guasto) e quindi non sono più appropriati a questo riguardo.

    Un'altra eccezione al contorno che viene accusata di attraversare è quella tra implementazione e interfaccia. La solita risposta a questo è "eccezioni sono parte dell'interfaccia, esplicitamente in lingue con dichiarazioni di lancio e implicitamente in altre in quanto rappresentano un altro tipo di ritorno". A questo punto, le cose possono diventare religiose.

  • complessità: le eccezioni implicano una sorta di complessità del flusso di controllo, poiché hanno un impatto sul controllo e complessità del codice, sotto forma di gestori di eccezioni. I codici di errore introducono anche il flusso di controllo e la complessità del codice sotto forma di rami, in alcuni casi rami annidati. (C'è un'illustrazione della complessità che coinvolge diagrammi con triangoli, lo spazio bianco derivante dalla rientranza del ramo che è molto rilevante, ma non riesco a ricordare il riferimento.)

    I blocchi di codice potenzialmente ignorati dalle eccezioni possono assumere uno stato valido, che semplifica quei blocchi di codice (vengono saltati solo se lo stato non è valido), mentre i codici di errore e lo stato non valido devono essere gestiti ad ogni livello con codici di errore Quest'ultimo potrebbe essere un vantaggio, se è importante vedere lo stato di errore ad ogni livello, anche se questa importanza esiste può essere una questione religiosa.

Un argomento a favore dei codici di errore è che, poiché devono essere gestiti ad ogni livello, non ci saranno informazioni mancanti, il che non è il caso delle eccezioni.

Gli argomenti possono essere più confusi (o forse chiariti) quando si esamina il modello di codice di errore di ritorno di tipo alternativo (specialmente usando un tipo E, ma includendo una coppia) e rendersi conto che può essere implementato equivalente alle eccezioni, sebbene in un livello di programma piuttosto che a livello di lingua. È un modo standard in cui le eccezioni sono implementate nei linguaggi funzionali, utilizzando un tipo con caratteristiche della classe del tipo nullable (le operazioni sul valore null vengono assorbite) ma che ha più valori (messaggi di errore + tracce dello stack + altri dati utili) per l'errore genere. Da una visione concettuale, i due non sono così diversi. Nell'analisi finale, gli errori e la necessità di gestire gli errori sono ciò che introduce la complessità, non un particolare approccio di gestione degli errori (sebbene alcuni approcci possano essere i più semplici, a seconda della natura degli errori e dell'elaborazione normale).

Sulla questione di oggetti inutili, evitare la costruzione di oggetti deriva da una vecchia posizione. Generalmente non è necessario evitare la costruzione di oggetti per motivi di prestazioni (tuttavia, vedere il prossimo paragrafo), specialmente perché la costruzione di oggetti predefiniti spesso ha un impatto sufficientemente basso; dovrebbe essere possibile implementare classi che incapsulano i comandi dell'utente con una costruzione predefinita a basso impatto.

In definitiva, la migliore pratica è iniziare con qualsiasi approccio è più semplice da testare, implementare e amp; mantenere e apportare modifiche solo se le prestazioni non soddisfano i requisiti non funzionali e la profilazione rivela che l'approccio alla gestione degli errori ha un impatto significativo.

    
risposta data 16.08.2017 - 20:59
fonte
1

If I go back to the exception route I get a simplified return code and I don't have to construct any object which I don't need, but it seems to me that exceptions are to be reserved for exceptional cases and not for what I expect to be very common occur

Ho headbutted così tanti sviluppatori su questo argomento. Mi sto stressando solo a pensarci! :-D

Se si stanno propagando codici di errore nello stack di chiamate come un programmatore C in C ++ (sebbene io ami C, ma questa è una lingua diversa nel mio libro e in gran parte perché l'introduzione di EH cambia tutto il modo in cui gestire la gestione delle risorse) perché non consideri che un caso di branca sia "abbastanza eccezionale", quindi lo zelo religioso sulle interpretazioni umane delle parole ha sostituito la produttività a quel punto.

In particolare mi sono scontrato con più sviluppatori perché volevo lanciarlo quando un utente ha premuto un pulsante di interruzione su un'operazione accompagnata da una barra di avanzamento. E non hanno considerato questo "abbastanza eccezionale" in modo da sostituire religiosamente il mio codice di gestione delle eccezioni con il codice disseminato dappertutto come:

if (err == user_abort)
    return err;

Solo per il chiamante per ripetere lo stesso codice su tutto il posto che fa paura, senza alcun beneficio prestazionale percepibile al tempo necessario per interrompere l'operazione solo per un blocco try/catch nell'operazione ancora richiesta comunque per altri casi eccezionali . Mi è piaciuto, "Grazie mille per aver degradato la nostra produttività e aver reso il codice molto più incline agli errori umani reinventando ciò che la gestione delle eccezioni risolve già! (... idioti!)" :-D *

  • I don't understand how pencil necks with PhDs in computer science can be so stupidly counter-productive and so brilliant at the same time. I suggest they get a refund on their student loans and try to actually ship something on their own.

La mia visione pragmatica e smussata sulla gestione delle eccezioni è che se stai scrivendo un codice di propagazione degli errori del tipo che la gestione delle eccezioni intende evitare in luoghi in cui il tempo di lancio non è nemmeno percepibile all'utente, allora hai dei veri dubbi ABI come buttare oltre i confini dei moduli, o sei uno zelota controproducente.

Quindi vorrei solo throw in questi casi, a meno che tu non abbia cose come il limite del modulo o problemi di prestazioni reali (misurati). La cartina di tornasole sulla dissezione delle idee umane di "percorsi di esecuzione eccezionali" (che possono entrare in un carico di soggettività) dovrebbe essere quella di considerare le prestazioni pratiche e quale tipo di codice scriverebbe in alternativa senza l'uso di eccezioni.

how to handle, according to current best practice, and taking full advantage of all modern C++ technique, errors which are not exceptional situations?

Il tuo caso è eccezionale nel mio libro. Non costa necessariamente tanto per il lancio (il lancio è costoso, relativamente parlando, in alcuni contesti granulari, ma non relativamente per un codice di interpretazione dell'errore di sintassi), e il codice alternativo che si scrive somiglia alla gestione e alla propagazione degli errori in stile C dei codici di errore. Fatto. Usa EH e gitter spedito.

Quello che non considero veramente eccezionale è un caso d'uso che non è dovuto a qualche input esterno (file, server, allocatore di memoria, utente, qualsiasi cosa - qualcosa al di fuori del controllo dello sviluppatore) che fornisce qualcosa di diverso dal previsto, come un metodo map find che non trova una chiave ricercata (non è nemmeno necessariamente "esterno" in quanto il client potrebbe aver costruito quella mappa con l'aspettativa che possano cercare le chiavi che potrebbero non esistere). Questo non è necessariamente un caso eccezionale, in quanto l'utente / cliente / consumatore potrebbe scrivere il codice con l'aspettativa che la chiave non possa esistere. In tal caso, puoi solo restituire un iteratore o un puntatore nullo o quant'altro.

E se questo è ancora vago, allora considera solo quale tipo di codice alternativo potresti scrivere per usarlo senza eccezioni e possibilmente misurando le prestazioni. Se inizia ad assomigliare al codice di gestione degli errori C che rileva e diffonde gli errori su più livelli nello stack delle chiamate, probabilmente ti stai privando dogmaticamente dei vantaggi della gestione delle eccezioni.

Per errori del programmatore un assert spesso è sufficiente, a meno che tu non stia lavorando in un campo così mission-critical che anche i bug che sfuggono al radar dei test automatizzati dovrebbero portare ad un recupero aggraziato per evitare di uccidere utenti o qualcosa del genere. p>

if my way is reasonable, what can I do to ensure that the cost of the "useless" default constructed return type is minimized?

Ancora una volta il tuo caso è eccezionale nel mio libro. Soprattutto con la gestione delle eccezioni "a costo zero", se restituire dati extra per indicare che cosa è andato storto anche nei casi di successo sta subendo un calo di prestazioni, allora le eccezioni risolvono il problema spostando il costo da "successo" (e penso che "successo "è una formulazione più chiara di" normale ") rami di esecuzione in cambio di costi più elevati per" falliti "percorsi di esecuzione.

Vuoi ottimizzare il tuo interprete per eseguire più righe di codice corretto il più velocemente possibile o segnalare errori di sintassi un millisecondo (o qualsiasi altra cosa) più veloce in cambio di rallentamento nell'interpretazione del codice corretto?

    
risposta data 11.12.2018 - 15:45
fonte

Leggi altre domande sui tag