Kapitel 16 - Ausnahmebehandlung

Peter Ulbrich

🚀 by Decker

Einleitung

  • Bisher: Auftreten von Fehlern war nebensächlich
    • Im ersten Teil der Vorlesung: errno
    • Auẞerdem: Bestimmte Rückgabewerte von Funktionen deuten auf Fehler hin
    • Beispiel: open() (Posix-Funktion)
  • Aber: Fehlerbehandlung ist aber essentiell wichtig!
  • Ein gutes Programm …
    1. verarbeitet unerwartete Ereignisse („Fehlerfall“) angemessen.
    2. verursacht im Katastrophenfall einen möglichst geringen Schaden.
  • Beachtet
    • Berücksichtigen der möglichen Fehlerfälle ist die hohe Kunst!
    • Nehmt aus Faulheit keine Abkürzungen

Rückblick – Fehlerbehandlung in C

  • Aus C sind bereits einige Arten der Fehlerbehandlung bekannt
  • Die einfachste Methode: Programm beenden
    • Radikal: Zum Beispiel durch Aufruf exit() (stdlib.h) oder vorzeitiges return in main()
bool func_call() { /*...*/ }

int main() {
    bool success = func_call();
    if (!success) {
        exit(1);
    } else {
        exit(0);
    }
}
  • Funktioniert prinzipiell einwandfrei, aber:
    • Nur für sehr kleine Programme sinnvoll
    • Sehr schlecht geeignet für den Dauerbetrieb (z.B. Webserver)
    • Nicht akzeptabel in sicherheitsrelevanten Anwendungen

Rückblick – Fehlerbehandlung in C

  • Besserer Ansatz: Kodierung über Rückgabewert, z.B. int
  • Unmittelbare Vorteile
    • Kein Abbruch mehr notwendig
    • Fehlergrund kann an Ort und Stelle erkannt und behandelt werden
// Beispiel aus Posix
#include <fcntl.h>

int main() {
    // - 1, falls Fehler auftritt
    int ret = open("file.txt", O_RDONLY);
    if (ret < 0) {
        // Fallunterscheidung für Fehlertyp
    }
}
  • Unmittelbarer Nachteil: Code wird komplexer
    • Fehlerfälle müssen nun einzeln kodiert bzw. dekodiert und behandelt werden
    • Das ist leider der Tradeoff 🙁
  • Ein weiteres Problem: Es ist nur ein Rückgabetyp möglich

Rückblick - Fehlerbehandlung in C

  • Zusätzlich zum Rückgabewert: errno
    • Kodiert den Fehlergrund
    • Globale Variable
    • Gilt für Funktionen der C-Standardbibliothek
#include <fcntl.h>
#include <errno.h>

int main() {
    int ret = open("file.txt", O_RDONLY);
    if (ret < 0) {
        if (errno == ENOENT) { /*...*/ }
    }
}
  • errno hat eine Reihe von Problemen
    • Globale Variable aus errno.h

    • Kodiert nur den jeweils letzten Fehlergrund (automatische Überschreibung)

      → Prüfung wird sehr leicht vergessen, Grund ist dann ggf. bei Folgefehlern nicht mehr nachvollziehbar

    • Wertekodierung ist abhängig von der Zielplattform

      → andere Fehlercodes unter Linux/UNIX als unter Windows

Ausnahmebehandlung in C++

  • errno und Rückgabewerte existieren in C++ natürlich weiterhin
  • Gerade bei Systemfunktionen führt oft kein Weg daran vorbei, wie open()

 

  • Es gibt allerdings noch weitere Wege

    • Fehlerbehandlung mithilfe von Ausnahmen (Exceptions)
    • Kombinierter Rückgabewert (Union von Wert + Fehler) mithilfe von std::expected – nicht Thema in diesem Kapitel

Exceptions

  • Ausnahmebehandlung ist tief in C++ verankert

    • Beispiel: new und delete können Ausnahmen werfen
  • Konzept hinter Exceptions ist relativ simpel

    • Bei Auftritt eines Fehlers, z.B. Datei existiert nicht, wird dieser zunächst nur erkannt
    • Behandlung des Fehlers erfolgt nicht unmittelbar, keine lokale Fehlerbehandlung wie bisher
  • Stattdessen: Signalisierung eines Fehlerfalls an aufrufende Funktion

    → „Heißes Eisen wird weggeschoben und ist das Problem des Vorgesetzten

Terminologie

  • Bei der Signalisierung wird die Ausnahme geworfen (throw an exception)
  • Aufrufende Funktion fängt die Ausnahme (catch an exception)
  • Umsetzung
    • try: Beginn eines Code-Blocks, in dem eine Ausnahme ausgelöst werden könnte
    • throw: Werfen einer Ausnahme
    • catch: Code-Block, der potenziell die Ausnahme behandeln kann
