Vorwort
Exception Handling in funktionalen Programmiersprachen
Ursprünglich lautete der Konsens in der funktionalen Programmierung: Keine Exceptions!
An sich eigentlich ein guter Plan, bedenkt man die mitunter ausufernden Handler-Orgien in diversen Hochsprachen. Allerdings resultieren daraus auch mindestens zwei wesentlich problematische Aspekte:
- Was, wenn der Code nicht vollständig selbst kontrolliert werden kann? (Bspw. beim Einsatz von Bibliotheken)
- Was, wenn die Kommunikation der Anwendung mit der Außenwelt Fehler produziert?
Insbesondere der letztgenannte Punkt resultierte letztlich in Überlegungen, wie Exception-Handling in funktionalen Stil integriert werden kann. Seit sich dieser Gedanke durchgesetzt hat, verfügen selbst rein funktionale Sprachen wie Haskell über verschiedene Strategien, Exceptions einzufangen und zu behandeln.
Worum es in diesem Artikel gehen wird
Wir werden uns an dieser Stelle auf Scala fokussieren, da es neben den funktionalen Wegen auch noch die Standardmittel aus Java erbt und damit noch mehr Wege bietet, Exceptions zu behandeln und zu kontrollieren. Dabei wird auf die drei im wesentlichen zur Verfügung stehenden Wege Bezug genommen, als da wären:
- Klassischer try-catch-Block
- Try-Blöcke
- Runtime Control
Die folgenden Abschnitte werden diese Begriffe noch etwas näher erläutern.
Nötige Vorkenntnisse
- Option
- Either
- PartialFunction
- Generelle Kenntnisse zum Thema Exceptions
- Generische Programmierung
- Pattern Matching / Unapply für Case Classes
Klassisch
Der klassische Ansatz ist der Java-Variante sehr nahe, mit der Ausnahme, dass Exceptions direkt per Case-Match selektiert werden können. Dieser Block ist dabei als PartialFunction anzusehen, d.h. nicht jeder potentielle Fall muss abgedeckt sein (bezeichnet man in der Regel als nicht exhaustive).
try { /* Auszuführender Code */ } catch { case e: IllegalArgumentException => case e: IllegalStateException => /* ... */ } finally { /* Immer auszuführender Code */ }
Der Ansatz folgt keinem funktionalen Paradigma – das Ergebnis kann nicht monadisch (also über fortführende Abbildungen wie map, filter etc.) verwertet oder nennenswert wiederverwertet werden und ist daher nur begrenzt empfehlenswert.
Try-Blöcke: scala.util.Try
Die Klasse Try beschreibt im logischen Sinne einen algebraischen Datentyp mit den beiden Ausprägungen Success und Failure. Algebraisch ist hier so zu verstehen, dass ein Ergebnis vom Typ Try entweder Success oder Failure bedeutet. Beides sind kovariante Kinder von Try. Success tritt dabei auf, wenn der auszuführende Code keine Exception geworfen hat, Failure im Falle einer Exception. Entsprechend trägt Success das potentielle Resultat, während Failure eine Exception (hier als Throwable) dabei hat. Um die genaue Exception zu erhalten, muss das in Failure enthaltene Resultat erneut per match inspiziert werden.
Zunächst einmal ein einfaches Beispiel:
val tryRes: Try[T] = Try {... /* Irgendein sinniger Code, der eine Exception werfen kann */ ...} tryRes match { case Success(result) => /* Code für den Erfolgsfall */ case Failure(except) => /* Code für den Exception-Fall */ }
Der Nutzen dieser Variante ist vor allem in ihrer monadischen Verwertbarkeit zu finden – ein Try kann nämlich, ähnlich wie ein Option, unter anderem per map, filter, flatMap oder transform verarbeitet und auch per getOrElse mit Default-Werten abgebgraben werden. Die wirkliche Verarbeitung erfolgt hier entsprechend nur, falls ein Success vorliegt, andernfalls wird das existierende Failure weitergereicht. Weiterhin ist es auch möglich, ähnlich wie bei PartialFunction, ein orElse zur Verkettung anzugeben, oder wie bei Futures ein recover bzw. ein recoverWith. In begrenzter Form ist auch eine Nutzung in for-comprehensions möglich.
val tryRes: Try[T] = Try {... /* Irgendein sinniger Code, der eine Exception werfen kann */ ...} val realRes: T = tryRes getOrElse defaultValue
Der wesentliche Nachteil von Try-Blöcken ergibt sich aus den nicht unmittelbar unterstützten finally-Blöcken. Diesen kann man bspw. über ein entsprechend eingepflegtes transform realisieren:
val tryRes: Try[T] = Try {... /* Irgendein sinniger Code, der eine Exception werfen kann */ ...} tryRes.transform({ result => finallyFunc() result }, { exception => finallyFunc() exception })
Allerdings wird an dieser Stelle entsprechend Code dupliziert.
Die Wiederverwertbarkeit dieser Variante ergibt sich aus der Kombinierbarkeit der Funktionen und Blöcke, die zum Handling eingesetzt werden können. Insgesamt lässt sich hier mehr wiederverwerten als bei der klassischen Variante, allerdings ergeben sich wie am Beispiel des finally-Workarounds gezeigt auch einige zusätzliche, sich wiederholende Abschnitte.
Runtime-Control: scala.util.control.Exception
Die Module zur Runtime-Control von Exceptions in Scala dienen vor allem einem wesentlichen Zweck: Handler an beliebiger Stelle definierbar zu machen, welche wiederverwertet und variabel angepasst werden können.
In diesem Zusammenhang wird ein Aufruf benötigt, der die Class-Info einer Klasse bereitstellen kann: classOf
val classOfString = classOf[String]
Die Grundidee von Kontroll-Blöcken liegt in der Kapselung der try-catch-finally-Logik in Containern, welche Funktionen anbieten, um einen Code-Block gemäß definierter Absicherung auszuführen und das Resultat in etwas – idealerweise monadisch – weiterverwertbares zu packen. Dabei werden stets nur die angegebenen Exceptions gefangen (wobei es einige Ausnahmen bzgl. fataler Exceptions gibt). Je nach genutzter Strategie sind gesonderte Aufrufe notwendig, um die jeweils genau gefangene Exception zu determinieren. Bevor wir in die Details der Nutzung übergehen, werden zunächst die notwendigen Hilfsklassen betrachtet.
Allgemeine Definition
In der offiziellen Dokumentation stehen im Exception-Object mehrere Funktionen zur Verfügung, mit denen Catch-Container generiert werden können – eine direkte Anlegung ist in der Regel nicht notwendig. All diesen Funktionen ist gemein, dass sie eine variable Anzahl von Argumenten entgegennehmen und entweder einen Catcher, einen Catch oder ein By zurückliefern, die entweder eingesetzt oder komplettiert werden können bzw. müssen.
Die variable Anzahl von Argumenten wird hier mit
exceptions: Class[_]*
benannt. Der _ gilt hier der Notation entsprechend als Wildcard. Um einen Code-Block ausführen zu lassen, stehen neben dem üblichen apply noch einige Konversionen zur Verfügung. Die genaue Nutzung und ihre Varianten werden unten noch genauer beschrieben.
Die unten aufgeführte Tabelle listet die am häufigsten genutzten Generatorfunktionen auf.
Generator | erzeugt |
---|---|
catching | Einen Catch-Container, der die aufgelisteten Exceptions fangen kann, es sei denn, es handelt sich um fatale Exceptions, bspw. ControlThrowable, InterruptedException, OutOfMemoryError. |
catchingPromiscuously | Einen Catch-Container, der die aufgelisteten Exceptions fangen kann, auch wenn diese fatal sind (nur begrenzt empfohlen). |
failing | Einen Catch-Container, der bei Ausführung ein Option[T] erzeugt, wobei die gelisteten Exceptions auf None abgebildet werden. |
failAsValue | Einen Catch-Container, der bei Ausführung eine gefangene Exception auf den gegebenen Default-Wert abbildet. |
handling | Einen By-Container, dem noch über den Aufruf von by ein Handler für die zu fangenden Exceptions mitgegeben werden muss. |
Ein existierender Container kann unter anderem kombiniert oder in neue Catcher mit anderer Apply-Logik per Member-Funktion abgebildet werden. Hieraus resultiert jeweils ein neuer Catch-Container, bei dem die jeweils aufgerufene Eigenschaft geändert wurde, ohne das die übrigen Komponenten davon berührt werden. Zu den wichtigsten dieser Funktionen zählt wohl andFinally, durch welche das finally-Handling festgelegt werden kann.
Hilfsklassen
Catcher
Catcher beschreiben weniger eine Klasse als eine Type-Alias für PartialFunction[Throwable, T], also einer partiellen Funktion, die einige Exceptions behandelt und daraus Parameter vom Typ T erzeugt. Sie stellen einer der Varianten dar, auf deren Basis Catch-Container angelegt werden können.
val catchy = catching { case e: NoSuchElementException => {/* Handling für diesen Fall */} }
Catch[+T]
Der eigentliche Container muss mit einer Liste von Exceptions angelegt werden, die von ihm gehandhabt werden sollen. Optional sind dabei die Angaben für ein gesondertes Handling aufgetretener Exceptions sowie des finally-Blocks.
val catchy = catching(classOf[NoSuchElementException])
By[T, R]
Beschreibt einen quasi halbfertigen Catch-Container, in dem noch nicht definiert ist, wie die zu fangenden Exceptions gehandhabt werden sollen, aber die Forderung für ihre Behandlung durchgesetzt werden soll. T beschreibt hier eigentlich eine Funktion, die aus einer Throwable ein Resultat vom Typ R macht.
val catchy = handling(classOf[NoSuchElementException]) by (_.printStackTrace)
Ausführung & Nutzung als Option/Either/Try
Ohne Angabe eines konkreten Exception-Handlers wird ein Catch-Container die von ihm eingesammelten Exceptions einfach nur weiterwerfen (Rethrow). Die vier im folgenden näher betrachteten Varianten decken die meisten Anwendungsfälle ab.
apply[U >: T](body: => U): U
Mit apply wird der gegebene Body ausgeführt, wobei die für den Catch-Container definierten Exceptions klanglos abgefangen werden. Der Einsatz dieser Variante bietet sich an, wenn gewisse Exceptions als weitestgehend unwichtig angesehen werden können, bspw. weil sie für den Kontrollfluss eingesetzt werden (was insbesondere bei einigen Java-Bibliotheken gerne vorkommt), wofür sie eigentlich nicht gedacht sind. Es ist hierbei im Nachhinein nicht möglich festzustellen, ob und falls ja welche der definierten Exceptions geworfen wurde.
val emptyList = List.empty[U] val result: U = catching(classOf[NoSuchElementException]) { emptyList.head }
opt[U >: T](body: => U): U
Mit opt wird der gegebene Body ausgeführt, wobei die im Catch-Container definierten Exceptions auf None abgebildet werden, sofern sie auftreten. Eine erfolgreiche Ausführung endet in einem Some(result). Diese Variante bietet sich an, wenn im umgebenden Code festgestellt werden soll, dass eine Exception aufgetreten ist, aber nicht von Interesse ist, welche das genau war – denn wie bei apply ist hier nicht feststellbar, welche genau geworfen wurde.
val emptyList = List.empty[U] val result: Option[U] = catching(classOf[NoSuchElementException]).opt { emptyList.head } result match { case Some(head) => // ... case None => // ... }
either[U >: T](body: => U): Either[Throwable, U]
Mit either wird der gegebene Body ausgeführt, wobei bei Auftreten einer der definierten Exceptions ein Left mit der genauen Exception zurückgegeben wird, während eine erfolgreiche Ausführung ein Right mit dem Resultat zurückgibt.
val emptyList = List.empty[U] val result: Either[Throwable, U] = catching(classOf[NoSuchElementException]).either { emptyList.head } result match { case Left(exception) => // ... case Right(head) => // ... }
Wie bei Try-Blöcken kann die in Left enthaltene Exception per match noch genauer inspiziert werden.
withTry[U >: T](body: => U): Try[U]
Mit withTry wird der gegebene Body ausgeführt, wobei eine Abbildung auf ein Try, also auf ein Success(result) oder ein Failure(exception) erfolgt.
val emptyList = List.empty[U] val result: Try[U] = catching(classOf[NoSuchElementException]).withTry { emptyList.head } result match { case Failure(exception) => // ... case Success(head) => // ... }
Spezielle Varianten
Hierbei geht es um vordefinierte Catch-Varianten, die im Object scala.util.control.Exception definiert sind. Sie sind eher für speziellere Fälle gedacht und in vielen Fällen aufgrund der weit gefassten Catch-Breite nicht anzuraten.
Variante | Für |
---|---|
allCatch | Catch-Container, der jede Exception einfängt. Ist mit Vorsicht zu genießen, da auch fatale darunter sind, bei denen ein Recover kaum oder schwer möglich ist. |
noCatch | Catch-Container, der gar nichts fängt. Klingt erst einmal sinnfrei, ist aber konsistent hinsichtlich funktionaler Programmierung – wenn irgendwo als Parameter ein Catch[T] mitgegeben werden soll, kann man diesen hier benutzen, wenn nichts gefangen werden soll. |
nonFatalCatch | Catch-Container, der alle nicht-fatalen Exceptions einfängt. |
Kovarianz und Kontravarianz
Dem geneigten Leser dürfte in den obigen Abschnitten etwas aufgefallen sein: Während Catch den generischen Parameter T als kovariant einstuft, nehmen die Funktionen zur Ausführung einen generischen Parameter U an, der kontravariant zu T ist. Warum?
Um dies zu erläutern, betrachten wir noch einmal das oben genannte Beispiel:
val catchy = catching(classOf[NoSuchElementException])
An dieser Stelle wurde zugunsten der Typeninferenz auf die Angabe eines konkreten Datentyps verzichtet. Nun soll der generische Parameter T von Catch den Rückgabetyp im Erfolgsfall angeben – aber: Woher ist dieser bekannt, wenn bei der Generierung nur Exceptions berücksichtigt wurden?
Streng genommen ist der Parameter T nicht bekannt bzw. kann nicht inferiert werden, weshalb der Rückgabetyp im o.g. Beispiel ein Catch[Nothing] ist:
val catchy: Catch[Nothing] = catching(classOf[NoSuchElementException])
Nothing erbt in Scala implizit von allen anderen Klassen, weshalb der Ausdruck T >: Nothing immer wahr ist. Um auszuführende Blöcke zu ermöglichen, die nicht Nothing zurückgeben, ist bei den Funktionen auf Catch Kontravarianz notwendig. Prinzipiell bedeutet dies, dass von einem Catch gehandhabte Ausführungen prinzipiell beliebige Werte zurückliefern können, ohne dabei auf das unkonkrete Any zurückgreifen zu müssen.
In den meisten Fällen genügt diese Restriktion. Allerdings kann man sie auch modifizieren. Der Parameter T für Catch wird zwar in der Regel mit Nothing inferiert, aber prinzipiell kann man ihn auch explizit angeben. Dafür kann man entweder den Rückgabetyp explizit angeben, oder den Typparameter der Generatorfunktion nutzen:
val catchy1 = catching[String](classOf[NoSuchElementException]) // Typparameter der Generatorfunktion val catchy2: Catch[String] = catching(classOf[NoSuchElementException]) // Expliziter Rückgabetyp
Die letztegenannte Variante ist aufgrund der Kovarianz von T in Catch valide. Durch Angabe des resultierenden Typs können die genannten Funktionen zur Ausführung nur Datentypen zurückgeben, die in der Vererbungshierarchie identisch oder höhergestellt sind (im o.g. Beispiel wäre Any ein möglicher Rückgabetyp).
Ein konkretes Beispiel
Um den Nutzen der Catch-Container etwas plastischer zu gestalten, sei an dieser Stelle ein Beispiel angeführt, welches in einem aktuellen Projekt zum Einsatz kommt. Dazu ist zunächst die Definition eines Catchers zu betrachten.
val executionCatcher = catching(classOf[AlreadyExistsException], classOf[NoHostAvailableException], classOf[QueryExecutionException], classOf[QueryValidationException], classOf[SyntaxError]).withApply { e: Throwable => Global.systemStatistics ! CountEvents.DatabaseReqFailed throw e }
Die in diesem Beispiel angegebenen Exceptions werden vom Cassandra-Treiber von Datastax produziert. Damit ist es unmittelbar möglich, genau diese und nur diese Exceptions von diesem Catch-Container behandeln zu lassen, was mit wenig notwendigem Code eine einfache Separierung von Auslösern ermöglicht: Wurde eine Exception gefangen und von diesem Catch behandelt, war sie konkret auf die Datenbank bezogen, wurde sie nicht behandelt, lag ein andere Fehler vor. Dies vereinfacht letztlich die Suche dem Auslöser der Exception.
Die Nutzung von withApply erfolgt hier, um sicherzustellen, dass eine aufgetretene Exception statistisch erhoben, aber weiter geworfen wird. Dies bedeutet allerdings, dass die Exception auf einem anderen Weg mitgenommen werden muss. Hierzu betrachten wir beispielhaft, wie dies für die Ausführung von Update-Statements umgesetzt wurde.
val a: Either[Throwable, ResultSet] = CassDB.executionCatcher.either { val res = session.execute(new SimpleStatement(stmt).setConsistencyLevel(this.defaultUpdateConsistency())) Global.systemStatistics ! CountEvents.DatabaseReqSent res }
Durch die Nutzung von either wird die Exception als Left rausgegeben, was deren konkrete Evaluierung ermöglich. Die Varianten opt und withTry wären hier auch möglich, da beide auftretende Exceptions einfangen – apply wäre an dieser Stelle allerdings nicht möglich.
Durch die Definition des o.g. executionCatcher steht eine an vielen Stellen wiederverwertbare Variante zur Verfügung, bei der bei Bedarf auch einzelne Teile des Handlers ausgetauscht werden können, ohne dafür komplett neue Catch-Container definieren zu müssen.
Fazit
Scala bietet dem Entwickler mehrere Optionen, mit Exceptions umzugehen. Die aus Java geerbte Variante ist zwar simpel, bietet sich aber aufgrund der nicht vorhandenen Wiederverwertbarkeit und den eingeschränkten Möglichkeiten nicht unbedingt für die Nutzung in einer funktionalen Sprache an. Sowohl Try als auch Catch integrieren sich deutlich besser und bieten Komponenten zur Wiederverwertung und monadischen Weiterverarbeitung an. Ihre Nutzung erfordert zwar auch einige Vorkenntnisse hinsichtlich algebraischer Datentypen und Pattern Matching, aber einmal durchschaut bieten sie deutlich mehr Optionen als die geerbte Version und können besser in funktionale Formulierungen integriert werden.