Das Problem:
Seit langer Zeit exceptions
mache ich mir Sorgen um den Mechanismus, weil ich der Meinung bin, dass er nicht wirklich löst, was er sollte.
BEANTRAGUNG: Es gibt lange Debatten über dieses Thema, und die meisten von ihnen haben Schwierigkeiten, exceptions
einen Fehlercode zu vergleichen oder zurückzugeben. Dies ist definitiv nicht das Thema hier.
Beim Versuch, einen Fehler zu definieren, stimme ich CppCoreGuidelines von Bjarne Stroustrup & Herb Sutter zu
Ein Fehler bedeutet, dass die Funktion ihren angekündigten Zweck nicht erreichen kann
BEANTRAGUNG: Der exception
Mechanismus ist eine Sprachsemantik für die Behandlung von Fehlern.
Für mich gibt es keine Entschuldigung dafür, dass eine Funktion eine Aufgabe nicht erfüllt: Entweder haben wir die Vor- / Nachbedingungen falsch definiert, sodass die Funktion keine Ergebnisse erzielen kann, oder ein bestimmter Ausnahmefall wird als nicht wichtig genug angesehen, um Zeit in die Entwicklung zu investieren eine Lösung. Wenn man bedenkt, IMO, ist der Unterschied zwischen normalem Code und Fehlercode-Behandlung (vor der Implementierung) eine sehr subjektive Zeile.
BEANTRAGUNG: Die Verwendung von Ausnahmen, um anzuzeigen, wann eine Vor- oder Nachbedingung nicht eingehalten wird, ist ein weiterer Zweck des exception
Mechanismus, hauptsächlich zu Debug-Zwecken. Ich beziehe mich nicht auf diese Verwendung von exceptions
hier.
In vielen Büchern, Tutorials und anderen Quellen wird die Fehlerbehandlung als eine recht objektive Wissenschaft dargestellt, die mit gelöst wird, exceptions
und man braucht catch
sie nur, um eine robuste Software zu haben, die in der Lage ist, sich von jeder Situation zu erholen. Aber meine mehrjährige Erfahrung als Entwickler hat mich dazu veranlasst, das Problem aus einem anderen Blickwinkel zu betrachten:
- Programmierer tendieren dazu, ihre Aufgabe zu vereinfachen, indem sie Ausnahmen auslösen, wenn der spezifische Fall zu selten erscheint, um sorgfältig implementiert zu werden. Typische Fälle hierfür sind: Probleme mit nicht genügend Arbeitsspeicher, Probleme mit vollem Datenträger, Probleme mit beschädigten Dateien usw. Dies ist möglicherweise ausreichend, wird jedoch nicht immer auf architektonischer Ebene entschieden.
- Programmierer neigen dazu, die Dokumentation über Ausnahmen in Bibliotheken nicht sorgfältig zu lesen und wissen normalerweise nicht, welche und wann eine Funktion ausgelöst wird. Außerdem verwalten sie sie nicht wirklich, selbst wenn sie es wissen.
- Programmierer neigen dazu, Ausnahmen nicht früh genug abzufangen, und wenn sie dies tun, ist es meistens, sie zu protokollieren und weiter zu werfen. (siehe ersten Punkt).
Dies hat zwei Konsequenzen:
- Häufig auftretende Fehler werden frühzeitig in der Entwicklung erkannt und behoben (was gut ist).
- Seltene Ausnahmen werden nicht verwaltet und führen dazu, dass das System beim Benutzer zu Hause abstürzt (mit einer netten Protokollmeldung). Manchmal wird der Fehler gemeldet oder gar nicht.
In Anbetracht dessen sollte IMO der Hauptzweck eines Fehlermechanismus sein:
- In Code sichtbar machen, in dem ein bestimmter Fall nicht verwaltet wird.
- Kommunizieren Sie die Problemlaufzeit mit dem zugehörigen Code (mindestens dem Aufrufer), wenn diese Situation eintritt.
- Bietet Wiederherstellungsmechanismen
Der Hauptfehler der exception
Semantik als Fehlerbehandlungsmechanismus ist IMO: Es ist leicht zu erkennen, wo sich a throw
im Quellcode befindet, aber es ist absolut nicht ersichtlich, ob eine bestimmte Funktion durch einen Blick auf die Deklaration ausgelöst werden könnte. Dies bringt all das Problem mit sich, das ich oben vorgestellt habe.
Die Sprache erzwingt und überprüft den Fehlercode nicht so streng, wie es für andere Aspekte der Sprache (z. B. starke Variablentypen) erforderlich ist.
Ein Versuch zur Lösung
In der Absicht, dies zu verbessern, habe ich ein sehr einfaches Fehlerbehandlungssystem entwickelt, das versucht, die Fehlerbehandlung auf die gleiche Wichtigkeit wie den normalen Code zu bringen.
Die Idee ist:
- Jede (relevante) Funktion erhält einen Verweis auf ein
success
sehr leichtes Objekt und kann es gegebenenfalls in einen Fehlerstatus versetzen. Das Objekt ist sehr leicht, bis ein Fehler mit Text gespeichert wird. - Eine Funktion wird aufgefordert, ihre Aufgabe zu überspringen, wenn das bereitgestellte Objekt bereits einen Fehler enthält.
- Ein Fehler darf niemals außer Kraft gesetzt werden.
Das vollständige Design berücksichtigt jeden Aspekt (ca. 10 Seiten) sorgfältig und auch die Anwendung auf OOP.
Beispiel der Success
Klasse:
class Success
{
public:
enum SuccessStatus
{
ok = 0, // All is fine
error = 1, // Any error has been reached
uninitialized = 2, // Initialization is required
finished = 3, // This object already performed its task and is not useful anymore
unimplemented = 4, // This feature is not implemented already
};
Success(){}
Success( const Success& v);
virtual ~Success() = default;
virtual Success& operator= (const Success& v);
// Comparators
virtual bool operator==( const Success& s)const { return (this->status==s.status && this->stateStr==s.stateStr);}
virtual bool operator!=( const Success& s)const { return (this->status!=s.status || this->stateStr==s.stateStr);}
// Retrieve if the status is not "ok"
virtual bool operator!() const { return status!=ok;}
// Retrieve if the status is "ok"
operator bool() const { return status==ok;}
// Set a new status
virtual Success& set( SuccessStatus status, std::string msg="");
virtual void reset();
virtual std::string toString() const{ return stateStr;}
virtual SuccessStatus getStatus() const { return status; }
virtual operator SuccessStatus() const { return status; }
private:
std::string stateStr;
SuccessStatus status = Success::ok;
};
Verwendung:
double mySqrt( Success& s, double v)
{
double result = 0.0;
if (!s) ; // do nothing
else if (v<0.0) s.set(Error, "Square root require non-negative input.");
else result = std::sqrt(v);
return result;
}
Success s;
mySqrt(s, 144.0);
otherStuff(s);
saveStuff(s);
if (s) /*All is good*/;
else cout << s << endl;
Ich habe das in vielen meiner (eigenen) Codes verwendet und es zwingt den Programmierer (mich), über mögliche Ausnahmefälle nachzudenken und wie man sie löst (gut). Es hat jedoch eine Lernkurve und lässt sich nicht gut in Code integrieren, der es jetzt verwendet.
Die Frage
Ich möchte die Auswirkungen der Verwendung eines solchen Paradigmas in einem Projekt besser verstehen:
- Stimmt die Voraussetzung für das Problem? oder Habe ich etwas Relevantes verpasst?
- Ist die Lösung eine gute architektonische Idee? oder ist der preis zu hoch?
BEARBEITEN:
Methodenvergleich:
//Exceptions:
// Incorrect
File f = open("text.txt"); // Could throw but nothing tell it! Will crash
save(f);
// Correct
File f;
try
{
f = open("text.txt");
save(f);
}
catch( ... )
{
// do something
}
//Error code (mixed):
// Incorrect
File f = open("text.txt"); //Nothing tell you it may fail! Will crash
save(f);
// Correct
File f = open("text.txt");
if (f) save(f);
//Error code (pure);
// Incorrect
File f;
open(f, "text.txt"); //Easy to forget the return value! will crash
save(f);
//Correct
File f;
Error er = open(f, "text.txt");
if (!er) save(f);
//Success mechanism:
Success s;
File f;
open(s, "text.txt");
save(s, f); //s cannot be avoided, will never crash.
if (s) ... //optional. If you created s, you probably don't forget it.
quelle
Antworten:
Fehlerbehandlung ist vielleicht der schwierigste Teil eines Programms.
Im Allgemeinen ist es einfach zu erkennen, dass ein Fehlerzustand vorliegt. Es ist jedoch sehr schwierig, es auf eine Art und Weise zu signalisieren, die nicht umgangen werden kann, und es angemessen zu behandeln (siehe Abrahams Ausnahmesicherheitsstufe ).
In C werden Signalisierungsfehler durch einen Rückkehrcode verursacht, der für Ihre Lösung isomorph ist.
C ++ Ausnahmen eingeführt , weil der Kurz kommenden eines solchen Ansatzes; Es funktioniert nämlich nur, wenn Anrufer daran denken, zu prüfen, ob ein Fehler aufgetreten ist oder nicht und scheitert ansonsten entsetzlich. Immer wenn Sie feststellen, dass Sie sagen: "Es ist in Ordnung, solange Sie ...", haben Sie ein Problem. Menschen sind nicht so akribisch, auch wenn sie sich darum kümmern.
Das Problem ist jedoch, dass Ausnahmen ihre eigenen Probleme haben. Unsichtbarer / versteckter Kontrollfluss. Dies war beabsichtigt: den Fehlerfall zu verbergen, damit die Logik des Codes nicht durch das Fehlerbehandlungs-Boilerplate verschleiert wird. Dies macht den "glücklichen Pfad" viel klarer (und schneller!), Was dazu führt, dass die Fehlerpfade nahezu undurchschaubar werden.
Ich finde es interessant zu sehen, wie andere Sprachen das Problem angehen:
Früher gab es in C ++ eine Form von überprüften Ausnahmen. Möglicherweise haben Sie bemerkt, dass sie veraltet und
noexcept(<bool>)
stattdessen in Richtung Basic vereinfacht wurden : Entweder wird eine Funktion als möglicherweise auszulösen deklariert, oder sie wird als niemals auszulösen deklariert. Überprüfte Ausnahmen sind insofern problematisch, als ihnen die Erweiterbarkeit fehlt, was zu umständlichen Zuordnungen / Verschachtelungen führen kann. Und verschachtelte Ausnahmehierarchien (einer der Hauptanwendungsfälle der virtuellen Vererbung sind Ausnahmen ...).Im Gegensatz dazu gehen Go und Rust folgendermaßen vor:
Letzteres ist ziemlich offensichtlich , dass (1) sie ihre Ausnahmen nennen panics und (2) gibt es keine Typhierarchie / kompliziert Klausel hier. Die Sprache bietet keine Möglichkeit, den Inhalt einer "Panik" zu überprüfen: Keine Typhierarchie, kein benutzerdefinierter Inhalt, nur ein "Ups, es ist so schief gegangen, dass keine Wiederherstellung möglich ist".
Dies ermutigt die Benutzer effektiv, eine ordnungsgemäße Fehlerbehandlung zu verwenden, und bietet dennoch eine einfache Möglichkeit, in Ausnahmesituationen zu helfen (z. B .: "Warten Sie, das habe ich noch nicht implementiert!").
Natürlich ähnelt der Go-Ansatz leider dem Ihren, da Sie leicht vergessen können, den Fehler zu überprüfen ...
... der Rust-Ansatz konzentriert sich jedoch hauptsächlich auf zwei Arten:
Option
, das ist ähnlich wiestd::optional
,Result
Dies ist eine Variante mit zwei Möglichkeiten: Ok und Err.Dies ist viel übersichtlicher, da es keine Möglichkeit gibt, ein Ergebnis versehentlich zu verwenden, ohne es auf Erfolg überprüft zu haben. Andernfalls kommt das Programm in Panik.
FP-Sprachen bilden ihre Fehlerbehandlung in Konstrukten, die in drei Ebenen unterteilt werden können: - Functor - Applicative / Alternative - Monads / Alternative
Functor
Werfen wir einen Blick auf Haskells Typenklasse:Zuallererst sind Typklassen etwas ähnlich, aber nicht gleich Schnittstellen. Haskells Funktionssignaturen sehen auf den ersten Blick etwas beängstigend aus. Aber lasst sie uns entziffern. Die Funktion
fmap
nimmt eine Funktion als ersten Parameter an, die etwas ähnlich zu iststd::function<a,b>
. Das nächste ist einm a
. Sie können sichm
so etwasstd::vector
undm a
so etwas vorstellenstd::vector<a>
. Aber der Unterschied ist, dasm a
heißt nicht, dass es explizit sein mussstd:vector
. So könnte es auch seinstd::option
. Indem wir der Sprache mitteilen, dass wir eine Instanz für die TypenklasseFunctor
für einen bestimmten Typ wiestd::vector
oder habenstd::option
, können wir die Funktionfmap
für diesen Typ verwenden. Das Gleiche muss für den typeclasses getan werdenApplicative
,Alternative
undMonad
Dadurch können Sie zustandsbehaftete, möglicherweise fehlgeschlagene Berechnungen durchführen. DieAlternative
Typenklasse implementiert Fehlerbehebungsabstraktionen. Damit kann man sagen,a <|> b
dass es sich entweder um einen Begriffa
oder um einen Begriff handeltb
. Wenn keine der beiden Berechnungen erfolgreich ist, liegt immer noch ein Fehler vor.Werfen wir einen Blick auf Haskells
Maybe
Typ.Dies bedeutet, dass Sie dort, wo Sie ein erwarten
Maybe a
, entwederNothing
oder erhaltenJust a
. Wenn mann diefmap
von oben könnte eine Umsetzung aussehenDer
case ... of
Ausdruck heißt Pattern Matching und ähnelt dem, was in der OOP-Welt als bekannt istvisitor pattern
. Stellen Sie sich vor, die Zeilecase m of
alsm.apply(...)
und die Punkte sind die Instanziierung einer Klasse, die die Dispatch-Funktionen implementiert. Die Zeilen unter demcase ... of
Ausdruck sind die jeweiligen Versandfunktionen, mit denen die Felder der Klasse namentlich direkt in den Gültigkeitsbereich gebracht werden. In derNothing
Verzweigung, die wir erstellen,Nothing
und in derJust a
Verzweigung benennen wir unseren einzigen Werta
und erstellen einen anderenJust ...
mit der Transformationsfunktion, die auff
angewendet wirda
. Lesen Sie es als:new Just(f(a))
.Dies kann nun fehlerhafte Berechnungen handhaben, während die eigentlichen Fehlerprüfungen entfernt werden. Es gibt Implementierungen für die anderen Schnittstellen, die diese Art von Berechnungen sehr leistungsfähig machen. Eigentlich
Maybe
ist das die Inspiration für RustsOption
-Typ.Ich würde Sie dort ermutigen, stattdessen Ihre
Success
Klasse zu überarbeitenResult
. Alexandrescu schlug tatsächlich etwas sehr Nahes vor, genanntexpected<T>
, für das Standardvorschläge gemacht wurden .Ich werde mich einfach an die Rust-Benennung und -API halten, weil ... sie dokumentiert ist und funktioniert. Natürlich hat Rust einen raffinierten
?
Suffix-Operator, der den Code viel süßer machen würde. In C ++ werden wir denTRY
Makro- und den GCC- Anweisungsausdruck verwenden , um ihn zu emulieren.Hinweis: Dies
Result
ist ein Platzhalter. Eine ordnungsgemäße Implementierung würde Kapselung verwenden und aunion
. Es ist jedoch ausreichend, den Punkt zu vermitteln.Was mir erlaubt zu schreiben ( in Aktion zu sehen ):
was ich sehr ordentlich finde:
Success
Klasse) führt das Nichtbeachten von Fehlern zu einem Laufzeitfehler 1 und nicht zu einem zufälligen Verhalten.concepts
in den Standard einsteigen . Dies würde diese Art der Programmierung weitaus angenehmer machen, da wir die Wahl über die Art der Fehler lassen könnten. ZB mit einer Implementierung vonstd::vector
als Ergebnis könnten wir alle möglichen Lösungen auf einmal berechnen. Oder wir könnten uns dafür entscheiden, die Fehlerbehandlung zu verbessern, wie Sie vorgeschlagen haben.1 Mit einer richtig gekapselten
Result
Implementierung;)Hinweis: Im Gegensatz zur Ausnahme verfügt dieses Lightweight
Result
über keine Backtraces, wodurch die Protokollierung weniger effizient ist. Möglicherweise ist es hilfreich, zumindest die Datei- / Zeilennummer zu protokollieren, mit der die Fehlermeldung generiert wird, und im Allgemeinen eine umfangreiche Fehlermeldung zu verfassen. Dies kann durch Erfassen der Datei / Zeile bei jeder Verwendung desTRY
Makros, im Wesentlichen durch manuelles Erstellen der Rückverfolgung oder durch Verwendung von plattformspezifischem Code und Bibliotheken,libbacktrace
um die Symbole im Aufrufstapel aufzulisten, ergänzt werden.Es gibt jedoch eine große Einschränkung: Bestehende C ++ - Bibliotheken und sogar solche
std
, die auf Ausnahmen basieren. Die Verwendung dieses Stils ist ein harter Kampf, da die API einer Drittanbieter-Bibliothek in einen Adapter eingeschlossen werden muss ...quelle
({...})
es sich um eine GCC-Erweiterung handelt, aber sollte das nicht auch so seinif (!result.ok) return result;
? Ihr Zustand erscheint rückwärts und Sie machen eine unnötige Kopie des Fehlers.({...})
der Ausdruck von gccs Anweisungen ist .std::variant
dem implementieren ,Result
wenn Sie mit C ++ 17. Um eine Warnung zu erhalten, wenn Sie einen Fehler ignorieren, verwenden Sie[[nodiscard]]
std::variant
Anbetracht der Kompromisse bei der Ausnahmebehandlung ist es eine Geschmackssache, ob man etwas verwendet oder nicht.[[nodiscard]]
ist in der Tat ein reiner Gewinn.Ausnahmen sind ein Kontrollflussmechanismus. Die Motivation für diesen Kontrollflussmechanismus bestand darin , die Fehlerbehandlung speziell vom Code für die fehlerfreie Behandlung zu trennen , in dem allgemeinen Fall, dass die Fehlerbehandlung sich sehr wiederholt und für den Hauptteil der Logik von geringer Relevanz ist.
Bedenke: Ich versuche eine Datei zu erstellen. Das Speichergerät ist voll.
Nun, dies ist kein Fehler bei der Definition meiner Voraussetzungen: Sie können "Es muss genügend Speicher vorhanden sein" im Allgemeinen nicht als Voraussetzung verwenden, da der gemeinsame Speicher Wettkampfbedingungen unterliegt, die es unmöglich machen, diese zu erfüllen.
Sollte mein Programm also irgendwie Speicherplatz freigeben und dann erfolgreich fortfahren, sonst bin ich einfach zu faul, um "eine Lösung zu entwickeln"? Dies scheint ehrlich gesagt unsinnig. Die "Lösung" für die Verwaltung des gemeinsam genutzten Speichers liegt außerhalb des Anwendungsbereichs meines Programms , und es ist in Ordnung , wenn mein Programm ordnungsgemäß fehlschlägt und erneut ausgeführt wird, sobald der Benutzer Speicherplatz freigegeben oder weiteren Speicher hinzugefügt hat .
Ihre Erfolgsklasse verschachtelt die Fehlerbehandlung sehr explizit mit Ihrer Programmlogik. Jede einzelne Funktion muss vor dem Ausführen prüfen, ob bereits ein Fehler aufgetreten ist, was bedeutet, dass sie nichts tun sollte. Jede Bibliotheksfunktion muss in eine andere Funktion mit einem weiteren Argument (und hoffentlich perfekter Weiterleitung) eingeschlossen werden, die genau dasselbe tut.
Beachten Sie auch, dass Ihre
mySqrt
Funktion einen Wert zurückgeben muss, auch wenn dieser fehlgeschlagen ist (oder eine frühere Funktion fehlgeschlagen ist). Sie geben also entweder einen magischen Wert (wieNaN
) zurück oder Sie fügen einen unbestimmten Wert in Ihr Programm ein und hoffen, dass nichts davon Gebrauch macht, ohne den Erfolgsstatus zu überprüfen, den Sie durch Ihre Ausführung gezogen haben.Aus Gründen der Korrektheit und Leistung ist es viel besser, die Kontrolle wieder außer Reichweite zu bringen, wenn Sie keine Fortschritte erzielen können. Dies wird durch Ausnahmen und explizite Fehlerprüfung im C-Stil mit vorzeitiger Rückkehr erreicht.
Zum Vergleich: Ein Beispiel für Ihre Idee, die wirklich funktioniert, ist die Fehlermonade in Haskell. Der Vorteil gegenüber Ihrem System besteht darin, dass Sie den Großteil Ihrer Logik normal schreiben und dann in die Monade einbinden, um die Auswertung anzuhalten, wenn ein Schritt fehlschlägt. Auf diese Weise ist der einzige Code, der das Fehlerbehandlungssystem direkt berührt, der Code, der möglicherweise fehlschlägt (einen Fehler auslösen), und der Code, der den Fehler behandeln muss (eine Ausnahme abfangen).
Ich bin mir nicht sicher, ob Monadenstil und verzögerte Evaluierung sich gut in C ++ übersetzen lassen.
quelle
and allowing my program to fail gracefully, and be re-run
wenn er gerade 2 Stunden Arbeit verloren hat:std::exception
auf der höheren Ebene der logischen Operation an, teilen dem Benutzer mit, dass X aufgrund von ex.what () fehlgeschlagen ist , und bieten an, die gesamte Operation zu wiederholen, wenn sie bereit ist.showing the Save dialog again along with an error message and allowing the user to specify an alternative location to try
. Dies ist eine ordnungsgemäße Behandlung eines Problems, die normalerweise nicht mit dem Code durchgeführt werden kann, der feststellt, dass der erste Speicherort voll ist.Ihr Ansatz bringt einige große Probleme in Ihren Quellcode:
Es basiert auf dem Client-Code, der sich immer daran erinnert, den Wert von zu überprüfen
s
. Dies ist üblich bei der Verwendung von Rückkehrcodes für die Fehlerbehandlung der Fall und einer der Gründe, warum Ausnahmen in die Sprache eingefügt wurden: Mit Ausnahmen schlagen Sie bei einem Fehlschlag nicht unbemerkt fehl.Je mehr Code Sie mit diesem Ansatz schreiben, desto mehr Fehlercode müssen Sie ebenfalls hinzufügen, um Fehler zu behandeln (Ihr Code ist nicht mehr minimalistisch) und Ihren Wartungsaufwand zu erhöhen.
Die Lösungen für diese Probleme sollten auf technischer Ebene oder Teamebene erarbeitet werden:
Wenn Sie feststellen, dass Sie ständig mit jeder Art von Ausnahme fertig werden, ist das Design nicht gut. Welche Fehler behandelt werden, sollte anhand der Projektspezifikationen entschieden werden, nicht anhand der Art und Weise, in der Entwickler sich in der Umsetzung fühlen.
Lösung durch Einrichtung automatisierter Tests, Trennung von Spezifikation der Komponententests und Durchführung (zwei verschiedene Personen tun dies).
Sie werden dies nicht beheben, indem Sie mehr Code schreiben. Ich denke, Ihre beste Wette sind akribisch angewandte Codeüberprüfungen.
Eine ordnungsgemäße Fehlerbehandlung ist schwierig, aber mit Ausnahmen weniger mühsam als mit Rückgabewerten (unabhängig davon, ob sie tatsächlich zurückgegeben oder als E / A-Argumente übergeben werden).
Der schwierigste Teil der Fehlerbehandlung ist nicht, wie Sie den Fehler erhalten, sondern wie Sie sicherstellen, dass Ihre Anwendung bei Fehlern einen konsistenten Status beibehält.
Um dies zu beheben, muss der Identifizierung und Ausführung unter Fehlerbedingungen mehr Aufmerksamkeit gewidmet werden (mehr Tests, mehr Unit- / Integrationstests usw.).
quelle