try {
    bool error = may_error();
    if (error) {
        throw MyException;
    }
}
catch (MyException ex) {
    // Behandlung der Ausnahme
    // Zum Beispiel: Ausgabe
    cout << ex.what();
}

Fangen einer Ausnahme

  • Betreten des catch-Blocks, wenn die Signatur der Ausnahme passt

  • Ansonsten: An den Nächsten weiterwerfen

    rethrow

  • Achtung: Beim Werfen wird die bisherige Funktion verlassen

    • Nach der Behandlung der Ausnahme erfolgt keine Rückkehr an die ursprüngliche Stelle des Kontrollflusses
    • Daraus resultierende Folgen werden gleich beleuchtet
try {
    if (error) {
        throw MyException;
    }
}
catch (MyException ex) {
    // Behandlung der Ausnahme
    // Zum Beispiel: Ausgabe
    cout << ex.what();
}
// ...
catch (OtherException ex) {
    // ...
}
// ...
catch (int i) {
    // ...
}
// ... und so weiter

Ablauf einer Ausnahmebehandlung

  1. Der Reihe nach catch-Handler ablaufen

  2. Falls ein Ausnahmetyp auf einen Handler passt, wird er verwendet

    → Nachfolgende Handler werden ignoriert

  3. Kein passender Handler?

    Aufwärtstraversierung der Aufrufkette zu darüberliegenden Funktionen

  4. Falls auf der Ebene ein try-Block existiert → Schritt 1, sonst Schritt 3

  • Ende der Ausnahmekette erreicht? → Aufruf von std::terminate()
    • Sofortiger Abbruch des Programms ohne Rückkehr zur main()
    • Gleiches geschieht übrigens, wenn eine neue Ausnahme während der Ausnahmebehandlung entsteht

Das Wurfobjekt

  • Beliebiger Datentyp mittels throw geworfen werden
  • Es sollte ein entsprechendes catch vorhanden sein
    • Kann aber leicht vergessen werden
throw 1;
throw "Error"; // const char *
throw SpecialErrorClass("Error");
try {
    throw int i;
}
catch (MyException ex) {}
catch (const char * msg) {}
catch (...) { // Catch-All
    cout << "Caught unhandled exception";
}
  • Alternative: Die sogenannte Ellipse (...)
    • Eigentliche Verwendung: Akzeptieren beliebiger Parameter bei Funktionsaufruf (siehe printf)
    • Bei Ausnahmen: Akzeptieren beliebiger Ausnahmetypen (Catch All)

Beispiel – Exceptions

#include <iostream>
#include <string>

using std::cout, std::endl;

class Error {
    std::string msg;
public:
    Error(std::string s) : msg(s) {}
    const char* message() { return msg.c_str(); }
};

void throws_char_ptr() {
    try {
        throw "pointer error";
    }
    catch(int i) {  // Kein Matching gegen geworfenen Datentyp -> weiterreichen
        cout << i << endl;
    }
}

void throws_class() {
    try {
        throw Error("class error");
    }
    catch(Error e) {
        cout << "Handled in function catch: " << e.message() << endl;
    }
}
int main() {
    cout << "main(): enter" << endl;
    try {
        throws_class();
        throws_char_ptr();
    }
    catch(...) {
        cout << "Caught unhandled exception" << endl;
    }
    cout << "main(): exit" << endl;
}
cpp

Eigenschaften von Ausnahmeklassen

  • Wie bei anderen Klassen auch
    • Erstellung wie bisher gewohnt
    • Dynamische Allokierung mit new und delete ebenfalls erlaubt (problematisch)
    • Referenzen ebenfalls erlaubt
    • Klassen dürfen Vererbung und Polymorphie verwenden
  • Achtung: Fallstricke und Untiefen! 😱
  • Beispiel: Aufräumen in jedem catch-Block erforderlich
try {
    throw new MyException;
}
catch(MyException * me) {
    delete me; // Ok...
}
// ...
catch(...) {} // ...und hier?

Throw by value, catch by reference

  • Allgemeine Herangehensweise bei Ausnahmen:

    Throw by value, catch by reference

  • Der Hintergrund ist simpel

    • Throw by value stellt sicher, dass keine Speicherlecks entstehen

    • catch by reference erlaubt Zugriff auf Methoden der Kinder einer gefangenen Klasse

      (Polymorphie statt versehentlicher Typkonvertierung)

class Base {};
class MyException :
        public Base {}
try {
    throw MyException();
}
catch(Base& e) {
    // So ist es korrekt
}
// ...
catch(...) {}

