Suche
Funktionale Programmierung mit Java 9: Exceptions

Checkpoint Java: Elegante Exceptions mit multiplen Fallunterscheidungen

Sven Ruppert

© Shutterstock / Suttha Burawonk

Mit Funktionaler Programmierung können Entwickler elegant mit Exceptions umgehen. Sie hilft auch dabei, allgemeiner mit Fallunterscheidungen umzugehen und mehrere, verschiedene Fälle abzudecken.

Im letzten Teil haben wir uns damit beschäftigt, wie wir mit Exceptions umgehen können. Das hat dazu geführt, dass wir immer einer Fallunterscheidung hatten: zum einen den Glattläufer und zum anderen den Fehlerfall. Wir konnten es elegant mit einem Optional oder noch funktionaler mit einem Result formulieren. Wie aber gehen wir noch allgemeiner mit solchen Fallunterscheidungen um? Was ist, wenn es mehr als nur zwei Wege gibt?

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.

Klassische Konstrukte

In Java gibt es die Möglichkeit, eine Fallunterscheidung mit if(..) und else zu formulieren. Im einfachsten Fall bekommen wir dann so etwas:

if(value == null) { /* do something */}
else              { /* do something*/ }

Nun kann man die if-Konstrukte miteinander kombinieren, um so n Fälle abzufangen.

    if      (value.equals("a")) { /* do something */}
    else if (value.equals("b")) { /* do something */}
    else if (value.equals("c")) { /* do something */};

Es gibt auch noch eine andere Schreibweise, die man verwenden kann, wenn man einen Rückgabewert erwartet. Den Ausdruck selbst kann man auch wieder kombinieren, um mehr als zwei Fälle zu berücksichtigen.

    String x = /* something */;
    
    String valueA = (x == null) ? "" : x ;
    
    String valueB = (x == null) ? "" : (x.equals("a") ? "A" : "xx");
    
    String valueC = (x == null) ? "" 
                                : (x.equals("a")) ? "A" 
                                : (x.equals("b")) ? "B" 
                                : "xx";

Der prinzipielle Aufbau ist recht einfach. Innerhalb der Klammern wird ein Ausdruck fomruliert, der ein Boolean als Ergebnis haben muss. Ist der Wert true, wird der Ausdruck hinter dem ? ausgeführt. Und wenn der Wert false ist, wird der Ausdruck hinter dem : verwendet. Nun kann der Ausdruck hinter dem : ebenfalls wieder eine Fallunterscheidung sein. So können wir hintereinander beliebig viele Fälle abfangen. Wichtig zu wissen ist, dass der Ausdruck immer aus zwei Werten bestehen muss. Also einmal ein Ergebnis für true und ein explizites Ergebnis für den Fall false. Somit kann kein Fall vergessen werden, wie es vorkommen kann, wenn man dem if kein else folgen lässt.

Und zu guter Letzt noch die gute alte switch-Anweisung. Auch hier kann man mehrere Fälle behandeln, inklusive einer default-Lösung, wenn keiner der explizit angegebenen Werte zutreffend ist.

    final String x = "A";

    switch (x) {
      case "A": break;
      case "B": break;
      case "C": break;
      default : break;
    }

Alle Ansätze beruhen darauf, dass ein Zustand ausgewertet wird. Die Formulierung ist imperativ und damit auch abhängig von der Reihenfolge, in der die Überprüfungen stattfinden. Außerdem sind diese Konstrukte alle darauf ausgelegt, das alle Kombinationen schon zur Zeit der Quelltexterstellung bekannt sind. Wir haben den Zustand immer außerhalb der auswertenden Konstrukte. Teilweise können diese Werte, auf denen die Endscheidungen getroffen werden, auch noch verändert werden.

Die funktionale Lösung

Ziel ist es, das wir alle Fälle unabhängig voneinander definieren können. Das Paar besteht immer aus der Kombination einer Bedingung und dem dazugehörigen Ergebnis. Das Ergebnis kann wahlweise positiv oder negativ sein. Es sollen keine Kontrollstrukturen vorhanden sein. Oder besser gesagt, es soll ausschließlich beschrieben werden, was passiert und nicht wie. Als Ergebnis bekommen wir ein Result zurückgeliefert.

Sehen wir uns als erstes die Formulierung eines solchen Paares an. Hierzu definieren wir eine Methode, die zwei Parameter erwartet. Der erste Parameter ist ein Supplier, mit dem signalisiert wird, ob der gerade vorhandene Fall positiv oder negativ ist. Der zweite Parameter
ist das Ergebnis, besser gesagt ein Supplier<Result>.

public static <T> Case<T> matchCase(Supplier<Boolean> condition ,
                                    Supplier<Result<T>> value) {
    return new Case<>(condition , value);
  }

Der Rückgabewert dieser Methode ist eine Instanz vom Typ Case.

