Zum Inhalt springen

Funktionale Interfaces in Java (mit Beispielen)

Raster türkis leuchtender Kontaktpunkte einer CPU-Unterseite auf magentafarbenem Grund, von einer diagonalen Naht geteilt

Ein funktionales Interface ist ein Interface mit genau einer abstrakten Methode. Diese eine Methode ist es, die ein Lambda-Ausdruck implementiert: book -> book.year() < 1850 hat für sich allein keinen Typ – als Predicate<Book> wird daraus ein Objekt mit der Methode test(), die für ein Buch true oder false liefert.

Funktionale Interfaces gibt es seit Java 8, zusammen mit den Lambda-Ausdrücken. Das JDK bringt im Package java.util.function 43 davon mit – von Function und Predicate bis ObjDoubleConsumer. Welches du wann brauchst, wie du ihre Namen liest und wann du ein eigenes schreibst, zeige ich dir in diesem Artikel.

In diesem Artikel erfährst du,

  • was ein funktionales Interface ist und welche Methoden bei der einen abstrakten Methode nicht mitzählen,
  • was die Annotation @FunctionalInterface prüft,
  • welche Interfaces das Package java.util.function enthält und wie du an ihrem Namen erkennst, was sie tun,
  • wie du funktionale Interfaces mit andThen(), compose(), and(), or() und negate() kombinierst,
  • was ? super T und ? extends R in den Signaturen der Stream-API bedeuten,
  • welche funktionalen Interfaces es außerhalb von java.util.function gibt,
  • wann du ein eigenes funktionales Interface schreibst,
  • welche Fehler du vermeiden solltest.

Die Beispiele in diesem Artikel

Die Beispiele verwenden das Datenmodell des Artikels über Java Streams – ein Enum Genre, einen Record Book und eine kleine Bibliothek mit elf Klassikern:

public enum Genre {
  NOVEL,
  GOTHIC,
  ADVENTURE,
  FANTASY,
  SCIENCE_FICTION
}

public record Book(String title, String author, int year, Genre genre) {}
public class Library {

  public static final List<Book> BOOKS = List.of(
      new Book("Pride and Prejudice", "Jane Austen", 1813, NOVEL),
      new Book("Frankenstein", "Mary Shelley", 1818, GOTHIC),
      new Book("Moby-Dick", "Herman Melville", 1851, ADVENTURE),
      new Book("From the Earth to the Moon", "Jules Verne", 1865, SCIENCE_FICTION),
      new Book("Alice's Adventures in Wonderland", "Lewis Carroll", 1865, FANTASY),
      new Book("Around the World in Eighty Days", "Jules Verne", 1873, ADVENTURE),
      new Book("Treasure Island", "Robert Louis Stevenson", 1883, ADVENTURE),
      new Book("Kidnapped", "Robert Louis Stevenson", 1886, ADVENTURE),
      new Book("The Time Machine", "H. G. Wells", 1895, SCIENCE_FICTION),
      new Book("Dracula", "Bram Stoker", 1897, GOTHIC),
      new Book("The War of the Worlds", "H. G. Wells", 1898, SCIENCE_FICTION));
}

Den vollständigen Code aller Beispiele findest du im GitHub-Repository java-streams-examples, im Package eu.happycoders.functionalinterfaces.

Was ist ein funktionales Interface?

Ein funktionales Interface ist ein gewöhnliches Java-Interface, das genau eine abstrakte Methode deklariert. Das einfachste Beispiel im JDK ist Runnable:

@FunctionalInterface
public interface Runnable {
  void run();
}

Eine Methode, run(), ohne Parameter und ohne Rückgabewert – sonst nichts. Die Annotation erkläre ich im nächsten Kapitel; für die Definition spielt sie keine Rolle. Ein Lambda ist die kürzeste Implementierung dieser einen Methode. Im folgenden Beispiel gibt es den Titel des ersten Buchs aus:

Runnable printFirstTitle = () -> System.out.println(BOOKS.getFirst().title());
printFirstTitle.run();
Pride and Prejudice

Die Methode run() rufst du auf dem Lambda auf wie auf jedem anderen Objekt, das Runnable implementiert.

Ein funktionales Interface darf aber mehr als eine Methode haben – solange nur eine davon abstrakt ist. Comparator ist so ein Fall, hier gekürzt auf die Methoden, an denen du die Regeln siehst:

@FunctionalInterface
public interface Comparator<T> {

  int compare(T o1, T o2);

  boolean equals(Object obj);

  default Comparator<T> reversed() { … }

  default Comparator<T> thenComparing(Comparator<? super T> other) { … }

  static <T, U> Comparator<T> comparing(
      Function<? super T, ? extends U> keyExtractor) { … }

  // … und 15 weitere default- und static-Methoden
}

Abstrakt ist nur compare(). Die übrigen Methoden zählen nicht mit, und zwar aus drei Gründen:

  • default-Methoden wie reversed() und thenComparing() haben einen Rumpf und sind deshalb nicht abstrakt.
  • static-Methoden wie comparing() gehören zum Interface, nicht zu seinen Instanzen.
  • equals() ist zwar abstrakt deklariert, zählt aber trotzdem nicht: Es ist eine Methode von Object, und jede Klasse erbt eine Implementierung von dort – ein Lambda muss sie also nicht liefern. Die Java-Sprachspezifikation nimmt solche Methoden ausdrücklich von der Zählung aus. Comparator deklariert equals() nur, um im Javadoc zu beschreiben, wann zwei Comparatoren gleich sind.

Auch eine abstrakte Methode, die das Interface von einem Super-Interface erbt, zählt: Ein leeres Interface Sub extends Runnable ist funktional, seine eine Methode ist run(). Erbt ein Interface dieselbe Methode von zwei Super-Interfaces, zählt sie einmal.

Ob ein Interface funktional ist, entscheidet allein die Zahl seiner abstrakten Methoden – nicht die Annotation, nicht das Package, nicht sein Alter: Comparator gibt es seit Java 1.2, und seit Java 8 kannst du es mit einem Lambda implementieren – seine abstrakte Methode ist dieselbe geblieben, nur die default- und static-Methoden sind mit Java 8 dazugekommen.

Ein Lambda ist die kürzeste Implementierung der einen abstrakten Methode, bei Comparator genauso wie bei Runnable. Im folgenden Beispiel implementiert eine benannte Klasse Comparator<Book> auf dem klassischen Weg, und ein Lambda tut dasselbe in einer Zeile:

public class YearComparator implements Comparator<Book> {
  @Override
  public int compare(Book a, Book b) {
    return Integer.compare(a.year(), b.year());
  }
}
// Instanz der benannten Klasse – braucht zusätzlich die Klasse aus dem Block darüber
Comparator<Book> byYearClass = new YearComparator();

// Das Lambda ist die vollständige Implementierung – es braucht keine Klasse
Comparator<Book> byYearLambda = (a, b) -> Integer.compare(a.year(), b.year());

List<Book> books = new ArrayList<>(BOOKS);
books.sort(byYearClass);
books.sort(byYearLambda);

Beide Sortierungen liefern dieselbe Reihenfolge, denn sort() bekommt in beiden Fällen ein Objekt mit einer compare()-Methode übergeben – und beide compare()-Methoden vergleichen mit Integer.compare(a.year(), b.year()) dasselbe. Die compare()-Methode kannst du auch selbst aufrufen, wie jede andere Methode:

Book frankenstein = BOOKS.get(1);
Book dracula = BOOKS.get(9);
System.out.println(byYearLambda.compare(frankenstein, dracula));
System.out.println(byYearLambda.compare(dracula, frankenstein));
-1
1

Die default-Methoden stehen dem Lambda ebenfalls zur Verfügung: byYearLambda.reversed() beispielsweise sortiert absteigend, Comparator.comparing(Book::author).thenComparing(byYearLambda) erst nach Autor:in und dann nach Jahr.

Die Annotation @FunctionalInterface

Mit der Annotation @FunctionalInterface sagst du dem Compiler, dass ein Interface funktional sein soll. Der Compiler prüft dann, dass es ein Interface ist (keine Klasse, kein Enum, keine Annotation) und dass es genau eine abstrakte Methode hat.

Das folgende Interface TitleFormatter aus dem Beispielcode ist so ein annotiertes Interface. Es formatiert ein Buch als String:

@FunctionalInterface
public interface TitleFormatter {
  String format(Book book);
}
TitleFormatter withYear = book -> book.title() + " (" + book.year() + ")";
System.out.println(withYear.format(BOOKS.get(9)));
Dracula (1897)

Fügst du dem Interface eine zweite abstrakte Methode hinzu, kompiliert es nicht mehr:

@FunctionalInterface
public interface TitleFormatter {
  String format(Book book);
  String formatShort(Book book); // kompiliert nicht
}
error: Unexpected @FunctionalInterface annotation
  TitleFormatter is not a functional interface
    multiple non-overriding abstract methods found in interface TitleFormatter

Ohne die Annotation würde die zweite Methode kompilieren – und stattdessen würde jedes Lambda vom Typ TitleFormatter im ganzen Projekt nicht mehr kompilieren, mit einem Fehler, der nicht auf die Ursache zeigt. Die Annotation verschiebt den Fehler dorthin, wo er entsteht: in das Interface.

Für den Compiler ist die Annotation keine Voraussetzung: Er behandelt jedes Interface mit genau einer abstrakten Methode als funktionales, ob annotiert oder nicht. Ich empfehle dir trotzdem, jedes eigene funktionale Interface zu annotieren. Die Annotation dokumentiert, dass das Interface für Lambdas gedacht ist, und der Compiler passt darauf auf, dass es so bleibt – dieselbe Arbeitsteilung wie bei @Override: Eine Methode überschreibt ihre Obermethode auch ohne die Annotation, aber mit ihr meldet der Compiler, wenn sie das nicht mehr tut.

Im JDK sind alle 43 Interfaces in java.util.function annotiert, ebenso Runnable, Callable und Comparator.

Das Package java.util.function

Vor Java 8 brachte jede API ihre eigenen Interfaces für eine Verhaltensdefinition mit: Runnable für Threads, Comparator zum Sortieren, FileFilter für Verzeichnislisten. Mit den Lambdas kam ein Package mit allgemeinen funktionalen Interfaces dazu, die jede API verwenden kann: java.util.function.

Das Package enthält in Java 27 exakt 43 Interfaces. Das klingt nach viel, folgt aber einem einfachen Schema: vier Grundformen, dazu Varianten mit zwei Parametern (für alle außer Supplier), mit demselben Typ als Ein- und Ausgabe (für Function und BiFunction) und mit primitiven Typen statt Objekten (für alle vier). Ich zeige dir die vier Grundformen zuerst, denn mit ihnen schreibst du den größten Teil deines Codes:

InterfaceMethodeDas Lambda …
Function<T, R>R apply(T t)nimmt ein T entgegen und liefert ein R
Predicate<T>boolean test(T t)nimmt ein T entgegen und liefert true oder false
Consumer<T>void accept(T t)nimmt ein T entgegen und liefert nichts
Supplier<T>T get()nimmt nichts entgegen und liefert ein T

Die Methodennamen in der zweiten Spalte – apply(), test(), accept(), get() – sind die Methoden, die dort aufgerufen werden, wohin du das Lambda übergibst: filter() ruft auf seinem Predicate test() auf, map() auf seiner Function apply(). Selbst rufst du sie selten auf – in den Beispielen dieses Kapitels tue ich es trotzdem, damit du siehst, was die Methode liefert.

Die folgenden vier Abschnitte zeigen jedes der vier Interfaces mit seinem Aufruf, den default-Methoden zum Kombinieren und den Stellen im JDK, die es erwarten.

Function<T, R> – ein Wert rein, ein anderer raus

Eine Function bildet einen Wert auf einen anderen ab. map() erwartet eine Function, ebenso u. a. flatMap(), Collectors.groupingBy() und Optional.map().

Im folgenden Beispiel ist die Funktion eine Methodenreferenz auf Book.title(), und apply() ruft sie für das Buch „Dracula“ auf:

Book dracula = BOOKS.get(9);
Function<Book, String> title = Book::title;
System.out.println(title.apply(dracula));
Dracula

Zwei Funktionen kannst du zu einer verketten: andThen() führt nach der ersten die zweite aus, compose() davor. Im folgenden Beispiel liefert title.andThen(length) die Länge des Titels – und length.compose(title) dieselbe Funktion, nur andersherum geschrieben:

Function<Book, String> title = Book::title;
Function<String, Integer> length = String::length;

Function<Book, Integer> titleLength = title.andThen(length);
System.out.println(titleLength.apply(dracula));

