Funktionale Programmierung mit Java 9: Funktionen, Typdefinitionen und Streams

Checkpoint Java: Noch funktionaler mit Java 9

Sven Ruppert

© Shutterstock / Suttha Burawonk

Es kann ein wenig kniffelig sein in der Funktionalen Programmierung Aufgaben in einzelne Funktionen zu zerlegen. Vor allem muss man einen strengen Blick auf die Typdefinitionen werfen, damit die Streams mit ihnen arbeiten können.

In unserem letzten Teil haben wir uns angesehen, wie wir die Klasse Optional erweitern können. Das Ergebnis war das Interface Result, das uns einige neue Dinge ermöglicht. In diesem Teil wollen wir uns nochmals mit den Funktionen selbst auseinandersetzen.

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.

Ziel ist es, einige Funktionen auf einer Menge von Autos zu definieren. Wir nennen die Klasse Car und leiten diese von der Klasse Quint<T1,T2,T3,T4,T5> ab. Die Klasse Quint ist aus dem Projekt Funktional-Reaktive. Wir hatten einem vorherigen Artikel die Klasse Pair verwendet. Die Klasse Quint ist sozusagen der große Bruder. Auch hier fügen wir die fachlichen get-Methoden hinzu.

  public class Car 
        extends Quint<String, 
                      Colour, 
                      Integer, 
                      Integer, 
                      Float> {
    
    public Car(String brand , 
               Colour colour , 
               Integer speed , 
               Integer year , 
               Float price) {
      super(brand , colour , speed , year , price);
    }

    public String brand() {return getT1();}

    public Colour colour() {return getT2();}

    public Integer speed() {return getT3();}

    public Integer year() {return getT4();}

    public Float price() {return getT5();}
  }
  
  public enum Colour {
    UNDEFINED,
    BLUE,
    RED,
    WHITE,
    GREEN
  }

Wir erzeugen uns nun eine Menge an Instanzen der Klasse Car und stellen diese als Stream zur Verfügung.

    final Stream<Car> carsStream = Stream
        .of(
            new Car("BMW" , Colour.BLUE , 200 , 2017 , 10000f) ,
            new Car("BMW" , Colour.GREEN , 215 , 2017 , 10432f) ,
            new Car("BMW" , Colour.UNDEFINED , 200 , 2017 , 10000f) ,
            new Car("VW" , Colour.BLUE , 180 , 2017 , 10000f) ,
            new Car("VW" , Colour.WHITE , 220 , 2017 , 10000f) ,
            new Car("VW" , Colour.RED , 200 , 2017 , 10000f));

Die Aufgabe

Nun wollen wir diese Menge zweimal filtern. Einmal wollen wir die Menge der BMWs und einmal die Menge der VWs. Eine erste erste Implementierung sieht meist wie folgt aus.

    Stream
        .of(
            new Car("BMW" , Colour.BLUE , 200 , 2017 , 10000f) ,
            new Car("BMW" , Colour.GREEN , 215 , 2017 , 10432f) ,
            new Car("BMW" , Colour.UNDEFINED , 200 , 2017 , 10000f) ,
            new Car("VW" , Colour.BLUE , 180 , 2017 , 10000f) ,
            new Car("VW" , Colour.WHITE , 220 , 2017 , 10000f) ,
            new Car("VW" , Colour.RED , 200 , 2017 , 10000f))
        .filter(c -> c.brand().equals("BMW"))
        .forEach(System.out::println);

    Stream
        .of(
            new Car("BMW" , Colour.BLUE , 200 , 2017 , 10000f) ,
            new Car("BMW" , Colour.GREEN , 215 , 2017 , 10432f) ,
            new Car("BMW" , Colour.UNDEFINED , 200 , 2017 , 10000f) ,
            new Car("VW" , Colour.BLUE , 180 , 2017 , 10000f) ,
            new Car("VW" , Colour.WHITE , 220 , 2017 , 10000f) ,
            new Car("VW" , Colour.RED , 200 , 2017 , 10000f))
        .filter(c -> c.brand().equals("VW"))
        .forEach(System.out::println);

Anstelle die jeweilige Menge in einer Liste final vorzuhalten, schreiben wir die Elemente einfach auf die Kommandozeile. Wenn man sich diesen Quelltext ansieht, erkennt man schnell, dass diese Lösung alles andere als optimal ist. Beginnen wir nun alles ein wenig umzuformen.

