Subject: Exception-Handling, Ihr ECOOP2003-Papier From: Robert Will Date: Tue, 17 Feb 2004 11:55:22 +0100 To: Johannes Siedersleben Lieber Prof. Siedersleben, für meine Diplomarbeit über Programmiermethodik untersuche ich zur Zeit auch den sinnvollen Einsatz von Exceptions. Ich habe meine Zwischenergebnisse dazu jüngst in einer Notiz[3] niedergelegt, die auch vom Ansatz in ihrer Quasar-Broschüre beeinflusst wurde. Kurz darauf habe ich Ihr ECCOP-Papier über Exceptions gelesen, und Ihr Architektur- Kapitel aus dem "sd&m Buch" [4]. Erstaunliche Feststellung: Obwohl sowohl Ihr als auch mein Ansatz einer starken aktuellen Tendenz entgegengesetzt sind, stimmen sie fast überein. Mit Freude und sofortiger Wirkung habe ich Ihren Begriff "emergency"/ "Notfall" übernommen; zuvor hatte ich das Wort "run-time error" zur Abgrenzung von fachlichen und technischen "Ausnahmen" / "exceptions" verwendet. Unterschied zwischen meinem und Ihrem Ansatz ist eigentlich nur noch mein Bezug auf Michael Jacksons Arbeiten ([5], erklärt in [3]), der nichts weiter darstellt, als mehr theoretischen Hintergrund; und als zweiter Unterschied mein Standpunkt zu Zusicherungen, den ich im Folgenden darlege (Abschnitt ** 2 **). Besonders hinweise möchte ich auch auf Abschnitt ** 4 **, in dem ich einige Kommentare zu Ihrem ECOOP-Papier gesammelt habe. Ich hoffe, diese sind Ihnen besonders nützliche, da meine Sicht in gewisser Weise von sd&m Publikationen geprägt ist, und ich daher vielleicht eher Ihre Sprache spreche, als andere. Okay, ich hoffe Sie haben Zeit für meine Nachricht und "es bringt Sie weiter". Mit freundlichem Gruß, Robert Will ** 1 ** Bob's Emergency Plan Grundregeln zur Notfall-Behandlung 1. Notfälle kann man nur behandeln, wenn man den normalen Betrieb genau kennt -- inclusive aller seiner Ausnahmen. Jede Software braucht eine Spezifikation. 2. Es ist die allererste Aufgabe des Softwareingenieurs Nofälle zu _verhindern_ (indem er die Software zu gut wie möglich entwickelt). Die _Erkennung_ von Notfällen ist eine davon getrennte Angelegenheit und darf nicht mit dem normalen Code vermischt werden. Code zur Notfall-Erkennung ist _redundant_ zum normalen Code (denn letzterer verlässt sich ja gerade auf den Nicht- Notfall). 3. Notfallbehandlung ist schwer: in einem Notfall kann man sich nicht mehr auf Dinge verlassen, die eigentlich unerlässlich sind. (Gültigkeit von Zusicherungen, ...) Daraus folgen 4. und 5.: 4. Kleine Module können (und müssen) so entwickelt werden, dass sie absolut notfallfrei funktionieren. Notfallbehandlung im Kleinen ist zwecklos. (Notfallmeldungen "überspringen" kleine Bausteine.) 5. Im Notfall kann ein System entweder seine Funktion gar nicht mehr erfüllen (nur noch den Schaden in Form von Datenverlust minimieren), oder es fällt zumindest ein großer Teil der Funktionalität weg. 6. Daraus folgt die Einteilung des Systems in verschiedene Schutzbereiche: jeder Bereich arbeitet entweder exakt nach seiner Spezifikation oder er signalisiert "Notfall". Jeder Bereich, der im Notfall auf einen anderen angewiesen ist, muss entscheiden ob er: a) ohne den anderen Bereich nicht mehr arbeiten kann, dann hat er auch "Notfall". b) ohne den anderen Bereich doch noch arbeiten kann, indem er einen gesonderten Aufwand betreibt, um seine Spezifikation noch zu erfüllen. Um letzteres zu ermöglichen, muss die normale Spezifikation des Notfall-empfangenden Bereichs gegebenenfalls von vornherein etwas schwächer abgefasst sein, in der Regel indem gewisse Funktionalitäten (wie Online-Zugriff) optional sind. [[Zur Abgrenzung von "security" / "Sicherheit" übersetze ich "safety" mit "Schutz-". Weil die Schutzbereiche nicht unbedingt mit den Komponenten der Auslieferung und anderer Sichten übereinstimmen müssen, verwende ich diesen eigenen Begriff.]] ** 2 ** Notfälle und Exceptions Wenn man diese Regeln praktisch umsetzen will, so braucht man in der Programmiersprache zwei Dinge: 1. Eine Notation für Spezifikationen und Zusicherungen, die redundant zum Code den normalen Betrieb beschreiben. Wenn eine solche Zusicherung verletzt wird, tritt ein Notfall sein. 2. Ein Mechanismus, der Notfälle bis zu den Schutzfassaden weiterleitet und der es ermöglicht: - den Schaden zu begrenzen (Ressourcen freizugeben, Daten zu sichern u.ä.), und - Notfälle an einer Fassade zu stoppen, so dass sie vom empfangenden Bereich wie eine Ausnahme in der Anwendungslogik oder im technischen Ablauf behandelt werden. (Keine Online-Bestellung möglich; Ersatzdatenbank nehmen; etc.) Ich stimmen mit Ihnen überein, dass Exceptions nur für die Notfall- Behandlung eingesetzt werden sollten, und zu keinem anderen Zweck. Die Performance-Strafen von Exceptions sind aber nicht mein Hauptargument, sondern schlicht und einfach die Trennung der Angelegenheiten: Ausnahmen in der Anwendungslogik (oder Technik) kann (und soll) man genauso behandeln wie die normalen Fällen: im normalen Code. (Alles andere wäre zu beliebig und würde die Software nur unverständlicher machen.) Abweichend von Ihrem Ansatz, gehören für mich aber die Notfall- erkennung und die normalen Zusicherungen und Verträge in die selbe Notation. Die Begründung ist ganz einfach: 1. Wollen wir ja Notfälle so früh wie möglich erkennen. Wir sollten also dazu alle Überprüfungen nutzen, die wir haben. 2. Ist die Entscheidung, welche Zusicherungen man im laufenden Betrieb abschaltet (Performance!) und welche man drin lässt (Notfall- Schutz!), eine separate Angelegenheit. Man sollte diese Entscheidung nicht mit der Wahl einer Notation / eines Mechanismus zusammen treffen, sondern für jede Zusicherung unabhängig, je nach den Anforderungen von Performance und Risiko (die sich teilweise erst durch Messungen am Objekt ergeben). 3. Sind sowohl Notfallerkennung als auch Zusicherungen und Verträge vom ausführbaren Code (Kontrollfluss) zu trennen (mindestens andere Farbe im Editor), wenn man dazu zwei unterschiedliche Mechanismen verwendet, erhöht sich die Komplexität der Programmier- methode (und -Sprache). Gerade weil aber Zusicherungen und Verträge ohnehin schon (zum Kontrollfluss) redundant sind, muss die Sprache/Methode ihre Erstellung und Pflege durch besondere Einfachheit fördern. In der Programmiersprache Eiffel ist diese Zusicherungsproblematik sehr gut gelöst: Es gibt eine Zusicherungssprache (Bool'sche Ausdrücke plus kleine Extras) und Zusicherungen haben feste Plätze in der Eiffel- Syntax, welche Eiffel- Programme selbsterklärend machen. Man kann jeder Zusicherung einen informellen Namen zuordnen, der beim Zuschlagen der Zusicherung angezeigt wird (Beschreibung der Exception). Soweit ich mir das vorstelle, kann man jeder Zusicherung auch eine "Schutzstufe" zuordnen und beim Kompilieren wählt man aus, welche Stufe zur Laufzeit geprüft werden soll. Beim Unit-Test alles, beim System-Test weniger, und im Betrieb nur noch die Erkennung von Notfällen. So muss das sein! In Java kann man dass aber genauso machen: Man übergibt die Schutzstufe einfach als weiterem Parameter an "assert". Bei einem guten Compiler mit Inlining und Konstanten- Auswertung bleibt davon gar nicht mehr Maschinen- Code übrig, als wenn man einen Präprozessor verwendet hätte. Zu Dokumentationszwecken verwende ich "assert" unter den verschiedenen Namen "require ensure invariant assert" (Schlüsselwörter aus der Eiffel- Syntax) um Vor- und Nachbedingungen bzw. Invarianten zu kennzeichnen. ** 3 ** Diskussion der Prinzipien Diese Methode ist nun theoretisch fundiert und praktisch richtig, aber trotzdem widerspricht sie einer weit verbreiteten Auffassung. Tatsächlich sagte mir jemand über Robustheit: "Software kann man gar nicht fehlerfrei entwickeln und deswegen muss man versuchen, dass ein Programm wenn schon nicht das Korrekte, so doch noch etwas Sinnvolles tut." Und damit rechtfertigt man dann eine ganze Reihe von Programmiertricks, die vor allem daraus hinauslaufen, Routinen immer "total" zu machen, also unabhängig von jeder Vorbedingungen ein "möglichst sinnvolles Resultat" liefern, mit der Folge, dass das Routinenverhalten unspezifizierbar kompliziert wird. Diese Auffassung wird leider nicht nur von Programmierern vertreten, sondern auch von richtigen Gurus, großen Autoritäten. Man nehme dazu noch die Spezifikations- Aversion von XP und man landet in einer ganz anderen Welt, in der Programme zusammengebastelt werden, nicht konstruiert. Viele Programmierer meinen wohl, das Exceptions ein normaler Teil der Programmiersprache sind, den man immer benutzen kann (und sollte), und dass aber Software-Spezifikationen etwas abgehobenes sind, dass man nur bei besonderen Projekten braucht (und insbesondere nicht beim eigenen Projekt). Meine Überzeugung ist genau entgegengesetzt: Spezifikationen braucht man in jedem Softwareprojekt, und das Minimum (für ganz kleine Programme) sind Schnittstellenbeschreibungen (Design by Contract) und eine Benutzeranleitung (möglicherweise interaktiv). Wer meint, ohne dies auszukommen, sperrt sich selbst im Keller ein: er wird gewisse Niveaus von Produktivität und Qualität nie erreichen. (Wenn sich Design by Contract erstmal durchgesetzt hat, sind dessen Gegner einfach nicht mehr konkurrenzfähig. Und tschüss!) Exceptions hingegen sind ein Tool ausschließlich für große Projekte, denn Schutzfassaden lohnen sich nur zwischen sehr großen Komponenten (Schutzbereichen) und machen diese auch noch größer. [[Für die universitäre Lehre folgt daraus, dass man Programmierung von Anfang an mit Spezifikationen (Verträgen) durchführen sollte: Erstellen von Code bei gegebenem Vertrag, Bestimmen der Verträge zu gegebenem Code, und ganz wichtig: Entwurf von Verträgen, inclusive Vorbedingungen. (Alles Erstes Studienjahr.) Erst wenn die Studenten das aus dem FF beherrschen, kann man Notfallbehandlung und Exceptions einführen. (Im Fach "Architektur", möglicherweise erst im Hauptstudium.) Man sollte zu diesem Zeitpunkt auch schon in der Lage sein, wirklich große Beispiele zu präsentieren, bei denen sich Schutzbereiche wirklich lohnen. (Die aktuelle Situation ist wohl eher, dass Studenten im Grundstudium eine komplette Programmiersprache lernen inclusive Exceptions mit denen sie noch gar nichts anfangen können, und Spezifikationen und Verträge kommen -- wenn überhaupt -- nur ganz spät unter "Architektur" oder "formale Methoden".)]] Für die Notfallbehandlung halte ich es auch für ausschlaggebend, dass die meisten Notfall-Ursachen außerhalb der Software liegen, und es sich dabei um Fälle handelt, die man der Anfordungs- und DV-Analyse einfach wegabstrahiert hat. Zum Beispiel ein ausgestöpfeltes Netzwerkkabel. Daher meine Unterscheidung zwischen internen und externen Fehlern, zwischen logischen Fehlern und Approximations- fehlern. (Siehe Jackson[5] und die Grafik unter dem Link [6].) Tests, Design by Contract, und (zukünftig) statische Prüfer können interne Fehler (zumindest auf Komponentenebene) praktisch komplett beseitigen. Externe logische Fehler bekämpft man durch methodisches Vorgehen, gescheite Notationen und Reviews. Approximationsfehler wird man nie ganz loswerden, dazu kommen wir nur in einer komplett automatisierten Welt mit allwissenden Computern. Naive Programmierer unterliegen dem entscheidenden Irrtum, alle Fehler für so immanent wie Approximationsfehler zu halten. Dann machen sie ihren Code negativ redundant und nennen das robust. Sie ersticken in Komplexität, weil sie Design by Contract nicht kennen. Die Armen! Dagegen ist die Welt nach dem Notfall-Modell sehr einfach: eine Komponente arbeitet entweder exakt nach Spezifikation, oder sie tut es nicht (--> Notfall). Jede Komponente kann für jeden ihren Anbieter entscheiden, ob sie einen Notfall dieses Anbieters verkraftet. Normalerweise tut sie das nicht, Notfälle verbreiten sich automatisch weiter. Falls eine Komponente doch einen Anbieter-Notfall verkraften soll, muss meist viel Aufwand betrieben werden (die Funktionalität redundant beschaffen), oder die Komponente fällt auf eine niedrigere Funktionalitäts- Stufe zurück (die vorher spezifiziert werden muss). Die Entscheidung, Notfälle zu verkraften oder nicht, ist sehr schwer: entweder wir haben auch Notfall, oder wir haben echte Mehr-Arbeit. Deswegen möchten sich die Programmierer wohl darum drücken, aber hier sind Kompromisse stets faul: eine Spezifikation kann man nur ganz oder gar nicht erfüllen, falls es Zwischenstufen gibt, müssen diese in der Spezifikation schon drin stehen. Einzig bei der "Schadensbegrenzung" -- also dem Freigeben von Ressourcen, Rollback der Transaktion -- stimmt die "Schlumpermethode" mit den "disziplinierten Exceptions" überein. ** 4 ** Es gibt also eine große Kluft zwischen aktueller, allgemeiner Praxis und der "guten Methode". sd&m ist dabei weit entfernt von anderen Praktikern, in Richtung der guten Methode, aber kann doch dem Einfluss des "Rests der Welt" nicht ganz entweichen. Die drei Punkte, die ich jetzt an Ihrem Papier kritisiere, kann man Ihnen schwerlich als Fehler vorwerfen, weil sie es genauso machen, wie Praktiker nach dem "aktuellen Stand der Kunst". Es geht aber noch besser. Zuerst schockte mich Ihre Exception-Klasse mit den Methoden "ifTrue", "ifFalse", "ifNull" und so weiter. Diese Redundanz wurde von JUnit in die Welt gesetzt, wohl weil deren Entwickler nicht glauben konnten, dass eine Klasse mit nur einer Methode "assert" oder "emergency_if" etwas gutes sein kann. Das kann sie aber durchaus; und so eine Klasse ist sogar noch besser, weil einfacher zu benutzen! Die logischen Operationen gibt es in der Programmiersprache schon einmal, und Programmierer können wesentlich leichter mit Bool'schen Ausdrücken umgehen, wenn sie nicht zwischen verschiedenen Darstellungen umdenken müssen. Ein Mini-Beispiel zur Illustration: (der "require" Abschnitt enthält in Eiffel die Vorbedingung) routine_machwas( buchung : BUCHUNG; konto : KONTO ) is require buchung.abgeflogen implies konto.gedeckt do if not konto.gedeckt then assert( not buchung.abgeflogen ) -- trivial oder nicht? ... end end Anstelle verschiedener Trues, Falses and Nulls, brauchen wir 1. eine klare Unterscheiden von Notfällen und abgewiesenen Aufrufen (durch Vorbedingungen), 2. eine Unterscheidung von Schutzstufen für Test und Betrieb, und 3. Informationen zur Dokumentation (Invariante oder Nachbedingung?) und zum Debugging. Meine zweite Bemerkung betrifft Ihr Beispiel mit der Routine "abheben". Ich behaupte ja, dass man bei der Behandlung von fachlichen Ausnahmen ("application errors") ganz und gar auf die Benutzung von Exceptions verzichten sollte. Fehler kann man stattdessen als weiteren Rückgabewert übermitteln oder im Zustand des Anbieter- Objekts speichern, wo sie später leicht abgerufen werden können. Ersteres lohnt sich meist nicht, weil man ja nicht beide Rückgabewerte gleichzeitig verwenden kann, der Aufrufer muss sie also erstmal in eine Variable legen, und diese Arbeit kann ihm der Anbieter ja auch gleich abnehmen. Objekt- Zustände geschickt zu benutzen hat uns Bertrand Meyer gelehrt [1] (und lehrt er gerade wieder [2]). Er hat auch das Prinzip der Trennung von Befehlen und Anfragen eingeführt (Command- Query- Seperation, CQS), demnach Funktionen (also Routinen mit Rückgabewerten) keine Seiteneffekte haben sollen, sie sind nur Anfragen. Seiteneffekte bleiben den Prozeduren (Befehlen) vorbehalten, die dafür keine Rückgabewerte haben. Sein markiger Merksatz lautet: "Asking a question should not change the answer." CQS erfordert etwas mehr Arbeit auf Anbieterseite, macht aber dafür Anwendungs- Code wesentlich einfacher zu schreiben (und zu lesen!). Anfragen (Funktionen, "Getter") verwendet man ja in Ausdrücken und von denen erwartet niemand einen Seiteneffekt. Insofern ist es bereits ein Entwurfsfehler, dass die Routine "abheben" aus ihrem Beispiel den neuen Kontostand zurückgibt! Den Kontostand sollte man jederzeit über einen einfach "Getter" erreichen, wozu also die ohnehinschon komplizierte Routine _abheben_ damit belasten? (_abheben_ ist kompliziert, weil eine Transaktion im Spiel ist, nehme ich mal an.) Damit fällt die komplette Exception- Problematik in diesem Beispiel weg, und so ist mit vielen der aktuell diskutierten Probleme: wenn man die richtige Methode verwendet, treten sie gar nicht auf. ((Die Lösung für das Beispiel mit der Matrix-Inversion ist nach Meyer übrigens eine Abfrage "is_regular : BOOLEAN" und eine "inverse : MATRIX" mit Vorbedingung "is_regular". Anwendungscode sieht dann ganz sauber aus: if m.is_regular then ... use m.inverse here ... end und in der Implementierung kann man die Berechnung in "is_regular" vornehmen und das Ergebnis heimlich für die zweite Routine zwischenspeichern. "Heimlich" ist eine Referenz an "information hiding", der Anwendungsprogrammierer merkt es nämlich nicht.)) Meine letzte Bemerkung betrifft die Vor- und Nachbedingungen: Zunächst einmal haben Sie vergessen zu schreiben, dass "Reject" im Prinzip nur von der "Schutzschicht" einer Komponente benutzt werden kann, also nur von Routinen, die von anderen Komponenten gerufen werden. Und nur die Notfall- Behandlung der aufrufenden Komponente muss die Exception ignorieren: denn sie behandelt ja Notfälle in der gerufenen Komponente während der eigentliche Notfall beim Aufrufer besteht (der die Vorbedingung nicht einhielt), also hinter der Notfall- Behandlung. Außerdem muss man die Exception unterwegs kennzeichnen, so dass sie dann von "nachfolgenden" Komponenten abgefangen werden kann. Weiterhin kann man natürlich die Non-Null-Vorbedingungen nicht "implizit" lassen, weil sie dann nicht geprüft werden. Stattdessen sollte man "standard-mäßig" immer die Rejects für Null-Argumente hinschreiben (oder vom Editor / Generator hinschreiben lassen) und nur explizit Null erlauben, also die Zusicherung wegnehmen. Der wirklich kritische Punkt ist aber ihre Empfehlung, die Nachbedingung einer Routine nach jedem Aufruf zu testen! Ein solcher Test steht dann ja an vielfacher Stelle im Code; das ist negative Redundanz! Stattdessen sollte man Nachbedingungen "zwischen Aufrufer und Anbieter" überprüfen: durch das Laufzeitsystem wie in Eiffel, oder von einem Assertion- Generator (wie iContract, glaube ich) in Java. In Eiffel sorgt dieser Mechanismus auch automatisch dafür, dass eine Verletzte Vorbedingung der Aufrufer trifft, und nicht den Anbieter. Die Idee sollte sein, dass die Verträge zur Schnittstelle gehören und damit zwischen Aufrufer und Anbieter liegen. In Eiffel schreibt man die Nachbedingung zwar im "ensure" Abschnitt direkt hinter die Routine, aber dort hängt sie nicht fest: Eiffels Schnittstellen- generatoren erstellen automatisch eine Schnittstellenbeschreibung, die nur den Vertrag (und die Signatur) enthält. Der Aufrufer sieht nur Schnittstelle, und durch die Laufzeitprüfungen der selbigen kann er sich darauf verlassen, dass die Nachbedingung immer eingehalten wird. Eiffel ist die beste Programmiersprache für die Praxis, und Design by Contract ist ihre wichtigste Eigenschaft. Verträge sind operationalisierte Dokumenation. Daher hat die Kombination von Dokumentations- und Zusicherungs- Generatoren in Jave eine große Zukunft. Referenzen [1] Bertrand Meyer, Object-Oriented Software Construction, 1st edition, Prentice Hall 1988 [2] -, Touch of Class, http://www.inf.ethz.ch/~meyer/down/touch/ [3] Robert Will, Disciplined Exceptions -or- Error Management is Risk Management, http://www.stud.tu-ilmenau.de/~robertw/eiffel/cov/b.txt [4] Johannes Siedersleben (Hrsg.), Softwaretechnik - Praxiswissen für Software-Ingenieure, 2. Aufl., Hanser, 2003 [5] Micheal Jackson, The Real World, 2000, http://dspace.dial.pipex.com/jacksonma/Hoare995.zip [6] Grafik der Fehlertypen, http://www.stud.tu-ilmenau.de/~robertw/eiffel/#messages [7] Eric Hehner: "A Practical Theory for Programming", 2nd ed., 2004 http://www.cs.toronto.edu/~hehner/aPToP/