Function<Book, Integer> titleLengthComposed = length.compose(title);
System.out.println(titleLengthComposed.apply(dracula));
7
7

Ich empfehle andThen(), weil es in der Reihenfolge steht, in der die Funktionen ausgeführt werden: erst title, dann length.

Die statische Methode Function.identity() liefert eine Funktion, die ihr Argument unverändert zurückgibt. Du brauchst sie dort, wo eine API eine Funktion verlangt, du aber das Element unverändert haben willst – z. B. in einer Map, deren Schlüssel der Titel und deren Wert das Buch selbst ist:

Map<String, Book> byTitle = BOOKS.stream()
    .collect(Collectors.toMap(Book::title, Function.identity()));

book -> book täte dasselbe; Function.identity() sagt es mit Namen.

Predicate<T> – eine Ja-Nein-Frage

Ein Predicate beantwortet eine Frage über einen Wert mit true oder false. filter() erwartet ein Predicate, ebenso u. a. anyMatch(), allMatch(), noneMatch(), takeWhile(), dropWhile() und Collection.removeIf().

Im folgenden Beispiel fragt das Predicate, ob ein Buch ein Schauerroman ist:

Book dracula = BOOKS.get(9);
Predicate<Book> isGothic = book -> book.genre() == GOTHIC;
System.out.println(isGothic.test(dracula));
true

Predicates kannst du mit and(), or() und negate() verknüpfen wie Bedingungen mit &&, || und !. Die folgende Hilfsmethode titles() filtert die Bibliothek mit einem Predicate und liefert die Titel; die drei Aufrufe darunter kombinieren isGothic mit einem zweiten Predicate before1850:

static List<String> titles(Predicate<Book> condition) {
  return BOOKS.stream().filter(condition).map(Book::title).toList();
}
Predicate<Book> isGothic = book -> book.genre() == GOTHIC;
Predicate<Book> before1850 = book -> book.year() < 1850;

