Rückwärtskompatible Funktionale Programmierung von Java 9 zu Java 8

Checkpoint Java: Am funktionalsten mit Java 9

Sven Ruppert

© Shutterstock / Suttha Burawonk

Die erweiterten Möglichkeiten zur Funktionalen Programmierung in Java 9 sind eine schöne Sache. Aber nicht jede Codebasis kann direkt auf die neue Version springen. Deswegen gilt es auch hier, auf Rückwärtskompatibilität zu achten.

In unserem letzten Teil haben wir uns mit der seit Java 8 vorhandenen Klasse Optional auseinandergesetzt. Wir haben gesehen, dass wir einige Kontrollstrukturen durch einen fluent- bzw. functional style ersetzen können. Ebenfalls ermöglicht es uns, der allseits bekannten NullpointerException entgegenzuwirken. Allerdings ist die Klasse Optional final definiert. Demnach ist eine Erweiterung nicht möglich. Das kann uns in mancher Hinsicht im Wege stehen. Warum die Klasse Optional genau als final definiert worden ist, konnte ich nicht herausfinden. Ich muss aber auch zugeben, dass ich nicht allzu lange versucht habe, es herauszufinden.

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.

Feature Backport

Mit der Version 9 von Java wird die Klasse Optional um einige Methoden erweitert. Zum Beispiel um die Methode ifPresentOrElse(..). Damit bekommen wir die Möglichkeit, für den Fall das ein Wert vorhanden ist, einen Consumer anzugeben. Aber auch für den Fall, dass kein Wert vorhanden ist, kann ein Runnable angegeben werden. Dieses Runnable wird übrigens im Common Fork And Join Pool abgearbeitet.

Nun gibt es die ein oder andere eher klassische Branche, in der eine neue Version eine verfügbares JDK ein wenig später zum Einsatz kommt. Was also tun? Der Weg ist derselbe wie der, das als final deklarierte Optional zu erweitern. Wir erzeugen ein Interface mit dem Namen Result. Hier können wir unsere Erweiterungen einfügen, egal ob neu oder als Backport. Nur die Interoperabilität zum klassischen Optional sollte möglichst gut sein.

Vom Optional zum Result

Beginnen wir damit, das Optional ein wenig zu erweitern. Was als erstes auffallen könnte, ist die unsymmetrische Ausprägung an manchen Stellen. Zum Beispiel gibt es ein isPresent(). Die dazu inverse Angabe gibt es nicht. Das kann immer mal wieder zu Konstrukten führen, bei denen in einem if-Statement mit Negation gearbeitet werden muss. Wir fügen also als erstes die inverse Methode Boolean isAbsent() ein. Ebenfalls gibt es nur ein void ifPresent(Consumer consumer). Das erweitern wir mit void ifAbsent(Runnable action). Da wir die Interoperabilität von Optional möglichst einfach halten wollen, gibt es natürlich auch die Signaturen zur Konvertierung in beide Richtungen:

  default Optional<T> toOptional() {
    return Optional.ofNullable(get());
  }

  static <T> Result<T> fromOptional(Optional<T> optional) {
    Objects.requireNonNull(optional);
    return ofNullable(optional.get(), "Optional hold a null value");
  }

Die Konvertierung in einen Stream ist ebenfalls schnell dargestellt:

  default Stream<T> stream() {
    if (!isPresent()) {
      return Stream.empty();
    } else {
      return Stream.of(get());
    }
  }

Kommen wir nun zu den Methoden, die Fallunterscheidung anbieten. Das Ziel ist, die klassischen if/else-Strukturen umzuformen. Hier kann man sehen, dass der Rückgabewert immer void ist. Demnach ist hier kein fluent-API Style vorgesehen. Die Werte werden also terminal konsumiert. Wenn man mit dem Wert weiter arbeiten möchte, muss man sich Methoden wie map() ansehen. Aber bleiben wir erst einmal bei den konsumierenden Methoden.

  void ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction);

  void ifPresentOrElse(Consumer<T> success, Consumer<String> failure);

  void ifPresentOrElseAsync(Consumer<? super T> action, Runnable emptyAction);

  void ifPresentOrElseAsync(Consumer<T> success, Consumer<String> failure);