Verwendung von std::exception

  • Beliebige Datentypen als Ausnahme erlaubt
  • Besser: verarbeitbare Datentypen verwenden
  • Dafür bietet C++ eine eigene Klasse: std::exception
    • Hat eine virtuelle Methode what()
    • Beschreibt den Fehler, muss entsprechend in Kindklasse überschrieben werden
    • const char* what() const noexcept override;
#include <exception>

using std::exception;

class MyException: public exception {
public:
    const char* what()
        const noexcept override {
        return "MyException :)";
    }
};

class IntError: public exception {
public:
    const char* what()
        const noexcept override {
        return "IntError";
    }
};

Beispiel – std::exception

#include <iostream>
#include <string>
#include <exception>

using std::cout, std::endl;
using std::exception;

class MyException: public exception {
public:
    const char* what() const noexcept override {
        return "MyException :)";
    }
};

class IntError: public exception {
public:
    const char* what() const noexcept override {
        return "IntError";
    }
};

int main() {
    try {
        try {
            throw IntError();
        } catch (IntError& me) { // Fängt aber nicht MyException
            cout << me.what() << endl;
        } catch (exception& e) { // Alle Klassen vom Typ std::exception -> MyException
            cout << e.what() << endl;
        }
    } catch (...) {
        // Zur Sicherheit, falls etwas übersehen wurde
        cout << "Caught unhandled exception" << endl;
    }
}
cpp

Ermöglicht deutlich bessere und übersichtlichere catch-Blöcke als generische Ellipse

Rethrowing

  • Eine besondere Eigenschaft ist das sogenannte Rethrowing
  • Nach dem Fangen und erneuten Abarbeiten kann eine Ausnahme erneut mit throw geworfen werden
  • Grund: Andere Teile des Systems könnten auch am Auftreten der Ausnahme interessiert sein
#include <iostream>
#include <string>
#include <exception>

using std::cout, std::endl;
using std::exception;

class MyException: public exception {
public:
    const char* what() const noexcept override {
        return "MyException :)";
    }
};

void foo() {
    try {
        throw MyException();
    } catch (MyException& me) {
        cout << "ME: " << me.what() << endl;
        throw; // Rethrow für generischen Handler
    }
}

int main() {
    try { foo(); }
    catch (exception& e) {
        cout << "E: " << e.what() << endl;
    }
}
cpp

Reihenfolge der Exceptions

  • Empfehlenswert: erst Spezialfälle behandlen
  • Je weiter unten die Ausnahme behandelt wird, desto generischer sollte der gefangene Typ sein
int divide(int dividend, int divisor) {
    try {
        if (divisor == 0) {
            throw DivByZeroException();
        }
    }
}

catch (DivByZeroException e) { /*... */ }
// ..
catch (MathException e) { /*... */ }
// ..
catch (std::exception e) { /*... */ }
// ..
catch (...) { /*... */ }

Einschub: finally in anderen Programmiersprachen

  • Ausnahmebehandlung in vielen populären Programmiersprachen ebenfalls üblich

    z.B. Java, Python oder Javascript

  • Unterschied: Nach den catch-Blöcken ein Block mit Schlüsselwort finally

// Datenbank-Interaktion
try {
    db_connection.open();
}
catch(Exception e) {
    // Print
}
finally {
    db_connection.close();
}
  • Funktional wie ein Destruktor für die Ausnahmebehandlung

    → Wird in jedem Fall ausgeführt, unabhängig vom catch-Block

  • Existiert in C++ nicht, wird aber garantiert einmal an anderer Stelle auftauchen

Ausnahmen im Konstruktor

  • Exceptions auch in Konstruktoren / Destruktoren möglich
  • 🚨 Gefährlich: Aufnahmebehandlung sorgt eigentlich für Aufruf der Destruktoren
    • Bei einem Konstruktoraufruf geschieht dies nicht
  • Objekt gilt erst nach Konstruktoraufruf als vollständig angelegt
    • Möglichst keine Nebeneffekte im Konstruktor, wenn throw auftreten könnte
#include <iostream>
using std::cout, std:: endl;
class Member {
public:
    Member() { cout << "MC" << endl; }
    ~Member() { cout << "MD" << endl; }
};

class Class {
    Member member;
public:
    Class() {
        cout << "CC" << endl;
        throw 4711;
    }
    ~Class() { cout << "CD" << endl; }
};

int main() {
    try {
        Class c;
    }
    catch (int i) {
        cout << "Caught " << i << endl;
    }
}
cpp

Ausnahmen im Destruktor

  • Kritisch wird es erst bei den Destruktoren

  • try-Block auch hier erlaubt, aber…

  • Es gilt: Destruktoren werden am Ende eines Scopes aufgerufen, beim Stack Unwinding

  • Tritt hier eine Ausnahme auf

    → C++-Runtime beendet das Programm ohne zu zögern

  • Konsequenz: Niemals throw im Destruktor propagieren

    • catch muss an Ort und Stelle geschehen