System.out.println(titles(isGothic.and(before1850)));
System.out.println(titles(isGothic.or(before1850)));
System.out.println(titles(isGothic.negate()));
[Frankenstein]
[Pride and Prejudice, Frankenstein, Dracula]
[Pride and Prejudice, Moby-Dick, From the Earth to the Moon, Alice's Adventures in Wonderland, Around the World in Eighty Days, Treasure Island, Kidnapped, The Time Machine, The War of the Worlds]

Seit Java 11 gibt es dazu die statische Methode Predicate.not(). Sie tut dasselbe wie negate(), funktioniert aber auch mit einer Methodenreferenz, die für sich allein keinen Typ hat und deshalb keine Methode aufrufen kann: filter(Predicate.not(String::isBlank)) kompiliert, filter(String::isBlank.negate()) nicht.

Die zweite statische Methode, Predicate.isEqual(value), liefert ein Predicate, das seinen Parameter mit equals() gegen den übergebenen Wert prüft – Predicate.isEqual(NOVEL) z. B. ist für das Genre NOVEL wahr und für alle anderen Genres falsch.

Consumer<T> – ein Wert rein, nichts raus

Ein Consumer nimmt einen Wert entgegen und tut etwas damit – meist gibt er ihn aus, speichert ihn oder sendet ihn weiter. forEach() erwartet einen Consumer, ebenso u. a. peek(), Optional.ifPresent() und Iterable.forEach().

Im folgenden Beispiel gibt der Consumer den Titel eines Buchs aus:

Book dracula = BOOKS.get(9);
Consumer<Book> printTitle = book -> System.out.println(book.title());
printTitle.accept(dracula);
Dracula

Mit andThen() verkettest du zwei Consumer zu einem, der beide nacheinander ausführt. Im folgenden Beispiel gibt der verkettete Consumer für jeden Schauerroman zuerst den Titel und dann, eingerückt, das Jahr aus:

Consumer<Book> printTitle = book -> System.out.println(book.title());
Consumer<Book> printYear = book -> System.out.println("  " + book.year());
BOOKS.stream().filter(isGothic).forEach(printTitle.andThen(printYear));
Frankenstein
  1818
Dracula
  1897

Ein Consumer ist die eine Stelle, an der ein Lambda einen Seiteneffekt haben soll – anders als in filter() und map(), deren Lambdas nur aus ihren Parametern einen Wert berechnen sollten. Was passiert, wenn du dich nicht daran hältst, zeigt der Abschnitt über Seiteneffekte in Lambdas im Streams-Artikel.

Supplier<T> – nichts rein, ein Wert raus

Ein Supplier liefert einen Wert, ohne einen entgegenzunehmen. Wozu brauchst du das? Um die Erzeugung eines Werts auf den Zeitpunkt zu verschieben, an dem er gebraucht wird – oder sie ganz zu vermeiden.

Im folgenden Beispiel liefert der Supplier das erste Buch der Bibliothek, aber erst beim Aufruf von get():

Supplier<Book> firstBook = () -> BOOKS.getFirst();
System.out.println(firstBook.get().title());
Pride and Prejudice

Eine Stelle, an der du den Unterschied siehst, ist Optional.orElseGet(): Es ruft den Supplier nur auf, wenn das Optional leer ist. Im folgenden Beispiel sucht findByTitle() aus dem Artikel über Java Optional ein Buch, das die Bibliothek nicht enthält:

Optional<Book> emma = Library.findByTitle("Emma");
Book fallback = emma.orElseGet(() -> new Book("Emma", "Jane Austen", 1815, NOVEL));
System.out.println(fallback);
Book[title=Emma, author=Jane Austen, year=1815, genre=NOVEL]

Mit orElse(new Book("Emma", …)) statt orElseGet() würde das Ersatzbuch dagegen bei jedem Aufruf erzeugt – auch dann, wenn findByTitle() das Buch gefunden hat und das Ersatzbuch verworfen wird.

Die zweite Stelle, an der ein Supplier sinnvoll ist, ist Collectors.toCollection(): Der Collector ruft den Supplier auf (im folgenden Beispiel die Konstruktorreferenz TreeSet::new), um die Collection zu erzeugen, in die er die Elemente einsammelt. Das folgende Beispiel sammelt die Titel in einem TreeSet, also sortiert:

TreeSet<String> sortedTitles = BOOKS.stream()
    .map(Book::title)
    .collect(Collectors.toCollection(TreeSet::new));

Weitere Stellen, die einen Supplier erwarten, sind Optional.orElseThrow() für die Exception, Stream.generate() für die Elemente eines unendlichen Streams und Objects.requireNonNullElseGet() für den Ersatzwert. Supplier hat keine default-Methoden – es gibt nichts, womit sich ein Supplier verketten ließe.

UnaryOperator<T> und BinaryOperator<T> – derselbe Typ rein und raus

Ein UnaryOperator<T> ist eine Function<T, T>, ein BinaryOperator<T> eine BiFunction<T, T, T>: Ein- und Ausgabe haben denselben Typ. Beide Interfaces erben von ihrer allgemeinen Form. Was sie hinzufügen, ist der kürzere Name, die Zusicherung, dass ein Wert des Typs T herauskommt, wo einer hineingeht – und drei statische Methoden: UnaryOperator.identity() sowie BinaryOperator.minBy() und maxBy(), die ich gleich zeige.

List.replaceAll() erwartet einen UnaryOperator: Es ersetzt jedes Element durch das Ergebnis des Operators. Das folgende Beispiel schreibt alle Titel in Großbuchstaben:

UnaryOperator<String> upper = String::toUpperCase;
List<String> titles = new ArrayList<>(BOOKS.stream().map(Book::title).toList());
titles.replaceAll(upper);
System.out.println(titles.subList(0, 3));
[PRIDE AND PREJUDICE, FRANKENSTEIN, MOBY-DICK]

Einen BinaryOperator erwartet reduce(), das aus zwei Elementen eines macht – solange, bis eines übrig ist. Das folgende Beispiel addiert die Erscheinungsjahre aller Bücher:

BinaryOperator<Integer> sum = Integer::sum;
int yearSum = BOOKS.stream().map(Book::year).reduce(0, sum);
System.out.println(yearSum);
20544

Die statischen Methoden BinaryOperator.minBy() und maxBy() liefern einen Operator, der von zwei Werten den kleineren bzw. größeren nach einem Comparator zurückgibt. Mit reduce() findet das folgende Beispiel so das zuletzt erschienene Buch:

BinaryOperator<Book> later =
    BinaryOperator.maxBy(Comparator.comparingInt(Book::year));
Optional<Book> latest = BOOKS.stream().reduce(later);
System.out.println(latest.map(Book::title).orElse("-"));
The War of the Worlds

Auch Map.merge() erwartet einen BinaryOperator, für den Fall, dass der Schlüssel schon einen Wert hat. Das folgende Beispiel zählt die Bücher pro Autor:in – für den ersten Treffer trägt merge() die 1 ein, für jeden weiteren addiert Integer::sum sie auf den vorhandenen Wert:

Map<String, Integer> booksPerAuthor = new TreeMap<>();
for (Book book : BOOKS) {
  booksPerAuthor.merge(book.author(), 1, Integer::sum);
}
System.out.println(booksPerAuthor);
{Bram Stoker=1, H. G. Wells=2, Herman Melville=1, Jane Austen=1, Jules Verne=2, Lewis Carroll=1, Mary Shelley=1, Robert Louis Stevenson=2}

Integer::sum passt übrigens genauso auf einen IntBinaryOperator, den reduce() auf einem IntStream erwartet – dieselbe Methodenreferenz, ein anderes Interface. Wie das zusammengeht, zeigt der Abschnitt über die primitiven Spezialisierungen.

Zwei Parameter: BiFunction, BiPredicate und BiConsumer

Für Lambdas mit zwei Parametern gibt es drei Varianten der Grundformen mit dem Präfix Bi: BiFunction<T, U, R> mit R apply(T t, U u), BiPredicate<T, U> mit boolean test(T t, U u) und BiConsumer<T, U> mit void accept(T t, U u). Einen BiSupplier gibt es nicht – ein Supplier hat keine Parameter, die er verdoppeln könnte.

Am häufigsten begegnen dir BiConsumer und BiFunction bei Map, deren Methoden Schlüssel und Wert gemeinsam übergeben. Das folgende Beispiel gruppiert die Bücher nach Autor:in und gibt mit Map.forEach(), das einen BiConsumer erwartet, die Anzahl pro Autor:in aus:

Map<String, List<Book>> byAuthor = BOOKS.stream()
    .collect(Collectors.groupingBy(Book::author, TreeMap::new, Collectors.toList()));

BiConsumer<String, List<Book>> printCount =
    (author, books) -> System.out.println(author + ": " + books.size());
byAuthor.forEach(printCount);
Bram Stoker: 1
H. G. Wells: 2
Herman Melville: 1
Jane Austen: 1
Jules Verne: 2
Lewis Carroll: 1
Mary Shelley: 1
Robert Louis Stevenson: 2

Eine BiFunction erwarten Map.computeIfPresent() und Map.replaceAll(): Beide übergeben Schlüssel und alten Wert und tragen den Rückgabewert als neuen Wert ein. Das folgende Beispiel erhöht den Zähler für Jules Verne aus der booksPerAuthor-Map des vorigen Abschnitts um eins und verzehnfacht danach alle Zähler:

booksPerAuthor.computeIfPresent("Jules Verne", (author, count) -> count + 1);
System.out.println(booksPerAuthor.get("Jules Verne"));

booksPerAuthor.replaceAll((author, count) -> count * 10);
System.out.println(booksPerAuthor.get("Jules Verne"));
3
30

Ein BiPredicate erwartet im JDK z. B. Files.find(), das für jeden Eintrag eines Verzeichnisbaums den Pfad und seine Attribute übergibt. Das folgende Beispiel zählt die Java-Dateien unter src/main/java:

BiPredicate<Path, BasicFileAttributes> isJavaFile =
    (path, attributes) ->
        attributes.isRegularFile() && path.toString().endsWith(".java");

try (Stream<Path> files = Files.find(Path.of("src/main/java"), 10, isJavaFile)) {
  System.out.println(files.count() + " Java files");
}
49 Java files

Auch die Bi-Formen haben default-Methoden zum Kombinieren:

  • BiFunction.andThen() hängt eine Function an, die den Rückgabewert der BiFunction weiterverarbeitet – aus einer BiFunction<String, Integer, String>, die Titel und Jahr zu "Dracula (1897)" verbindet, macht .andThen(String::toUpperCase) eine, die "DRACULA (1897)" liefert.
  • BiPredicate hat and(), or() und negate(), wie Predicate.
  • BiConsumer.andThen() hängt einen zweiten BiConsumer an, der dieselben zwei Argumente bekommt.

Ein compose() gibt es bei BiFunction nicht – BiFunction erwartet zwei Parameter, und compose() kann nicht zwei Rückgabewerte liefern.

Primitive Spezialisierungen: IntPredicate, ToIntFunction und die anderen

Ein Predicate<Integer> funktioniert auch für int-Werte – aber jeder Aufruf von test() verpackt den int in ein Integer-Objekt, weil ein Typparameter kein primitiver Typ sein kann. Dieses Boxing kostet pro Wert ein Objekt von 16 Bytes – nur die Werte von −128 bis 127 nimmt die JVM aus einem Cache, alle anderen erzeugt sie erst bei Bedarf. Bei einem Stream mit Millionen von Werten sind das Millionen von Objekten, die der Garbage Collector wieder einsammeln muss.

Deshalb gibt es für int, long und double eigene Interfaces, deren Methoden mit den primitiven Typen arbeiten. Die primitiven Streams IntStream, LongStream und DoubleStream erwarten sie in ihren Operationen – und dort begegnen dir die meisten der 43 Interfaces.

Ihre Namen folgen einem Schema, das du nur einmal lernen musst. Die folgende Grafik zeigt es an sechs Namen aus java.util.function – die Teile des Namens, die die Parameter beschreiben, in Blau, die Teile für den Rückgabetyp in Hellblau, und daneben Parameter, Rückgabetyp und Grundwort ausgeschrieben:

Sechs Zeilen mit je einem Interface-Namen, dessen Teile farbig markiert sind, und daneben in drei Spalten Parameter, Rückgabetyp und Grundwort: IntFunction<R> – int, R, Function; ToIntFunction<T> – T, int, Function; IntToLongFunction – int, long, Function; ObjIntConsumer<T> – T und int, void, Consumer; ToIntBiFunction<T, U> – T und U, int, Function; IntBinaryOperator – int und int, int, BinaryOperator
Das Namensschema in java.util.function: Ein Präfix wie Int nennt einen primitiven Parameter, ein To… den primitiven Rückgabetyp, Bi zwei Objekt-Parameter, Obj einen Objekt-Parameter neben einem primitiven – und das Grundwort sagt, ob etwas zurückkommt

Das Schema in drei Regeln:

  1. Ein Präfix Int, Long oder Double nennt den Typ eines Parameters: IntPredicate prüft einen int, IntFunction<R> bildet einen int auf ein R ab, IntConsumer nimmt einen int entgegen.
  2. To plus Typ nennt den Rückgabetyp: ToIntFunction<T> bildet ein T auf einen int ab, IntToLongFunction einen int auf einen long.
  3. Bi steht für zwei Objekt-Parameter, Obj für einen Objekt-Parameter neben einem primitiven: ToIntBiFunction<T, U> bildet ein T und ein U auf einen int ab, ObjIntConsumer<T> nimmt ein T und einen int entgegen.

Das Grundwort am Ende – Function, Predicate, Consumer, Supplier, UnaryOperator, BinaryOperator – hat dieselbe Bedeutung wie bei den Objekt-Formen: IntUnaryOperator bildet einen int auf einen int ab, IntBinaryOperator zwei int auf einen, IntSupplier liefert einen int ohne Parameter.

Die folgenden Beispiele zeigen sechs dieser Interfaces an einem IntStream der Erscheinungsjahre der Bücher unserer Bibliothek. mapToInt() wandelt den Stream<Book> in diesen IntStream um und erwartet dafür eine ToIntFunction<Book>; die Operationen filter(), map(), mapToObj(), reduce() und collect() des IntStream erwarten die fünf weiteren Interfaces – welches, steht jeweils in der Variablendeklaration über dem Aufruf:

ToIntFunction<Book> year = Book::year;
int newest = BOOKS.stream().mapToInt(year).max().orElseThrow();
System.out.println(newest);

IntPredicate nineteenthCentury = y -> y >= 1801 && y <= 1900;
System.out.println(
    BOOKS.stream().mapToInt(Book::year).filter(nineteenthCentury).count());

IntUnaryOperator decade = y -> y / 10 * 10;
System.out.println(
    BOOKS.stream().mapToInt(Book::year).map(decade).distinct().boxed().toList());

IntFunction<String> century = y -> ((y - 1) / 100 + 1) + "th century";
System.out.println(
    BOOKS.stream().mapToInt(Book::year).mapToObj(century).distinct().toList());

IntBinaryOperator max = Math::max;
System.out.println(BOOKS.stream().mapToInt(Book::year).reduce(0, max));

ObjIntConsumer<StringBuilder> appendYear = (sb, y) -> sb.append(y).append(' ');
StringBuilder years = BOOKS.stream()
    .mapToInt(Book::year)
    .limit(3)
    .collect(StringBuilder::new, appendYear, StringBuilder::append);
System.out.println(years.toString().trim());
1898
11
[1810, 1850, 1860, 1870, 1880, 1890]
[19th century]
1898
1813 1818 1851

Die Methodenreferenz Book::year steht in diesem Listing für eine ToIntFunction<Book>, im Abschnitt über Function dagegen für eine Function<Book, Integer> – dieselbe Referenz, zwei Typen. Welcher gilt, entscheidet der Zieltyp, also die Methode, der du die Referenz übergibst: mapToInt() erwartet die ToIntFunction, map() die Function.

Alle 43 Interfaces auf einen Blick

Die folgenden zwei Tabellen zeigen alle 43 Interfaces des Packages, geordnet nach Eingabe (Zeilen) und Ausgabe (Spalten). Die Typparameter sind weggelassen – Function steht für Function<T, R>, BiPredicate für BiPredicate<T, U>. UnaryOperator und BinaryOperator stehen in Klammern hinter der Function, deren Sonderfall sie sind, und ✗ bedeutet, dass es für diese Kombination kein Interface gibt.

Die erste Tabelle enthält die Interfaces, die ein Objekt, ein boolean oder nichts liefern:

Eingabe ↓ Ausgabe →Objektbooleankeine
ObjektFunction (UnaryOperator)PredicateConsumer
intIntFunctionIntPredicateIntConsumer
longLongFunctionLongPredicateLongConsumer
doubleDoubleFunctionDoublePredicateDoubleConsumer
keineSupplierBooleanSupplier✗
zwei ObjekteBiFunction (BinaryOperator)BiPredicateBiConsumer
Objekt, int✗✗ObjIntConsumer
Objekt, long✗✗ObjLongConsumer
Objekt, double✗✗ObjDoubleConsumer

Die zweite Tabelle enthält die Interfaces, die einen primitiven Zahlentyp liefern:

Eingabe ↓ Ausgabe →intlongdouble
ObjektToIntFunctionToLongFunctionToDoubleFunction
intIntUnaryOperatorIntToLongFunctionIntToDoubleFunction
longLongToIntFunctionLongUnaryOperatorLongToDoubleFunction
doubleDoubleToIntFunctionDoubleToLongFunctionDoubleUnaryOperator
keineIntSupplierLongSupplierDoubleSupplier
zwei ObjekteToIntBiFunctionToLongBiFunctionToDoubleBiFunction
int, intIntBinaryOperator✗✗
long, long✗LongBinaryOperator✗
double, double✗✗DoubleBinaryOperator

Die Lücken in den Tabellen sind Absicht: Die JDK-Entwickler:innen haben, so das Javadoc des Packages, keinen vollständigen Satz aller Formen aufgenommen, sondern genug, um die üblichen Anforderungen abzudecken. Für das ✗ in der ersten Tabelle, Zeile „keine“ und Spalte „keine“ – kein Parameter, kein Rückgabewert –, gibt es Runnable in java.lang; für alles Übrige – z. B. eine IntBiFunction, ein BooleanPredicate oder eine Funktion mit drei Parametern – schreibst du ein eigenes Interface, wie der Abschnitt Eigene funktionale Interfaces schreiben zeigt.

? super T und ? extends R: die Signaturen im Javadoc lesen

Im Javadoc der Stream-API steht nicht filter(Predicate<T> predicate), sondern:

Stream<T> filter(Predicate<? super T> predicate);
<R> Stream<R> map(Function<? super T, ? extends R> mapper);

Die Wildcards machen die Methoden großzügiger, als es die einfache Form wäre. Predicate<? super T> heißt: ein Predicate für T oder für einen Obertyp von T. Ein Predicate<Object> kann jedes Objekt prüfen, also auch jedes Buch – und deshalb darf es einen Stream<Book> filtern:

Predicate<Object> nonNull = Objects::nonNull;
List<Book> withGap = new ArrayList<>(BOOKS);
withGap.add(null);
System.out.println(withGap.stream().filter(nonNull).count());
11

Wäre filter() ohne die Wildcard deklariert, also als filter(Predicate<T> predicate), würde derselbe Aufruf nicht kompilieren – ein Predicate<Object> ist kein Predicate<Book>, auch wenn es jedes Buch prüfen kann. Denn generische Typen sind in Java invariant: Predicate<Object> ist weder Unter- noch Obertyp von Predicate<Book>, obwohl Object der Obertyp von Book ist.

error: incompatible types: Predicate<Object> cannot be converted to Predicate<Book>

Bei map() gilt für den Eingabeparameter der Funktion dasselbe: ? super T lässt eine Funktion zu, die ein Object entgegennimmt. Und ? extends R erlaubt ihr, als Rückgabewert einen Untertyp von R zu liefern.

Im folgenden Beispiel nutzt eine Funktion beide Freiheiten: Sie nimmt ein Object entgegen, also auch jedes Buch, und liefert einen StringBuilder – und map() liefert trotzdem den Stream<CharSequence>, den die Variable verlangt, denn ein StringBuilder ist eine CharSequence:

Function<Object, StringBuilder> describe = o -> new StringBuilder(o.toString());
Stream<CharSequence> descriptions = BOOKS.stream().map(describe);
System.out.println(descriptions.findFirst().orElseThrow());
Book[title=Pride and Prejudice, author=Jane Austen, year=1813, genre=NOVEL]

Ohne die Wildcards müsste die Funktion genau eine Function<Book, CharSequence> sein – weder Object als Parametertyp noch StringBuilder als Rückgabetyp wären erlaubt.

Für die Lambdas, die du direkt in filter() und map() schreibst, ändern die Wildcards nichts: Der Compiler leitet ihren Typ aus dem Zieltyp ab, und der passt. Einen Unterschied machen sie, sobald du ein Predicate oder eine Funktion in einer Variablen hast, die für einen Obertyp deklariert ist – oder sobald du selbst eine Methode schreibst, die ein funktionales Interface erwartet.

Dann empfehle ich dir dieselbe Form wie im JDK: Predicate<? super Book> statt Predicate<Book>. Die folgende Methode select() nimmt dank der Wildcard das Predicate<Object> aus dem nonNull-Beispiel entgegen:

static List<Book> select(List<Book> books, Predicate<? super Book> condition) {
  return books.stream().filter(condition).toList();
}
List<Book> nonNullBooks = select(withGap, nonNull);

Mit Predicate<Book> als Parametertyp würde derselbe Aufruf nicht kompilieren:

error: incompatible types: Predicate<Object> cannot be converted to Predicate<Book>

Die Merkregel dahinter heißt PECS – „producer extends, consumer super“: Ein Typparameter, aus dem die Methode Werte liest, bekommt ? extends; einer, in den sie Werte hineingibt, bekommt ? super. Das Predicate bekommt Bücher hineingegeben, also super; die Funktion liefert Werte, also extends für ihren Rückgabetyp.

Funktionale Interfaces außerhalb von java.util.function

java.util.function enthält die allgemeinen Interfaces. Daneben hat das JDK Interfaces mit genau einer abstrakten Methode, die für einen bestimmten Zweck in dem Package liegen, das sie verwendet – die meisten davon sind älter als Lambdas und passten ab Java 8 ohne Änderung. Vier davon begegnen dir regelmäßig:

  • Runnable (seit Java 1.0, java.lang) mit void run() ist eine Aufgabe ohne Ergebnis – für Thread, ExecutorService.execute() und CompletableFuture.runAsync().
  • Callable<V> (seit Java 5, java.util.concurrent) mit V call() throws Exception ist eine Aufgabe mit Ergebnis, und die einzige der hier genannten, die eine Checked Exception werfen darf – für ExecutorService.submit().
  • Comparator<T> (seit Java 1.2, java.util) mit int compare(T a, T b) – für sort(), sorted(), min() und max(). Wie du Comparatoren mit comparing(), thenComparing() und reversed() baust, zeigt der Artikel über Comparator und Comparable.
  • FileFilter (seit Java 1.2, java.io) mit boolean accept(File file) – für File.listFiles().

Das folgende Beispiel zeigt die vier, jeweils als Lambda oder Methodenreferenz:

Runnable task =
    () -> System.out.println("running in " + Thread.currentThread().getName());
Thread thread = new Thread(task);
thread.start();
thread.join();

Callable<Integer> countBooks = () -> BOOKS.size();
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
  Future<Integer> count = executor.submit(countBooks);
  System.out.println(count.get());
}

