C++/Tutorial: 16. Fehlerbehandlung

Aus Scientia
Version vom 6. April 2011, 07:33 Uhr von Alexis Hiemis (Diskussion | Beiträge)

(Unterschied) ← Nächstältere Version | Aktuelle Version (Unterschied) | Nächstjüngere Version → (Unterschied)
Wechseln zu: Navigation, Suche

In diesem Kapitel will ich auf ein paar Mittel eingehen, die sich um unterschiedliche Arten von Fehlern kümmern, da wären: Assertions, Exceptions sowie die #error und die #warning Preprozessordirektive.

Assertions

Fast kein Programm ist völlig fehlerfrei, Bugs gibt es immer und das aufspüren dieser macht einen nicht unerheblichen Teil des Entwicklungsprozesses aus. Aus diesem Grund bietet uns C++ sogenannte Assertions.
Bei Assertions handelt es sich nicht um ein Sprachmittel, sondern um einen Bestandteil der C-Standardbibliothek im Header cassert, der das Debuggen vereinfachen soll. Unter bestimmten Vorraussetzungen ist der Fehler in einem Programm schon sicher, bevor er zur Geltung kommt. Nehmen wir z.B. eine Funktion, die einen Pfad in Form eines Strings als Parameter nimmt und im Laufe der Funktion die Datei hinter diesem Pfad läd(wie wir das machen folgt später noch, aber das ist für dieses Beispiel nicht wichtig). Unabhängig davon, wie sich ein Fehler beim Laden der Datei äußert können wir bereits Aussagen treffen. Wir könnten zum Beispiel sagen, dass der Pfad nicht leer sein soll, sonst handelt es sich um einen Fehler im Programm. Genau solche Aussagen kann man mithilfe von assertions treffen, wenn dann zur Laufzeit festgestellt wird, dass der Pfad trotzdem leer ist wird eine Fehlermeldung normalerweise mit näheren Informationen(wie z.B. Datei und Zeile um sie wieder zu finden) ausgegeben und das Programm wird terminiert.
Okay, wie sieht das Ganze nun aus? Wir erstellen eine Funktion load_some_file, die besagtes tut:

#include <string>
#include <cassert>
 
using namespace std;
 
void load_some_file(string const& path) 
  {
  assert(path != "");
 
  //do something
  }
 
int main()
  {
  load_some_file(""); //es macht Bumm: "program: assert.cpp:8: void load_some_file(const std::string&): Assertion `path != ""' failed."
  }

Weitere mögliche Anwendungsfälle wären z.B. Pointer, die nicht NULL sein dürfen, oder Zahlen, die sich in bestimmten Bereichen aufhalten sollen.
Allerdings ist es nicht unwahrscheinlich, dass man die assertions wieder loswerden will, wenn man den Code schließlich ausliefert. Bei assert handelt es sich um ein Macro und es kann sehr einfach völlig aus dem Code entfernt werden, es reicht das Symbol NDEBUG zu definieren

#define NDEBUG

und schon sind wir alle assertions wieder los.

Exceptions