Exceptions im Destruktor am besten vermeiden

#include <iostream>
using std::cout, std:: endl;
class Member {
public:
    Member() { cout << "MC" << endl; }
    ~Member() { cout << "MD" << endl; }
};

class Class {
    Member member;
public:
    Class() { cout << "CC" << endl; }
    ~Class() {
        cout << "CD" << endl;
        try { throw 4711; }
        catch (int i) {} // So ok
    }
};

int main() {
    try {
        Class c;
    }
    catch (int i) {
        cout << "Caught " << i << endl;
    }
}
cpp

Exkurs: noexcept

  • Es ist möglich, einzelne Funktionen als noexcept zu markieren
    • Beispiel: void foo() noexcept { /* ... */ };
    • Versprechen an Compiler, dass hier keine Exception erzeugt wird
  • Warum ist das wichtig?
    • Manche Teile der STL haben sogenannte Strong Exception Guarantee
    • Dürfen unter keinen Umständen Ausnahmen erzeugen
  • Betrifft auch STL-Container (z.B. std::vector):
    • Bei interner Reallokierung (→ mehr Kapazität) wird aus Effizienzgründen Move-Konstruktor verwendet
    • Ohne noexcept → bei Move-Konstruktor: Rückfall auf Copy-Konstruktor 🙁 (deutlich langsamer)
    • Grund: Wenn bei Move eine Exception auftritt, dann könnte theoretisch der alte Vector-Speicher noch nicht auf nullptr gesetzt sein (→ ggf. doppeltes free(), darf niemals passieren)

Beispiel - noexcept

#include <iostream>
#include <vector>
#include <utility>

using std::cout, std::endl;

class Foo {
    int id_;
public:
    Foo(int id) : id_(id) { }
    Foo(const Foo & other) : id_(other.id_){
        cout << "Copy Foo" << id_ << endl;
    }
    Foo(Foo && other) noexcept : id_(other.id_) {
        cout << "Move Foo" << id_ << endl;
    }
};

int main() {
    Foo foo1(1);
    Foo foo2(2);
    Foo foo3(3);
    // std::vector anfangs leer
    std::vector<Foo> vec; // vec.capacity() == 0
    vec.push_back(std::move(foo1)); //      == 1
    // Internes malloc(vec.capacity() * 2)
    // Anschließendes Verschieben aus altem Speicher in neu allokierten Bereich
    // Verschieben ohne noexcept mittels Copy statt Move
    vec.push_back(std::move(foo2));
    // Kapazität (2) voll -> Verdoppelung auf 4 und wieder ein Verschieben
    vec.push_back(std::move(foo3));
}
cpp

Long story short: Annotiert ungefährliche Move-Konstruktoren mit noexcept

Probleme mit Exceptions

  • Sind leider nicht völlig unumstritten
  • In der Praxis teilweise sogar per Coding-Guideline verboten, z.B. bei Google
  • Ein Teil der Probleme wurde bereits dargelegt
    • Beispiel: Probleme mit Zeigern und delete
  • Auch im Bereich Embedded problematisch
    • Nehmen relativ viel Speicher ein
    • Bei 512 Kilobyte Gesamtspeicher können nicht mehrere hundert Kilobytes für Exceptions verwendet werden
    • Für Interessierte hier ein interessanter Vortrag zu Problemen und Lösungen (Youtube, Englisch)

Fazit zu Ausnahmen

  • Sind Exceptions deswegen schlecht? Nein
  • Ausnahmen sind nützlich, wenn etwas Performanceverlust im Tausch für schnellere Entwicklungszeit akzeptabel ist
  • Klassische Beispiele
    • Web-Programmierung
    • GUI-Programmierung, z.B. Qt-Framework
  • Aber: Seid Euch der Schwächen bewusst und berücksichtigt dies
  • Außerdem: Andere Programmiersprachen verfügen auch über Ausnahmen
    • Beispiele: Javascript, Python, Java, C#
    • Problembereiche nicht so präsent, weil diese sich eher an die Desktopprogrammierung richten

Zusammenfassung

  • Wiederholung: Klassische Fehlerbehandlung in C – inkl. der Schwächen
  • Jetzt: Ausnamen in C++
  • Drei neue Schlüsselwörter
    • try
    • catch
    • throw
  • Beliebige Datentypen dürfen geworfen werden
  • Reihenfolge der catch-Anweisungen ist wichtig
  • 🚨 keine Ausnahmen im Destruktor werfen
Logo Sys
Kapitel 16 - Ausnahmebehandlung