Diese Methoden gibt es in den beiden Ausprägungen synchron und asynchron. Wenn wir die synchrone Version verwenden, bedeutet das für uns, dass der Aufruf solange blockiert bis der Wert final konsumiert wurde. Das muss aber nicht immer die Anforderung sein. Deshalb kann man die Aufgabe auch in den Common Fork And Join Pool auslagern.

Wenn wir die Implementierung ansehen wollen, verlassen wir das Interface. Das Interface Result wird zuerst von der abstrakten Klasse AbstractResult implements Result implementiert. Hier kommen alle Details hin, die zustandsbehaftet sind. Der Wert ist als Attribut nun real vorhanden, wenn auch immer noch vom Typ T. Die letztendlichen Implementierungen gibt es in zwei Variationen. Zum einen die Klasse Success extends AbstractResult und zum anderen die Klasse Failure extends AbstractResult. Diese Implementierungen sind als nicht-statische innere Klasse realisiert. Das Erzeugen von Instanzen wird über die statischen Methoden static Result success(T value) und static Result failure(String errorMessage) im Interface angeboten.

  static <T> Result<T> failure(String errorMessage) {
    Objects.requireNonNull(errorMessage);
    return new Result.Failure<>(errorMessage);
  }

  static <T> Result<T> success(T value) {
    return new Result.Success<>(value);
  }

Nun stellt sich die Frage, warum das so aufgeteilt ist.

In der abstrakten Klasse können wir alle Implementierungen unterbringen, die auf dem realen Attribut basieren, jedoch nicht die Ausprägung haben, dass es ein erfolgreiches Ergebnis ist oder auch nicht.

  abstract class AbstractResult<T> implements Result<T> {
    protected final T value;

    public AbstractResult(T value) {
      this.value = value;
    }

    @Override
    public void ifPresent(Consumer<T> consumer) {
      Objects.requireNonNull(consumer);
      if (value != null) consumer.accept(value);
    }

    @Override
    public void ifAbsent(Runnable action) {
      Objects.requireNonNull(action);
      if (value == null) action.run();
    }


    public Boolean isPresent() {
      return (value != null) ? Boolean.TRUE : Boolean.FALSE;
    }

    public Boolean isAbsent() {
      return (value == null) ? Boolean.TRUE : Boolean.FALSE;
    }

    @Override
    public T get() {
      return Objects.requireNonNull(value);
    }

    @Override
    public T getOrElse(Supplier<T> supplier) {
      Objects.requireNonNull(supplier);
      return (value != null) ? value : Objects.requireNonNull(supplier.get());
    }
  }

Bei der Klasse Sucess handelt es ich um den Erfolgsfall. Es ist also ein Wert vorhanden. Hier kann direkt auf die angebotenen Consumer zugegriffen und der Aufruf mit dem intern vorhandenen Wert durchgeführt werden. Es gibt nun die synchrone und asynchrone Implementierung.

  class Success<T> extends AbstractResult<T> {

    public Success(T value) {
      super(value);
    }

    @Override
    public void ifPresentOrElse(Consumer<? super T> action , Runnable emptyAction) {
      action.accept(value);
    }

    @Override
    public void ifPresentOrElse(final Consumer<T> success, final Consumer<String> failure) {
      // TODO check if usefull -> Objects.requireNonNull(value);
      success.accept(value);
    }

    @Override
    public void ifPresentOrElseAsync(Consumer<? super T> action , Runnable emptyAction) {
      CompletableFuture.runAsync(()-> action.accept(value));
    }

    @Override
    public void ifPresentOrElseAsync(Consumer<T> success , Consumer<String> failure) {
      CompletableFuture.runAsync(()-> success.accept(value));
    }

  }

