Modernes Exception-Handling Teil 1 - Die Grundlagen


  • Mod

    Modernes Exception-Handling Teil 1 - Die Grundlagen

    In dem Artikel Exception-Handling haben Sie bereits erfahren, dass auch der beste Programmierer an Fehler denken muss. Sie haben Exceptions kennengelernt und gesehen, wie flexibel diese Ihnen die Arbeit erleichtern können. Doch Exceptions sind nicht nur ein Segen, sondern auch ein Fluch. Bevor wir jedoch die Nachteile von Exceptions näher betrachten, wollen wir uns die Vorteile ansehen - oder besser: die Alternativen.

    Die Alternativen

    Exceptions sind nicht das einzige Mittel um Fehlerbehandlung zu implementieren. Betrachten wir also kurz, welche alternativen Möglichkeiten wir denn noch haben.

    If-Then-Else

    Die wohl bekannteste Variante der Fehlerbehandlung ist das gute alte if-then-else.

    #include <stdio.h>
    #include <stdlib.h>
    #include <ctype.h>
    #include <string.h>
    
    void error(char const* msg) {
    	fprintf(stderr, "Ein Fehler ist aufgetreten: '%s'\nDas Programm wird beendet\n", msg);
    }
    
    int print_usage() {
    	if(puts("encode <src> <trg>")<0) {
    		return -1;
    	} else {
    		return 0;
    	}
    }
    
    int encode(char const* srcFileName, char const* trgFileName, char const* password) {
    	FILE* src = NULL;
    	FILE* trg = NULL;
    	size_t pwdPos;
    	size_t pwdLength;
    	int c;
    	int bytesWritten;
    	int returnCode;
    
    	if(srcFileName == NULL || trgFileName == NULL || password == NULL) {
    		return -1;
    	}
    
    	src = fopen(srcFileName, "r");
    	if(src==NULL) {
    		returnCode=-1;
    		goto cleanup;
    	}
    	trg = fopen(trgFileName, "w");
    	if(trg==NULL) {
    		returnCode=-2;
    		goto cleanup;
    	}
    
    	pwdPos=0;
    	bytesWritten=0;
    	pwdLength=strlen(password);
    
    	while( (c=fgetc(src)) != EOF) {
    		int encoded = c ^ password[pwdPos++%pwdLength];
    		if(fputc(encoded, trg) == EOF) {
    			returnCode=-4;
    			goto cleanup;
    		}
    		++bytesWritten;
    	}
    	if(!feof(src)) {
    		returnCode=-3;
    	} else if(pwdPos<pwdLength) {
    		returnCode=-5;
    	} else {
    		returnCode=bytesWritten;
    	}
    
    cleanup:
    	if(src!=NULL)
    		fclose(src);
    	if(trg!=NULL)
    		fclose(trg);
    
    	return returnCode;
    }
    
    int main(int argc, char* argv[]) {
    	char password[256];
    	int res;
    
    	if(printf("Passwort: ")==-1) {
    		error("Schreibfehler auf stdout");
    		return -5;
    	}
    	if(!fgets(password, 256, stdin)) {
    		error("Lesefehler von stdin");
    		return -5;
    	}
    
    	if(argc!=3) {
    		if(!print_usage()) {
    			return 0;
    		} else {
    			error("Unbekannter Fehler");
    			return -6;
    		}
    	}
    
    	res=encode(argv[1], argv[2], password);
    	if(res>0) {
    		return 0;
    	} else {
    		switch(res) {
    		case 0:
    			error("Datei leer");
    			return -2;
    		case -1:
    			error("Source Datei nicht lesbar");
    			return -3;
    		case -2:
    			error("Target Datei nicht schreibbar");
    			return -3;
    		case -3:
    			error("Lesefehler in Source Datei");
    			return -4;
    		case -4:
    			error("Schreibfehler in Target Datei");
    			return -4;
    		case -5:
    			/*kein fehler*/
    			return 0;
    		default:
    			error("Unbekannter Fehler");
    			return -6;
    		}
    	}
    }
    

    Es gibt 3 Probleme mit diesem Code.

    1. Die Fehlerbehandlung erfolgt lokal und macht dadurch den Code schwerer lesbar.
    2. Jede Funktion verlangt andere Behandlung von Fehlern.
    3. In Sprachen ohne Garbage-Collector muss man seinen eigenen Mist selbst wegräumen. Dieser Cleanup-Code ist kompliziert einzubauen. Hier z.B. über hässliche gotos gelöst. Alternativ auch mit tief verschachtelten ifs lösbar.

    Aber in C macht man es anders

    Ihnen als aufmerksamer Leser ist natürlich aufgefallen, dass encode() hier ein Designproblem hat: Der Returnwert beißt sich mit der Fehlerbehandlung. Der Programmierer war versucht, encode() leicht benutzbar zu machen und der Konvention zu folgen, die geschriebenen Bytes zurückzugeben. Wenn Sie Erfahrung mit C-APIs haben, werden Sie wissen, dass der richtige Weg gewesen wäre, bytesWritten als Zeiger der Funktion zu übergeben. Worauf das aber hinausläuft, ist, dass man den Returnwert nur noch als Fehlercode verwenden kann - was vielen Code unpraktisch macht.

    Deshalb geht man in C den Weg über errno oder genereller gesagt: eine globale Variable. Der Vorteil ist, dass wir nur einen ungültigen Returnwert definieren müssen, bei encode z.B. -1 und über errno könnten wir dann den genauen Fehler abfragen. So ganz löst das unser Problem aber nicht: denn der Fall, dass nach x geschriebenen Bytes ein Fehler auftritt, hindert uns daran bytesWritten als Returnwert zu verwenden. Wir haben also durch errno nicht viel gewonnen. Weiters wirft der errno -Ansatz ein Problem mit der Erweiterbarkeit auf. Welche Werte verwendet eine neue Funktion für Fehler? Bestehende Werte sollte man lieber nicht doppelt benutzen, sonst kommen automatisierte Auswertungstools wie z.B. strerror() nicht mehr mit. Unter Windows wird das ganze mit errno , GetLastError() , WSAGetLastError() , ... auch noch sehr unübersichtlich.

    Der PHP4-Ansatz

    In PHP4 hat man das zweite Problem sehr schön lösen können, und da PHP einen Garbage-Collector hat, fällt auch das dritte Problem weg:

    function some_function() {
      if(something_went_wrong()) {
        return PEAR::raiseError('something went wrong', MY_ERROR_CODE);
      }
      return some_value();
    }
    $var = some_function();
    if(PEAR::isError($var)) {
      do_something();
    } else {
      echo $var;
    }
    

    PHP macht sich zu Nutze, dass jede Variable jeden Typ haben kann. isError erkennt, ob $var vom Typ PEAR_Error (oder einer Subklasse davon) ist und liefert dementsprechend true oder false. Dadurch hat man verhindert, dass jede Funktion einen anderen Fehlerwert liefert; und mit leicht behandelbaren Fehlerobjekten lassen sich auch unterschiedliche Fehler leichter unterschiedlich behandeln.

    Doch auch das war nicht der Weisheit letzter Schluss, da wir immer noch die Fehler lokal behandeln müssen oder explizit weiter nach unten geben müssen und somit, auch wenn wir den Fehler nicht behandeln wollen, auf ihn (zumindest mit einem return) reagieren müssen. Wenn wir uns aber als Beispiel eine Anwendung mit Datenbankanbindung vorstellen und mitten in einem Query wird die Verbindung getrennt, dann brauchen wir eine zentrale Stelle im Code, um darauf zu reagieren. Wir wollen nicht an 100.000 verschiedenen Stellen im Code Fehlerbehandlung für eine getrennte Verbindung einbauen, sondern an einer Stelle zentral. Es gibt ja verschiedene Strategien, was man in so einem Fall machen kann. Mit der if-then-else-Methode müssen wir aber jeden Fehler händisch nach unten geben, bis wir irgendwann an einer Stelle sind, wo wir reagieren können.

    Was Exceptions bieten

    Und einen Punkt wollen wir nicht vergessen, Programmierer sind faul. Fehler die "nie" auftreten, muss man nicht behandeln. Wie viel C-Code haben sie schon gesehen, wo der Erfolg von printf() überprüft wurde? Das Problem mit Fehlern, die nie auftreten können, ist jedoch, dass die Hölle los ist, wenn sie doch auftreten.

    Exceptions bieten hier nun 3 essentielle Punkte an:

    1. Fehler können dort behandelt werden, wo es Sinn macht und müssen nicht (können aber) lokal behandelt werden.
    2. Fehler kann man nur noch explizit ignorieren.
    3. Fehlerbehandlung wird vereinheitlicht.

    Wie Exceptions generell funktionieren und was try/catch-Blöcke sind, haben Sie ja schon in Exception-Handling gelesen. Doch Sie, als aufmerksamer Leser, haben natürlich sofort erkannt, dass ich Ihnen ein Problem unterschlagen habe: wie verhält es sich mit dem Cleanup-Code?

    Der finally-Block

    Viele moderne Sprachen wie z.B. Java oder C# bieten hier das berüchtigte finally an, um dem Problem Herr zu werden:

    public void OutputFile(string name) {
      FileStream fs = null;
      StreamReader sr = null;
    
      try {
        fs = new FileStream(name, FileMode.Open);
        sr = new StreamReader(fs);
    
        string line;
        while( (line=sr.ReadLine())!=null ) {
           Console.WriteLine(line);
        }
      }
      finally {
        if(sr!=null) sr.Close();
        if(fs!=null) fs.Close();
      }
    }
    

    Der Vorteil des finally über der if-then-else-Methode ist offensichtlich: wir können den Cleanup-Code an einer zentralen Stelle lagern und unabhängig, wie die Funktion beendet wird, der Code wird ausgeführt. So ganz ideal ist es aber nicht - denn die Variablen müssen im finally-Block ja bekannt sein und sie müssen einen Status "uninitialisiert" annehmen können - denn wir wissen ja nicht, ob das Objekt schon initialisiert wurde. Der finally-Weg ist für Sprachen wie C#/Java durchaus gangbar, denn jedes Objekt ist lediglich eine Referenz und diese Referenz kann auf null zeigen. In C++ haben wir dieses Feature nicht und wenn wir dem schlechten Beispiel der Standard-Library und den Stream-Klassen nicht folgen wollen, dann sind unsere Objekte immer in einem initialisierten Zustand. finally fällt für C++ also weg. Aber C++ bietet uns ja RAII.

    RAII

    Ein grundlegendes Problem in C++ ist, dass wir unsere Ressourcen selbst freigeben müssen. Meistens, wie z.B. im Falle fstream , haben wir Ressourcen bereits in praktische, kleine Klassen gepackt und der Destruktor räumt für uns automatisch auf. Für alle Situationen, wo wir diese schöne Kapselung nicht haben, gibt es ScopeGuard. Mit C++0x wird das ganze dank Closures noch einfacher, aber das ist Thema eines anderen Artikels.

    Genaugenommen ist RAII auch nicht Thema dieses Artikels, aber das RAII-Konzept zeigt sehr schön, wie Exceptions in C++ verwendet werden. Exceptionsicherheit basiert auf dem RAII bzw. RRID (Ressource Release Is Destruction) Idiom. Bevor wir deshalb weiter in Exceptions eintauchen, wiederholen wir kurz was RAII/RRID eigentlich ist.

    Exceptions in C++ garantieren uns etwas wichtiges: das Zerstören aller Objekte, die Out Of Scope gehen. Das bedeutet, dass alle notwendigen Destruktoren aufgerufen werden. Die Idee ist nun, den Destruktor die Aufräumarbeit machen zu lassen, da es ihm egal ist, warum er aufgerufen wurde. Damit ist man nicht mehr von einem Single-Entry-Single-Exit, wie man es aus C kennt, abhängig und kann durchaus auch früher die Funktion beenden, da alle Ressourcen, die bis dahin initialisiert wurden, durch den Destruktor wieder freigegeben werden.

    Natürlich gibt es auch umfangreichere Einführungen in RAII.

    Während Java RAII nicht unterstützt, bietet C# eine explizite RAII-Schreibweise mit Hilfe des using-Schlüsselwortes an:

    using( File f=new File(filename) ) {
      //...
    } //hier wird automatisch f.Dispose() aufgerufen
    

    ScopeGuard

    ScopeGuard ermöglicht es uns, das RRID-Idiom einzusetzen, um Cleanup-Code automatisch ausführen zu lassen. Die einfachste Einsatzweise für ScopeGuard ist ON_BLOCK_EXIT :

    void writeFile(char const* name, char const* text) {
    	FILE* f=fopen(name, "w");
    	ON_BLOCK_EXIT(fclose, f);
    	//mit Ende des Blocks wird fclose(f) aufgerufen
    	fprintf(f, "Text: %s\n", text);
    }
    void lockedWrite(char const* text) {
    	Mutex m; m.lock();
    	ON_BLOCK_EXIT(&Mutex::unlock, m);
    	//mit Ende des Blocks wird m.unlock() aufgerufen
    	cout<<text<<endl;
    }
    

    ON_BLOCK_EXIT ist sehr praktisch, wenn man Cleanup-Code hat, der in keinem Destruktor steht. Natürlich wäre es besser, direkt RAII-Objekte zu verwenden, aber manchmal ist das unpraktisch und eine Zeile ScopeGuard ist effizienter. Gerade bei dem Mutex-Beispiel wäre aber eine vernünftige Mutex-Klasse eine bessere Wahl als überall ScopeGuards einzubauen.

    Ein weiteres wichtiges Feature von ScopeGuard ist das Ermöglichen von Transaktionen:

    void clone(LinkedList* src, LinkedList** trg) {
    	LinkedList* temp = create();
    	ScopeGuard guard = MakeGuard(destroy, temp);
    
    	do_clone(src, &temp);
    
    	destroy(*trg);
    	*trg=temp;
    	guard.Dismiss();	
    }
    

    Dismiss() verhindert, dass der Cleanup-Code ausgeführt wird. Indem wir Dismiss als letzte Aktion in der Funktion ausführen, garantieren wir, dass wenn die Funktion frühzeitig beendet wurde, sauber aufgeräumt wird - wenn die Funktion aber bis zum Ende durchläuft nichts zerstört wird.

    Exception-Garantien

    In C++ gibt es 3 Arten von Exception-Garantien.

    1. Basic - grundlegende Garantie
    2. Strong - starke Garantie
    3. nothrow - "keine Exception fliegt"-Garantie

    Nothrow

    Der einfachste Fall ist definitiv die nothrow-Garantie. Egal was passiert, die Funktion wirft keine Exception und leitet auch keine durch. Die Funktion garantiert also, immer erfolgreich zu sein. Ein einfaches Beispiel ist hier z.B. die Funktion std::swap() . Idealerweise sollten natürlich alle Funktionen diese Garantie erfüllen, praktisch ist das aber nicht möglich - oder wie wollen Sie garantieren, dass jede Netzwerkoperation erfolgreich endet? Funktionen mit der nothrow-Garantie sind aber ein essentieller Baustein in exceptionsicherem Code - wie und warum, das erfahren Sie gleich.

    Basic-Garantie

    Die Basic-Garantie verlangt, dass die Funktion die Daten in konsistentem Zustand hinterlässt (dass dies nicht immer ganz einfach ist, werden Sie etwas später erfahren). Was diese Definition uns verschweigt, ist, dass ein konsistenter Zustand nicht unbedingt immer der gewünschte Zustand ist. Nehmen wir als Beispiel die Funktion std::copy() . Es ist klar, dass copy nicht garantieren kann, dass jede Kopieroperation erfolgreich sein wird. Was passiert nun, wenn die x-te Kopieroperation fehlschlägt? Die Sequenz, auf den der out-Iterator zeigt, enthält x-1 neue Objekte und N-(x-1) alte Objekte. Wichtig dabei ist aber, dass alle Objekte in einem konsistenten Zustand sind.

    Strong-Garantie

    Die Strong-Garantie ist dagegen die Garantie, dass ein Rollback stattfindet, wenn etwas schief geht. Als Beispiel sehen wir uns std::uninitialized_copy an. Diese Funktion kopiert Daten in uninitialisierten Speicher; sollte ein Kopiervorgang fehlschlagen, werden die alten Daten per Destruktor zerstört. Hier kommt die nothrow-Garantie dazu - denn jeder Destruktor muss die nothrow-Garantie erfüllen (dass dies oft mit Problemen für die Implementierung des Destruktors verbunden ist, sehen wir später). Eben durch diese nothrow-Garantie kann uninitialized_copy() garantieren, dass der Rollback erfolgreich ist und somit kann die Funktion die starke Garantie gewährleisten.

    Einen etwas tieferen Einblick in Exception-Garantien bietet Ihnen unter Anderem Bjarne Stroustrup.

    Welche Garantie verwenden?

    Wie so oft in C++ gibt es auch hier eine einfache Regel, um zu bestimmen, welche Exception-Garantie eine Funktion abgeben soll: die stärkste die möglich ist. Sollte es möglich sein die nothrow-Garantie einhalten zu können - so tun Sie es. nothrow ist das wertvollste, was wir aus Sicht der Exceptionsicherheit kennen. Aber bedenken Sie auch, die Funktion soll erst ihre Aufgabe erledigen - dann erst die stärkste Garantie abgeben. std::copy() zum Beispiel wäre mit der starken Garantie implementierbar:

    template<typename InputIterator, typename OutputIterator>
    OutputIterator copy(InputIterator first, InputIterator last, OutputIterator target) {
    	typedef vector<
    			typename std::iterator_traits<
    				OutputIterator
    			>::value_type
    		>
    		target_container
    	;
    
    	target_container temp(first, last);
    
    	target_container::iterator i=temp.begin();
    	target_container::iterator e=temp.end();
    	while(i!=e) {
    		swap(*i, *target);
    		++i;
    		++target;
    	}
    	return target;
    }
    

    Wie Sie aber sehen, ist das nicht die effizienteste Art, ein copy zu implementieren. Denn wir erstellen N Kopien und swappen diese dann erst an die Zielposition und müssen nachher die Kopien auch wieder zerstören. Deshalb gilt die Regel "eine Funktion soll die stärkste Garantie unterstützen, die sie kann, ohne relevante Mehrkosten zu produzieren". Als kleiner Tip sei deshalb gesagt: die meisten Operationen lassen sich per Copy&Swap von der Basic- auf die Strong-Garantie upgraden (übrigens nur weil swap und Destruktoren uns ein nothrow garantieren). Wir können so auch aus fast jeder Funktion, die die Basic-Garantie abgibt eine Funktion, die die Strong-Garantie einhält machen:

    template<typename Container>
    void strong_sort(Container& c) {
      Container temp(c);
      temp.sort();
      swap(c, temp);
    }
    

    Was ich Ihnen hier aber unterschlagen habe, ist, dass auch dieses copy() nicht die Strong-Garantie liefert. Das Problem liegt in den Iteratoren, die z.B. istream-Iteratoren sein könnten und wenn wir nun die Werte aus einem Stream lesen (zum Beispiel von cin) dann können wir diese Werte nicht mehr ungelesen machen. Die Strong-Garantie verlangt, dass man nur Funktionen verwendet, die reversibel sind.

    Java/C# und Co.

    Diese 3 Garantien kann man auch auf Java/C# übertragen. Allerdings sind Operationen wie swap() und Destruktoraufrufe (bzw. eigentlich ja finalizer-Aufrufe) in diesen Sprachen sowieso automatisch nothrow. Da nur mit Referenzen hantiert wird, ist swap einfach per Zuweisung implementierbar und da nur Referenzen kopiert werden, kann auch keine Exception fliegen. Man muss in Java/C# deshalb nicht so genau schauen wie in C++, aber die 3 Exception-Garantien gelten in Java/C# genauso wie in C++.

    Die Implementierung der 3 Garantien

    Sie wissen nun, warum Exceptions Sinn machen und welche Garantien eine Funktion in Bezug auf Exceptionsicherheit vergeben kann. Sehen wir uns nun aber einmal an, wie wir diese Garantien auch durchsetzen können:

    Betrachten wir folgende Beispiele:

    template<typename InputIterator, typename OutputIterator>
    OutputIterator copy(InputIterator first, InputIterator last, OutputIterator result) {
    	while(first!=last) {
    		*result=*first;
    		//wenn diese Zuweisung fehlschlägt, wird *result nicht geändert
    		++first;
    		++result;
    	}
    	return result;
    }
    

    copy unterstützt die Basic-Garantie, solange der Zuweisungsoperator des verwendeten Typs diese Garantie ebenfalls unterstützt. Wenn wir dem Zuweisungsoperator nicht trauen können, dann können wir auch keinen exceptionsicheren Code schreiben. Jede Funktion muss deshalb die Basic-Garantie erfüllen.

    template<typename ForwardIterator, typename T>
    void uninitialized_fill(ForwardIterator first, ForwardIterator last, T const& obj) {
    	ForwardIterator marker = first;
    	try {
    		while(first!=last) {
    			new (&*first) T(obj);
    			//wenn der Konstruktor fehlschlägt, wird *first nicht geändert
    			++first;
    		}
    	}
    	catch(...) {
    		//Rollback
    		while(marker!=first) {
    			marker->~T();
    			//Elemente wieder zerstören
    			++marker;
    		}
    		throw;
    		//rethrow da wird den Fehler nicht behandeln können
    	}
    }
    

    unitialized_fill unterstützt die Strong-Garantie und wir brauchen deshalb Rollback-Code um im Falle eines Fehlers den Ursprungszustand wiederherstellen zu können. Dank ScopeGuard kann man das aber auch eleganter lösen:

    template<typename T, typename Function>
    struct DelayedRangeExecute {
    private:
    	T startValue;
    	T const* endValue;
    	Function func;
    
    public:
    	DelayedRangeExecute(T const& value, Function func)
    	: startValue(value), endValue(&value), func(func) {
    	}
    
    	void operator()() {
    		while(startValue!=*endValue) {
    			func(*startValue);
    			++startValue;
    		}
    	}
    };
    
    template<typename T, typename Function>
    DelayedRangeExecute<T, Function> makeDelayedRangeExecute(T const& value, Function func) {
    	return DelayedRangeExecute<T, Function>(value, func);
    }
    
    template<typename T>
    void destroy(T& obj) {
    	obj.~T();
    }
    
    template<typename ForwardIterator, typename T>
    void uninitialized_fill(ForwardIterator first, ForwardIterator last, T const& obj) {
    	ScopeGuard guard = MakeGuard(makeDelayedRangeExecute(first, destroy));
    	while(first!=last) {
    		new (&*first) T(obj);
    		++first;
    	}
    	guard.Dismiss();
    }
    

    Auf den ersten Blick wirkt der Aufwand für DelayedRangeExecute sehr hoch - aber wenn man bedenkt, dass wir DelayedRangeExecute jetzt überall verwenden können, wo wir ein Rollback über eine unbestimmte Iterator-Range machen müssen, wirkt es nicht mehr so viel.

    Ein Beispiel für eine Funktion, die die nothrow-Garantie unterstützt, ersparen wir uns. Denn nothrow-Funktionen dürfen nur andere nothrow-Funktionen verwenden oder sie müssten Fehler explizit ignorieren.

    Arten von Fehlern

    In dem Artikel Exception-Handling haben Sie bereits unterschiedliche Fehlerarten kennengelernt. Eine weitere Einteilung ist "interne Fehler" und "externe Fehler". Externe Fehler sind Fehler, die einfach passieren, auch wenn Ihr Programm ganz korrekt geschrieben ist. Es kann eine Netzwerkverbindung abbrechen, der Speicherplatz ausgehen, der User nicht die notwendigen Rechte besitzen, etc. Diese Fehler passieren. Man kann sich nicht gegen sie schützen, man kann nur richtig reagieren. Aber es gibt auch noch interne Fehler wie z.B. strlen(NULL) . Solche Fehler sollten nicht passieren und sollten in der Release-Version alle behoben sein. Interne Fehler sind immer fatale Fehler, da man auf sie nicht richtig reagieren kann. Egal was strlen() macht, wenn man ihm einen NULL-Zeiger gibt, es ist ein falsches Resultat. Man kann natürlich argumentieren dass ein NULL-Zeiger die Länge 0 hat - aber wie sieht das ganze mit fopen(NULL, "w") aus? fopen kann nun ebenfalls NULL liefern und sagen, die Datei konnte nicht geöffnet werden, aber warum? Was sagen wir dem User, warum wir seine Lieblingsdatei nicht öffnen konnten? Irgendetwas ist schief gelaufen, nie hätte der NULL-Zeiger an fopen übergeben werden dürfen.

    Assert

    Um solche internen Fehler von externen getrennt bearbeiten zu können, hat C bzw. C++ uns die Möglichkeiten eines assert() gegeben. assert ist ein Makro, dass in der Release-Version nichts macht, in der Debug-Version aber (je nach Compiler) direkt den Debugger startet und uns zeigt: hier haben wir irgendwo einen Logikfehler versteckt. Das ideale Beispiel für assert ist der operator[] einer Array-Klasse. Zugriffe außerhalb des Arraybereichs sind Logikfehler, die nicht auftreten dürfen - sollte es uns dennoch einmal passieren, über den Index hinaus zuzugreifen, springt gleich der Debugger an (sollte das bei Ihnen nicht der Fall sein, führen Sie in einem assert folgenden Code aus: ((*(char*)0)=0) - der Schreibzugriff auf einen NULL-Zeiger ruft in der Regel den Debugger auf) und zeigt uns, wo wir einen Fehler gemacht haben. Ganz wichtig aber ist es, externe Fehler nie nie nie mit assert zu prüfen, da assert in der Release-Version in der Regel abgeschaltet ist.

    C# und Java bieten auch asserts an, aber diese Technik ist in diesen beiden Sprachen nicht weit verbreitet. Meistens werden interne Fehler mit Exceptions geahndet, was den (je nachdem von welcher Warte man es sieht) Vorteil oder Nachteil hat, dass interne Fehler ignoriert werden können. Der Vorteil von asserts ist aber, dass man sie überall hinpacken kann, da sie in der Release-Version keine Performance kosten. Für viele kleine Tests mag das keinen Unterschied machen, aber asserts sind praktisch um Invarianten bzw. Pre/Post-Conditions zu testen (was durchaus auch einmal länger dauern kann (Beispielsweise muss ein sortierter Container nach jeder Einfügeoperation immer noch sortiert sein, dies durchzutesten kann aber dauern)).

    Design By Contract

    Ursprünglich stammt die Design By Contract-Idee von Bertrand Meyers Programmiersprache Eiffel. Eiffel ist eine der wenigen Sprachen, die eine starke Integration von Design By Contract in der Sprache selbst anbieten - aber die Idee hinter diesem Konzept kann in alle Sprachen übertragen werden.

    Jeder Funktionsaufruf ist genaugenommen nichts anderes als ein Vertragsabschluss zwischen der aufrufenden (caller) und der aufgerufenen (callee) Funktion. Der Caller erfüllt eine Handvoll Vorbedingungen (Preconditions) wie z.B. der string ist 0-terminiert und den Pfad zu einer Datei zu zeigen. Der Callee dagegen garantiert, dass eine bestimmte Situation eingetreten bzw. nicht eingetreten ist, wenn die Funktion abgeschlossen ist - so z.B. garantiert fopen, die Datei geöffnet zu haben und den Zugriff auf die Datei zu ermöglichen.

    Die Theorie besagt, wenn sich alle Caller und Callees an den Vertrag halten, dann ist das Programm fehlerfrei. Ganz so simpel ist es zwar nicht, aber Design by Contract ermöglicht uns dennoch 2 Fehlerquellen (bis zu einem gewissen Grad) auszuschließen:

    1. Logikfehler beim Funktionsaufruf
    2. Fehler in der Logik der Funktion selbst
      Sehen wir uns deshalb einmal etwas Pseudocode an (Vorlage dazu ist std::copy):
    template <class InputIterator, class OutputIterator>
    OutputIterator copy(InputIterator first, InputIterator last, OutputIterator result) {
    	precondition {
    		first<=last && //valid range
    		(result<first || last>result) //result not within [first,last) range
    		//theoretisch fehlt hier die Überprüfung ob result genug Speicher hat
    		//um die ganzen Elemente auch aufzunehmen
    	}
    	while(first!=last) {
    		*result = *first;
    		++result;
    		++first;
    	}
    	return result;
    	postcondition {
    		equal(first, last, result) &&//Zugriff auf die originalen Werte
    		return == result + (last-first)
    	}
    }
    

    Wir können durch so einen Code garantieren, dass sich Caller und Callee an den Vertrag gehalten haben - dass dabei nicht alle Fehler abgefangen werden, ist klar ( [first,last) könnte auf zu viele oder zu wenig Items zeigen, es könnte nur eine flache statt einer tiefen Kopie der Elemente erzeugt werden oder es könnten Ressourcen-Leaks entstehen), aber viele Fehler können so gefunden werden.

    Invarianten

    Ein wichtiger Bestandteil von Design by Contract sind sogenannte Invarianten. Invariant wird die Garantie genannt, dass eine Klasse in einem konsisten Zustand ist. std::map z.B. ist ein sortierter, balancierter Baum. std::map muss zu jedem Zeitpunkt sortiert und balanciert sein - das ist also die Invariante. Invarianten werden immer überprüft, nachdem eine öffentliche (public oder protected) Memberfunktion beendet wird, d.h. nach jeder Operation wird noch einmal überprüft, ob die map wirklich sortiert und balanciert ist. Die Theorie besagt: solange das Objekt in einem konsistenen Zustand ist, kann es zu keinem Fehler kommen. Auch hier ist die Theorie nicht 100% korrekt, aber Invarianten helfen uns, Logikfehler in Klassen zu finden.

    Praxis

    Ein wichtiger Punkt von Design By Contract ist aber, dass wir diese Tests nur dann durchführen müssen, wenn wir unser Programm testen. In der Release-Version müssen diese Tests nicht mehr enthalten sein - dies ermöglicht es, uns Tests zu verwenden, die sehr viel Zeit in Anspruch nehmen (z.B. eine map auf Sortiertheit und Balanciertheit zu testen). Da es sich aber nur um Logik-Fehler handelt, die wir abfangen wollen, hat z.B. ein Vergleich, ob eine Datei nun geöffnet werden konnte oder nicht, in den Postconditions wenig verloren.

    Warum werden Invarianten nur in public und protected Funktionen aufgerufen? Das liegt daran, dass private Funktionen durchaus den Status des Objektes zerstören dürfen - sofern eine andere Funktion sie wieder herstellt. Als Beispiel nehmen wir wieder unsere Map:

    class Map {
    //...
    public:
    	void insert(map const& other) {
    		do_insert(other);
    		do_balance();
    	}
    }
    

    Erst fügen wir alle Elemente sortiert ein und danach balancieren wir den Baum. do_insert zerstört den konsistenten Zustand des Objektes - aber da wir immer nach einem do_insert ein do_balance aufrufen, ist das kein Problem. Bedingung ist natürlich, dass do_insert und do_balance nicht von außen zugänglich sind - denn sonst können wir den konsistenten Zustand nicht mehr garantieren, da wir niemanden davon abhalten können, do_insert ohne do_balance aufzurufen.

    Die wenigsten Sprachen unterstützen Design by Contract direkt - aber unabhängig, ob man im Code direkt per Design By Contract die Validierung einbauen kann oder nicht, es ist meistens sinnvoll, die Pre- und Postconditions zu notieren. Mit assert kann man dann zumindest die meisten Preconditions forcieren - um gute Postcondition-Checks schreiben zu können, braucht man aber in der Regel Unterstützung der Sprache (da man ja auf die originalen Werte zugreifen können muss, nachdem die Funktion beendet wurde).

    Design by Contract-Libraries gibt für viele Sprachen, darunter natürlich auch für C++, C# und Java. Für die Implementierung von Invarianten bietet C# eine interessante Möglichkeit anhand von Conditional.

    Betrand Meyer bietet in einem Interview für Artima einen tieferen Einblick in Design By Contract.

    Exception-Spezifikationen

    Neben den Exception-Garantien gibt es auch Exception-Spezifikationen. Eine Exception-Spezifikation sagt aus, welche Exceptiontypen eine Funktion werfen darf. Im Prinzip nicht viel anders als eine Liste mit erlaubten Error-Codes - mit einem kleinen Unterschied: Die Funktion definiert Klassen, die sie werfen darf, aber sie darf natürlich auch Subklassen davon werfen. Das ist gegenüber den Returncodes ein erheblicher Vorteil.

    Ein weiterer Vorteil von Exception-Spezifikationen ist, dass sie Tools ermöglichen, die statisch untersuchen können, ob alle möglichen geworfenen Exceptions auch gefangen werden.

    Checked/Unchecked Exceptions

    Java geht sogar einen Schritt weiter und definiert zwei Arten von Exceptions: Checked und Unchecked Exceptions.

    Checked Exceptions sind externe Fehler wie z.B.: die Datei konnte nicht geöffnet werden, weil wir die Rechte nicht haben, oder: die Netzwerkverbindung wurde unterbrochen. Unchecked Exceptions sind dagegen die Fälle bei denen wir in C++ ein assert (oder manche auch lieber eine std::logic_error-Exception) nehmen würden, nämlich Vertragsverletzungen.

    Java verlangt, dass man alle Checked Exceptions abfängt oder explizit weiterwirft. Das bedeutet nun in der Theorie, dass in einem Java-Programm kein unerwarteter externer Fehler auftreten kann, praktisch sieht es aber anders aus. Die Idee der Checked Exceptions ist nicht schlecht - da man damit versucht, eben die beiden Arten von Fehlern zu trennen, die wir kennengelernt haben. Aber es gibt an der Umsetzung bzw. am Resultat der Umsetzung durchaus einige Kritikpunkte:

    • Versioning der Funktionen wird schwerer. In den meisten Fällen sind die einzelnen Exceptiontypen nicht so relevant, da man meistens ein catch-all macht; aber Checked Exceptions verbieten es Funktionen, nachträglich einen neuen Fehler produzieren zu dürfen.
    • Es ist nicht immer leicht für die Programmierer zu entscheiden, was jetzt eine Checked Exception sein soll und was nicht. Nehmen wir als Beispiel eine Datenbankanwendung. Der Abbruch der Verbindung zum Server während eines Queries ist definitiv eine Checked Exception. Doch was, wenn wir die Datenbank lokal direkt in die Anwendung integrieren und keine Verbindung zu einem externen Server haben, sondern nur ein WriteLock auf eine lokale Datei erstellen? Dann kann die Verbindung nicht mehr abbrechen, wir müssen aber dennoch auf diese Situation, die nie eintreten kann, reagieren.
    • Checked Exceptions verleiten die Programmierer dazu catch(Exception e){} zu schreiben.

    Das bedeutet nicht, dass Checked Exceptions schlecht sind - sie lösen einige Probleme und bringen dafür ein paar andere Probleme ins Spiel. Sehr schön auch in Unchecked Exception - The Controversy oder Does Java need Checked Exceptions dargelegt. Nichtsdestotrotz sind sie ein essentielles Designmittel in Java um den Programmierer auf Fehler hinzuweisen. C++ und C# sowie die meisten Sprachen trennen Exceptiontypen aber nicht.

    Die Implementierung der Exception-Spezifikationen in C++, C# und Java sind recht unterschiedlich.

    Implementierungen

    In Java sind Exception-Spezifikationen immer notwendig. Jede Methode muss genau definieren, welche Checked Exceptions sie werfen darf. Die Spezifikation von Unchecked Exceptions ist dagegen optional. Wenn eine Methode eine Checked Exception werfen will, die sie laut der Spezifikation nicht werfen darf, gibt es einen Compilerfehler.

    In C++ sind alle Exception-Spezifikationen optional und da der Check, welche Exception geworfen wird und welche geworfen werden dürfen, erst zur Laufzeit stattfindet, ist er ziemlich unnütz. Wenn eine unerlaubte Exception geworfen wird, wird die Funktion std::unexpected() aufgerufen, welche im Normalfall eine Exception vom Typ std::bad_exception wirft. Da aber, wie gesagt, diese Tests alle zur Laufzeit stattfinden, macht es keinen Sinn, in C++ Exception-Spezifikationen zu verwenden. Es kann vor allem auch gefährlich werden, da eben keine statischen Tests stattfinden, dass falsche Exception-Spezifikationen Fehler einführen. Und Fehler, die erst zur Laufzeit gefunden werden, sind die schwersten zu finden. Einige Compiler, wie zum Beispiel der Microsoft C++-Compiler, ignorieren Exception-Spezifikationen gleich vollständig.

    C# geht einen komplett anderen Weg und trennt nicht in Checked/Unchecked Exceptions, sondern verbietet jede Art der Exception-Spezifikation.

    Exception-Translation

    Oft kommt es vor, dass ein Fehler, der aufgetreten ist, zu low-levelig ist, als dass man ihn weiterwerfen will. Als Beispiel betrachten wir eine Datenbankanwendung, die alle Tabellen in Dateien ablegt. Bei einem Verbindungsaufbau wird ein WriteLock auf die entsprechende Datenbank-Datei gemacht und darauf operiert. Wenn nun diese Datei nicht vorhanden ist, dann fliegt eine FileNotFoundException . Der Anwender unseres Codes wird sich aber wundern, warum ein FileNotFound fliegt, da er ja eigentlich keine Datei öffnen wollte, sondern eine Verbindung zu einer Datenbank. Wir offenbaren unsere Implementierungsdetails. Das ist nie gut und wenn wir von Dateien weggehen und stattdessen einen echten Server irgendwo in unser Netzwerk stellen, fliegt plötzlich nie wieder eine FileNotFoundException . Um dieses Problem zu lösen, verwendet man Exception-Translation. Das ist ein hochtrabender Namen für das Umwandeln einer Low-Level-Exception wie FileNotFound in eine High-Level-Exception wie DatabaseConnectionException .

    Das ganze sieht wenig Spektakulär aus:

    try {
    	ConnectToDatabase();
    } catch(FileNotFoundException e) {
    	throw new DatabaseConnectionException();
    }
    

    Exception-Chaining

    Das Problem bei Exception-Propagation ist aber, dass wir die Low-Level-Daten verlieren, warum die Operation fehlgeschlagen ist. Wir können jedesmal natürlich alle Informationen aus der Low-Level-Exception rauskitzeln und in die High-Level-Exception übertragen. Aber es gibt einen einfacheren Weg: Exception-Chaining. Exception-Chaining erlaubt uns genau diese Umwandlung einer FileNotFoundException in eine DatabaseConnectionException -Exception ohne Informationsverlust:

    try {
    	ConnectToDatabase();
    } catch(FileNotFoundException cause) {
    	throw new DatabaseConnectionException(cause);
    }
    

    Der Vorteil von Exception-Chaining ist der, dass wir uns bis zu der ursprünglichen Exception durchhangeln können und wir selbst durch das Umwandeln von Low-Level-Exceptions in High-Level-Exceptions keine Information verlieren. Denn eine Exception ist nun nichts anderes als ein Knoten in einer einfach verketteten Liste. Wir können problemlos alle Umwandlungsschritte nachvollziehen.

    Die C++-Exceptionklassen haben dieses Feature nicht eingebaut - auch hat man in C++ das Problem, dass man dafür Exceptions kopieren müsste und eine Kopieroperation eine Exception werfen kann.

    rethrow

    Sie haben gesehen, dass man aus einem catch-Block problemlos eine Exception werfen kann. Das führt uns zu einem interessanten Punkt: kann man auf einen Fehler reagieren und ihn dann weiterreichen? Es kommt öfters vor, dass man Fehler nicht komplett behandeln kann - meistens kann man nichts tun und nur den Cleanup-Code ausführen. Dafür haben wir ja finally bzw. RAII kennengelernt. Manchmal müssen wir aber bei einem Fehler etwas anders reagieren als bei keinem Fehler - der finally-Block unterscheidet ja nicht zwischen Fehlerzustand und normaler Beendung der Funktion. Nehmen wir an, wir wollen den Fehler loggen:

    try {
    	something();
    } catch(SpecificException& e) {
    	log(e);
    	throw e; //???
    }
    

    Hier gibt es 2 Fälle zu unterscheiden: throw und rethrow. Ein throw wirft eine neue Exception - d.h. dass ein eventueller Stacktrace verloren geht, oder die Zeilennummer/Datei, in der die Exception geworfen wurde, wird auf diese aktuelle Zeile gesetzt - wo der Fehler aber nicht auftrat. Ein rethrow erlaubt uns die Exception ohne Änderung weiterzuwerfen. In C++ und C# macht man ein rethrow über ein throw ohne Exception-Paramater:

    try {
    	something();
    } catch(SpecificException& e) {
    	log(e);
    	throw;
    }
    

    In Java dagegen gibt es keinen Unterschied zwischen throw und rethrow - jedes throw von einer bereits geworfenen Exception ist ein rethrow.

    Unhandled Exceptions

    Wenn wir eine Exception werfen (bzw. weiter werfen) und diese nicht fangen, wird sie zu einer sogenannten Uncaught/Unhandled Exception und unser Programm beendet sich. Der C++-Standard definiert nicht, ob in so einem Fall der Stack aufgeräumt (alle Ressourcen korrekt zerstört werden sollen) oder das Programm sofort beendet werden soll. Auf den 1. Blick sieht es komisch aus, dass es gut sein soll, dass keine Ressourcen aufgeräumt werden, aber das ist genau das Richtige in dieser Situation: Wenn etwas total unerwartetes passiert (und eine Unhandled Exception ist nichts anderes als ein Fehlerfall, auf den wir nicht vorbereitet waren - ihn also nicht erwartet haben), dann sollte man die Anwendung so schnell wie möglich beenden. Denn in der Regel kann man sich von solchen Fehlern nicht mehr erholen, da man ja nicht weiß, was genau passiert ist. Wenn wir den Fall einmal ausschließen, dass wir einen Fehler gemacht haben und eine Exception, die wir fangen hätten sollen, durchgelassen haben - dann ist die Wahrscheinlichkeit groß, dass anwendungsinterne Daten korrupt sind. Unter Umständen haben wir einen Buffer Overflow produziert und irgendwo im RAM stehen ungültige Daten, die eben diesen Fehler provoziert haben. In so einer Situation will man die Anwendung so schnell wie möglich beenden und so wenig Code wie möglich ausführen (da man nicht mehr garantieren kann, dass der Code fehlerfrei arbeitet).

    Normalerweise haben wir einen globalen try-catch-Block, wo wir die meisten erwarteten Fehler abfangen, aber unerwartete Exceptions sollte man an das Betriebssystem durchlassen. Das Betriebssystem kann dann einen sehr schönen Crash Report erstellen - etwas, das die Anwendung selbst nicht kann. Denn das Betriebssystem kann garantiert funktionierenden Code ausführen (was die Anwendung ja nicht mehr kann). Essentielle Ressourcen kann man natürlich selbst ruhig noch frei geben - aber jeden Destruktor aufzurufen wäre zuviel des Guten (denn mit jedem Code, den wir ausführen, steigt das Risiko, noch mehr unerwartetes Verhalten zu produzieren).

    Im zweiten Teil dieses Artikels werden wir uns dann mit der Technik hinter und um die Exceptions herum beschäftigen.



  • Echt toller Artikel Shade! Ich freue mich schon auf den zweiten Teil 🙂



  • Feine Sache! Hat mir sehr gefallen. 👍

    Gruß


Anmelden zum Antworten