Der interessantere Teil der Fehlerbehandlung beschäftigt sich jedoch mit Fehlern, die auch in einem korrekten Programm auftreten können, aber dennoch Fehler sind, oder genauer gesagt "Ausnahmen". Üblicherweise geschieht das durch Einfluss von außen, nehmen wir z.B. an path enthält den Pfad zu einer Datei, die nicht mehr existiert, weil der User sie gelöscht hat, in dem Fall wären assertions relativ ungünstig, es handelt sich zwar noch um einen Fehler, aber keinen Programmierfehler in dem Sinne mehr, es wäre also ungünstig eine, für den User derart kryptische, Fehlermeldung auszugeben und dann das Programm zu terminieren. Viel eher wollen wir den Fehler behandeln und sei es nur um eine schönere Fehlermeldung auszugeben, im Idealfall soll das Programm allerdings weiterlaufen, sofern dies dann noch möglich ist.
Nehmen wir also eben jenen Fall an, dass eine Datei nicht mehr vorhanden ist, wie reagieren wir darauf? Optimal wäre es natürlich, wenn die Funktion, die die Datei läd das bereits behandeln würde(auf existenz überprüft und dann ggf. reagiert), aber normalerweise kann diese Funktion nicht wissen, wie wir auf eine nicht gefundene Datei reagieren wollen. Es kann sein, dass die Daten in der Datei so wichtig waren, dass wir nicht ohne sie weiter arbeiten können, es kann auch sein, dass eine Fehlermeldung und ein abbruch ausreicht. Wir müssen also die Verantwortung irgendwie wieder an die aufrufende Funktion zurückgeben und das soweit, bis eine Funktion weiß, wie auf die Ausnahme zu reagieren ist. Mit unserem bisherigen Wissen ist dies bereits möglich, indem wir Fehlercodes als Rückgabewert für Funktionen verwenden(und das eigentliche Resultat über Ausgabeparameter erhalten), das ist die Methode, wie sie z.B. in C üblich war, C++ bietet uns allerdings ein weiteres Sprachmittel, das genau das für uns übernimmt: sogenannte Exceptions.
Erläutern wir auch das mal anhand eines Beispiels: wir wissen zwar noch nicht, wie wir auf die Existenz einer Datei prüfen, aber wir wissen, dass ein leerer Pfad keine Datei ist und haben vorhin eine Assertion an dieser Stelle verwendet. Gehen wir aber davon aus, dass die Übergabe eines leeren Strings auftreten darf und kein Programmierfehler ist(es wird wohl einer sein, aber in dem Beispiel gehen wir einmal davon aus, dass nicht), sondern lediglich eine Ausnahme, dann werfen wir an dieser Stelle eine Exception, anstatt eine assertion auszulösen. Exceptions werden mit dem throw statement geworfen, dabei bricht die Ausführung der aktuellen Funktion ab und die Ausnahme wird in einer der aufrufenden Funktionen behandelt. Exception kann fast alles sein, das heißt wir könnten z.B. eine Zahl werfen mit throw 0; üblich ist es aber, dass wir ein Objekt werfen, dass von der Klasse exception erbt, die sich im gleichnamigen Header befindet. Diese Klasse definiert eine Funktion what(), die eine Beschreibung des Fehlers liefern soll. Im Header stdexcept befinden sich bereits einige Exceptionklassen von denen wir uns für unseren Fehler zunächst einmal die ganz allgemeine Klasse runtime_error raussuchen. Schmeißen wir mal einen runtime_error, wenn der Pfad leer ist:

void load_some_file(string const& path) 
  {
  if(path == "")
    throw runtime_error("file not found");
 
  //do something
  }

wir könnten an dieser Stelle noch genauere Informationen mitgeben, wenn mehr als nur path = "" überprüft wird, aber für den Moment genügt das. Wenn path ein leerer String ist wird runtime_error geworfen, das heißt die Funktion wird abgebrochen und es wird zum Aufrufer zurückgesprungen. Dieser kann die Exception behandeln, wenn er es nicht tut wird weiter zurückgesprungen, solange bis das Programm wegen einer nicht aufgefangenen Ausnahme abbricht.
Gut, wie fangen wir diese Exception nun? Dazu müssen wir den Aufruf in einem try Block kapseln, es können nun beliebig viele catch Blöcke der Form catch(exception_type& e) auftreten, die verschiedene Exceptions behandeln. Wenn wir an dieser Stelle alle Fehler behandeln wollen, fangen wir den Typ exception, wenn wir nur die runtime_errors fangen wollen, nehmen wir eben den Typ runtime_error. Interessant ist auch, dass bei Exceptions by value geworfen, aber by reference gefangen wird(dies ist auch notwendig, da hier Polymorphie benötigt wird).
Das Mindeste was wir tun können ist den Fehler irgendwie auszugeben und dann zu ignorieren, oder das Programm abzubrechen:

int main()
  {
  try 
    {
    load_some_file(""); 
    }
  catch(runtime_error& e)
    {
    cout << e.what() << endl; //gebe die Fehlerursache aus
    }
  }

Wir könnten jetzt noch weitere catch-Blöcke zur Behandlung anderer Exceptions folgen lassen.
Auch ist es möglich Ausnahmen nur teilweise zu behandeln, wir fangen sie, behandeln sie und werfen sie in diesem catch-Block dann einfach weiter, dazu gibt es eine Kurzform, die lautet einfach throw;

Eigene Exceptionklassen