Als erstes trennen wir die Daten vom Stream. Ein Stream kann immer nur einmal verwendet werden. Möchten wir also zweimal einen Workflow abarbeiten, muss zweimal ein Stream aus der Bestandsmenge der Daten erzeugt werden, die dem Stream zu Grunde liegen.

  //persistence storage
  public static List<Car> cars = Arrays.asList(
      new Car("BMW" , Colour.BLUE , 200 , 2017 , 10000f) ,
      new Car("BMW" , Colour.GREEN , 215 , 2017 , 10432f) ,
      new Car("BMW" , Colour.UNDEFINED , 200 , 2017 , 10000f) ,
      new Car("VW" , Colour.BLUE , 180 , 2017 , 10000f) ,
      new Car("VW" , Colour.WHITE , 220 , 2017 , 10000f) ,
      new Car("VW" , Colour.RED , 200 , 2017 , 10000f)
  );

  //query -> result from persistence storage
  public static Stream<Car> nextCarStream() {
    return cars.stream();
  }

Nun werden die Daten einmal vorgehalten oder aus einer persistenten Quelle geholt. Die Methode nextCarStream() liefert uns die gewünschte Sicht als Stream auf die Daten. Der Rest der Aufgabe kann wie folgt gelöst werden: Es wurden die Instanzen der Predicates, die zum filtern des Streams dienen, schon mal extrahiert.

    Predicate<Car> carBrandFilterBMW = (car) -> 
             car.brand().equals("BMW");
    Predicate<Car> carBrandFilterVW = (car) ->
             car.brand().equals("VW");

    nextCarStream()
        .filter(carBrandFilterBMW::test)
        .forEach(System.out::println);

    nextCarStream()
        .filter(carBrandFilterVW::test)
        .forEach(System.out::println);

Aber auch hier können wir immer erst nach dem Erzeugen des Streams die restlichen Komponenten anhängen. Und die Definition der Filter ist immer noch zu redundant. Auch die nachfolgenden Zeilen zur Definition der Predicates ist immer noch zu redundant.

    Predicate<Car> carBrandFilterBMW = (car) -> 
             car.brand().equals("BMW");
    Predicate<Car> carBrandFilterVW = (car) ->
             car.brand().equals("VW");

Wir formen das in eine Funktion um, die aus einem String ein entsprechendes Predicate erzeugt.

    Function<String,Predicate<Car>> brandFilter 
            = (brand) -> (car) -> car.brand().equals(brand);

Damit sieht die Verwendung schon recht ordentlich aus.

    nextCarStream()
        .filter(brandFilter.apply("BMW"))
        .forEach(System.out::println);

    nextCarStream()
        .filter(brandFilter.apply("VW"))
        .forEach(System.out::println);

Nun haben wir einen Stream, den wir nach und nach aufbauen wollen. In diesem Fall nur mit einer Modifikation, dem Filterelement. Wenn man es direkt definiert, kommt man zum Beispiel auf folgende Lösung.

  public static BiFunction<Stream<Car>, 
                           String, 
                           Stream<Car>> filteredStream =
      (stream , brand) -> stream.filter(brandFilter.apply(brand));

Wir beziehen uns hier auf die vorher definierte Funktion brandfilter. Jedoch kann man hier schon erkennen, das die Definition des Streams nicht erst nach dem Erzeugen stattfinden muss. Somit erhalten wir nun die Möglichkeit, diese Elemente generisch zu formulieren und in eine eigene Lib auszulagern. Es muss demnach noch gar nicht bekannt sein, wie und mit welchen Daten der Stream real erzeugt werden wird.

  public static Function<String, Predicate<Car>> brandFilter =
          (brand) -> (car) -> car.brand().equals(brand);

  public static BiFunction<Stream<Car>, 
                           String, 
                           Stream<Car>> filteredStream =
      (stream , brand) -> stream.filter(brandFilter.apply(brand));

  public static Consumer<Stream<Car>> printStream 
          = (stream) -> stream.forEach(System.out::println);

  public static void main(String[] args) {

    printStream
        .accept(filteredStream.apply(nextCarStream() , "BMW"));
    printStream
        .accept(filteredStream.apply(nextCarStream() , "VW"));
  }

