Sven Rautenberg: Wann wirft man eine Exception

Beitrag lesen

Moin!

Du schon wieder mit einem Dependency-Injection-Thema! ;)

Grundsätzlich sollte man nicht beliebig viele Exceptions werfen, nur weil man das Konzept mal gehört hat. Exceptions sind auch nichts, was man als Ersatz für GOTO heranziehen sollte, also zur Programmflusssteuerung.

Exceptions sollten insbesondere auch fangbar sein und gefangen werden. Es braucht also ein Konzept, wie man mit Exceptions aus diversen Codeabteilungen umgehen will. Dazu gibts mindestens zwei verbreitete Konzepte.

Und wenn man alle diese Dinge beachtet hat, kommt dann vermutlich noch die Realität daher und macht einem einen Strich durch die Rechnung. :)

Hab da ein Objekt. Dieses Objekt verwaltet einen Datensatz. Es gibt bei diesem Objekt unter anderem eine Methode "delete". Diese Löscht den Datensatz in der Datenbank. Die Methode braucht zwingend den Tabellenname. Es gibt 3 "Fehlerfälle":

  1. Tabellenname ist nicht bekannt -> Exception
  2. Es gibt keinen Primärschlüssel -> return false (Löschen war also nicht erfolgreich)
  3. Datensatz wurde bereits gelöscht -> return true (Löschen war bereits erfolgreich)

Ich streite gerade mit mir selbst wieso ich nicht bei 1,2 und 3 einfach Exceptions werfe. Da ist eine kleine Stimme und die sagt dass alles was die Methode nicht mit Erfolg beendet, sprich das die Methode ihre Arbeit erledigt, mit einer Exception quittiert werden sollte. Die etwas lautere Stimme sagt, dass nur eine Exception geworfen werden sollte, wenn es "Technisch" unmöglich ist, dass die Methode ausgeführt wird, was eben nur dann eintritt, wenn der Tabellenname nicht bekannt ist.

Wenn du mit return true|false schon beide möglichen Fälle für Booleans abgedeckt hast für "Fehlerfälle", und zusätzlich noch eine Exception für ganz harte Fails, wie sieht denn dann der Erfolgsfall-Rückgabewert der Funktion aus?

Grundsätzlich ist es eine gute Idee, wenn man den Code so gestaltet, dass möglichst keine Fehler gemacht werden können. Damit spiele ich auf deinen ersten Fehlerfall an: Wenn du dein Objekt so schreibst, dass man es nur dann erstellen kann, wenn ein Tabellenname übergeben wird, dann kann die delete-Methode nur aufgerufen werden, wenn der Tabellenname bekannt ist, und der Grund für die Exception entfällt, weil der Fehlerfall entfällt.

Da sind wir dann wieder bei der Dependency Injection: Wenn das Objekt im Konstruktor den Tabellennamen übergeben bekommen muss, ansonsten kein Objekt erstellt wird (Konstruktor wirft Exception), dann ist für den Entwickler die Sache sonnenklar, er wird sofort beim ersten Testlauf mit der Nase drauf gestoßen, weil er das Objekt, was er eigentlich gar nicht wirklich erstellen wollte, sondern das bitteschön erst mal zu Testzwecken nur schnellschnell hergestellt werden soll, eben nicht ohne diesen Aufwand bekommt. Es kann also gar kein Code entstehen, der das Objekt erst mal erstellt und dessen andere Methoden benutzt, und am Ende kommt man ans Löschen und entdeckt: "Oh, es braucht den Tabellennamen!", und vergisst zwei versteckte Stellen, wo dieser Name auch noch hätte gesetzt werden müssen.

Im übrigen frage ich mich, was ein Objekt, welches "einen Datensatz verwaltet", mit dem Tabellennamen will? Wenn ich die Delete-Methode dieses Objekts aufrufen würde, dann würde dieses Objekt sich intern an das Tabellen-Objekt wenden, aus dem es herkommt, und das als Referenz bei der Konstruktion mit übergeben wurde, und dort die Delete-Methode mit seiner eigenen ID aufrufen. Und das Tabellen-Objekt weiß dann natürlich, wie es etwas in der Datenbank löscht, indem es den DELETE-Query an das Datenbank-Objekt schickt, in dem die Tabelle steckt...

Auf diese Weise hat man gleich alle seine Abhängigkeiten schön ineinander gekapselt: Das Tabellen-Objekt kann nicht existieren, wenn es nicht aus dem DB-Objekt heraus erstellt wurde, und es enthält für weitere Querys eben dieses DB-Objekt intern. Das Daten-Objekt kommt aus dem Tabellen-Objekt heraus, wenn man die Methode zum Lesen eines Datensatzes aufruft, und es enthält intern das Tabellen-Objekt für die weitere Lebensdauer dieses Datensatzes.

Also braucht nur noch das DB-Objekt genannt bekommen, für welchen Tabellennamen es ein Tabellen-Objekt erzeugen soll, und über dieses Tabellenobjekt wird dann das Datensatzobjekt erzeugt, welches ohne Kenntnis des Tabellennamens weitere Aktionen für sich in seiner Tabelle veranlassen kann.

Ok, mal zurück zu den Exceptions:

Dein Datenobjekt hat jetzt also immer fest die Verbindung zu seiner zugehörigen Tabelle, der Grund für das Scheitern ohne diese Verbindung ist entfallen.

Bleiben noch zwei Fälle:
1. Der Lösch-Query kann erfolgreich ausgeführt werden und liefert die Anzahl der gelöschten Zeilen in der Tabelle zurück.
2. Der Query scheitert aus irgendeinem Grund.