Wie der Name Failure extends AbstractResult schon andeutet, handelt es sich um die Abbildung des Fehlerfalles, oder besser gesagt der Fall, in dem es keinen Wert gibt. Da kein Wert vorhanden ist, der Typ aber erhalten bleibt, wird ein anderes Attribut verwendet, um eine aussagekräftige Nachricht zu repräsentieren. In diesem Fall ein Attribut vom Typ String, um die Nachricht zu transportieren. Beim Aufruf der Methoden kann immer auf die angebotene Alternative zugegriffen werden.

class Failure<T> extends AbstractResult<T> {

    private final String errorMessage;

    public Failure(final String errorMessage) {
      super(null);
      this.errorMessage = errorMessage;
    }

    @Override
    public void ifPresentOrElse(Consumer<? super T> action , Runnable emptyAction) {
      emptyAction.run();
    }

    @Override
    public void ifPresentOrElse(final Consumer<T> success, final Consumer<String> failure) {
      failure.accept(errorMessage);
    }

    @Override
    public void ifPresentOrElseAsync(Consumer<? super T> action , Runnable emptyAction) {
      CompletableFuture.runAsync(emptyAction);
    }

    @Override
    public void ifPresentOrElseAsync(Consumer<T> success , Consumer<String> failure) {
      CompletableFuture.runAsync(() -> failure.accept(errorMessage));
    }
  }

combine und combineAsync

Nun gibt es hin und wieder die Anforderung, dass ein Ergebnis weiter verarbeitet werden muss. Dazu wird zum Beispiel ein weiterer Wert benötigt, um dann mit dem Ergebnis zusammen zu einem neuen Zustand verarbeitet zu werden. Um zwei Werte miteinander zu verarbeiten, haben wir bisher die BiFunction<A,B, R> kennengelernt. Der erste Wert ist das Ergebnis, der Wert des Result, und der zweite Parameter der Funktion ist der neu dazugekommene Wert, der mit dem Ergebnis zusammen verarbeitet werden soll. Das sieht in der Signatur und Implementierung wie folgt aus und ist schon auf der Interfaceebene als default-Implementierung verankert.

  default <V, R> Result<R> thenCombine(V value , BiFunction<T, V, Result<R>> func) {
    return func.apply(get() , value);
  }

Natürlich kann man das auch nicht-blockierend anbieten. Nur ist hier der Rückgabewert nun ein CompletableFuture<Result>:

  default <V, R> CompletableFuture<Result<R>> thenCombineAsync(V value , BiFunction<T, V, Result<R>> func) {
    return CompletableFuture.supplyAsync(() -> func.apply(get() , value));
  }

Hieraus ergeben sich nun recht interessante Einsatzgebiete.

Praktischer Einsatz von Result

Der erste Versuch ist ein statischer, derart, dass der zweite Wert schon feststeht.

    final Result<String> stringResult = Result.success("value");

    stringResult
        .thenCombine(100 , (s , integer) -> Result.ofNullable(s + integer))
        .ifPresent(System.out::println);

Das Beispiel ist sicherlich nicht komplex und auch wenig sinnvoll, was das Ergebnis betrifft. Jedoch zeigt es wie ein weiterer Wert mit dem Inhalt des Result verarbeitet werden kann.

Das soll uns eigentlich mehr Flexibilität ermöglichen. Deswegen wird in unserem Beispiel der statische Wert vom Typ Integer durch einen Supplier ersetzt. Jetzt wird der Wert, der zur Verarbeitung benötigt wird, erst zur Laufzeit ermittelt bzw. erzeugt. Wir können dynamisch auf den derzeitigen Zustand reagieren. Für unser Beispiel definieren wir ein Interface mit dem Namen Service und der Methode doWork(..):

  public interface Service {
    Result<String> doWork(String input);
  }

Die Implementierung für dieses FunctionalInterface halten wir sehr überschaubar:

    final Service service = input -> (Objects.nonNull(input))
                                            ? Result.success(input.toUpperCase())
                                            : Result.failure("Value was null");

