Funktionale Programmierung, Lambda-Ausdrücke, Stream-API, Bulk Operations on Collections, Filter-Map-Reduce

+ andere TechDocs
+


Zu den Neuerungen in Java 8 gehören Ansätze funktionaler Programmierung, Lambda-Ausdrücke, Stream-API, Bulk Operations on Collections, Filter-Map-Reduce und einiges mehr. Der folgende Text beschreibt nur einige Grundlagen. Weitergehende Informationen finden Sie zum Beispiel unter:



Inhalt

  1. Funktionale Programmierung, Funktionen, Code-as-Data
  2. Functional Interface Type, SAM Type
  3. Lambda-Ausdrücke
  4. Default-Methoden, interne Iterierung, Mehrfachvererbung
  5. Streams und Bulk Operations on Collections
  6. Filter-Map-Reduce
  7. Filter-Map-Reduce mit paralleler Verarbeitung



Funktionale Programmierung, Funktionen, Code-as-Data

Java ist eine "imperative, objektorientierte Programmiersprache" und keine "funktionale Programmiersprache". Aber seit Version 8 werden in Java einige Aspekte der "funktionalen Programmierung" unterstützt.

"Funktionen" ähneln Methoden. Aber anders als Methoden werden Funktionen bei "funktionaler Programmierung" weitergereicht, beispielsweise als Argumente an Methoden oder als Return-Wert einer Methode. Dies wird manchmal als "Code-as-Data" bezeichnet.

Anders als "funktionale Sprachen" bietet Java keine "Funktionstypen". Trotzdem können Referenzen auf Funktionen weitergereicht werden.

Beim so genannten "Currying" können Funktionen auch manipuliert werden.

"Rein funktionale Sprachen" ("pure functional Language") wie beispielsweise Haskell sind zustandslos und Funktionen haben keinerlei Seiteneffekte. Sie ändern keine Daten, sondern können höchstens neue Daten returnieren. Das ist bei Java anders. Hier können Funktionen durchaus Daten ändern.

"Rein funktionale Programmierung" eignet sich ideal zur Parallelverarbeitung sowie für mathematische Aufgaben, und steht in dem Ruf, dass so erstellte Programme weniger Fehler enthalten sollen. Nachteilig ist, dass sich reale Objekte und Zustandsänderungen nur schwierig abbilden lassen, was die Programmierung realer Vorgänge und Geschäftsprozesse erschwert. Dies ist mit objektorientierter zustandsbehafteter Programmierung wesentlich leichter realisierbar.



Functional Interface Type, SAM Type

Interfaces mit einer einzigen abstrakten Methode heißen neuerdings "Functional Interface Types" oder auch "SAM Types". "SAM" steht für "Single Abstract Method". Solche Interfaces können mit der Annotation @FunctionalInterface versehen werden und haben für Lambdas eine besondere Bedeutung, da sie die einzigen Typen sind, die als "Target-Typing-Zieltypen" in Frage kommen.

Beispiele:
Predicate, Function, Consumer und alle weiteren Functional Interfaces in java.util.function,
sowie z.B. ActionListener, AutoCloseable, Callable, Comparable, Comparator, FileFilter, FilenameFilter, Iterable, Runnable u.s.w.



Lambda-Ausdrücke

Lambda-Ausdrücke (entsprechend JSR 335) stellen Funktionen dar, ohne Namen, ohne explizite Angabe des Return-Typs und ohne Deklaration von Exceptions.

Die allgemeine Syntax für einen Lambda-Ausdruck lautet:

( Parameterliste ) -> { Ausdruck oder Anweisungen }

Beispiel:

( File f ) -> { return f.isFile(); }

Allerdings gibt es davon abweichende Kurzschreibweisen. Um verschiedene Schreibweisen für Lambda-Ausdrücke zu demonstrieren, erfüllen die folgenden Code-Schnipsel alle dieselbe Funktionalität: In dem Verzeichnis "/MeinVerzeichnis" werden alle Dateien, aber keine Unterverzeichnisse aufgelistet, mit der Methode File.listFiles(FileFilter). Die Variable meinVerz ist definiert durch:

File meinVerz = new File( "/MeinVerzeichnis" );