List<Book> books = new ArrayList<>(BOOKS);
books.sort(Comparator.comparing(Book::author).thenComparing(Book::year));
System.out.println(books.stream().map(Book::title).toList().subList(0, 3));

FileFilter directories = File::isDirectory;
File[] packages = new File("src/main/java/eu/happycoders").listFiles(directories);
System.out.println(Arrays.stream(packages).map(File::getName).sorted().toList());
running in Thread-0
11
[Dracula, The Time Machine, The War of the Worlds]
[functionalinterfaces, lambdas, optional, streams]

Weitere Beispiele aus dem JDK sind DirectoryStream.Filter<T> mit boolean accept(T entry) für Files.newDirectoryStream(), TemporalAdjuster mit Temporal adjustInto(Temporal temporal) für LocalDate.with() und Thread.UncaughtExceptionHandler mit void uncaughtException(Thread t, Throwable e).

Die Regel gilt genauso für Bibliotheken und deinen eigenen Code: Wo immer ein Methodenparameter ein Interface mit genau einer abstrakten Methode ist, passt ein Lambda – ob das Interface annotiert ist oder nicht.

Eigene funktionale Interfaces schreiben

Ein funktionales Interface zu schreiben ist einfach: ein Interface, eine abstrakte Methode, die Annotation. Die Frage ist nicht, wie du das tust, sondern wann – denn meistens gibt es im JDK schon eines, das passt.