Nun wollen wir einfach die aktuelle Systemzeit der JVM mit dem Ergebnis im Result zusammen weiter verarbeiten und haben zum Ziel, beide Werte zusammen in einer Instanz vom Typ Pair zu halten.

    final Service service = input -> (Objects.nonNull(input))
                                            ? Result.success(input.toUpperCase())
                                            : Result.failure("Value was null");
    service
        .doWork("Hello World")
        .thenCombine(System::nanoTime ,
                     (BiFunction<String, Supplier<Long>, Result<Pair<String, Long>>>)
                         (s , longSupplier) -> Result.success(new Pair<>(s , longSupplier.get())))
        .ifPresentOrElse(
            value -> System.out.println(" value present = " + value) ,
            errormessage -> System.out.println(" value not present error message is = " + errormessage)
        );

In diesem einfachen Fall kann man auch die Methode Result map(Function<? super T, ? extends > mapper) verwenden. Wir müssen ja keinen Service oder einen Wert von extern bekommen. nanoTime() lässt sich demnach natürlich direkt adressieren. Wird unser Beispiel damit implementiert, sieht es dann wie folgt aus:

    helloWorld
        .map(s -> new Pair<>(s , System.nanoTime()))
        .ifPresentOrElse(
            value -> System.out.println(" value present = " + value) ,
            errormessage -> System.out.println(" value not present error message is = " + errormessage)
        );

Wir haben zweimal die Methode ifPresentOrElse(..) verwendet. Dies kann man natürlich auch wieder extrahieren.

    final Consumer<Result<Pair<String, Long>>> resultConsumer = (result) ->
        result
            .ifPresentOrElse(
                value -> System.out.println(" value present = " + value) ,
                errormessage -> System.out.println(" value not present error message is = " + errormessage));

    resultConsumer.accept(helloWorld.map(s -> new Pair<>(s , System.nanoTime())));

Was kann das aber für ein Projekt bedeuten? Hierzu schreiben wir uns ein Beispiel, in dem drei frei gewählte Transformationen für ein Ergebnis vorgesehen sind. Jeder Schritt bekommt als weiteren Wert auch eine weitere Instanz der Klasse LocalDateTime.now(). So können wir zum Ende sehen, in welcher Reihenfolge und wann die jeweiligen Instanzen erzeugt worden sind. Ich habe hier absichtlich auf irgendwelche Berechnungen verzichtet, das das Beispiel nur sinnbildlich für Werte aus verschiedenen Quellen stehen soll. Der jeweilige Berechnungsschritt wird als Funktion zur Verfügung gestellt.

  public static interface Service {
    Result<String> doWork(String input);
  }

  //Demo for some Services to call
  public static Supplier<Step001> serviceA() { return () -> new Step001(now());}

  public static Function<Step001, Step002> serviceB() { 
      return (step001) -> new Step002(step001.timestamp01 , 
                                      now());
  }

  public static Function<Step002, Step003> serviceC() { 
      return (step002) -> new Step003(step002.timestamp01 , 
                                      step002.timestamp02 , 
                                      now());
  }

  //Demo for some classes
  public static class Step001 {
    private final LocalDateTime timestamp01;

    public Step001(LocalDateTime timestamp01) {this.timestamp01 = timestamp01;}
  }

  public static class Step002 {
    private final LocalDateTime timestamp01;
    private final LocalDateTime timestamp02;

    public Step002(LocalDateTime timestamp01 , 
                   LocalDateTime timestamp02) {
      this.timestamp01 = timestamp01;
      this.timestamp02 = timestamp02;
    }
  }

  public static class Step003 {
    private final LocalDateTime timestamp01;
    private final LocalDateTime timestamp02;
    private final LocalDateTime timestamp03;

    public Step003(LocalDateTime timestamp01 , 
                   LocalDateTime timestamp02 , 
                   LocalDateTime timestamp03) {
      this.timestamp01 = timestamp01;
      this.timestamp02 = timestamp02;
      this.timestamp03 = timestamp03;
    }

    @Override
    public String toString() {
      return "Step003{" +
             "timestamp01=" + timestamp01 +
             ", timestamp02=" + timestamp02 +
             ", timestamp03=" + timestamp03 +
             '}';
    }
  }