Code-SchnipselErläuterung
File[] dateien = meinVerz.listFiles(
   new FileFilter() {
      @Override
      public boolean accept( File f ) {
         return f.isFile();
      }
   }
);
Ohne Lambda:
Konventionelle Programmierung der Filterfunktion als anonyme Klasse.
FileFilter meineFilterFunktion = (File f) -> { return f.isFile(); };
File[] dateien = meinVerz.listFiles( meineFilterFunktion );
Zuweisung der Filterfunktion als Lambda-Ausdruck "(File f) -> { return f.isFile(); }" zu dem SAM-Interface FileFilter und Übergabe der so definierten Filterfunktion meineFilterFunktion als Methoden-Parameter an listFiles().
File[] dateien = meinVerz.listFiles( (File f) -> { return f.isFile(); } ); Direkte Übergabe des Lambda-Ausdrucks als Methoden-Parameter.
File[] dateien = meinVerz.listFiles( f -> f.isFile() ); Verkürzte Schreibweise ohne Typangabe und ohne Klammern.
Die Typangabe kann entfallen, wenn der Compiler sie aus dem Kontext "deduzieren" kann.
Wenn es nur einen Parameter ohne Typangabe gibt, können die linken runden Klammern entfallen.
Wenn rechts keine Anweisung, sondern nur ein einzelner Ausdruck steht, entfallen die rechten geschweiften Klammern.
File[] dateien = meinVerz.listFiles( File::isFile ); Noch kürzere Schreibweise mit der seit Java 8 möglichen "Methodenreferenz" "File::isFile".
Ein Lambda ist durch eine Methodenreferenz (oder Konstruktorreferenz, s.u.) ersetzbar, wenn außer dem Methodenaufruf keine weitere Aktion erforderlich ist.

Weitere Bespiele für Lambda-Ausdrücke:

Code-SchnipselErläuterung
( int x, int y ) -> x + y Es können mehrere Parameter übergeben werden.
( x, y ) -> x + y Auch bei mehreren Parametern können die Typangaben entfallen.
() -> "blupp" Auch eine leere Parameterliste ist erlaubt.
( x, y ) -> ( x > y ) ? x : y Der ?:-Operator gilt als ein einziger Ausdruck und die rechten geschweiften Klammern entfallen.
( x, y ) -> { if( x > y ) return x; return y; } Bei Anweisungen müssen geschweifte Klammern gesetzt werden.
Object obj1 = (FileFilter) f -> f.isFile();
Object obj2 = (FileFilter) File::isFile;
Lambda-Ausdrücke können nur SAM-Interfaces zugeordnet werden.
Aber dieses Beispiel zeigt, wie indirekt auch Zuordnungen zu anderen Typen möglich sind.


Default-Methoden, interne Iterierung, Mehrfachvererbung

Bis Java 7 konnten Interfaces nicht nachträglich erweitert werden, ohne die Rückwärtskompatibilität zu gefährden. Wenn ein Interface eine neue Methode erhält, müssen alle dieses Interface implementierende Klassen diese neue Methode implementieren, und können sonst nicht compiliert werden.

Ab Java 8 können Interfaces durch so genannte "Default-Methoden" erweitert werden, ohne die Rückwärtskompatibilität zu gefährden. Diese im Interface definierten Default-Methoden beinhalten eine Default-Implementierung, die bei Bedarf in der implementierenden Klasse überschrieben werden kann.

Beispiel für ein Interface mit Default-Methode:
Das Interface Iterable wurde in Java 8 um die Default-Methode forEach() erweitert, damit leichter über Collection-Elemente iteriert werden kann. Eine solche Iteration wird als "interne Iterierung" bezeichnet. Im Gegensatz zur herkömmlichen "externen Iterierung" bestimmt die Klasse selbst, wie und in welcher Reihenfolge die einzelnen Elemente durchlaufen und bearbeitet werden. Insbesondere könnten sie sogar parallel in getrennten Threads statt seriell bearbeitet werden.

Beispiel für eine interne Iterierung mit der neuen Default-Methode forEach():

List<String> meineListe = Arrays.asList( "Abc", "Xyz" );
meineListe.forEach( System.out::println );

Beispiel für interne Iterierungen mit den neuen Default-Methoden removeIf() und replaceAll():

List<String> meineStringListe = new ArrayList<>();
meineStringListe.add( "abc");
meineStringListe.add( "");
meineStringListe.add( "  def  ");
meineStringListe.add( null);
meineStringListe.add( "ghi");
meineStringListe.add( "  ");
meineStringListe.add( "xyz");
meineStringListe.removeIf( s -> s == null );
meineStringListe.replaceAll( String::trim );
meineStringListe.removeIf( String::isEmpty );
meineStringListe.forEach( System.out::println );