Jetzt sind wir zwar einen Schritt weiter. Aber richtig fertig sind wir noch nicht. Unschön an dieser Stelle ist zum Beispiel noch, wie die Elemente geschachtelt werden und dass wir mit einer BiFunction arbeiten. Eine BiFunction lässt sich nicht so gut mittels andThen() und combine() mit anderen Funktionen verbinden. Beides werden wir nun angehen.

Was wir benötigen, ist eigentlich eine Funktion, die aus der Eingabe einer Marke (BMW oder VW) einen gefilterten Stream erzeugt: Function<String, Function<Stream, Stream>. Der Stream, auf den dies angewendet werden soll, ist natürlich noch nicht existent. Demnach ist der Stream selbst ein Eingabeparameter. Nun ist es hier der Fall, dass wir aus einem String das Predicate erzeugen. Wenn man das allgemeiner formuliert, kommt man eher auf eine Signatur wie Function<Predicate, Function<Stream, Stream>. Nun können wir diesen Teil generisch zur Verfügung stellen.

  public static <T> Function<Predicate<T>, 
                             Function<Stream<T>, 
                                      Stream<T>>> streamFilter(){
    return (filter) 
               -> (Function<Stream<T>, Stream<T>>) inputStream 
                   -> inputStream.filter(filter);
  }

In der Verwendung sieht das wie folgt aus.

    Main.<Car>streamFilter()
        .apply(filter().apply("BMW"))
        .apply(nextCarStream())
        .forEach(printCar());

//or with andThen

    filter()
        .andThen(streamFilter())
        .apply("BMW")
        .apply(nextCarStream())
        .forEach(printCar());

Wir sind nun schon recht weit und haben es geschafft, die zustandslosen Anteile generisch zu extrahieren. Die Komposition erfolgt demnach nicht mehr nach der Erzeugung des Streams, wie es meist gezeigt und gelehrt wird. Hiermit erhalten wir eine wesentlich höhere Abstraktion und Wiederverwendbarkeit bestehender Codeteile. Aber da war noch etwas…

Typdefinitionen festlegen

Wir sind nun soweit, dass wir mit den Möglichkeiten, die uns mit Java 8 gegeben worden sind, ein wenig mehr spielen können. Was nicht bedeuten soll, dass man erst seit Java 8 funktional mit Java arbeiten kann. Es ist nur wesentlich deutlicher geworden.

Wir werden nun ausschließlich Funktionen von diesem Typ Function<Integer, Integer> miteinander verbinden.

    final Function<Integer, Integer> f1 = (x)-> x * 2;
    final Function<Integer, Integer> f2 = (x)-> x + 2;
    final Function<Integer, Integer> f3 = (x)-> x + 10;

    System.out.println("f1 f2 f3 (andThen) => " 
                       + f1.andThen(f2).andThen(f3).apply(1));

    System.out.println("f1 f2 f3 (compose) => " 
                       + f1.compose(f2).compose(f3).apply(1));

Das funktioniert ohne Probleme. Eine Kombination in beide Richtungen, gemeint ist damit andThen() und compose(), funktioniert. Versuchen wir das nun direkt ineinander zu schreiben.

    final Function<Integer, Integer> f1 = (x)-> x * 2;
    
        final Function<Integer, Integer>  fAndThen 
            = f1
                .andThen((x)-> x + 2)
                .andThen((x)-> x + 10);

Auch das funktioniert mit der Verknüpfung mittels andThen(). Wie sieht es nun mit compose() aus?

final Function<Integer, Integer>  fCompose 
    = f1
        .compose((x)-> x + 2).  //Operator + cannot be applied to j.l.Object
        .compose((x)-> x + 10);

OK, das funktioniert so schon mal nicht. Der Typ kann nicht richtig ermittelt werden. Deswegen geht der Compiler von Object aus. Und das geht halt schief. Hier hilft nur, dass der Typ explizit angegeben wird. Das führt dann zu folgendem Quelltext.

    final Function<Integer, Integer>  fCompose = f1
        .compose((Function<Integer, Integer>) (x)-> x + 2)
        .compose((x)-> x + 10);

Bitte beachten, dass der Typ in unserem Beispiel nur einmal angegeben werden muss. Das liegt daran, dass zum Ende die Typdefinition durch die Deklaration der Variablen angegeben worden ist. Wir müssen allerdings den Typ zweimal angeben, wenn wir die Kombination als Argument, also ohne finale Variablendeklaration, verwenden wie im nachfolgenden Beispiel.

    System.out.println("f1 f2 f3 (compose) => " + f1
        .compose((Function<Integer, Integer>)(x)-> x + 2)
        .compose((Function<Integer, Integer>)(x)-> x + 10)
        .apply(1));