Ich empfehle dir, die Interfaces aus java.util.function zu verwenden, wo immer eines die Form deiner Methode hat. Sie sind bekannt, jede API nimmt sie entgegen, und sie bringen die Kombinatoren andThen(), and(), or() und negate() mit.

Ein eigenes Interface lohnt sich in drei Fällen:

  1. Das JDK hat keine passende Form – drei Parameter, ein boolean als Eingabe, zwei int mit einem boolean als Ausgabe. Die Tabellen im Abschnitt Alle 43 Interfaces auf einen Blick sagen dir, ob das so ist.
  2. Die Methode darf eine Checked Exception werfen. Keine der 43 Methoden deklariert eine; wie du damit umgehst, zeigt der nächste Abschnitt.
  3. Der Name trägt eine fachliche Bedeutung – und das Interface bekommt einen Vertrag oder eigene default-Methoden, die eine Function<Book, String> nicht hätte. Das ist auch die Empfehlung von Joshua Bloch in Effective Java, Item 44: die Standard-Interfaces bevorzugen, ein eigenes nur mit sprechendem Namen, festem Vertrag oder eigenen default-Methoden.

Der dritte Fall – ein eigenes Interface mit derselben Signatur wie ein allgemeines aus java.util.function, nur um seines Namens willen – hat einen Preis, den du kennen solltest, bevor du dich für ihn entscheidest:

Das Interface TitleFormatter aus dem Abschnitt über die Annotation hat dieselbe Form wie eine Function<Book, String> – und ist für den Compiler trotzdem ein anderer Typ. Denn Java vergleicht Typen über ihren Namen, nicht über ihre Struktur. Ein TitleFormatter passt also nicht in map(), das eine Function erwartet:

TitleFormatter withYear = book -> book.title() + " (" + book.year() + ")";
List<String> labels = BOOKS.stream().map(withYear).toList(); // kompiliert nicht
error: method map in interface Stream<T> cannot be applied to given types;
  required: Function<? super Book,? extends R>
  found:    TitleFormatter

Was passt, ist eine Methodenreferenz auf seine Methode: map(withYear::format). Die Referenz ist ein neues Lambda vom Typ Function, das format() aufruft. Umgekehrt genauso: Eine Function<Book, String> in der Variablen title wird mit TitleFormatter plain = title::apply zu einem TitleFormatter:

List<String> labels = BOOKS.stream().map(withYear::format).toList();

Function<Book, String> title = Book::title;
TitleFormatter plain = title::apply;

Ein eigenes Interface ist also für sich allein nichts, was die Stream-API oder eine andere JDK-API entgegennimmt; an jeder Übergabe steht die Methodenreferenz dazwischen. Das ist in Ordnung, wenn der Name diese kleine Hürde wert ist – und ein Grund, im Zweifel bei Function zu bleiben.

Ein eigenes Interface für Checked Exceptions

Ein Lambda darf nur die Checked Exceptions werfen, die seine abstrakte Methode deklariert – und keine der 43 Methoden in java.util.function deklariert eine. Files.readAllLines() wirft eine IOException, deshalb kompiliert diese Pipeline nicht:

List<Path> files = List.of(Path.of("README.md"), Path.of("pom.xml"));
List<List<String>> contents = files.stream()
    .map(Files::readAllLines) // kompiliert nicht
    .toList();
error: unreported exception IOException; must be caught or declared to be thrown

Für den Einzelfall genügt eine Hilfsmethode, die genau diesen Aufruf kapselt und die Exception in eine Unchecked Exception verpackt; der Artikel über Java Streams zeigt sie im Abschnitt Checked Exceptions in Lambdas für Files.readAllLines(). Diese Hilfsmethode gilt aber nur für den einen Aufruf – für Files.size() oder Files.lines() bräuchtest du je eine weitere.

Brauchst du das häufiger, schreibst du den Wrapper einmal allgemein: für ein eigenes funktionales Interface, dessen Methode die Exception deklarieren darf, und mit einer statischen Methode, die jede solche Funktion in eine gewöhnliche Function verpackt:

@FunctionalInterface
public interface ThrowingFunction<T, R, E extends Exception> {

  R apply(T t) throws E;

  static <T, R> Function<T, R> unchecked(ThrowingFunction<T, R, ?> function) {
    return t -> {
      try {
        return function.apply(t);
      } catch (RuntimeException e) {
        throw e;
      } catch (Exception e) {
        throw new IllegalStateException(e);
      }
    };
  }
}