Case<T> extends Pair<Supplier<Boolean>, Supplier<Result<T>>>

Allerdings gibt es die Methode matchCase(..) in zwei Versionen. Die zweite Version ist für die Definition der default-Antwort. Hier wird kein Supplier mehr benötigt, da dieser ja indirekt mit true eine feste Antwort liefert. Die Antwort dieser Methode ist demnach eine Instanz vom Typ DefaultCase.

  public static <T> DefaultCase<T> matchCase(Supplier<Result<T>> value) {
    return new DefaultCase<>(() -> true , value);
  }
  public static class DefaultCase<T> extends Case<T> {
    public DefaultCase(final Supplier<Boolean> booleanSupplier , 
                       final Supplier<Result<T>> resultSupplier) {
      super(booleanSupplier , resultSupplier);
    }
  }

Kommen wir nur zur Verwendung, besser gesagt zu der Angabe aller Fälle aus der Sicht eines Entwicklers, der diesen Konstrukt verwendet.

   Integer x = 1;

    Result<String> result = Case
        .match(
            Case.matchCase(() -> Result.success("OK")) ,
            Case.matchCase(() -> x == 1 , () -> Result.success("Result 1")) ,
            Case.matchCase(() -> x == 2 , () -> Result.success("Result 2")) ,
            Case.matchCase(() -> x > 2 , () -> Result.success("Result 3")) ,
            Case.matchCase(() -> x < -2 , () -> Result.success("Result 4")) ,
            Case.matchCase(() -> false , () -> Result.failure("error message"))
        );
    result.ifPresentOrElse(
        sucess -> { System.out.println("sucess = " + sucess);} ,
        failed -> { System.out.println("failed = " + failed);}
    );

Das lässt sich mit static Imports ein wenig kompakter formulieren.

    match(
            matchCase(() -> success("OK")) ,
            matchCase(() -> x == 1 , () -> success("Result 1")) ,
            matchCase(() -> x == 2 , () -> success("Result 2")) ,
            matchCase(() -> x > 2 , () -> success("Result 3")) ,
            matchCase(() -> x < -2 , () -> success("Result 4")) ,
            matchCase(() -> false , () -> failure("error message"))
    )
    .ifPresentOrElse(
        sucess -> { System.out.println("sucess = " + sucess);} ,
        failed -> { System.out.println("failed = " + failed);}
    );

Wie findet hier intern die Verarbeitung statt? Dazu werden wir uns die Methode match(..) ansehen. Auch diese ist wieder static definiert und erwartet zwei Parameter. Der erste ist der default Case und der zweite ist eine beliebig lange Liste von Elementen mit weiteren Instanzen vom Typ Case.

  public static <T> Result<T> match(DefaultCase<T> defaultCase , 
                                    Case<T>... matchers) {
    return Arrays.stream(matchers)
        .filter(m -> m.getT1().get())
        .map(m -> m.getT2().get())
        .findFirst()
        .orElse(defaultCase.getT2().get());
  }

Hier wird die Liste der Fälle durchgegangen. Beim ersten Fall, der eine positive Rückmeldung gibt, wird das dazugehörige Ergebnis geholt. Sollte keiner der Fälle zutreffen, wird der default-Fall verwendet.

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

Nun kann man sich an dieser Stelle überlegen, dass man nicht nur finale Werte als Rückgabewert formuliert. Vielmehr kann man an dieser Stelle auch eine Funktion als Ergebnis akzeptieren. Dann kann man beginnen, dem jeweiligen Umstand entsprechend eine Funktion aus verschiedenen Blöcken zusammen zu bauen. Nachfolgend ein Beispiel, das, basierend auf dem Eingangswert, ein Paar, basierend auf dem Eingangswert und einer Funktion vom Typ Function<Integer, Integer>, als Ergebnis liefert.

     Result<Pair<Integer, Function<Integer, Integer>>> result =
        match(
            matchCase(() -> success(new Pair<>(x , (v) -> v + 1))) ,
            matchCase(() -> x == 1 , () -> success(new Pair<>(x , (v) -> v + 1))) ,
            matchCase(() -> x == 2 , () -> success(new Pair<>(x , (v) -> v + 2))) ,
            matchCase(() -> x > 2 , () -> success(new Pair<>(x , (v) -> v + 3))) ,
            matchCase(() -> x < - 2 , () -> success(new Pair<>(x , (v) -> v + 4))) ,
            matchCase(() -> false , () -> Result.failure("error message"))
        );
    result.ifPresentOrElse(
        sucess -> { System.out.println("sucess = " + sucess);} ,
        failed -> { System.out.println("failed = " + failed);}
    );

Ebenfalls kann man so auch beginnen, Streams zu konstruieren. Der Kreativität sind da kaum Grenzen gesetzt.

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.