Suche
Funktionale Programmierung mit Java 9: Exceptions

Checkpoint Java: Der Umgang mit Exceptions in der Funktionalen Programmierung

Sven Ruppert

© 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.

Wir werden für diese Artikelreihe von Beginn an mit JDK 9 arbeiten, auch wenn es zum Zeitpunkt der Veröffentlichung noch nicht final verfügbar ist. Das OpenJDK kann hier gefunden werden. Zuzüglich zu den Quelltextbeispielen in diesem Artikel verwende ich auch die Sourcen des Open-Source-Projekts Functional-Reactive. Die Sourcen liegen auf GitHub.

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.

Checkpoint Java

In dieser Kolumne klopft der Autor Sven Ruppert (Vaadin) Java auf alltägliche Probleme ab. Er gibt hilfreiche Tipps und Tricks, wie Entwickler gängige Stolperfallen vermeiden und klareren Code schreiben können. Einen besonderen Blick wirft er auf die neuen Möglichkeiten von Funktionaler und Reaktiver Programmierung. Alle Teile der Kolumne Checkpoint Java finden sich hier.

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 sven@vaadin.com oder per Twitter @SvenRuppert.

Happy Coding!

Geschrieben von
Sven Ruppert
Sven Ruppert
Sven Ruppert arbeitet seit 1996 mit Java und ist Developer Advocate bei Vaadin. In seiner Freizeit spricht er auf internationalen und nationalen Konferenzen, schreibt für IT-Magazine und für Tech-Portale. Twitter: @SvenRuppert
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.