7.6 Exceptions: Wenn der Kontrakt nicht eingehalten werden kann
Wie in unserem eigenen Leben, so gibt es auch im etwas profaneren Leben von Objekten Situationen, in denen Unerwartetes auftritt, das sie daran hindert, ihre Aufgaben wie geplant durchzuführen. In solchen Situationen kann ein Objekt den Kontrakt, den es eingegangen ist, nicht mehr erfüllen.
Der Mechanismus der Ausnahmebehandlung (engl. Exception Handling) bietet eine ganze Reihe von Möglichkeiten, mit solchen Situationen umzugehen. Außerdem stellen Exceptions einen etablierten und praktikablen Mechanismus dar, um generell mit Fehlersituationen in einem Programm umzugehen.
Was Sie in diesem Abschnitt erwartet
In diesem Abschnitt stellen wir den Mechanismus der Ausnahmebehandlung vor. Sie werden sehen, dass Exceptions in den meisten Situationen besser zur Fehlerbehandlung geeignet sind als Fehlercodes. Anschließend gehen wir darauf ein, wie Ausnahmen und die damit verbundenen Techniken zur Spezifikation und Überprüfung von Kontrakten eingesetzt werden können. Außerdem werden wir Kriterien dafür vorstellen, in welchen Situationen eine Ausnahme so schwerwiegend ist, dass sie zum Beenden des Programms führen muss.
7.6.1 Exceptions in der Übersicht
Ein aus dem Leben gegriffenes Beispiel
Wahrscheinlich haben Sie selbst auch schon einmal die Erfahrung gemacht, dass es hin und wieder schwer sein kann, Zusagen einzuhalten, die Sie anderen gegeben haben. Es kann sein, dass etwas Unerwartetes dazwischenkommt, zum Beispiel weil Sie sich eine Erkältung zugezogen haben. Oder Sie haben sich selbst auf jemand anderen verlassen, der seine Zusagen nicht einhält.
So kann es auch einem Objekt als Bestandteil eines Programms passieren, dass es aufgrund der Umstände den abgeschlossenen Kontrakt nicht einhalten kann. Die Gründe dafür sind eher selten in plötzlich auftretenden Erkältungen zu suchen. Aber wenn ein Objekt zum Beispiel zur Erfüllung seiner Aufgabe die Zuteilung von Arbeitsspeicher benötigt und kein weiterer Speicher mehr verfügbar ist, kann es seine Aufgabe beim besten Willen nicht erfüllen. [Im Bereich der Ausnahmebehandlung hat sich die Verwendung der englischen Begriffe auch im Deutschen etabliert. Wir benutzen deshalb im Folgenden die englischen Begriffe und geben bei der ersten Verwendung eine deutsche Übersetzung an. ]
Exception Handling (Ausnahmebehandlung) |
Der Begriff des Exception Handling bezeichnet ein Verfahren, bei dem bei Eintreten einer bestimmten Bedingung (einer Ausnahmesituation) der normale Kontrollfluss eines Programms verlassen wird. Die Kontrolle geht dann an den Mechanismus der Ausnahmebehandlung über. Es hängt nun davon ab, welche konkreten Mittel zur Behandlung einer Ausnahme das Programm aufweist, an welcher Stelle der Kontrollfluss wieder an das eigentliche Programm zurückgegeben wird. |
Die Bedingungen, unter denen der Kontrollfluss eines Programms unterbrochen wird, werden dabei selbst als Exceptions (Ausnahmen) bezeichnet.
Exceptions (Ausnahmen) |
Mit Exception (Ausnahme) wird eine Bedingung bezeichnet, die dazu führt, dass der normale Kontrollfluss eines Programms verlassen wird und die Kontrolle an das Exception Handling übergeht. Wir sprechen davon, dass eine Exception aufgetreten ist. In objektorientierten Systemen wird die Information über die aufgetretene Ausnahmesituation meistens durch ein Objekt repräsentiert, das an den Mechanismus der Ausnahmebehandlung übergeben wird. Dieses Objekt wird ebenfalls als Exception bezeichnet. Die Unterbrechung des Kontrollflusses bezeichnen wir auch als das Werfen einer Exception. Sogenannte Exception Handler definieren die Stelle, an der nach dem Werfen einer Exception die Kontrolle wieder an den regulären Programmablauf übergeht. Wir sprechen davon, dass durch die Exception Handler die Exception gefangen wird. |
Betrachten Sie zunächst ein einfaches Beispiel in der Sprache Java. In Abbildung 7.57 sind drei Klassen dargestellt, die in unterschiedlicher Weise mit einer Exception vom Typ AktionNichtMöglichException umgehen.
Abbildung 7.57 Klassen und Operationen, die Exceptions verwenden
Die Klasse WirftException tut das, was ihr Name verspricht: Sie wirft unter bestimmten Bedingungen eine Exception.
class WirftException { void kritischeOperationA() { bearbeiteTeil1(); aktionMitAusnahme(); bearbeiteTeil2(); } private void aktionMitAusnahme() { if (!AktionZurzeitMöglich()) { throw new AktionNichtMöglichException("zu spät"); } } ...
class AktionNichtMöglichException extends RuntimeException { AktionNichtMöglichException(String grund) { super("Aktion nicht möglich: " + grund); } }
Listing 7.45 Werfen einer Exception
In der mit markierten Zeile wird das Java-Statement throw verwendet, um eine Exception der Klasse AktionNichtMöglichException zu werfen. Diese Klasse wird in Zeile eingeführt. [In Java muss eine Operation für Exceptions explizit deklarieren, dass diese geworfen werden können. Diesen Mechanismus, der Checked Exceptions genannt wird, beschreiben wir in Abschnitt 7.6.4, »Exceptions als Teil eines Kontraktes«. Eine Ausnahme bilden die Klasse RuntimeException und ihre Unterklassen, die wir deshalb in diesem Beispiel verwenden. ]
Aufgerufen wird die kritische Operation von der Klasse WeissNichtsVonException. Allerdings führt diese keinerlei Fehlerbehandlung durch. Sie nutzt einfach eine Operation; da sie aber eine mögliche Fehlersituation gar nicht behandeln kann, muss sie das auch nicht tun.
class WeissNichtsVonException { void irgendeineOperation() { WirftException genutztesObjekt = new WirftException(); genutztesObjekt.kritischeOperationA(); } }
Die Fehlerbehandlung erfolgt schließlich in der Klasse BehandeltException. Diese ruft die kritische Operation zwar gar nicht selbst auf, da Exceptions aber von Aufrufer zu Aufrufer weitergereicht werden, bis ein entsprechender Exception Handler gefunden wird, kann sie die Fehlerbehandlung durchführen.
class BehandeltException { void nutzendeOperationA() { WeissNichtsVonException genutztesObjekt = new WeissNichtsVonException(); try { genutztesObjekt.irgendeineOperation(); } catch (AktionNichtMöglichException exception){ // Dann eben Plan B durchführen planB(); } } }
Listing 7.46 Fangen und Behandeln einer Exception
Im sogenannten try-catch-Block, der in Zeile beginnt, wird festgelegt, wenn eine Exception vom Typ AktionNichtMöglichException im try-Teil des Blocks geworfen wird, wird diese im catch-Teil gefangen (). In diesem Fall wird die Exception nicht nur gefangen, sondern auch behandelt, indem anstelle des gescheiterten Aufrufs einfach Plan B durchgeführt wird (). Der Kontrollfluss wird also beim Werfen der Exception komplett durch das Exception Handling übernommen. Nach der Behandlung der Exception und Ausführung von Plan B geht die Kontrolle wieder an das Programm über, und es wird mit der Bearbeitung von nutzendeOperationA fortgefahren.
Klassen von Exceptions können wie andere Klassen in Hierarchien organisiert werden. Ein catch-Statement, das alle Exceptions einer bestimmten Klasse fängt, fängt dann auch alle Exceptions, die zu einer Unterklasse gehören.
Einsatz von Exceptions: Was ist normal, und was die Ausnahme?
Mit einer Exception kann eine Methode signalisieren, dass sie ihre Aufgabe nicht erfüllen und den vereinbarten Kontrakt nicht einhalten kann. Die Ursachen, warum die Methode ihre Aufgabe nicht erfüllen kann, können verschieden sein. So ist es z. B. möglich, dass eine der Methoden, die unsere Methode benutzt, ihre Teilaufgabe nicht erfüllen kann. Denkbar ist auch, dass die vorhandenen Daten die Erfüllung der Aufgabe grundsätzlich nicht ermöglichen oder dass die benötigten Ressourcen nicht zur Verfügung stehen.
In welchen Fällen sollte eine Methode also eine Exception werfen?
Exceptions sind nicht der Normalfall |
Exceptions sollen verwendet werden, um ein gewöhnlich nicht erwartetes Ergebnis einer Operation zu kommunizieren. Was ein erwartetes Ergebnis ist und wann die Aufgabe einer Methode nicht erfüllt werden kann, hängt von der Definition des Kontrakts für die betreffende Operation ab. |
Verdeutlichen wir das am Beispiel einer Klasse, die ein Wörterbuch repräsentiert. In Abbildung 7.58 ist diese Klasse mit zwei Operationen operator[] und sucheWort dargestellt.
Wörterbuch
Nehmen Sie an, dass Sie eine Operation für das Wörterbuch umsetzen wollen, die zu einem Schlüsselwort einen Wert zurückgeben soll. Was soll die entsprechende Methode machen, wenn es zu dem übergebenen Schlüssel keinen Eintrag im Wörterbuch gibt? Soll sie einen Null-Wert zurückgeben oder eine Exception werfen? Beide Vorgehensweisen haben ihre Berechtigung, und die Entscheidung, welche Sie verwenden sollten, hängt davon ab, wie Sie das Wörterbuch betrachten.
Abbildung 7.58 Ein Wörterbuch mit zwei Zugriffsoperationen
Zum einen können Sie das Wörterbuch als ein assoziatives Array betrachten, aus dem Sie vorher gespeicherte Daten auslesen wollen. In diesem Fall ist es die Aufgabe der Methode, zu dem übergebenen Schlüssel den zugehörigen Wert zurückzugeben. Es wird nicht erwartet, dass ein nicht vorhandener Schlüssel übergeben wird. Deshalb würde die Methode bei einem nicht vorhandenen Schlüssel eine Exception werfen.
Die andere Sichtweise auf das Wörterbuch ist die einer Datenbank, in der bestimmte Einträge vorhanden sein können, aber nicht müssen. Damit lautet also die Aufgabe der Methode: »Schau nach, ob wir einen Eintrag zu diesem Schlüssel haben und, wenn ja, gib mir den Wert zurück.« Dafür ist es passender, die zugehörige Operation sucheWort zu nennen. Ist ein Eintrag nicht vorhanden, ist der Rückgabewert ein Null-Wert.
Beide Vorgehensweisen sind also möglich, und wie in Abbildung 7.58 dargestellt, können auch beide über verschiedene Operationen einer einzigen Klasse umgesetzt werden.
Beim geschilderten Vorgehen würde ein Aufrufer also nie gezwungen, sich mit Exceptions zu beschäftigen, nur um zu prüfen, ob ein Eintrag existiert. Falls ein Aufrufer die Information braucht, ob ein Eintrag mit einem Schlüssel existiert, bietet es sich an, neben operator[] auch eine Operation existiertEintrag() zur Verfügung zu stellen.
Eine ähnliche Situation liegt vor, wenn Sie die Operation dividieren für Klassen von Zahlen betrachten. In Abbildung 7.59 ist die Klasse der natürlichen Zahlen und die der reellen Zahlen dargestellt. Beide setzen die Operation dividieren um.
Abbildung 7.59 Divisionsoperation für verschiedene Klassen von Zahlen
Bei ganzen Zahlen macht es keinen Sinn, bei der Division einer ganzen Zahl durch 0 ein Ergebnis zu liefern. Es ist einfach nicht möglich, eine ganze Zahl durch 0 zu dividieren, deshalb ist es in diesem Fall korrekt, eine Exception zu werfen. Diese signalisiert, dass die Division gescheitert ist.
Bei reellen Zahlen könnten Sie aber durchaus definieren, dass das Ergebnis einer Division durch 0 die positive oder negative Unendlichkeit oder bei 0/0 eine »Nichtzahl« ist. Die positive und negative Unendlichkeit und die »Nichtzahl« sind hier kein Indikator dafür, dass die Division gescheitert ist – es sind nur speziell definierte Werte, die den Wertebereich der reellen Zahlen pragmatisch erweitern.
Dass eine Methode scheitert, bedeutet in diesem Kontext auch nicht unbedingt, dass das Programm einen Fehler hat oder sich in einem inkonsistenten Zustand befindet, sondern nur, dass die primäre Aufgabe der Methode nicht erfüllt werden konnte.
Exceptions oder Fehlercodes?
Exceptions sind nicht die einzige Möglichkeit, das Scheitern einer Methode anzuzeigen. Eine andere häufig verwendete Vorgehensweise ist es, einen speziell definierten Wert als Ergebnis oder einen speziell für diesen Zweck definierten Ausgabeparameter zu benutzen, in dem ein Fehlercode zurückgegeben wird.
Vorteile von Exceptions
Exceptions haben aber zwei entscheidende Vorteile gegenüber Fehlercodes:
- Um das Scheitern einer aufgerufenen Operation weiter an den Aufrufer zu kommunizieren, brauchen Sie nichts zu tun. Wenn Sie die von der aufgerufenen Operation geworfene Exception nicht fangen, wird diese automatisch an den Aufrufer weitergeleitet.
-
- Bei Fehlercodes dagegen muss für jeden Aufruf einer Operation explizit überprüft werden, ob dabei eine Fehlersituation aufgetreten ist. Das heißt, dass die Methode in der Mitte der Aufrufkette, die weder den Grund des Scheiterns feststellt noch darauf irgendwie reagieren kann (außer selbst zu scheitern), die Fehlerbehandlung überhaupt nicht implementieren muss – und trotzdem wird der Grund des Scheiterns an die behandelnde Stelle signalisiert.
- Eine nicht gefangene Exception meldet sich mit aller Deutlichkeit. Bei Fehlercodes dagegen kann es durchaus passieren, dass sie einfach ignoriert werden. Programmiersprachen können nicht kontrollieren, dass ein Fehlerstatus überhaupt ausgewertet wird.
Fehlercodes sind allerdings viel besser als Exceptions geeignet, um Warnungen oder Hinweise, die sich auf die Ausführung einer Operation beziehen, an den Aufrufer zurückzumelden. Bei Warnungen und Hinweisen soll in der Regel gerade nicht der Kontrollfluss unterbrochen werden, da der Aufruf nicht komplett gescheitert ist. Eine Exception ist für einen solchen Fall ungeeignet, und Sie sollten Fehlercodes verwenden, die dann allerdings besser die Bezeichnung Statuscodes tragen.
7.6.2 Exceptions und der Kontrollfluss eines Programms
Die Verwendung von Exceptions verändert den Kontrollfluss eines Programms. Beim Auftreten einer Exception wird die reguläre Abarbeitung des Programms abgebrochen und an anderer Stelle erst wieder aufgenommen, wenn die ausgelöste Exception in irgendeiner Weise behandelt worden ist.
Exceptions
Exceptions haben dabei den großen Vorteil, dass sie den normalen Ausführungspfad eines Programms von den möglichen fehlerhaften Ausführungspfaden frei halten. Wenn Sie Fehlercodes zur Übermittlung einer Fehlersituation verwenden, so müssen auch an der eigentlichen Fehlerbehandlung völlig unbeteiligte Methoden diese Codes weiterreichen. Betrachten Sie einfach einmal das sehr einfache Java-Beispiel aus Listing 7.47 mit einem Aufruf von drei Operationen, die alle möglicherweise scheitern, also eine Exception werfen können.
void eineOperation() { a(); b(); c(); }
Listing 7.47 Einfacher Aufruf von drei Operationen
Fehlercodes
Die Behandlung von möglichen Fehlern kann ein Aufrufer der Operation eineOperation übernehmen, die Methode selbst ist völlig frei von Fehlerbehandlung. Wenn Sie diese Fehlermöglichkeiten nicht über Exceptions, sondern über Fehlercodes signalisieren, sieht das Ganze bereits etwas anders aus, zum Beispiel wie in Listing 7.48.
Errorcode eineOperation() { Errorcode result = Errorcode.OK; result = a(); if (result == Errorcode.OK) { result = b(); if (result != Errorcode.OK) { result = c(); } } return result; }
Listing 7.48 Fehlerbehandlung durch Errorcodes
Zweck einer Methode
Der eigentliche Ablauf der Methode ist nun weit weniger klar, da die Behandlung der möglichen Fehler und des resultierenden Ablaufs den Großteil des Codes ausmacht. Außerdem wurde die Signatur der Methoden nur zum Zweck der Fehlerbehandlung angepasst, so dass sie jeweils einen Errorcode als Ergebnis liefern. Das führt zu einer weiteren Vermischung der eigentlichen Aufgaben und der Fehlerbehandlung.
Die Verwendung von Exceptions führt dazu, dass der eigentliche Zweck einer Methode viel klarer ersichtlich wird. Mit Exceptions ist der Weg, der zum Erfolg einer Methode führt, also die Implementierung der Umsetzung der eigentlichen Aufgabe der Methode, deutlicher. Um die Behandlung von Fehlern kümmern sich nur die Codestelle, an welcher der Fehler auftritt, sowie die Stelle, an der er behandelt werden kann.
Die Ausführungspfade des Scheiterns sind implizit und automatisch da. Aber eben weil sie da sind, müssen Sie auch immer mit diesen zusätzlichen Ausführungspfaden rechnen. Code, der mit Exceptions arbeitet, hat deshalb eine besondere Qualitätsanforderung: Sie müssen immer damit rechnen, dass der Aufruf einer Operation durch eine Exception unterbrochen wird. Auch für diese Ausführungspfade muss sich das Programm korrekt verhalten. Diese Anforderung wird auch die Forderung nach Exception-Sicherheit (Exception Safety) genannt.
Exception Safety |
Durch die Verwendung von Exceptions werden zusätzliche Ausführungspfade in ein Programm eingeführt. Ein Programm wird sicher bezüglich der Behandlung von Exceptions genannt (exception safe), wenn das Programm sich auch nach dem Durchlaufen dieser Pfade in einem korrekten Zustand befindet. Von einem korrekten Zustand sprechen wir, wenn auch in diesem Fall die festgelegten Invarianten weiterhin gelten. Außerdem dürfen keine Speicherlecks entstehen, und auch die Freigabe von anderen belegten Ressourcen muss korrekt stattfinden. |
Betrachten Sie zur Illustration ein einfaches Beispiel in C++. Ohne Exceptions ist dieser C++-Code korrekt:
MeinObjekt* pMeinObjekt = new MeinObjekt(); meineOperation(pMeinObjekt); delete pMeinObjekt;
Wenn allerdings meineOperation(pMeinObjekt) eine Exception wirft, haben Sie ein Speicherleck vorliegen, weil der Speicher, auf den pMeinObjekt verweist, nie freigegeben wird. Weil der normale Kontrollfluss bei Auftreten einer Exception unterbrochen wird, wird der Code zum Freigeben von pMeinObjekt in diesem Fall nicht durchlaufen. In Sprachen, die eine automatische dynamische Speicherverwaltung (Garbage Collection) aufweisen, besteht das Problem bezüglich der Anlage von neuen Objekten nicht. Andere Ressourcen können aber durchaus belegt bleiben, wenn eine Exception auftritt.
Freigabe von Ressourcen
Deshalb ist es in Programmen, die mit Exceptions arbeiten, meist notwendig, die Freigabe von Ressourcen explizit zu behandeln und dies auch so abzusichern, dass diese Freigabe auch im Exception-Fall erfolgt. Werden Invarianten innerhalb einer Methode zeitweise verletzt, muss das Gelten der Invariante beim Auftreten einer Exception wieder hergestellt werden. Es ist in diesen Fällen möglich, dass wir die Betrachtung von Exceptions durch diese Randbedingungen doch wieder in Methoden einfügen müssen, die weder mit dem Auslösen noch mit dem eigentlichen Behandeln der Exception etwas zu tun haben.
Programme exception-sicher gestalten
Was ist nun konkret zu tun, um Programme exception-sicher zu gestalten? Betrachten wir dazu zwei Beispiele in Java und C++. Wir beginnen mit der Programmiersprache Java und verwenden dazu eine modifizierte Variante eines Beispiels aus Abschnitt 4.1. Dieses beschäftigt sich mit elektrischen Leitungen und den idealisierten Annahmen, die sich mit dem Verhältnis von Stromstärke, Spannung und Wiederstand beschäftigen.
Abbildung 7.60 Invariante für Ohm´sches Gesetz
Nehmen wir an, Sie haben sich für die in Abbildung 7.60 gezeigte Umsetzung entschieden: Die drei Attribute Spannung, Widerstand und Stromstärke haben Sie jeweils als Datenelemente voltage, resistance und current umgesetzt. Bei jedem Zugriff von außen muss dann die angegebene Invariante greifen: U = R * I, hier also voltage = resistance * current.
Wird der Wert für die Spannung geändert, möchten Sie diese Änderung in einer Datei mitprotokollieren. Die Umsetzung der Operation setVoltage muss in diesem Fall exception-sicher erfolgen. Listing 7.49 zeigt eine mögliche Umsetzung in Java.
void setVoltage(Double voltage) throws IOException { FileOutputStream out = null; try { this.voltage = voltage; // Die Invariante U = R * I gilt nicht mehr out = new FileOutputStream( "C:/logs/trace.txt"); // Hier ist eine Datei geöffnet PrintStream p = new PrintStream(out); p.println("Setting voltage to " + voltage); } finally { if (out != null) { out.close(); } this.current = this.voltage / this.resistance; } }
Listing 7.49 Java: Exception-sichere Umsetzung von »setVoltage«
Die Methode verwendet in den mit markierten Zeilen Exemplare der Klassen FileOutputStream und PrintStream, um eine Protokollierung zu schreiben. Bei dieser Verwendung können Exceptions auftreten. Diese werden allerdings nicht behandelt, sondern die Behandlung bleibt anderen Aufrufebenen überlassen. Trotzdem muss die Methode dafür sorgen, dass im Fall einer Exception korrekt aufgeräumt wird.
Dies geschieht im sogenannten finally-Block, der in Zeile umgesetzt ist. Der dort enthaltene Code wird in jedem Fall durchlaufen, auch wenn im davor aufgeführten try-Block eine Exception auftritt. In unserem Beispiel werden dort zwei verschiedene Aktionen durchgeführt. Zum einen wird die möglicherweise bereits geöffnete Datei auf jeden Fall geschlossen. Wäre das nicht der Fall, würden die entsprechende Datei und die damit verbundenen Ressourcen nicht mehr freigegeben. Zum anderen wird die Stromstärke auf jeden Fall auf den Wert gesetzt, welcher der Invariante entspricht. Wenn dies nämlich nicht im finally-Block stattfindet, kann eine Exception dazu führen, dass die für das Objekt definierte Invariante verletzt wird: Die Spannung ist bereits neu gesetzt, die resultierende Stromstärke hat aber weiter den alten Wert. Die Invariante inv: getVoltage() = getResistance() * getCurrent() gilt dann nicht mehr, und das Objekt würde den geschlossenen Kontrakt verletzen.
In Sprachen wie C++ können Objekte auf dem Stack angelegt und dann beim Verlassen des Sichtbarkeitsbereichs automatisch destruiert werden. In diesen Sprachen kann zur Herstellung von Exception Safety ein Mechanismus verwendet werden, der unter dem Namen Ressourcenbelegung ist Initialisierung bekannt geworden ist.
RAII: Ressourcenbelegung ist Initialisierung.
Ressourcenbelegung ist Initialisierung (engl. Resource Acquisition is Initialisation, RAII) |
Ressourcen wie zum Beispiel verwendete Dateien oder Sperren zur Synchronisation von nebenläufigen Programmteilen können dadurch verwaltet werden, dass sie im Konstruktor eines Objekts angelegt und im Destruktor desselben Objekts freigegeben werden. Werden solche Objekte in Programmiersprachen mit automatischer Verwaltung von Variablen (zum Beispiel C++) auf dem Stack angelegt, wird durch den Compiler sichergestellt, dass der Destruktor in jedem Fall beim Verlassen des Sichtbarkeitsbereichs aufgerufen wird. Damit werden im Destruktor die verwendeten Ressourcen in jedem Fall freigegeben, insbesondere auch dann, wenn der Sichtbarkeitsbereich deshalb verlassen wird, weil eine Exception aufgetreten ist. Dieses Verfahren ist ein wichtiges Mittel, um die Exception-Sicherheit eines Programms herzustellen. |
In Abbildung 7.61 ist ein Beispiel aufgeführt, in dem eine Klasse RAII (für Resource Acquisition is Initialisation) explizit eine Ressource verwaltet.
Abbildung 7.61 RAII-Objekt zur Absicherung von Ressourcen
Betrachten Sie die zugehörige Umsetzung von Konstruktor und Destruktor in Listing 7.50, das eine Umsetzung in C++ aufführt. Dabei wird deutlich, dass die zugehörige Ressource im Konstruktor komplett angelegt und reserviert wird, im Destruktor wird die Ressource dann wieder freigegeben.
RAII::RAII() { pMyResource = new MyResource(); pMyResource->acquire(); } RAII::~RAII() { pMyResource->release(); delete pMyResource; }
Listing 7.50 Verwaltung von Ressourcen in Konstruktor und Destruktor
Die Verwendung des absichernden Objekts ist in Listing 7.51 dargestellt.
void RAIITester::RunTest() { RAII raii; bool condition_red = false; MyResource* pResource = raii.GetResource(); // ... Aktionen mit der Ressource ausführen if (condition_red) { throw std::exception(); } // .. weitere Aktionen }
Listing 7.51 RAII in Verwendung
Dabei wird eine lokale Variable für ein Exemplar von RAII angelegt, der dabei implizit aufgerufene Konstruktor sorgt dafür, dass die benötigte Ressource reserviert wird. Sobald der Sichtbarkeitsbereich von RunTest verlassen wird, wird der Destruktor von RAII aufgerufen, der dann die belegte Ressource in jedem Fall freigibt. Dies gilt auch in dem Fall, dass während des Ablaufs im Code eine Exception auftritt.
Vorteile von Exceptions überwiegen.
Auch wenn Exceptions also die Notwendigkeit mit sich bringen, Code exception-sicher zu gestalten, überwiegen doch die Vorteile ihres Einsatzes. Weil Exceptions bestimmte Ausführungspfade verstecken, erhöhen sie die Übersichtlichkeit von Quelltexten, weil sie die wichtigen Abläufe klar erkennbar machen.
Es bietet sich eine Analogie zur Verwendung von Polymorphie an: Aus dem Quelltext einer Methode, die eine Operation aufruft, können Sie nicht erkennen, welche konkrete Implementierung der Operation aufgerufen wird. Diese Information ist nicht offensichtlich, sie ist versteckt. Trotzdem, nein, gerade deswegen erhöht der Einsatz von virtuellen Methoden die Übersichtlichkeit des Quelltextes. Auch Exceptions erhöhen die Übersichtlichkeit und Wartbarkeit von Quelltexten, indem sie eine Reihe von Ausführungspfaden vor dem Programmierer verstecken.
7.6.3 Exceptions im Einsatz bei Kontraktverletzungen
In Abschnitt 7.5.2, »Übernahme von Verantwortung: Unterklassen in der Pflicht«, haben Sie ein Beispiel kennen gelernt, bei dem ein Aufrufer den Kontrakt bezüglich einer Operation verletzt hat. Der Aufruf der Operation tanken an Ihrer Salatöl-Tankstelle hat in einem recht unerfreulichen Fall zu einer Verletzung von Vorbedingungen geführt. Die Vorbedingung hatten Sie durch eine sogenannte Assertion abgesichert, so dass glücklicherweise kein Salatöl im Tank eines Diesel-LKW gelandet ist.
Die verwendete Assertion führte im genannten Beispiel zu einer Exception vom Typ AssertionError, die wiederum dazu führte, dass Ihr Programm abgebrochen wurde. Allerdings hatte das einen etwas unangenehmen Seiteneffekt: Es konnte nun überhaupt niemand mehr tanken, das ganze System stand.
Damit stellt sich die grundsätzliche Frage: Wenn in einem Programm eine Verletzung eines Kontrakts festgestellt wird, sollte das Programm dann grundsätzlich abgebrochen werden, oder kann es trotzdem weiterarbeiten? Was soll also ein Programm machen, wenn es selbst erkennt, dass es fehlerhaft programmiert oder falsch konfiguriert ist?
Tote Programme lügen nicht.
Tote Programme lügen nicht |
Wird innerhalb eines Programms durch Überprüfung von Kontrakten zur Laufzeit eine Kontraktverletzung festgestellt, ist für die weitere Ausführung des Programms die korrekte Funktion nicht mehr gewährleistet. Eine Faustregel, wie das Programm sich in solchen Situationen verhalten soll, formulieren die Pragmatiker Andy Hunt und Dave Thomas im Buch The Pragmatic Programmer ([Hunt 1999]): Dead programs tell no lies. Tote Programme lügen nicht. Diese Regel besagt, dass es oft besser ist, ein Programm zu beenden, das sich in einen undefinierten Zustand begeben hat. Damit wird verhindert, dass zum Beispiel die Datenbank durcheinander gebracht wird oder ein Patient eine falsche Dosis Strahlung erhält oder andere noch schlimmere Effekte entstehen. Da das Programm sich bei einer Kontraktverletzung nicht mehr in einem definierten Zustand befindet, ist theoretisch jeder Effekt möglich. |
Durch ein komplettes Beenden wird erreicht, dass ein Programm nicht in einem undefinierten Zustand weiterläuft. Dadurch werden mögliche Folgeschäden vermieden. Das Programm soll außerdem wieder in einen definierten Zustand gebracht werden. Und Neustart ist eine ziemlich sichere Methode, wie man in einen definierten Zustand zurückfinden kann.
Diskussion: Ist Neustart grundsätzlich besser?
Gregor: Ist es für ein Serversystem nicht meistens besser, wenn es weiterläuft? Nicht jede Kontraktverletzung führt automatisch zu dramatischen Inkonsistenzen. Und wenn sich zum Beispiel ein Datenbankserver beendet, weil er in einer einzelnen Aktion eine Kontraktverletzung entdeckt hat, ist es doch reichlich unverhältnismäßig, den gesamten Server zu beenden. Die Folgen des Beendens könnten doch wesentlich kritischer sein: Möglicherweise ist eine ganze Reihe von Applikationen längere Zeit nicht verfügbar, und es entstehen hohe Kosten.Bernhard: Das kann in der Praxis richtig sein. Dennoch bleibe ich dabei, dass bei einer Kontraktverletzung in der Regel ein Neustart des betroffenen Programms die korrekte Lösung ist. In deinem Beispiel ist aber eher die Frage, was das betroffene Programm oder der betroffene Programmteil ist. Wenn ein Fehler in einem Bereich auftritt, der nur Aktionen für genau einen angemeldeten Benutzer der Datenbank ausführt, dann reicht es, genau diesen Teil zu beenden und neu zu starten. Es wäre wirklich weit über das Ziel hinausgeschossen, wenn dann in jedem Fall die Datenbank heruntergefahren wird.
Gregor: Und wenn die Kontraktverletzung in einem zentralen Teil des Datenbankservers festgestellt wird? Zum Beispiel beim Schreiben von Daten aus dem Arbeitsspeicher auf die Festplatte?
Bernhard: In diesem Fall sollte wahrscheinlich sogar der komplette Server beendet werden, weil die Datenbank möglicherweise ihre zentralen Konsistenzbedingungen nicht mehr einhalten kann. In den meisten Fällen wird es dann besser sein, den Server neu zu starten, anstatt möglicherweise inkonsistente Daten zu schreiben.
Im Fall einer Kontraktverletzung muss also ein Teil der auf einem Rechner laufenden Software beendet und neu gestartet werden, um in einen definierten Zustand zurückzukehren. Welcher Teil das ist, hängt davon ab, wie stark ein Programmteil von den anderen Teilen eines Programms isoliert ist. Wenn ein Fehler in einem Programmteil andere Teile nicht beeinträchtigen kann, so müssen diese auch nicht durchgestartet werden. Wenn auf einem Webserver ein Servlet ausgeführt wird und in diesem tritt eine Kontraktverletzung auf, so ist es nicht notwendig, den Webserver durchzustarten, es wird ausreichen, die Verbindung für den aktuell angemeldeten Anwender zurückzusetzen.
Aber wie wird eigentlich ein Programm am besten beendet, wenn eine Kontraktverletzung festgestellt wurde? Exceptions bieten hier eine Möglichkeit, Programme in definierter Weise zu beenden.
Exception bei erkannten Programmierfehlern
Im Fall eines Programmierfehlers sollte eine Exception geworfen werden, die signalisiert, dass eine Kontraktverletzung aufgetreten ist und dass ein Programmteil durchgestartet werden muss. Durch die Verwendung einer Exception ist es auch möglich, auf verschiedenen Ebenen des Programms notwendige Aufräumarbeiten durchzuführen, bevor das Programm beendet wird. So können zum Beispiel vom Programm angelegte temporäre Dateien noch gelöscht werden.
Wenn der Programmteil in einer Umgebung eingesetzt wird, in der ein kompletter Neustart nicht notwendig ist, kann die Exception auch gefangen werden, um dann nur den Programmteil neu zu starten, in dem die Exception aufgetreten ist. Die Verwendung von Exceptions überlässt die Entscheidung, welcher Teil neu gestartet werden muss, den aufrufenden Stellen.
Alternativ: Direkter Abbruch des Programms
Zur Illustration dieser Vorteile betrachten wir die zur Verfügung stehende Alternative. Die meisten Programmiersprachen bieten auch die Möglichkeit, unmittelbar das Beenden eines Programms auszulösen. In Java könnten Sie System.exit aufrufen, in C++ kann der Aufruf von abort oder exit mit einem Fehlercode als Parameter verwendet werden. Dadurch wird das Programm direkt beendet, eine Behandlung von Exceptions kann nicht mehr stattfinden.
Der einzige Vorteil dieser Vorgehensweise ist es, dass das Programm keinen weiteren Schaden mehr anrichten kann, weil es einfach direkt und unmittelbar beendet wird. Der sofortige Abbruch gleicht einem ehrenwerten Samurai, der sich seiner Unwürdigkeit bewusst wird und sich so für ein Seppuku entschließt. Kein gut gemeinter catch-Block, der alle Exceptions fängt, kann ihn dazu bringen, in einem undefinierten Zustand weiterzumachen und so möglicherweise seinem Meister noch mehr Schaden zuzufügen.
Nachteile des sofortigen Abbruchs
Aber die Nachteile des unmittelbaren Abbruchs sind offensichtlich. Bei einem sofortigen Abbruch kann die Anwendung notwendige Aufräumarbeiten nicht mehr erledigen. Den belegten Speicherplatz gibt das Betriebssystem frei, es löscht auch die Locks an geöffneten Dateien. Wer löscht aber die temporären Dateien? Wer benachrichtigt den Webserver, dass die Session beendet ist? Eine geworfene Exception erlaubt der Anwendung einen geordneten Rückzug, indem sie vor ihrer Wiedergeburt den Frieden mit der Welt schließen kann.
Ein weiterer Nachteil des sofortigen Abbruchs ist, dass er die Modularität des Programms verschlechtert. Eine Prozedur braucht nichts über das Programm zu wissen, in dem sie verwendet wird. Und wenn sie dieses Wissen nicht braucht, soll sie es auch nicht haben. Eine Prozedur, in der ein Programmierfehler festgestellt wurde, soll also nicht wissen, dass es in diesem Fall unsere Absicht ist, die Anwendung zu beenden. Vielleicht wird sie irgendwann in einer Anwendung verwendet, die ihre Teile besser isoliert und nur die Teile neu starten muss, in denen der Fehler aufgetreten ist. Wenn der sofortige Abbruch ausgelöst wird, sind solche Anpassungen nicht mehr möglich. [Code smell lässt sich etwa mit »müffelnder Code« übersetzen. Martin Fowler hat den Begriff geprägt für Code, bei dem irgendetwas nicht in Ordnung ist, obwohl er in den meisten Situationen trotzdem funktioniert. ]
Fangen aller Exceptions?
Achtung Code Smell: catch(...) oder catch (Throwable) |
Programmiersprachen, die Exceptions unterstützen, bieten in der Regel auch einen Mechanismus, um alle potenziell auftretenden Exceptions zu fangen. In C++ steht dafür das Statement catch(...) zur Verfügung, in Java kann die Basisklasse aller Exceptions, Throwable, verwendet werden. Damit besteht auch die Möglichkeit, für bestimmte Programmteile jegliche Exception ohne Ansehen der konkreten Klassenzugehörigkeit einfach zu fangen und dann im Programmablauf weiterzumachen. Dieses Vorgehen ist ein starkes Indiz für problematischen Code, einen sogenannten Code Smell37. Ein catch-Block dieser Art sollte entweder dafür sorgen, dass das Programm beendet wird, oder die Exception weiterwerfen, damit ein anderer Programmteil das erledigen kann. Einfach die Exception zu protokollieren und weiterzumachen führt dazu, dass auftretende Fehler und Kontraktverletzungen ignoriert werden. Die daraus resultierenden Folgefehler können wesentlich schwerwiegender und vor allem schwieriger zu finden sein. |
Der unten stehende Java-Code »müffelt« also ziemlich stark:
try { // ... verschiedene Aktionen } catch (Throwable t) { System.out.println(t.toString()); }
Und auch der entsprechende C++-Code riecht nicht besser:
try { // ... verschiedene Aktionen } catch (...) { cout << "Non recoverable unexpected error"; }
Beide Code-Stücke enthalten das Problem, dass sie alle möglichen Fehlerarten abfangen, diese aber weder behandeln noch die gefangene Exception weiterwerfen.
7.6.4 Exceptions als Teil eines Kontraktes
In Abschnitt 7.5.1, »Überprüfung von Kontrakten«, haben Sie gesehen, wie Kontrakte zwischen Klassen und Objekten formuliert werden können. Dabei werden unter anderem für den Aufruf von Operationen Vorbedingungen und Nachbedingungen festgelegt. Die Einhaltung der Vorbedingungen muss dabei durch den Aufrufer sichergestellt werden. Wenn diese eingehalten sind, sichert ein Objekt zu, dass anschließend die Nachbedingungen gelten.
Nun, auch wenn der Aufrufer seinen Verpflichtungen nachgekommen ist und die Methode fehlerfrei implementiert wurde, kann es passieren, dass sie ihre Aufgabe nicht erledigen kann und scheitert. Damit müssen Sie bei jeder Methode rechnen, die Ressourcen nutzt, die außerhalb der Kontrolle des Programms stehen. Das Scheitern kann die Methode in diesem Fall dem Aufrufer durch das Werfen einer Exception signalisieren.
Kontrakte formulieren
Wie aber lassen sich die Kontrakte, die das Verhalten bei einer Exception beschreiben, formulieren und formalisieren?
Zunächst müssen wir hierfür klar machen, dass es ganz unterschiedliche Fehlersituationen sind, die in einem Programm entstehen können. Dabei sind zwei grundsätzliche Kategorien zu unterscheiden: Kontraktverletzungen durch Programmierfehler auf der einen Seite und bekannte Fehlersituationen, mit denen unser Programm umgehen kann, auf der anderen Seite.
Kontraktverletzungen durch Programmierfehler
Kontraktverletzungen durch Programmierfehler |
Ein Programmierfehler entsteht dadurch, dass sich Methoden oder die Aufrufer von Operationen nicht entsprechend den Kontrakten verhalten, die für sie gelten. Wenn beim Ablauf einer Methode ein Programmierfehler festgestellt wird, kann das verschiedene Ursachen haben:
|
Bei anderen Fehlern ist es aber schon bekannt, dass sie unter bestimmten Umständen auftreten können. Ein Programm muss mit diesen Fehlersituationen umgehen können.
Bekannte Fehlersituationen
Bekannte Fehlersituationen |
Bekannte Fehlersituationen sind solche, deren Behandlung im Programm vorgesehen ist. Beispiele für solche Fehler:
|
Es wäre ziemlich widersinnig, Kontrakte zwischen dem Aufrufer und der aufgerufenen Operation zu spezifizieren, die sich mit Programmierfehlern befassen. Die Kontrakte sollen uns grade helfen, Programmierfehler zu vermeiden, also sollte es unser Ziel sein, dass solche Programmierfehler in einem fertigen Programm nicht mehr auftauchen. Wenn uns die Umsetzung einer Operation beschreiben würde, dass sie aufgrund eines bestimmten Umsetzungsfehlers in manchen Situationen die Exception ProgrammingErrorException wirft, würden wir dem zuständigen Programmierer mit gutem Recht sagen können: Dann beheb doch einfach den Fehler, anstatt in diesem Fall eine Exception zu werfen.
Eine Methode kann und braucht also nicht zu versprechen, dass sie nie wegen eines Programmierfehlers scheitert. Einerseits ist dieses Versprechen sowieso immer implizit gegeben, anderseits dürfen Sie dem Versprechen nie glauben.
Kontrakt bezüglich Exception
Bei den als möglich bekannten Fehlern und den daraus resultierenden Exceptions sieht es anders aus. Eine Operation kann in zweierlei Hinsicht einen Kontrakt bezüglich Exceptions formulieren. Zum einen kann sie zusichern, dass sie in bestimmten Fehlersituationen eine ganz bestimmte Exception wirft. Zum anderen kann sie auch zusichern, dass sie bestimmte Exceptions unter gar keinen Umständen werfen wird. Im letzteren Fall hat ein Aufrufer den Vorteil, dass er sich um diese Exceptions auch auf keinen Fall kümmern muss.
Beide Informationen können über eine Liste von Exception-Klassen angegeben werden, die von einer Operation ausgelöst werden können. Diese Liste wird über eine sogenannte throws-Klausel einer Operation zugeordnet.
Findet für bestimmte Klassen von Exceptions eine Überprüfung dieses Kontrakts durch den Compiler statt, werden diese in Anlehnung an die Java-Terminologie als Checked Exceptions bezeichnet.
Checked Exceptions(überprüfte Exceptions) |
Als Checked Exceptions38 werden solche Exception-Klassen bezeichnet, für die bereits zur Übersetzungszeit eines Programms Prüfungen stattfinden, die eine Behandlung der Exception erzwingen. Wird innerhalb einer Methode, welche die Operation myOperation umsetzt, eine Exception vom Typ der Klasse CheckedException geworfen, so muss diese entweder innerhalb der Methode wieder gefangen werden oder die Methode muss explizit deklarieren, dass sie diese Exception wirft. Deklariert die Operation myOperation, dass sie eine Exception vom Typ CheckedException wirft, so muss jede Methode, welche die Operation aufruft, diese Exception entweder fangen oder ebenfalls deklarieren, dass diese Exception geworfen wird. |
Eine Methode, die eine Checked Exception in ihrer throws-Klausel nicht aufführt, sichert damit zu, dass diese Checked Exception von ihr nie geworfen wird. Somit ist der Aufrufer von der Notwendigkeit befreit, solche Exceptions zu behandeln.
Eine Operation kann im Rahmen des für sie gültigen Kontrakts versprechen, dass sie bestimmte Checked Exceptions nicht wirft. Sie tut es, indem sie diese Exceptions (oder ihre Oberklassen) nicht in ihrer throws-Klausel angibt. Will oder kann eine Methode so eine Verpflichtung nicht übernehmen, muss sie alle Checked Exception-Klassen, die sie werfen möchte, in der throws-Klausel aufzählen.
Checked Exceptions und Java
Betrachten wir ein einfaches Beispiel in der Programmiersprache Java, bei dem Checked Exceptions zum Einsatz kommen. In der Exception-Hierarchie von Java sind alle Exception-Klassen checked. Eine Ausnahme sind die Klasse RuntimeException und ihre Unterklassen. In Listing 7.52 ist eine Situation dargestellt, in der ein Java-Compiler einen Fehler signalisieren würde.
class MyCheckedException extends Exception { }
public class CheckedExceptionExample { void eineOperation() { kritischeOperation(); } void kritischeOperation() { // ... if (!aktionIstMoeglich()) { throw new MyCheckedException(); } // ... } private boolean aktionIstMoeglich() { return false; } }
Listing 7.52 Fehlerhafter Code mit Checked Exception
In Zeile wird eine neue Exception-Klasse deklariert. Als Unterklasse von Exception handelt es sich um eine Checked Exception. Innerhalb der Methode kritischeOperation in Zeile kann es dazu kommen, dass eine solche Exception geworfen wird (Zeile ). Ein Java-Compiler wird für diesen Code die Meldung generieren "Unhandled exception type MyCheckedException". Die Methode kritischeOperation muss nämlich entweder die Exception fangen oder die Exception-Klasse in ihrer throws-Klausel angeben. In Abbildung 7.62 ist zu sehen, dass zum Beispiel die Entwicklungsumgebung Eclipse in diesem Fall genau die beiden genannten Möglichkeiten zur Korrektur vorschlägt.
Abbildung 7.62 Die IDE Eclipse und Checked Exceptions
Wenn Sie die Exception nicht direkt behandeln können, ist also die Erweiterung der throws-Klausel die einzige Alternative:
void kritischeOperation() throws MyCheckedException { // ...
Im Fall unseres Beispiels verlagert dies allerdings nur das Problem, da nun der Aufruf aus eineOperation heraus nicht mehr zulässig ist. eineOperation ruft nämlich kritischeOperation auf. Damit muss auch hier die Exception entweder gefangen oder die throws-Klausel angepasst werden:
void eineOperation() throws MyCheckedException { kritischeOperation(); }
Mit dieser Anpassung haben Sie die Aufgabe, die Exception zu behandeln, an die jeweiligen Aufrufer von eineOperation delegiert.
Keine Checked Exceptions in C#
Der Mechanismus von Checked Exceptions wird in Java sehr intensiv genutzt. Bei anderen Sprachen wie zum Beispiel C# haben sich die Sprachdesigner explizit dagegen entschieden, diesen Mechanismus aufzunehmen. [Anders Hejlsberg, der Chefarchitekt der Sprache C#, begründet in einem Gespräch mit Bill Venners (http://www.artima.com/intv/handcuffs.html), warum Checked Exceptions nicht in C# integriert wurden. ] Obwohl der Mechanismus der Checked Exceptions auf den ersten Blick sehr vernünftig aussieht, verursacht er in der Praxis oft mehr Probleme, als er löst.
Eigentlich handelt es sich ja um eine einfache Idee: Es wird lediglich verlangt, dass eine Methode eine Exception entweder behandelt oder signalisiert, dass sie eine Behandlung der Exception nicht zusichern kann und das der Aufrufer tun muss.
In den folgenden Abschnitten stellen wir deshalb an Java-Beispielen vor, auf welche Arten Checked Exceptions dort behandelt werden können und zu welchen Problemen das jeweilige Vorgehen führt. Dennoch müssen Sie gerade in Java mit den Checked Exceptions umgehen. Es ist dabei aber in der Praxis oft besser, die Checked Exceptions in andere Exceptions einzubetten, die selbst nicht überprüft werden.
7.6.5 Der Umgang mit Checked Exceptions
Wenn Sie in einer Java-Methode eine Operation aufrufen, die in ihrer throws-Klausel eine Checked Exception aufführt, müssen Sie in Ihrer Methode mit dieser Exception umgehen können. Ein Java-Compiler wird es Ihnen nicht erlauben, die benötigte Operation aufzurufen, wenn Sie nicht eine adäquate Behandlung der Exception vornehmen.
Es gibt nun abhängig von der Art des Aufrufs und der Art der Exception verschiedene Möglichkeiten, was Sie tun können. Wenn Sie die Exception in Ihrer Methode so behandeln können, dass Sie trotz der Exception normal weiterarbeiten können, sind Sie natürlich aus dem Schneider. Sie können die Exception einfach fangen und dann weitermachen. Oft ist das aber nicht der Fall, und die Exception muss in irgendeiner Form weitergereicht werden. Bei Checked Exceptions bleiben Ihnen dann drei Möglichkeiten:
1. | Sie erweitern die throws-Klausel der Methode, so dass die Checked Exception darin enthalten ist. |
2. | Sie fangen die Exception und übersetzen sie in eine eigene Checked Exception. |
3. | Sie fangen die Exception und überführen sie in eine Exception, die nicht überprüft wird, eine Unchecked Exception. |
In den folgenden Abschnitten betrachten wir jeweils kurz die beschriebenen Möglichkeiten an Beispielen.
Erweiterung der eigenen throws-Klausel
Die einfachste und schnellste Lösung, um mit einer Checked Exception umzugehen, ist die Erweiterung der eigenen throws-Klausel. Wenn der Aufrufer die Exception nicht behandeln kann, führt diese Anpassung dazu, dass er die benötigte Operation nun aufrufen kann.
Obwohl diese Vorgehensweise die einfachste ist, ist sie nicht ohne Probleme. Damit reichen Sie nämlich die internen Abhängigkeiten der Methodenimplementierung einfach weiter. Sie verlagern die Verantwortung, mit der Exception umzugehen, auf Ihre eigenen Aufrufer. Und da sich eine solche Abhängigkeit nicht aus der Spezifikation einer Operation ergibt, sondern aus der konkreten gewählten Umsetzung, wird die Art der Umsetzung relevant für die Schnittstelle. Wenn Sie die Implementierung später noch einmal ändern und eine andere Operation aufrufen, die wieder eine andere Checked Exception wirft, wären alle Ihre Aufrufer betroffen, wenn Sie diese einfach weiterreichen.
Betrachten Sie dazu das Java-Beispiel aus Listing 7.53. Die dort aufgeführte Klasse CustomerProvider benutzt JDBC, um den Zugriff auf eine Datenbank zu realisieren. Die dabei genutzte Operation executeQuery in Zeile enthält in ihrer throws-Klausel die Klasse SQLException. Diese gehört in Java zu den Checked Exceptions. Damit muss auch die Methode getCustomers die Klasse in ihrer Liste führen, es resultiert die throws-Klausel in Zeile .
public class CustomerFilter { public Customer getBestCustomer(CustomerProvider provider) throws SQLException { Collection<Customer> customers = provider.getCustomers(); // ... weitere Aktionen } } public class CustomerProvider { ... public Collection<Customer> getCustomers() throws SQLException { ResultSet rs = connection.executeQuery(...); // ... weitere Aktionen } }
Listing 7.53 Operationen mit Checked Exceptions
Die Klasse CustomerFilter, deren Methode getBestCustomer den besten Kunden aussuchen soll, benutzt ein Exemplar von CustomerProvider, das sie als Parameter bekommt, um an die Kundenliste zu kommen. Obwohl CustomerFilter in keinerlei eigener Abhängigkeit zu JDBC steht, muss sie entweder die SQLException behandeln, oder sie muss sie, wie in unserem Beispiel in Zeile , selbst in der throws-Klausel deklarieren. In Abbildung 7.63 sind die entstehenden Abhängigkeiten aufgeführt.
Abbildung 7.63 Abhängigkeiten durch erweiterte throws-Klausel
Auch die Klasse CustomerProvider weist nun eine Abhängigkeit zu SQLException und damit zu JDBC auf. Das ist unangenehm, denn hier vermischen wir die Domäne der Kundenverwaltung mit der Domäne der JDBC-basierten Datenhaltung. Das fachliche Anliegen ist nicht mehr klar von den technischen Anliegen getrennt. Das läuft dem Prinzip der Trennung der Anliegen zuwider.
Die Option, Checked Exception einfach in die throws-Klausel zu übernehmen, verlieren wir also, wenn die Domäne, die wir für die Implementierung einer Methode betreten, außerhalb der Domäne der Aufgabe liegt, die wir zu erfüllen haben. In unserem Beispiel benutzen wir die Methode executeQuery, die in dem Bereich der JDBC-Datenhaltung liegt. Die Aufgabe der Methode getCustomers liegt aber in dem Bereich Kundenverwaltung. Wir sollten den Quelltexten, die getCustomers verwenden, die Abhängigkeit zu SQLException und somit zu JDBC nicht aufzwingen. Eine Übernahme einer Exception in die eigene throws-Klausel ist also nur dann anzuraten, wenn die Exception in derselben Domäne liegt wie die Methode, die Sie umsetzen.
Eine Alternative zum einfachen Weiterreichen über die throws-Klausel ist die sogenannte Exception Translation.
Exception Translation
Die Methode getCustomers aus dem Beispiel in Listing 7.53 muss scheitern, wenn die verwendeten JDBC-Aufrufe scheitern. Wie Sie im vorigen Abschnitt gesehen haben, sollte getCustomers aber keine SQLException werfen. Sie kann allerdings eine Exception werfen, die der Domäne Kundenverwaltung zugeordnet ist. Um dies zu verdeutlichen, haben wir in Abbildung 7.64 die Schnittstelle und die Implementierung klarer getrennt. Die Klasse CustomerProvider ist nun eine Schnittstelle, zu der eine JDBC-spezifische Implementierung vorliegt. Diese wird über die Klasse JdbcCustomerProvider realisiert. In der Abbildung ist die resultierende Klassenstruktur dargestellt.
Die Abhängigkeit von CustomerProvider und damit von CustomerFinder zur SQLException ist in dieser Variante beseitigt. Beide verwenden eine eigene Exception, die aus der Domäne Kundenverwaltung stammt, nämlich CustomerException.
Der angepasste Quelltext für die Umsetzung der Operation getCustomers ist in Listing 7.54 aufgeführt.
Abbildung 7.64 Exception Translation und resultierende Abhängigkeiten
public class JdbcCustomerProvider implements CustomerProvider { public Collection<Customer> getCustomers() throws đ CustomerException { try { ResultSet rs = connection.executeQuery(...); ... usw. ... } catch (SQLException sqle) { throw new CustomerException("Datenbankproblem!"); } finally { // JDBC-Objekte schließen ... usw. ... } } }
Listing 7.54 Exception Translation für SQLException
Eine auftretende SQLException wird in Zeile gefangen und in Zeile in eine CustomerException aus der eigenen Domäne übersetzt.
Eine andere, zum Beispiel webbasierte, Implementierung HttpCustomerProvider würde ihre internen Exceptions auch abfangen müssen und sie in CustomerExceptions umwandeln. Dies wird durch die Schnittstelle CustomerProvider erzwungen. Die Schnittstelle legt die Verpflichtung fest, keine anderen Checked Exceptions zu werfen als eine CustomerException. Eine Implementierung der Schnittstelle kann keine Verpflichtung, die durch die Schnittstelle übernommen wurde, ablehnen. Sie kann sich aber zu mehr verpflichten und ihre throws-Klausel leer lassen oder nur bestimmte Unterklassen von CustomerException angeben.
Diese Lösung der Exception Translation ist für viele Fälle anwendbar und ermöglicht es, eine Trennung zwischen unterschiedlichen Domänen auch in Bezug auf die Behandlung von Exceptions durchzuhalten. Allerdings wird der durch die Checked Exceptions geschlossene Kontrakt durch diesen Mechanismus häufig einfach umgangen.
Im nächsten Abschnitt werden wir erläutern, warum auch die Exception Translation problematisch und eine Lösung unter Verwendung von normalen Unchecked Exceptions vorteilhaft sein kann.
Eine Checked Exception als Unchecked Exception weiterreichen
Im vorigen Abschnitt haben wir die Exception CustomerException in der Domäne Kundenverwaltung vorgestellt. Diese domänenspezifische Exception kann grundsätzlich zwei Ursachen haben.
Einerseits kann die Ursache tatsächlich in der Domäne Kundenverwaltung liegen. Ein Beispiel für eine solche Ursache wäre, wenn Sie einen Kunden anlegen möchten, der noch nicht volljährig ist, und die Geschäftsbedingungen des Unternehmens lassen dies nicht zu. Eine Methode createCustomer würde in diesem Falle eine CustomerException werfen.
Auch wenn die Ursache in der Domäne Kundenverwaltung liegt, kann es trotzdem sein, dass der Fehler in einer anderen (technischen) Domäne festgestellt wird. Zum Beispiel kann ein Fehler beim Einfügen eines Datensatzes in der Datenbank bedeuten, dass eine Kundennummer bereits vergeben ist. In diesem Falle könnte die Methode createCustomer die SQLException abfangen und sie in eine CustomerException übersetzen.
Andererseits aber kann das Problem tatsächlich in der anderen, technischen Domäne liegen. Es kann sein, dass die Datenbank keinen Festplattenplatz mehr hat oder dass sie einfach überlastet ist oder dass der Datenbankserver gerade lichterloh brennt. Abbildung 7.65 zeigt einen solchen Fall.
Abbildung 7.65 Auslöser für eine ServerOnFireException
Sie haben zwar die Schicht, in der die Datenhaltung geschieht, gekapselt und abstrahiert, aber Abstraktionen tendieren dazu, Lecks zu haben, [Diese These wird als Law of leaky Abstractions von Joel Spolsky vertreten: http://www.joelonsoftware.com/articles/LeakyAbstractions.html ] und Probleme in der Schicht der Datenhaltung werden hin und wieder auch in den anderen Schichten als solche sichtbar werden.
Wenn der Datenbankserver brennt, lässt sich das kaum als eine sinnvolle Exception in der Domäne Kundenverwaltung ausdrücken. Sie könnten zwar die SQLException abfangen und eine nichts sagende CustomerException werfen. Damit hätten Sie aber das Leck in der Abstraktion nicht behoben, Sie hätten es nur verschleiert – um letztendlich dem Benutzer eine Fehlermeldung der Art »Ein unerwarteter Fehler ist aufgetreten. [OK] [Cancel] [Dankeschön]« zu präsentieren.
Damit berauben Sie den Benutzer der Chance, den tatsächlichen Fehler schnell zu identifizieren, ihn eventuell zu beheben und mit dem Feuerlöscher in den Serverraum zu rennen.
Wenn ein Fehler auftritt, den Sie nicht einer Exception, die tatsächlich in unserer Domäne liegt, zuordnen können, sollten Sie diese Tatsache nicht verschleiern. Wenn dieser Fehler durch eine Checked Exception signalisiert wird, können und müssen Sie diese zwar in eine andere Exception übersetzen, Sie sollten die ursprüngliche Exception dabei aber nicht komplett ersetzen, sondern sie zumindest in die neue Exception einbetten.
So gibt es zum Beispiel in Java Exceptions seit der Version 1.4 des JDK die Eigenschaft cause, die im Konstruktor gesetzt werden kann und genau diesem Zweck dient. Mit diesem Mechanismus können Sie die ursprüngliche Exception in eine neue Exception einbetten. Der angepasster Quelltext ist in Listing 7.55 zu sehen.
public class JdbcCustomerProvider implements CustomerProvider { public Collection<Customer> getCustomers() throws CustomerException { try { ResultSet rs = connection.executeQuery(...); // ... } catch (SQLException sqle) { throw new CustomerException( "Datenbankproblem!", sqle); } finally { // JDBC-Objekte schließen // ... } } }
Listing 7.55 Eingebettete Exception in Java
So weit, so gut. Sie werfen zwar eine CustomerException, es ist aber in Wirklichkeit keine. Tatsächlich ist es eine verschleierte SQLException, die Sie aber nicht direkt durchlassen dürfen, weil es der definierte Kontrakt verbietet.
Sie haben also einen Weg gefunden, den Kontrakt zwar formal zu erfüllen, tatsächlich umgehen Sie ihn aber. Nicht gerade ein Zeichen hoher Moral, aber was bleibt Ihnen anderes übrig? Das System zwingt Sie zum Mogeln. Wäre SQLException nicht checked, könnten Sie die Exception ganz offen durchlassen. So aber müssen sie diese in eine waschechte CustomerException umwandeln.
Den Kontrakt, der Sie dazu verpflichtet, keine SQLException zu werfen, gibt es aus zwei Gründen: Sie wollen Ihrem Aufrufer die Mühe ersparen, dass er sich mit JDBC befassen muss. Und Sie wollen die Quelltextabhängigkeiten des direkten Aufrufers zu JDBC vermeiden. Schließlich kann es sein, dass er sonst gar nichts mit JDCB zu tun hat, es gibt ja keine logischen Abhängigkeiten zu JDBC.
Die erste noble Absicht können Sie aber, wie sich gezeigt hat, leider nicht erfüllen. Die Abstraktionen haben Lecks, und Sie werden gezwungen, entweder die SQLException unbehandelt einfach wegzufischen und sie durch eine CustomerException zu ersetzen. Alternativ können Sie den Kontrakt auch beugen, indem Sie die SQLException Ihrer throws-Klausel hinzufügen und diese dann zum Aufrufer weiterreichen, wahrscheinlich noch weiter, bis zu einer Stelle, an der einem Benutzer dann die Exception angezeigt wird.
Die zweite Absicht ist erfüllbar, und sie ist auch sehr wichtig. In den Quelltexten der Kundenverwaltungsschicht sollten tatsächlich keine JDBC-Bezüge stehen, wenn sie nicht unvermeidbar sind. Diese Absicht ließe sich aber mit viel weniger Tipparbeit erledigen, wenn Sie die SQLException unchecked machen könnten.
Bei SQLException bleibt Ihnen nichts anderes übrig, aber wenn Sie eigene Exceptions definieren, spricht wenig dafür, diese als Checked Exceptions zu deklarieren.
Die SQLExceptions selbst ist checked, Sie können diese aber fangen und in eine Exception einbetten, die selbst nicht als checked deklariert ist. Dabei kann es sich je nach Bedarf der Anwendung um eine unspezifische SoftenedCheckedException oder um eine spezifische SoftenedSQLException handeln.
7.6.6 Exceptions in der Zusammenfassung
In den vorhergehenden Abschnitten haben Sie die verschiedenen Verwendungsmöglichkeiten von Exceptions kennen gelernt. In diesem Abschnitt finden Sie noch einmal eine kurze Zusammenfassung der vorgestellten Eigenschaften.
- Exceptions bieten einen etablierten und in vielen Fällen vorteilhaften Mechanismus zur Fehlerbehandlung. Sie verstecken die Pfade der Programmausführung im Fehlerfall und tragen so zur Übersichtlichkeit von Code bei.
- Exceptions können verwendet werden, um Verletzungen von Kontrakten beim Aufruf einer Operation zu signalisieren. Als Reaktion auf die Kontraktverletzung ist es meist notwendig, das betroffene Programm oder einen Programmteil neu zu starten. Die Verwendung von Exceptions erlaubt es, vorher abschließende Aufgaben durchzuführen, so dass Aufräumarbeiten vor dem Beenden möglich sind.
- Exceptions können auch selbst Teil des Kontrakts sein, der zwischen Aufrufer und Umsetzer einer Operation geschlossen wird.
- Die Checked Exceptions in Java ermöglichen das formelle Deklarieren eines Kontraktes zwischen dem Aufrufer und der Methode, indem sich die Methode verpflichtet, bestimmte Exceptions nicht zu werfen. Der Vorteil für den Aufrufer ist, dass er sich um solche Checked Exceptions nicht kümmern muss.
- Allerdings wird der Kontrakt in vielen Fällen nur formell eingehalten, und die ursprünglichen Checked Exceptions werden trotzdem geworfen, allerdings eingebettet in andere Checked oder Unchecked Exceptions. Die Verpflichtung des Kontraktes wird also häufig umgangen.
- Der Aufrufer kann oft auf den Vorteil, den er aus einem solchen Kontrakt ziehen könnte, verzichten, weil er die Exception durchaus behandeln könnte, indem er einfach dem Benutzer eine Fehlermeldung anzeigt.
Ihre Meinung
Wie hat Ihnen das Openbook gefallen? Wir freuen uns immer über Ihre Rückmeldung. Schreiben Sie uns gerne Ihr Feedback als E-Mail an kommunikation@rheinwerk-verlag.de.