ThrowingFunction ist das funktionale Interface: Seine Methode apply() deklariert die Exception E, deshalb darf das Lambda Files::readAllLines sie werfen. Die statische Methode unchecked() nimmt eine solche Funktion entgegen und liefert eine gewöhnliche Function zurück, die die Checked Exception in eine IllegalStateException verpackt – und die passt in map():

List<Integer> lineCounts = files.stream()
    .map(unchecked(Files::readAllLines))
    .map(List::size)
    .toList();

Der Typparameter E sorgt dafür, dass ThrowingFunction<Path, List<String>, IOException> genau die IOException deklariert und nicht pauschal Exception. Wer das Interface direkt aufruft, statt es mit unchecked() zu verpacken, muss also genau diese Exception behandeln.

Typische Fehler und Empfehlungen

Function<Book, Boolean> statt Predicate<Book>

Ein Lambda, das true oder false liefert, ist ein Predicate – auch wenn es sich als Function<Book, Boolean> deklarieren lässt. Die Function hat kein negate(), kein and() und kein or(), und filter() nimmt sie nicht entgegen.

Dasselbe gilt für Function<Book, Void> statt Consumer<Book>: Der Rückgabetyp Void zwingt das Lambda zu einem return null, das nichts bedeutet.

Und für Function<Integer, Integer> statt UnaryOperator<Integer>: Der Operator sagt schon im Typ, dass derselbe Typ herauskommt, der hineingeht.

Ich empfehle dir, den Typ nach der Form des Lambdas zu wählen: Liefert es ein boolean, ist es ein Predicate; liefert es nichts, ein Consumer; nimmt es nichts entgegen, ein Supplier; liefert es denselben Typ, den es entgegennimmt, ein UnaryOperator. Function ist für den Rest.

UnaryOperator<Integer> statt IntUnaryOperator

Auf einem IntStream kannst du einen UnaryOperator<Integer> erst nach einem boxed() anwenden – und jedes Element wird dafür in ein Integer-Objekt verpackt. Das folgende Listing zeigt beide Wege; beide liefern dieselben Jahre:

UnaryOperator<Integer> nextYearBoxed = year -> year + 1;
System.out.println(
    BOOKS.stream().mapToInt(Book::year).boxed().map(nextYearBoxed).toList());

IntUnaryOperator nextYear = year -> year + 1;
System.out.println(
    BOOKS.stream().mapToInt(Book::year).map(nextYear).boxed().toList());
[1814, 1819, 1852, 1866, 1866, 1874, 1884, 1887, 1896, 1898, 1899]
[1814, 1819, 1852, 1866, 1866, 1874, 1884, 1887, 1896, 1898, 1899]

Bei elf Büchern sind das elf Integer-Objekte. Für einen Stream mit Millionen von Werten empfehle ich die primitive Form: Sie erspart eine Allokation pro Element und hält den Stream als IntStream, dessen sum(), average() und summaryStatistics() du danach noch aufrufen kannst.

Überladene Methoden mit gleichförmigen Interfaces

Zwei Überladungen einer Methode, die sich nur im funktionalen Interface unterscheiden – eine mit Consumer<String>, eine mit Function<String, String> –, kann der Compiler für ein implizit typisiertes Lambda nicht auseinanderhalten. Der Artikel über Lambda-Ausdrücke zeigt den Fehler und die drei Schreibweisen, die ihn auflösen, im Abschnitt Überladene Methoden. Für deine eigenen APIs empfehle ich, solche Überladungen zu vermeiden und den Methoden stattdessen verschiedene Namen zu geben.

Zusammenfassung

Ein funktionales Interface ist ein Interface mit genau einer abstrakten Methode; default-Methoden, static-Methoden und die Methoden von Object zählen nicht. Diese eine Methode ist es, die ein Lambda oder eine Methodenreferenz implementiert – und ob ein Interface funktional ist, entscheidet allein diese Zählung, nicht die Annotation @FunctionalInterface, die sie nur absichert.

Das Package java.util.function enthält 43 solcher Interfaces nach einem Schema: die vier Grundformen Function, Predicate, Consumer und Supplier, dazu die Operatoren mit gleichem Ein- und Ausgabetyp, die Bi-Formen für zwei Parameter und die Spezialisierungen für int, long und double, deren Namen Eingabe und Ausgabe nennen.

Drei Empfehlungen für den Alltag:

  • Verwende die Interfaces aus java.util.function, wo immer eines passt – ein eigenes nur für eine Form, die es dort nicht gibt, für eine Checked Exception oder für einen Namen mit eigenem Vertrag.
  • Nimm auf primitiven Streams die primitive Form (IntPredicate statt Predicate<Integer>), sonst zahlst du pro Element eine Allokation.
  • Deklariere Parameter eigener Methoden mit Wildcards, so wie das JDK es tut: Predicate<? super T>, Function<? super T, ? extends R>.

Wie der Compiler ein Lambda gegen die eine Methode abgleicht und was es sonst über Lambdas zu wissen gibt, steht im Artikel über Java Lambda-Ausdrücke. Wo die Interfaces im Alltag am häufigsten stehen – in filter(), map(), collect() und den anderen Operationen – zeigt der Artikel über Java Streams.

Hat dir dieser Artikel Zeit gespart? Dann freue ich mich, wenn du eine Minute davon in eine Bewertung auf meinem ProvenExpert-Profil investierst. Dein Feedback zeigt mir, dass sich die Arbeit an diesen Artikeln lohnt.

👉 Bewertung abgeben

Möchtest du informiert werden, wenn ich das nächste Java-Release unter die Lupe nehme? Dann klicke hier, um dich für den HappyCoders-Newsletter anzumelden.

👉 Newsletter-Anmeldung

Dieses Thema im eigenen Code?

Du hast den Artikel gelesen – im Training arbeitet ihr damit. Einen Tag lang gehen wir die Themen an euren eigenen Projekten durch, statt an konstruierten Beispielen.

Praxisnah, verständlich und direkt auf euren Projektalltag übertragbar. Statt Theorie vermittle ich Prinzipien, die euch helfen, Code langfristig besser, wartbarer und performanter zu schreiben.

Java Streams BasicsAlle Trainings ansehen

Werde ein:e bessere:r Java-Entwickler:in

Mit meinem kostenlosen Newsletter bleibst du vorn. Modernes Java: neue Versionen & Features, Performance und JVM-Insights – 1x im Monat.

Suche