Während es in Java bis Java 7 keine Mehrfachvererbung gab, ist dies seit Java 8 in sehr eingeschränktem Umfang durch die neuen Default-Methoden möglich. Falls eine Klasse zwei Interfaces implementiert, welche beide eine Default-Methode mit identischer Signatur beinhalten, muss diese Klasse die Default-Methode überschreiben, indem sie eine eigene Implementierung definiert oder mit "super" angibt, welche Implementierung gelten soll:

interface MeinInterface1 {
  default void meineDefaultMethode() { System.out.println( "1" ); }
}
interface MeinInterface2 {
  default void meineDefaultMethode() { System.out.println( "2" ); }
}
class MeineKlasse implements MeinInterface1, MeinInterface2 {
  @Override public void meineDefaultMethode() {
    MeinInterface2.super.meineDefaultMethode();
  }
}


Streams und Bulk Operations on Collections

Die seit Java 8 in neuen Interfaces und Klassen vorwiegend in dem Package java.util.stream definierten "Streams" bieten eine Abstraktion für Folgen von Verarbeitungsschritten ("Stream Pipeline") auf Datensequenzen, beispielsweise in Collections.


Es gibt drei übergeordnete Kategorien von Stream-Operationen:


Beispiele für "Create Operations":

Beispiel-Code-Schnipsel für "Create Operations"Erläuterung
String[]     meinArray  = { "Anton", "Berta", "Cäsar" };
List<String> meineListe = Arrays.asList( "Anton", "Berta", "Cäsar" );
(Vorbereitung).
Stream<String> streamAusArray = Arrays.stream( meinArray ); Stream aus Array mit Arrays.stream().
Stream<String> streamAusListe = meineListe.stream(); Stream aus Collection mit Collection.stream().
Stream<String> parallelStream1 = Arrays.stream( meinArray ).parallel(); Parallel verarbeitbarer Stream aus Array mit BaseStream.parallel().
Stream<String> parallelStream2 = meineListe.parallelStream(); Parallel verarbeitbarer Stream aus Collection mit Collection.parallelStream().
Stream<String>  stringStream  = Stream.of( "Anton", "Berta", "Cäsar" );
Stream<Integer> integerStream = Stream.of( 1, 2, 7, 42, 4711 );
Stream-Erzeugung mit Stream.of().
IntStream intStream1 = IntStream.range( 7, 42 ); Stream-Erzeugung mit IntStream.range().
IntStream intStream2 = "Mein Text".chars(); Stream-Erzeugung mit CharSequence.chars().
IntStream intStream3 = IntStream.iterate( 0, x -> x + 1 ); "Unbegrenzter Stream" mit IntStream.iterate().
AtomicInteger ai = new AtomicInteger( 0 );
Stream<Integer> atomIntStream = Stream.generate( ai::getAndIncrement );
"Unbegrenzter Stream" mit Stream.generate().
Stream<Path> verzeichnisInhalt = Files.list( Paths.get( "C:/Temp/" ) );
Stream-Erzeugung aus Verzeichnisinhalt mit Files.list().
Stream<String> zeilen = Files.lines( Paths.get( "MeineTextDatei.txt" ),
                                     StandardCharsets.UTF_8 );
Stream-Erzeugung aus Textdatei mit Files.lines().
Bitte beachten: Files.lines() hat anderen Default für das Character-Encoding als FileReader und InputStreamReader, deshalb sollte das Character-Encoding immer explizit angegeben werden.

Beispiele für "Intermediate Operations":

Beispiel-Code-Schnipsel für "Intermediate Operations"Erläuterung
Stream<Integer> integerStream = Stream.of( 1, 2, 7, 42, 4711 );
Stream<File>    meineDateien  = Arrays.stream( (new File( "/MeinVerzeichnis" ))
                                               .listFiles() );
(Vorbereitung).
Stream<File> filtered = meineDateien.filter( File::isFile )
                                    .filter( f -> f.length() > 100 )
                                    .filter( f -> f.getName().startsWith( "X" ) );
Mehrfache "Filterung" durch mehrfache Anwendung von Stream.filter( Predicate ).
Stream<File> filtered = meineDateien.filter( ((Predicate<File>) File::isFile)
                                     .and( f -> f.length() > 100 )
                                     .and( f -> f.getName().startsWith( "X" ) ) );
Mehrfache "Filterung" durch Verknüpfung verschiedener Filterbedingungen mit Predicate.and(), .or(), .negate() oder .isEqual().
Stream<Path> alleTxtDateien = Files.list( Paths.get( "C:/Temp/" ) )
                .filter( path -> path.toString().endsWith( ".txt" ) );
