Checkpoint Java: Der Umgang mit Exceptions in der Funktionalen Programmierung

© Shutterstock / Suttha Burawonk
Die Java-Welt könnte so schön sein ohne Exceptions. Aber was muss, das muss. Das gilt auch für funktionales Programmieren mit Java 9. Hier gibt es verschiedene elegante und weniger elegante Möglichkeiten mit Exceptions umzugehen.
Im letzten Teil haben wir uns damit beschäftigt, wie wir Funktionen definieren und wie wir das mit Streams kombinieren können. Ausgelassen haben wir allerdings bisher immer das Thema Exceptions. Nur leider begegnen einem diese Exceptions in Java immer und immer wieder. Wie kann man nun damit umgehen und welche Möglichkeiten ergeben sich daraus? Genau das werden wir uns jetzt genauer ansehen und ausprobieren.
Die Exception
Um uns anzusehen, wie es sich mit den Exceptions verhält, gehen wir vom folgendem Service-Interface aus. Es ist ein FunctionalInterface, jedoch leider mit der Definition einer Exception in der Methodensignatur.
public static interface Service { String doWork(String txt) throws Exception; }
Wenn wir dieses Interface verwenden wollen, kommen wir im klassischen Stil zu einer einfachen Implementierung. Die Beispielimplementierung selbst hat hier definitiv keinen tieferen Sinn.
try { new Service() { @Override public String doWork(String txt) throws Exception { return txt.toUpperCase() + "-workedOn"; } }.doWork(""); } catch (Exception e) { e.printStackTrace(); }
Wir können hier schön den try-catch-Block erkennen, der dazu führt, dass wir uns überlegen müssen, wie wir mit einer Exception umgehen möchten. Soll die Exception einfach weiter durchgereicht werden? Da es sich um ein FunctionInterface handelt, können wir das Ganze natürlich auch als Lambda schreiben.
try { ((Service) txt -> txt.toUpperCase() + "-workedOn").doWork(""); } catch (Exception e) { e.printStackTrace(); }
Wenn man dieses Lambda-Konstrukt auf Klassenebene definiert, sieht es erst einmal recht einfach aus.
public static Service serviceA = txt -> txt.toUpperCase() + "-workedOnA"; public static Service serviceB = txt -> txt.toUpperCase() + "-workedOnB";
Nur leider kommen dann wieder die try-catch-Blöcke zum Vorschein.
try { final String helloA = serviceA.doWork("Hello A"); } catch (Exception e) { e.printStackTrace(); } try { final String helloB = serviceB.doWork("Hello B"); } catch (Exception e) { e.printStackTrace(); }
Was aber ist nun eigentlich mit dem Rückgabewert? Da gibt es zwei Wege. Der eine bedeutet, dass man die restliche Logik mit in den try-catch-Block schreibt. Kann man so machen, wird aber unhandlich, wenn weitere Methoden mit Exceptions verwendet werden. Beginnt man nun die Blöcke ineinander zu verschachteln? Oder gibt es einen großen catch-Block am Ende aller Anweisungen? Alles recht unschön. Die zweite Möglichkeit besteht darin, den try-catch-Block so kurz wie möglich zu halten. Der Ergebniswert wird dann in einer Variablen gespeichert, die vor dem try-catch-Block definiert wurde. Ich habe mich dafür entschieden, gleich mit einem Optional
zu arbeiten, da es auf jeden Fall im JDK vorhanden ist.
Optional<String> optional; try { final String result = ((Service) txt -> txt.toUpperCase() + "-workedOn").doWork(""); optional = Optional.of(result); } catch (Exception e) { e.printStackTrace(); optional = Optional.empty(); } optional.ifPresent((result)-> System.out.println("result = " + result));
Wie wäre es, wenn man diesen try-catch-Block ganz weg bekommen würde? Immerhin ist es immer dasselbe Stück Quelltext. Um diesem Punkt näher zu kommen, definieren wir erst einmal eine Funktion, die mit Methodensignaturen umgehen kann, die eine Exception
definieren. Damit es wieder ein FunctionalInterface wird, müssen wir uns überlegen, wie wir den Aufruf der Methode mit der definierten Exception
ummanteln können. Ebenfalls gehe ich davon aus, das ein möglicher Ergebniswert in einem Optional verpackt ausgeliefert wird. Damit erhalten wir als erstes eine Funktion von T auf Optional. Da der Eingangstyp nicht gleich dem Ausgangstyp sein muss, definieren wir es ein wenig allgemeiner: CheckedFunction<T, R> extends Function<T, Optional>
. Wir fügen nun eine Methode ein, die ebenfalls eine Exception in der Signatur definiert hat: R applyWithException(T t) throws Exception;
. Der Ablauf ist in beiden Fällen, also mit und ohne dem Auftreten einer Exception
, klar definiert. Diese Implementierung kann man nun als default-Implementierung für die Methodensignatur default Optional apply(T t)
nehmen.
@FunctionalInterface public interface CheckedFunction<T, R> extends Function<T, Optional<R>> { @Override default Optional<R> apply(T t) { try { return Optional.ofNullable(applyWithException(t)); } catch (Exception e) { return Optional.empty(); } } R applyWithException(T t) throws Exception; }
Eine klassische Implementierung dieses Interface sieht dann wie folgt aus, wenn wir die vorherige Implementierung des Interface-Service als Grundlage nehmen.
final CheckedFunction<String, String> checkedFunction = new CheckedFunction<String, String>() { @Override public String applyWithException(String s) throws Exception { return ((Service) txt -> txt.toUpperCase() + "-workedOn").doWork(s); } };
Wenn diese CheckedFunction
verwendet wird, sieht man noch die selbst definierte Methodensignatur applyWithException
. Um diese wieder los zu werden und damit die IDE nur noch das gewohnte apply
anbietet, kann man es wieder auf eine Funktion casten.
final Function<String, Optional<String>> f = checkedFunction;
Die Verwendung kann nun auf die jeweiligen Fälle Bezug nehmen, da wir wie gewohnt mit einem Optional arbeiten.
f.apply("Hello") .ifPresent((result) -> System.out.println("result = " + result));
Was hier allerdings noch fehlt, ist der Zugriff auf die Exception
bzw. die Fehlermeldung, die im Fehlerfall geliefert wird. Hierzu modifizieren wir die CheckedFunction
so, dass wir nicht das Optional
verwenden, sondern die in dem vorherigen Artikeln vorgestellte Klasse Result
.
@FunctionalInterface public interface CheckedFunction<T, R> extends Function<T, Result<R>> { @Override default Result<R> apply(T t) { try { return Result.success(applyWithException(t)); } catch (Exception e) { final String message = e.getMessage(); return Result.failure((message != null) ? message : e.getClass().getSimpleName()); } } R applyWithException(T t) throws Exception; }
Nun können wir auf die erweiterten Möglichkeiten zugreifen, die uns das Result liefert.
final Consumer<String> print = System.out::println; final Function<String, Result<String>> checkedFunction = (CheckedFunction<String, String>) ((Service) txt -> txt.toUpperCase() + "-workedOn")::doWork; checkedFunction.apply("Hello") .ifPresentOrElse( (result) -> print.accept("result = " + result), (failed) -> print.accept("failed = " + failed) );
Ich greife im Folgenden auf die in dem Open-Source-Projekt Functional-Reactive implementierten CheckFunctions zu. Ebenfalls gibt es dort auch CheckedConsumer
, CheckedSupplier
oder CheckedBiFunction
. Kommen wir nun zu einem Beispiel, in dem wir die CheckedFunction
in der Kombination mit einem Stream verwenden. Hier sieht man schon, wie kompakt die Implementierungen dabei werden kann. Denn auch wenn man Streams verwendet, ist man immer wieder mit dem Umgang von Exceptions konfrontiert.
public static interface Service { String doWork(String txt) throws Exception; } private static IntConsumer print = System.out::println; public static void main(String[] args) { //from now on using functional-reactive lib //https://github.com/functional-reactive/functional-reactive-lib final Function<String, Result<Integer>> f = (CheckedFunction<String, Integer>) Integer::valueOf; Stream .of("1" , "2" , "Hi" , "3") .parallel() .map(f) .filter(Result::isPresent) .mapToInt(Result::get) .reduce((left , right) -> left + right) .ifPresent(print); //alternative way Stream .of("1" , "2" , "Hi" , "3") .map((CheckedFunction<String, Integer>) Integer::valueOf) .flatMap(Result::stream) .reduce((left , right) -> left + right) .ifPresent(System.out::println); }
Hier wurde nur der Deutlichkeit halber die Definition der CheckedFunction
außerhalb des Streams realisiert. Natürlich kann man das auch direkt im Mapping schreiben:
.map((CheckedFunction<String, Integer>) Integer::valueOf))
.
Nun kann es sein, dass einige Methoden nacheinander aufgerufen werden müssen, die jedoch alle eine Exception werfen können. Als Beispiel kann man sich vorstellen, das nach einem jUnit-Test mehrere Systemkomponenten heruntergefahren werden müssen. Um sicherzustellen, das alle Methoden aufgerufen werden, muss man im klassischen Fall um jeden Methodenaufruf einen try-catch-Block legen.
public static interface Service { String doWork(String txt) throws Exception; } public static Service serviceA = txt -> txt.toUpperCase() + "-workedOnA"; public static Service serviceB = txt -> txt.toUpperCase() + "-workedOnB"; //both must be executed, even if first one fails try { final String resultA = serviceA.doWork(null); } catch (Exception e) { e.printStackTrace(); } try { final String resultB = serviceB.doWork("Hello"); } catch (Exception e) { e.printStackTrace(); }
Bei Verwendung der CheckedFunction
wird die Quelltextstelle wesentlich kompakter.
final Function<String, Result<String>> fA = (CheckedFunction<String, String>) (txt) -> serviceA.doWork(txt); final Function<String, Result<String>> fB = (CheckedFunction<String, String>) (txt) -> serviceB.doWork(txt); final Result<String> resultFA = fA.apply(null); final Result<String> resultFB = fB.apply("Hello");
Dies kann man in verschiedenen Varianten in einem Projekt verwenden. Zum einen kann man die Funktion zum Umsetzen der CheckedFunction auf eine Function selbst wieder als Funktion anbieten, zum Beispiel die Funktion tryIt
. Dann kann man mittels Lambda oder Methodenreferenz die Transformation angeben. Zum anderen kann es auch als statische Methode angeboten werden, hier ebenfalls tryIt
. Im folgenden Listing kann man beide Versionen im Vergleich sehen.
public static interface Service { String doWork(String txt) throws Exception; } public static Service serviceA = txt -> txt.toUpperCase(); public static Function<CheckedFunction<String, String>, Function<String, Result<String>>> tryIt = (f) -> f; public static Function<String, Result<String>> tryIt(CheckedFunction<String, String> function) { Objects.requireNonNull(function); return function; } public static void main(String[] args) { Function<String, Result<String>> fA = tryIt.apply(serviceA::doWork); Function<String, Result<String>> fB = tryIt.apply(txt -> txt.toUpperCase()); Function<String, Result<String>> fC = tryIt(serviceA::doWork); Function<String, Result<String>> fD = tryIt(txt -> txt.toUpperCase()); out.println("fA = " + fA.apply(null)); out.println("fB = " + fB.apply(null)); out.println("fC = " + fC.apply(null)); out.println("fD = " + fD.apply(null)); }
Nun haben wir nicht immer Funktionen vom Typ String auf String. Was noch ansteht, ist die generische Formulierung des gerade gelernten. Hier gehen wir wieder den Weg, dass wir eine statische Methode für das Erzeugen der Funktion anbieten.
public static <T,R> Function<CheckedFunction<T, R>, Function<T, Result<R>>> tryIt(){ return (f) -> f; }
Eine nette Besonderheit möchte ich hier noch erwähnen. Bisher habe ich das FunctionalInterface implementiert und dann eine Methodenreferenz von der Instanz serviceA gebildet. Das kann man natürlich von jeder Objektinstanz gewinnen. Das Einzige, was hier von Bedeutung ist, ist die Besonderheit, dass der Eingabewert und der Ausgabewert ein String ist. Dadurch kann man es auf eine Function<String, String>
mappen. Das geht aber ebenso mit einer Methode, die zwei Eingangswerte hat und einen Ausgangswert. Dann bekommen wir eine BiFunction<X,Y,R>
. Hier lohnt es sich ein wenig zu experimentieren. Somit kann man neue Funktionalität mittels Funktionen realisieren und doch weiterhin die alten Implementierungen verwenden. Die Typsicherheit ist gegeben und die IDE wird einen weiterhin voll unterstützen können.
Fazit
Wir haben uns in diesem Teil damit beschäftigt, wie wir mit Exceptions umgehen können. Wir sind hiermit einiges an Quelltext losgeworden und können so auch Methoden mit Exceptions
elegant in der Kombination mit Streams verwenden. Auch hier hat sich gezeigt, dass die Integration von Legacy-Quelltext mittels Methodenreferenzen gut möglich ist. Den Quelltext findet ihr auf GitHub. Bei Fragen und Anregungen einfach melden unter [email protected] oder per Twitter @SvenRuppert.
Happy Coding!
Hinterlasse einen Kommentar