Für den Fall 1 braucht es keine Exception. Weil es sich hier um eine DB-Operation handelt, wird unabhängig von der Sinnhaftigkeit des Ergebnisses dieses einfach zurückgegeben, bei DELETE eben die affected_rows. Wenn die ausführende Applikation sich für die Anzahl der gelöschten Datensätze interessiert und bei "0" was meckern möchte, hat sie dadurch genau die benötigte Information.

Und für den Fall 2 wirft die Methode, die den Query an die Datenbank sendet und hinterher auf Fehler prüft, sinnvollerweise eine Exception mit der DB-Fehlermeldung als Grund. Auf diese Weise kann man in der Entwicklungsphase schnell und direkt an die DB-Fehlermeldung gelangen, und der Code kann entweder auf try/catch beim löschenden Code verzichten, und einem allgemeinen Exception-Handler überlassen, diesen Fail durch eine allgemeine Fehlerseite dem User zu kommunizieren, oder der löschende Code ist so programmiert, dass try/catch diese DB-Fehler-Exception fängt, und irgendeine Art von Alternativprogrammierung greifen kann.

Noch ein paar Anmerkungen zu den Exception-Klassen: Man kann die von PHP gelieferte Klasse "Exception" erweitern, um individuelleres catch zu erlauben. Details dazu stehen in http://de2.php.net/manual/de/language.exceptions.extending.php. Es gibt in PHP auch ein paar vordefinierte Exceptions, insbesondere diverse durch SPL eingeführte. Diese Exceptions sind alle dafür vorgesehen, bei einem bestimmten Problemtyp zu passen: Die InvalidArgumentException wirft man z.B., wenn irgendwo ein Funktionsaufruf ungültige Parameter festgestellt hat. Der Programmierer wird die Exception dann sehen und sieht: "Ungültiges Argument".

Ich persönlich mag diese Art Exceptions nicht. Was hilft es mir, wenn ich weiß, dass irgendwo (tief?) in meinem Aufruf-Stack mal ein ungültiges Argument übergeben wurde und eine Exception ausgelöst hat? Auf einer sehr hohen Ebene habe ich gar nicht die Möglichkeit, die feinen Details einer sehr tiefen Ebene zu lösen. Ich müsste diese Exception also wegen Unlösbarkeit des Problems entweder fangen und komplett ignorieren (warum dann überhaupt verschiedene Typen nehmen), oder bis ganz nach "oben" ins Hauptprogramm durchschlagen lassen und dem User eine allgemeine Fehlermeldung zeigen.

Mein Exception-Stil, der u.A. auch vom Zend Framework gepflegt wird, fügt im Prinzip für jede Klasse oder Klassengruppe eine eigene Exception ein, die von allen zugehörigen Klassen geworfen werden kann:

class Abc_Def_Service -> throw new Abc_Def_Exception()

class Abc_Def_Exception extends Abc_Exception

class Abc_Exception extends Exception

Auf diese Weise kann man ebenfalls alle Exceptions fangen mit catch (Exception $e), man kann alle Exceptions seiner eigenen Library fangen mit catch (Abc_Exception $e), und man kann selektiv alle Exceptions vom Abc_Def_Service, Abc_Def_Handler, Abc_Def_Dataobject und so weiter fangen: catch (Abc_Def_Exception $e).

Wenn man das letztgenannte tut, kann man sicher sein, dass man keine Exceptions vom Abc_Xyz_Service fängt, denn der würde Abc_Xyz_Exception werfen. Man kann die Quelle gefangener Exceptions also eingrenzen auf die bekannten Codeteile. Wenn Abc_Def_Service intern tatsächlich Abc_Xyz_Service benutzt, und dieser dann eine Exception wirft, gibts zwei Möglichkeiten:

Entweder Abc_Def_Service weiß, dass da Abc_Xyz_Exceptions kommen könnten, und hat ein passendes try/catch. Das wird dann greife, die Exception fangen, und der Abc_Def_Service wird wissen, was im Falle einer Exception als Alternativprogramm zu tun ist. Sollte der Programmierer zu der Überzeugung kommen, dass diese Abc_Xyz_Exception als Fehlerfall nicht behebbar ist, und eigentlich eine Exception geworfen gehört, so würde er dann eine neue Abc_Def_Exception werfen - und ab PHP 5.3 die gefangene Exception als dritten Parameter in den Konstruktor mit hineingeben, damit diese interne Information der Fehlerauslösung fürs eventuelle Debugging zur Verfügung steht.

Alternativ kann der Abc_Def_Service auf das Auftreten einer solchen Exception nicht vorbereitet sein und fängt sie nicht. Das wird dazu führen, dass die Abc_Xyz_Exception weiter nach außen durchschlägt. Der Code, der eine Methode des Abc_Def_Service aufgerufen hat, hat aber nur ein Try/Catch für eine Abc_Def_Exception, weil er speziell nur die internen Fehler des Abc_Def_Service behandeln kann, also wird die Abc_Xyz_Exception hier nicht gefangen, sondern gelangt vermutlich ganz nach außen auf die Hauptprogrammebene, ggf. auch in den definierten Exception-Handler, und wird dort entweder mit einem allgemeinen "Blitzableiter" catch (Exception $e) gefangen, oder verursacht auf andere Art eine allgemeine "funzt nicht"-Seite.

Gibt es vielleicht irgendeine Regel wann man Exceptions werfen sollte?

Gruß
der etwas kränkelnde und deshalb gramatikalisch schwer zu verstehende
T-Rex

PS: Hab deine Mail erhalten und wohlwollend zur Kenntnis genommen. ;)

- Sven Rautenberg