Andere Art der Filterung auf Dateiendung.
Stream<Integer> sortedAndDistinct = integerStream.sorted().distinct(); "Stream Chaining" ("fluent API"): Zuerst wird mit Stream.sorted() sortiert und dann werden mit Stream.distinct() Dubletten aussortiert.
Stream<File> paged = meineDateien.skip( 40 ).limit( 20 ); "Paging" mit Stream.skip() und Stream.limit().
Stream<StringBuilder> sb = meineListe.stream().map( StringBuilder::new ); "Mapping" mit Typumwandlung: Aus den Elementen werden mit Hilfe einer seit Java 8 möglichen so genannten "Konstruktorreferenz" "StringBuilder::new" StringBuilder-Typen erzeugt.
Stream<Double> dblStrm1 = integerStream.map( i -> Math.sqrt( i.doubleValue() ) ); "Mapping" mit Typumwandlung: Aus Integer-Werten werden Double-Werte berechnet, mit Stream.map( Function ).
DoubleStream   dblStrm2 = integerStream.mapToDouble( Math::sqrt ); Alternative Schreibweise mit Stream.mapToDouble().
meinStream = meinStream.peek( System.out::println ); Peek ermöglicht die Untersuchung der Elemente, ohne die Iteration fortzusetzen.

Beispiele für "Terminal Operations" (bitte die Ergebnisse jeweils anzeigen mit System.out.println( ... )):

Beispiel-Code-Schnipsel für "Terminal Operations"Erläuterung
meinStream.forEach( System.out::println ); Behandlung aller Elemente mit Stream.forEach( Consumer ).
List<File> dateienListe = meineDateien.collect( Collectors.toList() ); Speicherung resultierender Elemente in eine Liste mit Stream.collect() und Collectors.toList().
List<File> dateienListe = meineDateien.collect( Collectors.toCollection(
                                                         ArrayList::new ) );
Speicherung resultierender Elemente in einer genau festgelegten Art von Liste mit Collectors.toCollection() und der "Konstruktorreferenz" "ArrayList::new".
Map<String,Long> dl = meineDateien.collect(
                         Collectors.toMap( File::getName, File::length ) );
Speicherung resultierender Elemente in eine Map mit Stream.collect() und Collectors.toMap().
Map<Boolean,List<File>> m = meineDateien.collect(
                               Collectors.groupingBy( File::canExecute ) );
Gruppierung mit Stream.collect() und Collectors.groupingBy().
boolean existX = meineDateien.anyMatch( f -> f.getName().startsWith( "X" ) ); Reduzierung auf einen einzelnen Wert mit Stream.anyMatch().
Optional<File> datei = meineDateien.findFirst(); Reduzierung auf einen einzelnen Wert mit Stream.findFirst().
Integer summe1 = integerStream.reduce( 0, ( a, b ) -> a + b ); Reduzierung auf einen einzelnen Wert (im Beispiel Summe) mit Stream.reduce().
int     summe2 = intStream.sum(); Alternative Schreibweise mit IntStream.sum().


Filter-Map-Reduce

"Filter-Map-Reduce" hat einige Analogien zu dem MapReduce-Algorithmus von Google, ist aber nicht dasselbe. Siehe hierzu auch Wikipedia.

"Filter-Map-Reduce" beschreibt ein Pattern, bei dem eine Menge von Daten in einer Abfolge von bestimmten Schritten verarbeitet werden:

Beispiele für "Filter-Map-Reduce" (lassen Sie sich jeweils das Ergebnis anzeigen mit System.out.println( ... )):

Beispiel-Code-Schnipsel für "Filter-Map-Reduce"Erläuterung
class Buch {
   String titel; String autor; int jahr; double preis;
   public Buch( String titel, String autor, int jahr, double preis ) {
      this.titel = titel; this.autor = autor;
      this.jahr = jahr; this.preis = preis;
   }
}
List<Buch> meineBuecherListe = Arrays.asList(
      new Buch( "Fortran", "Ferdinand", 1957, 57.99 ),
      new Buch( "Java in 3 Tagen", "Anton", 2005, 11.99 ),
      new Buch( "Java in 4 Tagen", "Berta", 2005, 22.99 ),
      new Buch( "Filter-Map-Reduce mit Lambdas", "Cäsar", 2014, 33.99 ) );
(Vorbereitung)
String s = meineBuecherListe.stream()
      .filter( b -> b.jahr >= 2004 )
      .map( b -> b.autor )
      .reduce( "", ( s1, s2 ) -> (s1.isEmpty()) ? s2 : s1 + ", " + s2 );