Der Vollständigkeit halber: Wie sieht das als Parameter einer Methode aus, die einen passenden Typ hat?

static void use(Function<Integer, Integer> f){
    System.out.println("f.apply(1) = " + f.apply(1));
}

    use(f1.compose((Function<Integer, Integer>)(x)-> x + 2)
          .compose((x)-> x + 10));

Hier reicht es wieder aus, wie im Fall der deklarierten Variablen. Es sind also am anderen Ende genug Typinformationen vorhanden.

Warum eigentlich der Unterschied zwischen der Verwendung mittels andThen() und compose? Der Grund ist recht einfach. andThen setzt nachfolgend an die bestehenden Funktion an. Hier ist der Typ also vorhanden. Im Falle von compose wird die Funktion vorne erweitert, was bedeutet das der Typ nicht in allen Fällen fix ist.

Es kann aber auch ein anderer Weg gegangen werden, um die notwendigen Typinformationen zur Verfügung zu stellen. Wir definieren hierzu eine Methode die die uns ein compose abbildet.

static <T, U, V> 
       Function<
           Function<U, V>, 
           Function<Function<T, U>, Function<T, V>>> compose() {
    return (Function<U, V> f) 
        -> (Function<T, U> g) 
            -> (T x) -> f.apply(g.apply(x));
  }

Hier wurde nicht nur der Vorgang des compose schrittweise abgebildet, sondern auch alle notwendigen Typinformationen mit definiert. In der Verwendung sieht das dann wie folgt aus.

    Function<Integer, Integer> fx = Main.<Integer, Integer, Integer>compose()
        .apply((x)-> x + 2)
        .apply((x)-> x * 2)

Eine Funktion, die eine Funktion liefert

Zu Beginn sind wir immer so vorgegangen, dass wir die Eingangsparameter in einen anderen Wert transformiert haben. Aber es spricht nichts dagegen, dass der Rückgabewert wiederum eine Funktion ist. Sehen wir uns hierzu doch folgendes Beispiel an. Erst aus der OO-Welt, nachfolgend werden wir es ein wenig umformen.

Wir wollen verschiedene Operatoren implementieren. Eingabe wird ein Zeichen sein, das dann in einen Operator transformiert wird. Zum Beispiel wird ein + in den Operator Add überführt. Dazu definieren wir erst einmal ein Interface und dann die verschiedenen Implementierungen.

  public static interface Operator {
    public Float work(Integer a , Integer b);
  }

  public static final class Add implements Operator {
    @Override
    public Float work(Integer a , Integer b) {
      return (float) (a + b);
    }
  }

  public static final class Sub implements Operator {
    @Override
    public Float work(Integer a , Integer b) {
      return (float) (a - b);
    }
  }

  public static final class Mult implements Operator {
    @Override
    public Float work(Integer a , Integer b) {
      return (float) (a * b);
    }
  }

  public static final class Divide implements Operator {
    @Override
    public Float work(Integer a , Integer b) {
      return (float) (a / b);
    }
  }

Nun sehen wir uns nachfolgend die Implementierung für die Endscheidungsfindung an.

  public static Operator operator(String op) {
    Operator result;
    switch (op) {
      case "+": result = new Add(); break;
      case "-": result = new Sub(); break;
      case "/": result = new Divide();break;
      case "*": result = new Mult(); break;
      default: throw new RuntimeException("bad op");
    }
    return result;
  }

Ich lasse hier bewusst eine Menge an Code weg, um die Implementierung robust zu gestallten und das Beispiel kurz zu halten. Die Verwendung ist recht geradlinig, wollen wir doch lediglich einen Operator auswählen und verwenden: final Float result = operator("+").work(1 , 2);. Einer mehrfachen Verwendung des Operators steht nichts im Wege, wenn man hier davon ausgehen kann, dass der Operator keinen internen Zustand besitzt, der nachfolgende Verwendungen beeinflusst. Beginnen wir nun mit der Umformung.