stdexcept bietet einige Exceptionklassen um relativ allgemein übliche Fehler auszumerzen, manchmal wollen wir aber eine spezielle Exceptionklasse, entweder um spezielle Informationen für die Fehlerbehandlung mitzunehmen, oder einfach um diese Exceptions gesondert behandeln zu können. Entwerfen wir für unser Beispiel also einmal eine Exceptionklasse file_not_found_error, ich werde diesmal mit dem Beispiel starten und dieses dann erklären:

#include <string>
#include <iostream>
#include <exception>
 
using namespace std;
 
class file_not_found_error : public std::exception
  {
  public:
    virtual char const* what() throw();
  };
 
char const* file_not_found_error::what() throw() 
  {
  return "file not found";
  }
 
void load_some_file(string const& path) 
  {
  if(path == "")
    throw file_not_found_error();
 
  //do something
  }
 
int main()
  {
  try 
    {
    load_some_file(""); 
    }
  catch(file_not_found_error& e)
    {
    cout << e.what() << endl; //gebe die Fehlerursache aus
    }
  }

Wie erwartet können wir nun file_not_found_error werfen und auch fangen, alle anderen Ausnahmen werden weiterhin unbehandelt bleiben, also gehen wir einmal auf die Definition der Klasse file_not_found_error ein.
Diese erbt von der Klasse std::exception, wie gefordert und überschreibt die virtuelle Funktion what, die eine Fehlermeldung zurück gibt. Alles in allem nichts neues, interessant sind jedoch 2 Fragen: was bedeuted das throw() und warum wird char const* zurückgegeben und nicht std::string. Kurz: throw() hinter der Funktion gibt an, dass keine exception geworfen werden kann(gleich mehr dazu), das ist in dem Fall notwendig, deswegen muss auch char const* verwendet werden, da std::string durchaus eine exception werfen kann. Grundsätzlich darf aber nur eine Exception aktiv sein. Auch an anderen Stellen sollten keine Exceptions geworfen werden, auch wenn es nicht verboten ist, z.B. in Destruktoren.

function try

Werfen wir noch einmal einen Blick auf die main Funktion des letzte Beispiels, diese können wir auch anders schreiben:

int main() try
  {
  load_some_file(""); 
  }
catch(file_not_found_error& e)
  {
  cout << e.what() << endl; //gebe die Fehlerursache aus
  }

Das ist kürzer und deswegen vl angenehmer zu benutzen, aber nichts unglaubliches. Es gibt allerdings einen speziellen Fall für den diese Form von try Blöcke benötigt werden und zwar in Konstruktoren. Nehmen wir zum Beispiel an, wir haben folgenden Konstruktor:

some_class::some_class() try : m_some_member(load_some_file("")) 
  {
  //...
  }
catch(file_not_found_error& e)
  {
  cout << e.what() << endl; //gebe die Fehlerursache aus
  }

der function-try Block umschließt die Initialisierungsliste mit und fängt die Exception, anders könnten wir sie an dieser Stelle nicht fangen. Interessant ist noch, dass Exceptions in Initialiserungslisten implizit weiter geschmissen werden. Endgültig fangen können wir sie an dieser Stelle also nicht.

Exception Specifications

Kommen wir also noch einmal zurück auf das throw(). Es ist in C++ möglich anzugeben, welche Exceptions eine Funktion werfen darf und welche nicht, diese geben wir mit throw(a,b,c) hinter der Funktion an. Wenn eine andere Exception geworfen wird, wird die Funktion unexpected aufgerufen, wir können das ganze behandeln, indem wir einen Handler als Funktionspointer an die Funktion set_unexpected übergeben. Das ist allerdings nicht ganz unproblematisch, denn es handelt sich nicht um einen Compilercheck, es kostet Performance und verursacht noch weitere Probleme. Es ist daher kein übliches Sprachfeature, allgemein ist es kein schlechter Rat nur die throw() Spezifikation zu verwenden, wenn eben keine Exception geworfen werden darf und Exception Specifications ansonsten links liegen zu lassen.

#error und #warning

Ganz kurz möchte ich auch noch auf die beiden Präprozessordirektiven #error und #warning eingehen. Manchmal ist es bereits zur Compilezeit bzw. Präprozessorzeit möglich zu sagen, dass ein Programm nicht funktionieren wird, z.B. anhand von Symbolen, die ein bestimmter Compiler in einer bestimmten Version definiert. In dem Fall können wir künstlich Compilerfehler und Warnungen erzeugen, dazu verwenden wir die Direktiven #error und #warning gefolgt von der Fehlermeldung oder Warnung.