a) Filtere bestimmte Buch-Objekte, z.B. alle ab 2004,
b) Mappe bzw. extrahiere die gewünschte Information, z.B. den Autor-Namen,
c) Reduziere auf einen Ergebnis-String, z.B. Komma-separierte Namen.
String s = meineBuecherListe.stream()
      .filter( b -> b.jahr >= 2004 )
      .map( b -> b.autor )
      .collect( Collectors.joining( ", " ) );
Alternative Schreibweise mit demselben Ergebnis.
Map<Integer,Long> m = meineBuecherListe.stream()
      .filter( b -> b.jahr >= 2004 )
      .collect( Collectors.groupingBy( b -> Integer.valueOf( b.jahr ),
                                       Collectors.counting() ) );
Gruppierung, z.B. nach Jahr, sowie Zählen pro Gruppe.
OptionalDouble d = meineBuecherListe.stream()
      .filter( b -> b.jahr >= 2000 )
      .mapToDouble( b -> b.preis )
      .average();
Reduziere mit Aggregat-Funktion, z.B. auf den Durchschnittswert.
OptionalDouble d = meineBuecherListe.stream()
      .peek( b -> System.out.println( b.titel + ", " + b.preis ) )
      .filter( b -> b.jahr >= 2000 )
      .mapToDouble( b -> b.preis )
      .peek( System.out::println )
      .average();
Debuggen mit Stream.peek().
long anzahlZeichen = Files.lines( Paths.get( "MeineTextDatei.txt" ),
                                  StandardCharsets.UTF_8 )
      .mapToInt( String::length )
      .sum();
Stream-Erzeugung mit Files.lines(), Mapping auf Anzahl Zeichen pro Zeile und Reduzierung auf einen Summenwert.
long anzahlWoerter = Files.lines( Paths.get( "MeineTextDatei.txt" ),
                                  StandardCharsets.UTF_8 )
      .flatMap( Pattern.compile( "[^\\p{L}]" )::splitAsStream )
      .count();
Stream-Erzeugung mit Files.lines(), pro Zeile Erzeugung von "Unter-Streams" mit Pattern.splitAsStream() und wieder Zusammenführung der Streams mit Stream.flatMap(), sowie Reduzierung auf die Anzahl der Elemente mit Stream.count().


Filter-Map-Reduce mit paralleler Verarbeitung

Beispiel für "Filter-Map-Reduce" mit serieller und paralleler Verarbeitung (Definition von meineBuecherListe siehe oben):

Beispiel-Code-Schnipsel für "Filter-Map-Reduce",
seriell versus parallel
Erläuterung
OptionalDouble durchschnittlicherPreisAb2000seriell()
{
   Instant anfang = Instant.now();
   OptionalDouble avg = meineBuecherListe.stream()
         .filter( b -> b.jahr >= 2000 )
         .mapToDouble( b -> { try { Thread.sleep( 1000 );
                                  } catch( Exception e ) {}
                              return b.preis; } )
         .average();
   System.out.println( Duration.between( anfang, Instant.now() ) );
   return avg;
}
Zeitmessung bei serieller Verarbeitung.
Um eine sinnvolle Zeitmessung zu ermöglichen, wird simuliert, dass die Map-Funktion eine Sekunde Berechnungszeit benötigt.
Bei serieller Verarbeitung addieren sich die Zeiten pro gefiltertem Buch.
OptionalDouble durchschnittlicherPreisAb2000parallel()
{
   Instant anfang = Instant.now();
   OptionalDouble avg = meineBuecherListe.parallelStream()
         .filter( b -> b.jahr >= 2000 )
         .mapToDouble( b -> { try { Thread.sleep( 1000 );
                                  } catch( Exception e ) {}
                              return b.preis; } )
         .average();
   System.out.println( Duration.between( anfang, Instant.now() ) );
   return avg;
}
Zeitmessung bei paralleler Verarbeitung (mit Collection.parallelStream()).
Es wird nur ein Bruchteil der Zeit wie bei serieller Verarbeitung benötigt.

Nicht alle Intermediate Operations eignen sich gut zur Parallelisierung. Beachten Sie hierzu die Javadoc-Kommentare zu den Intermediate Operations, die häufig explizit darauf hinweisen, ob die Methode "stateful" oder "non-interfering, stateless" arbeitet. Sehen Sie sich hierzu auch die Stream-Package-Doku an.

Damit es bei der Parallelisierung nicht zu unerwünschten Nebenwirkungen kommt, beachten Sie unbedingt die Hinweise zu Concurrency (Multithreading).




Weitere Themen: andere TechDocs
© 2014 Torsten Horn, Aachen