Definieren wir zuerst, was wir erreichen wollen. Der Operator ist eine Funktion, die zwei Integer-Werte übergeben bekommt und einen Float-Wert zurückliefert. Wir können das recht einfach als Funktion mit zwei Eingangsparametern schreiben: BiFunction<Integer, Integer,Float>. Hiermit ersetzten wir erst einmal die Implementierungen.

  public static BiFunction<Integer, Integer, Float> add = (a , b) -> (float) (a + b);
  public static BiFunction<Integer, Integer, Float> sub = (a , b) -> (float) (a - b);
  public static BiFunction<Integer, Integer, Float> div = (a , b) -> (float) (a / b);
  public static BiFunction<Integer, Integer, Float> mul = (a , b) -> (float) (a * b);

  static BiFunction<Integer, Integer, Float> operator(String operator) {
    BiFunction<Integer, Integer, Float> fkt = null;
    switch (operator) {
      case "+":
        fkt = add;
        break;
      case "-":
        fkt = sub;
        break;
      case "/":
        fkt = div;
        break;
      case "*":
        fkt = mul;
        break;
      default:
        throw new RuntimeException("bad op !!");
    }
    return fkt;
  }

Wir haben im letzten Schritt die Imlementierungen ersetzt. Mann kann das auch gleich in die Methode operator(String operator) hineinnehmen.

  static Function<String, BiFunction<Integer, Integer, Float>> operator() {
    return (operator) -> {
      BiFunction<Integer, Integer, Float> fkt = null;
      switch (operator) {
        case "+": fkt = (a , b) -> (float) (a + b); break;
        case "-": fkt = (a , b) -> (float) (a - b); break;
        case "/": fkt = (a , b) -> (float) (a / b); break;
        case "*": fkt = (a , b) -> (float) (a * b); break;
        default:
          throw new RuntimeException("bad op !!");
      }
      return fkt;
    };
  }

In der Implementierung, die wir im letzten Schritt erreicht haben, ist immer noch der Fall vorhanden, das eine Exception geworfen werden kann. Dies könnten wir zum Beispiel durch ein Optional oder Result beheben.

  static Function<String, 
                  Result<BiFunction<Integer, Integer, Float>>> operator() {
    return (operator) ->
        (operator.equals("+")) ? Result.success((a , b) -> (float) (a + b)) 
        : (operator.equals("-")) ? Result.success((a , b) -> (float) (a - b))
        : (operator.equals("/")) ? Result.success((a , b) -> (float) (a / b))
        : (operator.equals("*")) ? Result.success((a , b) -> (float) (a * b))
        : Result.failure("bad op !!");
  }


  public static void main(String[] args) {
    operator()
        .apply("+")
        .ifPresentOrElse(
            success -> System.out.println("result = " + success) ,
            failure -> System.out.println(failure)
        );
  }

Fazit

Wir haben uns in diesem Teil damit beschäftigt, wie wir Aufgaben in einzelne Funktionen zerlegen können. Hierbei haben wir uns angesehen wie sich die Typinformationen bei der Kombination von Funktionen verhalten und unterschiedliche Ansätze geliefert, wie man damit umgehen kann. Einer der Hauptpunkte ist auch der Unterschied bei der Definition von Streams gewesen und der Weg, wie man die Definition von der Erzeugung von Streams trennen kann. Den Quelltext findet ihr auf GitHub. Bei Fragen oder Anregungen einfach melden unter sven@vaadin.com oder per Twitter @SvenRuppert.

Happy Coding!

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.

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. Daniel2017-08-31 13:06:57

    Danke für den interessanten Artikel.
    Mir stellt sich derzeit nur eine Frage:
    Warum ist Car ein Quint?

    Das ist in keinem der gezeigten Codebeispiele wirklich relevant (oder?) und ermöglicht sinnlose Dinge wie car.getT1()!

  2. Sven Ruppert2017-08-31 22:36:14

    Hallo Daniel,
    ich verwende das recht gerne aus zwei Gründen. Zum ersten weil ich mir die Dinge wie toString, hashCode und so spare. (darüber kann man vortrefflich diskutieren ob es gut ist oder nicht ;-) )
    Und zum zweiten, kann ich an anderer Stelle Dinge sehr generisch formulieren, da ich eben die getT1() .... Methoden habe. Ich benötige also nicht die Fachlichkeit der Methodennamen.

    Auf jeden Fall danke für Deine Frage, ich werde es aufgreifen und darauf eingehen in einem der nächsten Artikel.

    Cheers Sven

    PS: ich bekomme keine Info das hier Kommentare eingehen, muss also manuell danach suchen. Am besten per Twitter DM oder mail anschreiben .

Schreibe einen Kommentar

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