Teilen wir die einzelnen Dinge mal in zeitlich unabhängige Teile auf. Der Service wird zur Verfügung gestellt und berechnet initial seinen Wert, den er als Result zurückliefert.

    // some Service....
    final Service service = input -> (Objects.nonNull(input))
                                     ? Result.success(input.toUpperCase())
                                     : Result.failure("Value was null");

Nun rufen wir den Service auf und geben diesen Rückgabewert einem Workflow.

    // service will be invoked
    modifiedWorkflow.apply(service.doWork("Hello World"));

Dieser Workflow wurde zu einem anderem Zeitpunkt erstellt und dann auch nochmal modifiziert.

  public static Function<Result<String>, Result<Step003>> workflow = (input) ->
      input
          .or(() -> Result.success("nooop")) // default per demo definition here -> convert failure´s
          .thenCombine(
              serviceA() ,
              (value , supplier) -> Result.success(supplier.get()) // not working with value, to make it simple
          )
          .thenCombine(
              serviceB() ,
              (step001 , fkt) -> Result.success(fkt.apply(step001))
          )
          .thenCombine(
              serviceC() ,
              (step002 , fkt) -> Result.success(fkt.apply(step002))
          );

    final Function<Result<String>, Result<Step003>> modifiedWorkflow = workflow
        .andThen(result -> {
          result.ifPresentOrElse(
              value -> System.out.println(" value present = " + value) ,
              errormessage -> System.out.println(" value not present error message is = " + errormessage)
          );
          return result;
        });

Was man hier schön sehen kann, ist die Möglichkeit, Teile der Funktionen zu vorherigen Zeitpunkten zu definieren oder auch bei Bedarf noch zu verändern. Hier arbeiten wir wieder mit Funktionen, die wir kombinieren. Die Funktionen selbst sind zustandslos und können bei Bedarf erzeugt werden.

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 angesehen, wie wir mit der Klasse Optional, die seit Java 8 verfügbar ist und in Java 9 erweitert wurde, funktionale Aspekte in unsere tägliche Arbeit aufnehmen können. Wir haben gesehen, wie wir damit Kontrollstrukturen vermeiden können und uns gegen den klassischen Fall einer NPE rüsten können.

Da die Klasse Optional als final definiert ist, können wir diese mittels Ableitung nicht unseren Bedürfnissen anpassen. Deswegen haben wir das Interface Result eingeführt und damit einige Unzulänglichkeiten der Klasse Optional behoben. Nicht nur symmetrische Methoden wie ifAbsent() haben wir eingefügt. Ebenfalls haben wir gesehen, wie wir einfach einen Backport von Java 9 auf Java 8 realisieren können. Die Zusammenarbeit mit der Klasse Optional haben wir durch Konverter-Methoden realisiert, die eine Transformation zwischen beiden Welten zu jeden Zeitpunkt zulässt. Die Integration bestehender APIs ist damit kein Problem.

Der ganz große Unterschied jedoch liegt in der Möglichkeit, mit dem Wert weiter zu arbeiten, indem wir die Instanz Result nicht nur mit der Methode map(..) weiterverarbeiten können, sondern auch die Einbindung weiterer Werte zur Laufzeit mittels BiFunction, synchron mit combine(..) und asynchron mit combineAssync(..).

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
  1. TestP2017-08-24 11:35:50

    "Warum die Klasse Optional genau als final definiert worden ist, konnte ich nicht herausfinden. Ich muss aber auch zugeben, dass ich nicht allzu lange versucht habe, es herauszufinden."

    Angelika Langer schreibt hier was zu Optional:
    http://www.angelikalanger.com/Articles/EffectiveJava/80.Java8.Optional-Result/80.Java8.Optional-Result.html

    Vor allem der Absatz "Die Zukunft von Optional" ist sehr interessant. Wenn Oracle plant aus Optional ein Value Type zu machen dann würde ein nicht finales Optional u.U. die Abwärtskompatibilität brechen. Dürfte auch der Grund sein warum Optional auch nicht Serializable ist.

    Ansonsten, schöne Artikelserie. :)

    Grüße

Schreibe einen Kommentar

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