Subsections of Modern Java: Funktionaler Stil und Stream-API

Lambda-Ausdrücke und funktionale Interfaces

TL;DR

Mit einer anonymen inneren Klasse erstellt man gewissermaßen ein Objekt einer "Wegwerf"-Klasse: Man leitet on-the-fly von einem Interface ab oder erweitert eine Klasse und implementiert die benötigten Methoden und erzeugt von dieser Klasse sofort eine Instanz (Objekt). Diese neue Klasse ist im restlichen Code nicht sichtbar.

Anonyme innere Klassen sind beispielsweise in Swing recht nützlich, wenn man einer Komponente einen Listener mitgeben will: Hier erzeugt man eine anonyme innere Klasse basierend auf dem passenden Listener-Interface, implementiert die entsprechenden Methoden und übergibt das mit dieser Klasse erzeugte Objekt als neuen Listener der Swing-Komponente.

Mit Java 8 können unter gewissen Bedingungen diese anonymen inneren Klassen zu Lambda-Ausdrücken (und Methoden-Referenzen) vereinfacht werden. Dazu muss die anonyme innere Klasse ein sogenanntes funktionales Interface implementieren.

Funktionale Interfaces sind Interfaces mit genau einer abstrakten Methode. Es können beliebig viele Default-Methoden im Interface enthalten sein, und es können public sichtbare abstrakte Methoden von java.lang.Object geerbt/überschrieben werden.

Die Lambda-Ausdrücke entsprechen einer anonymen Methode: Die Parameter werden aufgelistet (in Klammern), und hinter einem Pfeil kommt entweder ein Ausdruck (Wert - gleichzeitig Rückgabewert des Lambda-Ausdrucks) oder beliebig viele Anweisungen (in geschweiften Klammern, mit Semikolon):

  • Form 1: (parameters) -> expression
  • Form 2: (parameters) -> { statements; }

Der Lambda-Ausdruck muss von der Signatur her genau der einen abstrakten Methode im unterliegenden funktionalen Interface entsprechen.

Lernziele
  • (K2) Funktionales Interfaces (Definition)
  • (K3) Einsatz innerer und anonymer Klassen
  • (K3) Erstellen eigener funktionaler Interfaces
  • (K3) Einsatz von Lambda-Ausdrücken

Problem: Sortieren einer Studi-Liste

List<Studi> sl = new ArrayList<>();

// Liste sortieren?
sl.sort(???);  // Parameter: java.util.Comparator<Studi>
public class MyCompare implements Comparator<Studi> {
    @Override  public int compare(Studi o1, Studi o2) {
        return o1.getCredits() - o2.getCredits();
    }
}
// Liste sortieren?
MyCompare mc = new MyCompare();
sl.sort(mc);

Da Comparator<T> ein Interface ist, muss man eine extra Klasse anlegen, die die abstrakte Methode aus dem Interface implementiert und ein Objekt von dieser Klasse erzeugen und dieses dann der sort()-Methode übergeben.

Die Klasse bekommt wie in Java üblich eine eigene Datei und ist damit in der Package-Struktur offen sichtbar und "verstopft" mir damit die Strukturen: Diese Klasse ist doch nur eine Hilfsklasse ... Noch schlimmer: Ich brauche einen Namen für diese Klasse!

Den ersten Punkt könnte man über verschachtelte Klassen lösen: Die Hilfsklasse wird innerhalb der Klasse definiert, die das Objekt benötigt. Für den zweiten Punkt brauchen wir mehr Anlauf ...

Erinnerung: Verschachtelte Klassen ("Nested Classes")

Man kann Klassen innerhalb von Klassen definieren: Verschachtelte Klassen.

  • Implizite Referenz auf Instanz der äußeren Klasse, Zugriff auf alle Elemente
  • Begriffe:
    • "normale" innere Klassen: "inner classes"
    • statische innere Klassen: "static nested classes"
  • Einsatzzweck:
    • Hilfsklassen: Zusätzliche Funktionalität kapseln; Nutzung nur in äußerer Klasse
    • Kapselung von Rückgabewerten

Sichtbarkeit: Wird u.U. von äußerer Klasse "überstimmt"

Innere Klassen ("Inner Classes")

  • Objekt der äußeren Klasse muss existieren
  • Innere Klasse ist normales Member der äußeren Klasse
  • Implizite Referenz auf Instanz äußerer Klasse
  • Zugriff auf alle Elemente der äußeren Klasse
  • Sonderfall: Definition innerhalb von Methoden ("local classes")
    • Nur innerhalb der Methode sichtbar
    • Kennt zusätzlich final Attribute der Methode

Beispiel:

public class Outer {
    ...
    private class Inner {
        ...
    }

    Outer.Inner inner = new Outer().new Inner();
}

Statische innere Klassen ("Static Nested Classes")

  • Keine implizite Referenz auf Objekt
  • Nur Zugriff auf Klassenmethoden und -attribute

Beispiel:

class Outer {
    ...
    static class StaticNested {
        ...
    }
}

Outer.StaticNested nested = new Outer.StaticNested();

Lösung: Comparator als anonyme innere Klasse

List<Studi> sl = new ArrayList<>();

// Parametrisierung mit anonymer Klasse
sl.sort(
        new Comparator<Studi>() {
            @Override
            public int compare(Studi o1, Studi o2) {
                return o1.getCredits() - o2.getCredits();
            }
        });  // Semikolon nicht vergessen!!!

=> Instanz einer anonymen inneren Klasse, die das Interface Comparator<Studi> implementiert

  • Für spezielle, einmalige Aufgabe: nur eine Instanz möglich
  • Kein Name, kein Konstruktor, oft nur eine Methode
  • Müssen Interface implementieren oder andere Klasse erweitern
    • Achtung Schreibweise: ohne implements oder extends!
  • Konstruktor kann auch Parameter aufweisen
  • Zugriff auf alle Attribute der äußeren Klasse plus alle final lokalen Variablen
  • Nutzung typischerweise bei GUIs: Event-Handler etc.

Vereinfachung mit Lambda-Ausdruck

List<Studi> sl = new ArrayList<>();

// Parametrisierung mit anonymer Klasse
sl.sort(
        new Comparator<Studi>() {
            @Override
            public int compare(Studi o1, Studi o2) {
                return o1.getCredits() - o2.getCredits();
            }
        });  // Semikolon nicht vergessen!!!


// Parametrisierung mit Lambda-Ausdruck
sl.sort( (Studi o1, Studi o2) -> o1.getCredits() - o2.getCredits() );

Anmerkung: Damit für den Parameter alternativ auch ein Lambda-Ausdruck verwendet werden kann, muss der erwartete Parameter vom Typ her ein "funktionales Interface" (s.u.) sein!

Syntax für Lambdas

(Studi o1, Studi o2)  ->  o1.getCredits() - o2.getCredits()

Ein Lambda-Ausdruck ist eine Funktion ohne Namen und besteht aus drei Teilen:

  1. Parameterliste (in runden Klammern),
  2. Pfeil
  3. Funktionskörper (rechte Seite)

Falls es genau einen Parameter gibt, können die runden Klammern um den Parameter entfallen.

Dabei kann der Funktionskörper aus einem Ausdruck ("expression") bestehen oder einer Menge von Anweisungen ("statements"), die dann in geschweifte Klammern eingeschlossen werden müssen (Block mit Anweisungen).

Der Wert des Ausdrucks ist zugleich der Rückgabewert des Lambda-Ausdrucks.

Varianten:

  • (parameters) -> expression

  • (parameters) -> { statements; }

Quiz: Welches sind keine gültigen Lambda-Ausdrücke?

  1. () -> {}
  2. () -> "wuppie"
  3. () -> { return "fluppie"; }
  4. (Integer i) -> return i + 42;
  5. (String s) -> { "foo"; }
  6. (String s) -> s.length()
  7. (Studi s) -> s.getCredits() > 300
  8. (List<Studi> sl) -> sl.isEmpty()
  9. (int x, int y) -> { System.out.println("Erg: "); System.out.println(x+y); }
  10. () -> new Studi()
  11. s -> s.getCps() > 100 && s.getCps() < 300
  12. s -> { return s.getCps() > 100 && s.getCps() < 300; }

Auflösung:

(4) und (5): return ist eine Anweisung, d.h. bei (4) fehlen die geschweiften Klammern. "foo" ist ein String und als solcher ein Ausdruck, d.h. hier sind die geschweiften Klammern zu viel (oder man ergänze den String mit einem return, also return "foo"; ...).

Definition "Funktionales Interface" ("functional interfaces")

@FunctionalInterface
public interface Wuppie<T> {
    int wuppie(T obj);
    boolean equals(Object obj);
    default int fluppie() { return 42; }
}

Wuppie<T> ist ein funktionales Interface ("functional interface") (seit Java 8)

  • Hat genau eine abstrakte Methode
  • Hat evtl. weitere Default-Methoden
  • Hat evtl. weitere abstrakte Methoden, die public Methoden von java.lang.Object überschreiben

Die Annotation @FunctionalInterface selbst ist nur für den Compiler: Falls das Interface kein funktionales Interface ist, würde er beim Vorhandensein dieser Annotation einen Fehler werfen. Oder anders herum: Allein durch das Annotieren mit @FunctionalInterface wird aus einem Interface noch kein funktionales Interface! Vergleichbar mit @Override ...

Während man für eine anonyme Klasse lediglich ein "normales" Interface (oder eine Klasse) benötigt, braucht man für Lambda-Ausdrücke zwingend ein passendes funktionales Interface!

Anmerkung: Es scheint keine einheitliche deutsche Übersetzung für den Begriff functional interface zu geben. Es wird häufig mit "funktionales Interface", manchmal aber auch mit "Funktionsinterface" übersetzt.

Das in den obigen Beispielen eingesetzte Interface java.util.Comparator<T> ist also ein funktionales Interface: Es hat nur eine eigene abstrakte Methode int compare(T o1, T o2);.

Im Package java.util.function sind einige wichtige funktionale Interfaces bereits vordefiniert, beispielsweise Predicate (Test, ob eine Bedingung erfüllt ist) und Function (verarbeite einen Wert und liefere einen passenden Ergebniswert). Diese kann man auch in eigenen Projekten nutzen!

Quiz: Welches ist kein funktionales Interface?

public interface Wuppie {
    int wuppie(int a);
}

public interface Fluppie extends Wuppie {
    int wuppie(double a);
}

public interface Foo {
}

public interface Bar extends Wuppie {
    default int bar() { return 42; }
}

Auflösung:

  • Wuppie hat genau eine abstrakte Methode => funktionales Interface
  • Fluppie hat zwei abstrakte Methoden => kein funktionales Interface
  • Foo hat gar keine abstrakte Methode => kein funktionales Interface
  • Bar hat genau eine abstrakte Methode (und eine Default-Methode) => funktionales Interface

Lambdas und funktionale Interfaces: Typprüfung

interface java.util.Comparator<T> {
    int compare(T o1, T o2);    // abstrakte Methode
}
// Verwendung ohne weitere Typinferenz
Comparator<Studi> c1 = (Studi o1, Studi o2) -> o1.getCredits() - o2.getCredits();

// Verwendung mit Typinferenz
Comparator<Studi> c2 = (o1, o2) -> o1.getCredits() - o2.getCredits();

Der Compiler prüft in etwa folgende Schritte, wenn er über einen Lambda-Ausdruck stolpert:

  1. In welchem Kontext habe ich den Lambda-Ausdruck gesehen?
  2. OK, der Zieltyp ist hier Comparator<Studi>.
  3. Wie lautet die eine abstrakte Methode im Comparator<T>-Interface?
  4. OK, das ist int compare(T o1, T o2);
  5. Da T hier an Studi gebunden ist, muss der Lambda-Ausdruck der Methode int compare(Studi o1, Studi o2); entsprechen: 2x Studi als Parameter und als Ergebnis ein int
  6. Ergebnis: a) Cool, passt zum Lambda-Ausdruck c1. Fertig. b) D.h. in c2 müssen o1 und o2 vom Typ Studi sein. Cool, passt zum Lambda-Ausdruck c2. Fertig.

Wrap-Up

  • Anonyme Klassen: "Wegwerf"-Innere Klassen

    • Müssen Interface implementieren oder Klasse erweitern
  • Java8: Lambda-Ausdrücke statt anonymer Klassen (funktionales Interface nötig)

    • Zwei mögliche Formen:
      • Form 1: (parameters) -> expression
      • Form 2: (parameters) -> { statements; }
    • Im jeweiligen Kontext muss ein funktionales Interface verwendet werden, d.h. ein Interface mit genau einer abstrakten Methode
    • Der Lambda-Ausdruck muss von der Signatur her dieser einen abstrakten Methode entsprechen
Challenges

Beispiel aus einem Code-Review im Dungeon-CampusMinden/Dungeon

Erklären Sie folgenden Code:

public interface IFightAI {
    void fight(Entity entity);
}

public class AIComponent extends Component {
    private final IFightAI fightAI;

    fightAI =
                entity1 -> {
                    System.out.println("TIME TO FIGHT!");
                    // todo replace with melee skill
                };
}

Sortieren mit Lambdas und funktionalen Interfaces

In den Vorgaben finden Sie die Klassen Student und StudentSort mit vorgefertigten Methoden zu den Teilaufgaben sowie eine Testsuite SortTest mit einzelnen Testfälllen zu den Teilaufgaben, mit der Ihre Implementierung aufgerufen und getestet wird.

Ziel dieser Aufgabe ist es, eine Liste von Studierenden mithilfe verschiedener syntaktischer Strukturen (Lambda-Ausdrücke, Methoden-Referenzen) zu sortieren. Dabei soll bei allen Teilaufgaben die Methode java.util.List#sort für das eigentliche Sortieren verwendet werden.

  1. In dieser Teilaufgabe sollen Sie der Methode List#sort das Sortierkriterium mithilfe eines Lambda-Ausdrucks übergeben. Greifen Sie im Lambda-Ausdruck für den Vergleich der Objekte auf die Getter der Objekte zu.

    Hinweis: Erstellen Sie hierzu keine neuen Methoden, sondern verwenden Sie nur Lambda-Ausdrücke innerhalb des Aufrufs von List#sort.

    1a Sortieren Sie die Studierendenliste aufsteigend nach dem Geburtsdatum (sort_1a()).

    1b Sortieren Sie die Studierendenliste absteigend nach dem Namen (sort_1b()).

  2. Erweitern Sie die Klasse Student um eine statische Methode, die zwei Student-Objekte anhand des Alters miteinander vergleicht. Die Methode soll die Signatur static int compareByAge(Student a, Student b) besitzen und die folgenden Werte zurückliefern:

    • a > b -> -1
    • a < b -> 1
    • a == b -> 0

    Verwenden Sie die neue statische Methode compareByAge zum Sortieren der Liste in sort_2a(). Nutzen Sie dabei einen Lambda-Ausdruck.

  3. Erweitern Sie die Klasse Student um eine Instanz-Methode, die das Student-Objekt mit einem anderen (als Parameter übergebenen) Student-Objekt vergleicht. Die Methode soll die Signatur int compareByName(Student other) besitzen und die folgenden Werte zurückliefern:

    • self > other -> -1
    • self < other -> 1
    • self == other -> 0

    Verwenden Sie die neue Methode compareByName zum Sortieren der Liste in sort_3a(). Nutzen Sie dabei einen Lambda-Ausdruck.

  4. Erstellen Sie ein generisches Funktionsinterface, dass die Methode compare definiert und zum Vergleichen von zwei Objekten mit generischen Typen dient.

    Erzeugen Sie mithilfe eines Lambda-Ausdrucks eine Instanz Ihres Interfaces, um damit zwei Objekte vom Typ Student in Bezug auf ihr Alter vergleichen zu können. Verwenden Sie die erzeugte Instanz, um die Studierendenliste absteigend zu sortieren (sort_4a()).

Quellen
  • [Ullenboom2021] Java ist auch eine Insel
    Ullenboom, C., Rheinwerk-Verlag, 2021. ISBN 978-3-8362-8745-6.
    Kapitel 12: Lambda-Ausdrücke und funktionale Programmierung
  • [Urma2014] Java 8 in Action: Lambdas, Streams, and Functional-Style Programming
    Urma, R.-G. und Fusco, M. und Mycroft, A., Manning Publications, 2014. ISBN 978-1-6172-9199-9.
    Kapitel 3: Lambda Expressions, Kapitel 5: Working with streams

Methoden-Referenzen

TL;DR

Seit Java8 können Referenzen auf Methoden statt anonymer Klassen eingesetzt werden (funktionales Interface nötig).

Dabei gibt es drei mögliche Formen:

  • Form 1: Referenz auf eine statische Methode: ClassName::staticMethodName (wird verwendet wie (args) -> ClassName.staticMethodName(args))
  • Form 2: Referenz auf eine Instanz-Methode eines Objekts: objectref::instanceMethodName (wird verwendet wie (args) -> objectref.instanceMethodName(args))
  • Form 3: Referenz auf eine Instanz-Methode eines Typs: ClassName::instanceMethodName (wird verwendet wie (o1, args) -> o1.instanceMethodName(args))

Im jeweiligen Kontext muss ein passendes funktionales Interface verwendet werden, d.h. ein Interface mit genau einer abstrakten Methode. Die Methoden-Referenz muss von der Syntax her dieser einen abstrakten Methode entsprechen (bei der dritten Form wird die Methode auf dem ersten Parameter aufgerufen).

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Funktionales Interfaces (Definition)
  • (K3) Einsatz von Methoden-Referenzen

Beispiel: Sortierung einer Liste

List<Studi> sl = new ArrayList<Studi>();

// Anonyme innere Klasse
Collections.sort(sl, new Comparator<Studi>() {
    @Override public int compare(Studi o1, Studi o2) {
        return Studi.cmpCpsClass(o1, o2);
    }
});


// Lambda-Ausdruck
Collections.sort(sl, (o1, o2) -> Studi.cmpCpsClass(o1, o2));

// Methoden-Referenz
Collections.sort(sl, Studi::cmpCpsClass);

Anmerkung

Für das obige Beispiel wird davon ausgegangen, dass in der Klasse Studi eine statische Methode cmpCpsClass() existiert:

public static int cmpCpsClass(Studi s1, Studi s2) {
    return s1.getCps() - s2.getCps();
}

Wenn man im Lambda-Ausdruck nur Methoden der eigenen Klasse aufruft, kann man das auch direkt per Methoden-Referenz abkürzen!

  • Erinnerung: Comparator<T> ist ein funktionales Interface
  • Instanzen können wie üblich durch Ableiten bzw. anonyme Klassen erzeugt werden
  • Alternativ kann seit Java8 auch ein passender Lambda-Ausdruck verwendet werden
  • Ab Java8: Referenzen auf passende Methoden (Signatur!) können ein funktionales Interface "implementieren"
    • Die statische Methode static int cmpCpsClass(Studi s1, Studi s2) hat die selbe Signatur wie int compare(Studi s1, Studi s2) aus Comparator<Studi>
    • Kann deshalb wie eine Instanz von Comparator<Studi> genutzt werden
    • Name der Methode spielt dabei keine Rolle

Überblick: Arten von Methoden-Referenzen

  1. Referenz auf eine statische Methode

    • Form: ClassName::staticMethodName
    • Wirkung: Aufruf mit (args) -> ClassName.staticMethodName(args)
  2. Referenz auf Instanz-Methode eines bestimmten Objekts

    • Form: objectref::instanceMethodName
    • Wirkung: Aufruf mit (args) -> objectref.instanceMethodName(args)
  3. Referenz auf Instanz-Methode eines bestimmten Typs

    • Form: ClassName::instanceMethodName
    • Wirkung: Aufruf mit (arg0, rest) -> arg0.instanceMethodName(rest) (arg0 ist vom Typ ClassName)

Anmerkung: Analog zur Referenz auf eine statische Methode gibt es noch die Form der Referenz auf einen Konstruktor: ClassName::new. Für Referenzen auf Konstruktoren mit mehr als 2 Parametern muss ein eigenes passendes funktionales Interface mit entsprechend vielen Parametern definiert werden ...

Methoden-Referenz 1: Referenz auf statische Methode

public class Studi {
    public static int cmpCpsClass(Studi s1, Studi s2) {
        return s1.getCredits() - s2.getCredits();
    }

    public static void main(String... args) {
        List<Studi> sl = new ArrayList<Studi>();

        // Referenz auf statische Methode
        Collections.sort(sl, Studi::cmpCpsClass);

        // Entsprechender Lambda-Ausdruck
        Collections.sort(sl, (o1, o2) -> Studi.cmpCpsClass(o1, o2));
    }
}

Collections.sort() erwartet in diesem Szenario als zweiten Parameter eine Instanz von Comparator<Studi> mit einer Methode int compare(Studi o1, Studi o2).

Die übergebene Referenz auf die statische Methode cmpCpsClass der Klasse Studi hat die selbe Signatur und wird deshalb von Collections.sort() genauso genutzt wie die eigentlich erwartete Methode Comparator<Studi>#compare(Studi o1, Studi o2), d.h. statt compare(o1, o2) wird nun für jeden Vergleich Studi.cmpCpsClass(o1, o2) aufgerufen.

Methoden-Referenz 2: Referenz auf Instanz-Methode (Objekt)

public class Studi {
    public int cmpCpsInstance(Studi s1, Studi s2) {
        return s1.getCredits() - s2.getCredits();
    }

    public static void main(String... args) {
        List<Studi> sl = new ArrayList<Studi>();
        Studi holger = new Studi("Holger", 42);

        // Referenz auf Instanz-Methode eines Objekts
        Collections.sort(sl, holger::cmpCpsInstance);

        // Entsprechender Lambda-Ausdruck
        Collections.sort(sl, (o1, o2) -> holger.cmpCpsInstance(o1, o2));
    }
}

Collections.sort() erwartet in diesem Szenario als zweites Argument wieder eine Instanz von Comparator<Studi> mit einer Methode int compare(Studi o1, Studi o2).

Die übergebene Referenz auf die Instanz-Methode cmpCpsInstance des Objekts holger hat die selbe Signatur und wird entsprechend von Collections.sort() genauso genutzt wie die eigentlich erwartete Methode Comparator<Studi>#compare(Studi o1, Studi o2), d.h. statt compare(o1, o2) wird nun für jeden Vergleich holger.cmpCpsInstance(o1, o2) aufgerufen.

Methoden-Referenz 3: Referenz auf Instanz-Methode (Typ)

public class Studi {
    public int cmpCpsInstance(Studi studi) {
        return this.getCredits() - studi.getCredits();
    }

    public static void main(String... args) {
        List<Studi> sl = new ArrayList<Studi>();

        // Referenz auf Instanz-Methode eines Typs
        Collections.sort(sl, Studi::cmpCpsInstance);

        // Entsprechender Lambda-Ausdruck
        Collections.sort(sl, (o1, o2) -> o1.cmpCpsInstance(o2));
    }
}

Collections.sort() erwartet in diesem Szenario als zweites Argument wieder eine Instanz von Comparator<Studi> mit einer Methode int compare(Studi o1, Studi o2).

Die übergebene Referenz auf die Instanz-Methode cmpCpsInstance des Typs Studi hat die Signatur int cmpCpsInstance(Studi studi) und wird von Collections.sort() so genutzt: Statt compare(o1, o2) wird nun für jeden Vergleich o1.cmpCpsInstance(o2) aufgerufen.

Ausblick: Threads

Erinnerung an bzw. Vorgriff auf “Threads: Intro”:

public interface Runnable {
    void run();
}

Damit lassen sich Threads auf verschiedene Arten erzeugen:

public class ThreadStarter {
    public static void wuppie() { System.out.println("wuppie(): wuppie"); }
}


Thread t1 = new Thread(new Runnable() {
    public void run() {
        System.out.println("t1: wuppie");
    }
});

Thread t2 = new Thread(() -> System.out.println("t2: wuppie"));

Thread t3 = new Thread(ThreadStarter::wuppie);

Ausblick: Datenstrukturen als Streams

Erinnerung an bzw. Vorgriff auf “Stream-API”:

class X {
    public static boolean gtFour(int x) { return (x > 4) ? true : false; }
}

List<String> words = Arrays.asList("Java8", "Lambdas", "PM",
        "Dungeon", "libGDX", "Hello", "World", "Wuppie");

List<Integer> wordLengths = words.stream()
        .map(String::length)
        .filter(X::gtFour)
        .sorted()
        .collect(toList());
  • Collections können als Datenstrom betrachtet werden: stream()
    • Iteration über die Collection, analog zu externer Iteration mit foreach
  • Daten aus dem Strom filtern: filter, braucht Prädikat
  • Auf alle Daten eine Funktion anwenden: map
  • Daten im Strom sortieren: sort (auch mit Comparator)
  • Daten wieder einsammeln mit collect

=> Typische Elemente funktionaler Programmierung

=> Verweis auf Wahlfach "Spezielle Methoden der Programmierung"

Wrap-Up

Seit Java8: Methoden-Referenzen statt anonymer Klassen (funktionales Interface nötig)

  • Drei mögliche Formen:

    • Form 1: Referenz auf statische Methode: ClassName::staticMethodName (verwendet wie (args) -> ClassName.staticMethodName(args))
    • Form 2: Referenz auf Instanz-Methode eines Objekts: objectref::instanceMethodName (verwendet wie (args) -> objectref.instanceMethodName(args))
    • Form 3: Referenz auf Instanz-Methode eines Typs: ClassName::instanceMethodName (verwendet wie (o1, args) -> o1.instanceMethodName(args))
  • Im jeweiligen Kontext muss ein passendes funktionales Interface verwendet werden (d.h. ein Interface mit genau einer abstrakten Methode)

Challenges

In den Vorgaben finden Sie die Klassen Student und StudentSort mit vorgefertigten Methoden zu den Teilaufgaben sowie eine Testsuite SortTest mit einzelnen Testfälllen zu den Teilaufgaben, mit der Ihre Implementierung aufgerufen und getestet wird.

Ziel dieser Aufgabe ist es, eine Liste von Studierenden mithilfe verschiedener syntaktischer Strukturen (Lambda-Ausdrücke, Methoden-Referenzen) zu sortieren. Dabei soll bei allen Teilaufgaben die Methode java.util.List#sort für das eigentliche Sortieren verwendet werden.

  1. Erweitern Sie die Klasse Student um eine statische Methode, die zwei Student-Objekte anhand des Alters miteinander vergleicht. Die Methode soll die Signatur static int compareByAge(Student a, Student b) besitzen und die folgenden Werte zurückliefern:

    • a > b -> -1
    • a < b -> 1
    • a == b -> 0

    Verwenden Sie die neue statische Methode compareByAge zum Sortieren der Liste in sort_2b(). Nutzen Sie dabei eine Methodenreferenz.

  2. Erweitern Sie die Klasse Student um eine Instanz-Methode, die das Student-Objekt mit einem anderen (als Parameter übergebenen) Student-Objekt vergleicht. Die Methode soll die Signatur int compareByName(Student other) besitzen und die folgenden Werte zurückliefern:

    • self > other -> -1
    • self < other -> 1
    • self == other -> 0

    Verwenden Sie die neue Methode compareByName zum Sortieren der Liste in sort_3b(). Nutzen Sie dabei eine Methodenreferenz.

Quellen
  • [Urma2014] Java 8 in Action: Lambdas, Streams, and Functional-Style Programming
    Urma, R.-G. und Fusco, M. und Mycroft, A., Manning Publications, 2014. ISBN 978-1-6172-9199-9.
    Kapitel 3: Lambda Expressions, Kapitel 5: Working with streams

Stream-API

TL;DR

Mit der Collection-API existiert in Java die Möglichkeit, Daten auf verschiedenste Weisen zu speichern (Collection<T>). Mit der Stream-API gibt es die Möglichkeit, diese Daten in einer Art Pipeline zu verarbeiten. Ein Stream<T> ist eine Folge von Objekten vom Typ T. Die Verarbeitung der Daten ist "lazy", d.h. sie erfolgt erst auf Anforderung (durch die terminale Operation).

Ein Stream hat eine Datenquelle und kann beispielsweise über Collection#stream() oder Stream.of() angelegt werden. Streams speichern keine Daten. Die Daten werden aus der verbundenen Datenquelle geholt.

Auf einem Stream kann man eine Folge von intermediären Operationen wie peek(), map(), flatMap(), filter(), sorted() ... durchführen. Alle diese Operationen arbeiten auf dem Stream und erzeugen einen neuen Stream als Ergebnis. Dadurch kann die typische Pipeline-artige Verkettung der Operationen ermöglicht werden. Die intermediären Operationen werden erst ausgeführt, wenn der Stream durch eine terminale Operation geschlossen wird.

Terminale Operationen wie count(), forEach(), allMatch() oder collect()

  • collect(Collectors.toList()) (bzw. direkt mit stream.toList() (ab Java16))
  • collect(Collectors.toSet())
  • collect(Collectors.toCollection(LinkedList::new)) (als Supplier<T>)

stoßen die Verarbeitung des Streams an und schließen den Stream damit ab.

Wir können hier nur die absoluten Grundlagen betrachten. Die Stream-API ist sehr groß und mächtig und lohnt die weitere selbstständige Auseinandersetzung :-)

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Streams speichern keine Daten
  • (K2) Streams verarbeiten die Daten lazy
  • (K2) map() ändert den Typ (und Inhalt) von Objekten im Stream, aber nicht die Anzahl
  • (K2) filter() ändert die Anzahl der Objekte im Stream, aber nicht deren Typ (und Inhalt)
  • (K2) Streams machen ausführlich Gebrauch von den funktionalen Interfaces in java.util.function
  • (K2) Streams sollten nicht in Attributen gehalten oder als Argument von Methoden herumgereicht werden
  • (K3) Anlegen eines Streams
  • (K3) Verkettung von intermediären Operationen
  • (K3) Durchführung der Berechnung und Abschluss des Streams mit einer terminalen Operation
  • (K3) Einsatz von flatMap()

Motivation

Es wurden Studis, Studiengänge und Fachbereiche modelliert (aus Gründen der Übersichtlichkeit einfach als Record-Klassen).

Nun soll pro Fachbereich die Anzahl der Studis ermittelt werden, die bereits 100 ECTS oder mehr haben. Dazu könnte man über alle Studiengänge im Fachbereich iterieren, und in der inneren Schleife über alle Studis im Studiengang. Dann filtert man alle Studis, deren ECTS größer 100 sind und erhöht jeweils den Zähler:

public record Studi(String name, int credits) {}
public record Studiengang(String name, List<Studi> studis) {}
public record Fachbereich(String name, List<Studiengang> studiengaenge) {}

private static long getCountFB(Fachbereich fb) {
    long count = 0;
    for (Studiengang sg : fb.studiengaenge()) {
        for (Studi s : sg.studis()) {
            if (s.credits() > 100) count += 1;
        }
    }
    return count;
}

Dies ist ein Beispiel, welches klassisch in OO-Manier als Iteration über Klassen realisiert ist. (Inhaltlich ist es vermutlich nicht sooo sinnvoll.)

Innere Schleife mit Streams umgeschrieben

private static long getCountSG(Studiengang sg) {
    return sg.studis().stream()
                      .map(Studi::credits)
                      .filter(c -> c > 100)
                      .count();
}

private static long getCountFB2(Fachbereich fb) {
    long count = 0;
    for (Studiengang sg : fb.studiengaenge()) {
        count += getCountSG(sg);
    }
    return count;
}

Erklärung des Beispiels

Im Beispiel wurde die innere Schleife in einen Stream ausgelagert.

Mit der Methode Collection#stream() wird aus der Collection ein neuer Stream erzeugt. Auf diesem wird für jedes Element durch die Methode map() die Methode Studi#credits() angewendet, was aus einem Strom von Studi einen Strom von Integer macht. Mit filter() wird auf jedes Element das Prädikat c -> c > 100 angewendet und alle Elemente aus dem Strom entfernt, die der Bedingung nicht entsprechen. Am Ende wird mit count() gezählt, wie viele Elemente im Strom enthalten sind.

Was ist ein Stream?

Ein "Stream" ist ein Strom (Folge) von Daten oder Objekten. In Java wird die Collections-API für die Speicherung von Daten (Objekten) verwendet. Die Stream-API dient zur Iteration über diese Daten und entsprechend zur Verarbeitung der Daten. In Java speichert ein Stream keine Daten.

Das Konzept kommt aus der funktionalen Programmierung und wurde in Java nachträglich eingebaut (wobei dieser Prozess noch lange nicht abgeschlossen zu sein scheint).

In der funktionalen Programmierung kennt man die Konzepte "map", "filter" und "reduce": Die Funktion "map()" erhält als Parameter eine Funktion und wendet diese auf alle Elemente eines Streams an. Die Funktion "filter()" bekommt ein Prädikat als Parameter und prüft jedes Element im Stream, ob es dem Prädikat genügt (also ob das Prädikat mit dem jeweiligen Element zu true evaluiert - die anderen Objekte werden entfernt). Mit "reduce()" kann man Streams zu einem einzigen Wert zusammenfassen (denken Sie etwa an das Aufsummieren aller Elemente eines Integer-Streams). Zusätzlich kann man in der funktionalen Programmierung ohne Probleme unendliche Ströme darstellen: Die Auswertung erfolgt nur bei Bedarf und auch dann auch nur so weit wie nötig. Dies nennt man auch "lazy evaluation".

Die Streams in Java versuchen, diese Konzepte aus der funktionalen Programmierung in die objektorientierte Programmierung zu übertragen. Ein Stream in Java hat eine Datenquelle, von wo die Daten gezogen werden - ein Stream speichert selbst keine Daten. Es gibt "intermediäre Operationen" auf einem Stream, die die Elemente verarbeiten und das Ergebnis als Stream zurückliefern. Daraus ergibt sich typische Pipeline-artige Verkettung der Operationen. Allerdings werden diese Operationen erst durchgeführt, wenn eine "terminale Operation" den Stream "abschließt". Ein Stream ohne eine terminale Operation macht also tatsächlich nichts.

Die Operationen auf dem Stream sind üblicherweise zustandslos, können aber durchaus auch einen Zustand haben. Dies verhindert üblicherweise die parallele Verarbeitung der Streams. Operationen sollten aber nach Möglichkeit keine Seiteneffekte haben, d.h. keine Daten außerhalb des Streams modifizieren. Operationen dürfen auf keinen Fall die Datenquelle des Streams modifizieren!

Erzeugen von Streams

List<String> l1 = List.of("Hello", "World", "foo", "bar", "wuppie");
Stream<String> s1 = l1.stream();

Stream<String> s2 = Stream.of("Hello", "World", "foo", "bar", "wuppie");

Random random = new Random();
Stream<Integer> s3 = Stream.generate(random::nextInt);

Pattern pattern = Pattern.compile(" ");
Stream<String> s4 = pattern.splitAsStream("Hello world! foo bar wuppie!");

Dies sind möglicherweise die wichtigsten Möglichkeiten, in Java einen Stream zu erzeugen.

Ausgehend von einer Klasse aus der Collection-API kann man die Methode Collection#stream() aufrufen und bekommt einen seriellen Stream.

Alternativ bietet das Interface Stream verschiedene statische Methoden wie Stream.of() an, mit deren Hilfe Streams angelegt werden können. Dies funktioniert auch mit Arrays ...

Und schließlich kann man per Stream.generate() einen Stream anlegen, wobei als Argument ein "Supplier" (Interface java.util.function.Supplier<T>) übergeben werden muss. Dieses Argument wird dann benutzt, um die Daten für den Stream zu generieren.

Wenn man aufmerksam hinschaut, findet man an verschiedensten Stellen die Möglichkeit, die Daten per Stream zu verarbeiten, u.a. bei regulären Ausdrücken.

Man kann per Collection#parallelStream() auch parallele Streams erzeugen, die intern das "Fork&Join-Framework" nutzen. Allerdings sollte man nur dann parallele Streams anlegen, wenn dadurch tatsächlich Vorteile durch die Parallelisierung zu erwarten sind (Overhead!).

Intermediäre Operationen auf Streams

private static void dummy(Studiengang sg) {
    sg.studis().stream()
            .peek(s -> System.out.println("Looking at: " + s.name()))
            .map(Studi::credits)
            .peek(c -> System.out.println("This one has: " + c + " ECTS"))
            .filter(c -> c > 5)
            .peek(c -> System.out.println("Filtered: " + c))
            .sorted()
            .forEach(System.out::println);
}

An diesem (weitestgehend sinnfreien) Beispiel werden einige intermediäre Operationen demonstriert.

Die Methode peek() liefert einen Stream zurück, die aus den Elementen des Eingabestroms bestehen. Auf jedes Element wird die Methode void accept(T) des Consumer<T> angewendet (Argument der Methode), was aber nicht zu einer Änderung der Daten führt. Hinweis: Diese Methode dient vor allem zu Debug-Zwecken! Durch den Seiteneffekt kann die Methode eine schlechtere Laufzeit zur Folge haben oder sogar eine sonst mögliche parallele Verarbeitung verhindern oder durch eine parallele Verarbeitung verwirrende Ergebnisse zeigen!

Die Methode map() liefert ebenfalls einen Stream zurück, der durch die Anwendung der Methode R apply(T) der als Argument übergebenen Function<T,R> auf jedes Element des Eingabestroms entsteht. Damit lassen sich die Elemente des ursprünglichen Streams verändern; für jedes Element gibt es im Ergebnis-Stream ebenfalls ein Element (der Typ ändert sich, aber nicht die Anzahl der Elemente).

Mit der Methode filter() wird ein Stream erzeugt, der alle Objekte des Eingabe-Streams enthält, auf denen die Anwendung der Methode boolean test(T) des Arguments Predicate<T> zu true evaluiert (der Typ und Inhalt der Elemente ändert sich nicht, aber die Anzahl der Elemente).

Mit sorted() wird ein Stream erzeugt, der die Elemente des Eingabe-Streams sortiert (existiert auch mit einem Comparator<T> als Parameter).

Diese Methoden sind alles intermediäre Operationen. Diese arbeiten auf einem Stream und erzeugen einen neuen Stream und werden erst dann ausgeführt, wenn eine terminale Operation den Stream abschließt.

Dabei sind die gezeigten intermediären Methoden bis auf sorted() ohne inneren Zustand. sorted() ist eine Operation mit innerem Zustand (wird für das Sortieren benötigt). Dies kann ordentlich in Speicher und Zeit zuschlagen und u.U. nicht/nur schlecht parallelisierbar sein. Betrachten Sie den fiktiven parallelen Stream stream.parallel().sorted().skip(42): Hier müssen erst alle Elemente sortiert werden, bevor mit skip(42) die ersten 42 Elemente entfernt werden. Dies kann auch nicht mehr parallel durchgeführt werden.

Die Methode forEach() schließlich ist eine terminale Operation, die auf jedes Element des Eingabe-Streams die Methode void accept(T) des übergebenen Consumer<T> anwendet. Diese Methode ist eine terminale Operation, d.h. sie führt zur Auswertung der anderen intermediären Operationen und schließt den Stream ab.

Was tun, wenn eine Methode Streams zurückliefert

Wir konnten vorhin nur die innere Schleife in eine Stream-basierte Verarbeitung umbauen. Das Problem ist: Die äußere Schleife würde einen Stream liefern (Stream von Studiengängen), auf dem wir die map-Funktion anwenden müssten und darin dann für jeden Studiengang einen (inneren) Stream mit den Studis eines Studiengangs verarbeiten müssten.

private static long getCountSG(Studiengang sg) {
    return sg.studis().stream().map(Studi::credits).filter(c -> c > 100).count();
}

private static long getCountFB2(Fachbereich fb) {
    long count = 0;
    for (Studiengang sg : fb.studiengaenge()) {
        count += getCountSG(sg);
    }
    return count;
}

Dafür ist die Methode flatMap() die Lösung. Diese Methode bekommt als Argument ein Objekt vom Typ Function<? super T, ? extends Stream<? extends R>> mit einer Methode Stream<? extends R> apply(T). Die Methode flatMap() verarbeitet den Stream in zwei Schritten:

  1. Mappe über alle Elemente des Eingabe-Streams mit der Funktion. Im Beispiel würde also aus einem Stream<Studiengang> jeweils ein Stream<Stream<Studi>>, also alle Studiengang-Objekte werden durch je ein Stream<Studi>-Objekt ersetzt. Wir haben jetzt also einen Stream von Stream<Studi>-Objekten.

  2. "Klopfe den Stream wieder flach", d.h. nimm die einzelnen Studi-Objekte aus den Stream<Studi>-Objekten und setze diese stattdessen in den Stream. Das Ergebnis ist dann wie gewünscht ein Stream<Studi> (Stream mit Studi-Objekten).

private static long getCountFB3(Fachbereich fb) {
    return fb.studiengaenge().stream()
            .flatMap(sg -> sg.studis().stream())
            .map(Studi::credits)
            .filter(c -> c > 100)
            .count();
}

Zum direkten Vergleich hier noch einmal der ursprüngliche Code mit zwei verschachtelten Schleifen und entsprechenden Hilfsvariablen:

private static long getCountFB(Fachbereich fb) {
    long count = 0;
    for (Studiengang sg : fb.studiengaenge()) {
        for (Studi s : sg.studis()) {
            if (s.credits() > 100) count += 1;
        }
    }
    return count;
}

Streams abschließen: Terminale Operationen

Stream<String> s = Stream.of("Hello", "World", "foo", "bar", "wuppie");

long count = s.count();
s.forEach(System.out::println);
String first = s.findFirst().get();
Boolean b = s.anyMatch(e -> e.length() > 3);

List<String> s1 = s.collect(Collectors.toList());
List<String> s2 = s.toList();   // ab Java16
Set<String> s3 = s.collect(Collectors.toSet());
List<String> s4 = s.collect(Collectors.toCollection(LinkedList::new));

Streams müssen mit einer terminalen Operation abgeschlossen werden, damit die Verarbeitung tatsächlich angestoßen wird (lazy evaluation).

Es gibt viele verschiedene terminale Operationen. Wir haben bereits count() und forEach() gesehen. In der Sitzung zu “Optionals” werden wir noch findFirst() näher kennenlernen.

Daneben gibt es beispielsweise noch allMatch(), anyMatch() und noneMatch(), die jeweils ein Prädikat testen und einen Boolean zurückliefern (matchen alle, mind. eines oder keines der Objekte im Stream).

Mit min() und max() kann man sich das kleinste und das größte Element des Streams liefern lassen. Beide Methoden benötigen dazu einen Comparator<T> als Parameter.

Mit der Methode collect() kann man eine der drei Methoden aus Collectors über den Stream laufen lassen und eine Collection erzeugen lassen:

  1. toList() sammelt die Elemente in ein List-Objekt (bzw. direkt mit stream.toList() (ab Java16))
  2. toSet() sammelt die Elemente in ein Set-Objekt
  3. toCollection() sammelt die Elemente durch Anwendung der Methode T get() des übergebenen Supplier<T>-Objekts auf

Die ist nur die sprichwörtliche "Spitze des Eisbergs"! Es gibt viele weitere Möglichkeiten, sowohl bei den intermediären als auch den terminalen Operationen. Schauen Sie in die Dokumentation!

Spielregeln

  • Operationen dürfen nicht die Stream-Quelle modifizieren

  • Operationen können die Werte im Stream ändern (map) oder die Anzahl (filter)

  • Keine Streams in Attributen/Variablen speichern oder als Argumente übergeben: Sie könnten bereits "gebraucht" sein!

    => Ein Stream sollte immer sofort nach der Erzeugung benutzt werden

  • Operationen auf einem Stream sollten keine Seiteneffekte (Veränderungen von Variablen/Attributen außerhalb des Streams) haben (dies verhindert u.U. die parallele Verarbeitung)

Wrap-Up

Stream<T>: Folge von Objekten vom Typ T, Verarbeitung "lazy" (Gegenstück zu Collection<T>: Dort werden Daten gespeichert, hier werden Daten verarbeitet)

  • Neuen Stream anlegen: Collection#stream() oder Stream.of() ...

  • Intermediäre Operationen: peek(), map(), flatMap(), filter(), sorted() ...

  • Terminale Operationen: count(), forEach(), allMatch(), collect() ...

    • collect(Collectors.toList())
    • collect(Collectors.toSet())
    • collect(Collectors.toCollection()) (mit Supplier<T>)
  • Streams speichern keine Daten

  • Intermediäre Operationen laufen erst bei Abschluss des Streams los

  • Terminale Operation führt zur Verarbeitung und Abschluss des Streams

Schöne Doku: "The Stream API", und auch "Package java.util.stream".

Challenges

In den Vorgaben finden Sie die Klasse Main, in der die Methoden Main#a, Main#b und Main#c "klassisch" mit for-Schleifen implementiert wurden.

Führen Sie für die drei Methoden Main#a, Main#b und Main#c ein Refactoring durch, so dass in diesen Methoden jeweils die Java Stream-API genutzt wird und es keine for-/foreach-/while-Schleifen mehr gibt.

Quellen

Optional

TL;DR

Häufig hat man in Methoden den Fall, dass es keinen Wert gibt, und man liefert dann null als "kein Wert vorhanden" zurück. Dies führt dazu, dass die Aufrufer eine entsprechende null-Prüfung für die Rückgabewerte durchführen müssen, bevor sie das Ergebnis nutzen können.

Optional schließt elegant den Fall "kein Wert vorhanden" ein: Es kann mit der Methode Optional.ofNullable() das Argument in ein Optional verpacken (Argument != null) oder ein Optional.empty() zurückliefern ("leeres" Optional, wenn Argument == null).

Man kann Optionals prüfen mit isEmpty() und ifPresent() und dann direkt mit ifPresent(), orElse() und orElseThrow() auf den verpackten Wert zugreifen. Besser ist aber der Zugriff über die Stream-API von Optional: map(), filter, flatMap(), ...

Optional ist vor allem für Rückgabewerte gedacht, die den Fall "kein Wert vorhanden" einschließen sollen. Attribute, Parameter und Sammlungen sollten nicht Optional-Referenzen speichern, sondern "richtige" (unverpackte) Werte (und eben zur Not null). Optional ist kein Ersatz für null-Prüfung von Methoden-Parametern (nutzen Sie hier beispielsweise passende Annotationen). Optional ist auch kein Ersatz für vernünftiges Exception-Handling im Fall, dass etwas Unerwartetes passiert ist. Liefern Sie niemals null zurück, wenn der Rückgabetyp der Methode ein Optional ist!

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Optionals sind kein Ersatz für null-Prüfung!
  • (K2) Optionals sollen nicht für Attribute oder Parameter genutzt werden
  • (K2) Es darf kein null zurückgeliefert werden, wenn der Rückgabetyp ein Optional ist
  • (K2) Optionals und null sind kein Ersatz für Exception-Handling
  • (K3) Einsatz von Optional in Rückgabewerten
  • (K3) Erzeugen von Optionals mit Optional.ofNullable()
  • (K3) Zugriff auf Optionals entweder direkt oder per Stream-API

Motivation

public class LSF {
    private Set<Studi> sl;

    public Studi getBestStudi() {
        if (sl == null) return null;  // Fehler: Es gibt noch keine Sammlung

        Studi best = null;
        for (Studi s : sl) {
            if (best == null) best = s;
            if (best.credits() < s.credits()) best = s;
        }
        return best;
    }
}

public static void main(String... args) {
    LSF lsf = new LSF();

    Studi best = lsf.getBestStudi();
    if (best != null) {
        String name = best.name();
        if (name != null) {
            // mach was mit dem Namen ...
        }
    }
}

Problem: null wird an (zu) vielen Stellen genutzt

  • Es gibt keinen Wert ("not found")
  • Felder wurden (noch) nicht initialisiert
  • Es ist ein Problem oder etwas Unerwartetes aufgetreten

=> Parameter und Rückgabewerte müssen stets auf null geprüft werden (oder Annotationen wie @NotNull eingesetzt werden ...)

Lösung

  • Optional<T> für Rückgabewerte, die "kein Wert vorhanden" mit einschließen (statt null bei Abwesenheit von Werten)
  • @NotNull/@Nullable für Parameter einsetzen (oder separate Prüfung)
  • Exceptions werfen in Fällen, wo ein Problem aufgetreten ist

Anmerkungen

  • Verwendung von null auf Attribut-Ebene (Klassen-interne Verwendung) ist okay!
  • Optional<T> ist kein Ersatz für null-Checks!
  • null ist kein Ersatz für vernünftiges Error-Handling! Das häufig zu beobachtende "Irgendwas Unerwartetes ist passiert, hier ist null" ist ein Anti-Pattern!

Beispiel aus der Praxis im PM-Dungeon

Schauen Sie sich einmal das Review zu den ecs.components.ai.AITools in https://github.com/Dungeon-CampusMinden/Dungeon/pull/128#pullrequestreview-1254025874 an.

Die Methode AITools#calculateNewPath soll in der Umgebung einer als Parameter übergebenen Entität nach einem Feld (Tile) suchen, welches für die Entität betretbar ist und einen Pfad von der Position der Entität zu diesem Feld an den Aufrufer zurückliefern.

Zunächst wird in der Entität nach einer PositionComponent und einer VelocityComponent gesucht. Wenn es (eine) diese(r) Components nicht in der Entität gibt, wird der Wert null an den Aufrufer von AITools#calculateNewPath zurückgeliefert. (Anmerkung: Interessanterweise wird in der Methode nicht mit der VelocityComponent gearbeitet.)

Dann wird in der PositionComponent die Position der Entität im aktuellen Level abgerufen. In einer Schleife werden alle Felder im gegebenen Radius in eine Liste gespeichert. (Anmerkung: Da dies über die float-Werte passiert und nicht über die Feld-Indizes wird ein Tile u.U. recht oft in der Liste abgelegt. Können Sie sich hier einfache Verbesserungen überlegen?)

Da level.getTileAt() offenbar als Antwort auch null zurückliefern kann, werden nun zunächst per tiles.removeIf(Objects::isNull); all diese null-Werte wieder aus der Liste entfernt. Danach erfolgt die Prüfung, ob die verbleibenden Felder betretbar sind und nicht-betretbare Felder werden entfernt.

Aus den verbleibenden (betretbaren) Feldern in der Liste wird nun eines zufällig ausgewählt und per level.findPath() ein Pfad von der Position der Entität zu diesem Feld berechnet und zurückgeliefert. (Anmerkung: Hier wird ein zufälliges Tile in der Liste der umgebenden Felder gewählt, von diesem die Koordinaten bestimmt, und dann noch einmal aus dem Level das dazugehörige Feld geholt - dabei hatte man die Referenz auf das Feld bereits in der Liste. Können Sie sich hier eine einfache Verbesserung überlegen?)

Zusammengefasst:

  • Die als Parameter entity übergebene Referenz darf offenbar nicht null sein. Die ersten beiden Statements in der Methode rufen auf dieser Referenz Methoden auf, was bei einer null-Referenz zu einer NullPointer-Exception führen würde. Hier wäre null ein Fehlerzustand.
  • entity.getComponent() kann offenbar null zurückliefern, wenn die gesuchte Component nicht vorhanden ist. Hier wird null als "kein Wert vorhanden" genutzt, was dann nachfolgende null-Checks notwendig macht.
  • Wenn es die gewünschten Components nicht gibt, wird dem Aufrufer der Methode null zurückgeliefert. Hier ist nicht ganz klar, ob das einfach nur "kein Wert vorhanden" ist oder eigentlich ein Fehlerzustand?
  • level.getTileAt() kann offenbar null zurückliefern, wenn kein Feld an der Position vorhanden ist. Hier wird null wieder als "kein Wert vorhanden" genutzt, was dann nachfolgende null-Checks notwendig macht (Entfernen aller null-Referenzen aus der Liste).
  • level.findPath() kann auch wieder null zurückliefern, wenn kein Pfad berechnet werden konnte. Hier ist wieder nicht ganz klar, ob das einfach nur "kein Wert vorhanden" ist oder eigentlich ein Fehlerzustand? Man könnte beispielsweise in diesem Fall ein anderes Feld probieren?

Der Aufrufer bekommt also eine NullPointer-Exception, wenn der übergebene Parameter entity nicht vorhanden ist oder den Wert null, wenn in der Methode etwas schief lief oder schlicht kein Pfad berechnet werden konnte oder tatsächlich einen Pfad. Damit wird der Aufrufer gezwungen, den Rückgabewert vor der Verwendung zu untersuchen.

Allein in dieser einen kurzen Methode macht null so viele extra Prüfungen notwendig und den Code dadurch schwerer lesbar und fehleranfälliger! null wird als (unvollständige) Initialisierung und als Rückgabewert und für den Fehlerfall genutzt, zusätzlich ist die Semantik von null nicht immer klar. (Anmerkung: Der Gebrauch von null hat nicht wirklich etwas mit "der Natur eines ECS" zu tun. Die Methode wurde mittlerweile komplett überarbeitet und ist in der hier gezeigten Form glücklicherweise nicht mehr zu finden.)

Entsprechend hat sich in diesem Review die nachfolgende Diskussion ergeben:

Erzeugen von Optional-Objekten

Konstruktor ist private ...

  • "Kein Wert": Optional.empty()

  • Verpacken eines non-null Elements: Optional.of() (NullPointerException wenn Argument null!)

  • Verpacken eines "unsicheren"/beliebigen Elements: Optional.ofNullable()

    • Liefert verpacktes Element, oder
    • Optional.empty(), falls Element null war

Es sollte in der Praxis eigentlich nur wenige Fälle geben, wo ein Aufruf von Optional.of() sinnvoll ist. Ebenso ist Optional.empty() nur selten sinnvoll.

Stattdessen sollte stets Optional.ofNullable() verwendet werden.

null kann nicht nicht in Optional<T> verpackt werden! (Das wäre dann eben Optional.empty().)

LSF liefert jetzt Optional zurück

public class LSF {
    private Set<Studi> sl;

    public Optional<Studi> getBestStudi() throws NullPointerException {
        // Fehler: Es gibt noch keine Sammlung
        if (sl == null) throw new NullPointerException("There ain't any collection");

        Studi best = null;
        for (Studi s : sl) {
            if (best == null) best = s;
            if (best.credits() < s.credits()) best = s;
        }

        // Entweder Optional.empty() (wenn best==null) oder Optional.of(best) sonst
        return Optional.ofNullable(best);
    }
}

Das Beispiel soll verdeutlichen, dass man im Fehlerfall nicht einfach null oder Optional.empty() zurückliefern soll, sondern eine passende Exception werfen soll.

Wenn die Liste aber leer ist, stellt dies keinen Fehler dar! Es handelt sich um den Fall "kein Wert vorhanden". In diesem Fall wird statt null nun ein Optional.empty() zurückgeliefert, also ein Objekt, auf dem der Aufrufer die üblichen Methoden aufrufen kann.

Zugriff auf Optional-Objekte

In der funktionalen Programmierung gibt es schon lange das Konzept von Optional, in Haskell ist dies beispielsweise die Monade Maybe. Allerdings ist die Einbettung in die Sprache von vornherein mit berücksichtigt worden, insbesondere kann man hier sehr gut mit Pattern Matching in der Funktionsdefinition auf den verpackten Inhalt reagieren.

In Java gibt es die Methode Optional#isEmpty(), die einen Boolean zurückliefert und prüft, ob es sich um ein leeres Optional handelt oder ob hier ein Wert "verpackt" ist.

Für den direkten Zugriff auf die Werte gibt es die Methoden Optional#orElseThrow() und Optional#orElse(). Damit kann man auf den verpackten Wert zugreifen, oder es wird eine Exception geworfen bzw. ein Ersatzwert geliefert.

Zusätzlich gibt es Optional#isPresent(), die als Parameter ein java.util.function.Consumer erwartet, also ein funktionales Interface mit einer Methode void accept(T), die das Objekt verarbeitet.

Studi best;

// Testen und dann verwenden
if (!lsf.getBestStudi().isEmpty()) {
    best = lsf.getBestStudi().get();
    // mach was mit dem Studi ...
}

// Arbeite mit Consumer
lsf.getBestStudi().ifPresent(studi -> {
    // mach was mit dem Studi ...
});

// Studi oder Alternative (wenn Optional.empty())
best = lsf.getBestStudi().orElse(anne);

// Studi oder NoSuchElementException (wenn Optional.empty())
best = lsf.getBestStudi().orElseThrow();

Es gibt noch eine Methode get(), die so verhält wie orElseThrow(). Da man diese Methode vom Namen her schnell mit einem Getter verwechselt, ist sie mittlerweile deprecated.

Anmerkung: Da getBestStudi() eine NullPointerException werfen kann, sollte der Aufruf möglicherweise in ein try/catch verpackt werden. Dito für orElseThrow().

Einsatz mit Stream-API

public class LSF {
    ...
    public Optional<Studi> getBestStudi() throws NullPointerException {
        if (sl == null) throw new NullPointerException("There ain't any collection");
        return sl.stream()
                 .sorted((s1, s2) -> s2.credits() - s1.credits())
                 .findFirst();
    }
}


public static void main(String... args) {
    ...
    String name = lsf.getBestStudi()
                     .map(Studi::name)
                     .orElseThrow();
}

Im Beispiel wird in getBestStudi() die Sammlung als Stream betrachtet, über die Methode sorted() und den Lamda-Ausdruck für den Comparator sortiert ("falsch" herum: absteigend in den Credits der Studis in der Sammlung), und findFirst() ist die terminale Operation auf dem Stream, die ein Optional<Studi> zurückliefert: entweder den Studi mit den meisten Credits (verpackt in Optional<Studi>) oder Optional.empty(), wenn es überhaupt keine Studis in der Sammlung gab.

In main() wird dieses Optional<Studi> mit den Stream-Methoden von Optional<T> bearbeitet, zunächst mit Optional#map(). Man braucht nicht selbst prüfen, ob das von getBestStudi() erhaltene Objekt leer ist oder nicht, da dies von Optional#map() erledigt wird: Es wendet die Methodenreferenz auf den verpackten Wert an (sofern dieser vorhanden ist) und liefert damit den Namen des Studis als Optional<String> verpackt zurück. Wenn es keinen Wert, also nur Optional.empty() von getBestStudi() gab, dann ist der Rückgabewert von Optional#map() ein Optional.empty(). Wenn der Name, also der Rückgabewert von Studi::name, null war, dann wird ebenfalls ein Optional.empty() zurückgeliefert. Dadurch wirft orElseThrow() dann eine NoSuchElementException. Man kann also direkt mit dem String name weiterarbeiten ohne extra null-Prüfung - allerdings will man noch ein Exception-Handling einbauen (dies fehlt im obigen Beispiel aus Gründen der Übersicht) ...

Weitere Optionals

Für die drei primitiven Datentypen int, long und double gibt es passende Wrapper-Klassen von Optional<T>: OptionalInt, OptionalLong und OptionalDouble.

Diese verhalten sich analog zu Optional<T>, haben aber keine Methode ofNullable(), da dies hier keinen Sinn ergeben würde: Die drei primitiven Datentypen repräsentieren Werte - diese können nicht null sein.

Regeln für Optional

  1. Nutze Optional nur als Rückgabe für "kein Wert vorhanden"

    Optional ist nicht als Ersatz für eine null-Prüfung o.ä. gedacht, sondern als Repräsentation, um auch ein "kein Wert vorhanden" zurückliefern zu können.

  2. Nutze nie null für eine Optional-Variable oder einen Optional-Rückgabewert

    Wenn man ein Optional als Rückgabe bekommt, sollte das niemals selbst eine null-Referenz sein. Das macht das gesamte Konzept kaputt!

    Nutzen Sie stattdessen Optional.empty().

  3. Nutze Optional.ofNullable() zum Erzeugen eines Optional

    Diese Methode verhält sich "freundlich" und erzeugt automatisch ein Optional.empty(), wenn das Argument null ist. Es gibt also keinen Grund, dies mit einer Fallunterscheidung selbst erledigen zu wollen.

    Bevorzugen Sie Optional.ofNullable() vor einer manuellen Fallunterscheidung und dem entsprechenden Einsatz von Optional.of() und Optional.empty().

  4. Erzeuge keine Optional als Ersatz für die Prüfung auf null

    Wenn Sie auf null prüfen müssen, müssen Sie auf null prüfen. Der ersatzweise Einsatz von Optional macht es nur komplexer - prüfen müssen Sie hinterher ja immer noch.

  5. Nutze Optional nicht in Attributen, Methoden-Parametern und Sammlungen

    Nutzen Sie Optional vor allem für Rückgabewerte.

    Attribute sollten immer direkt einen Wert haben oder null, analog Parameter von Methoden o.ä. ... Hier hilft Optional nicht, Sie müssten ja trotzdem eine null-Prüfung machen, nur eben dann über den Optional, wodurch dies komplexer und schlechter lesbar wird.

    Aus einem ähnlichen Grund sollten Sie auch in Sammlungen keine Optional speichern!

  6. Vermeide den direkten Zugriff (ifPresent(), orElseThrow() ...)

    Der direkte Zugriff auf ein Optional entspricht dem Prüfen auf null und dann dem Auspacken. Dies ist nicht nur Overhead, sondern auch schlechter lesbar.

    Vermeiden Sie den direkten Zugriff und nutzen Sie Optional mit den Stream-Methoden. So ist dies von den Designern gedacht.

Wrap-Up

Optional als Rückgabe für "kein Wert vorhanden"

  • Optional.ofNullable(): Erzeugen eines Optional

    • Entweder Objekt "verpackt" (Argument != null)
    • Oder Optional.empty() (Argument == null)
  • Prüfen mit isEmpty() und ifPresent()

  • Direkter Zugriff mit ifPresent(), orElse() und orElseThrow()

  • Stream-API: map(), filter(), flatMap(), ...

  • Attribute, Parameter und Sammlungen: nicht Optional nutzen

  • Kein Ersatz für null-Prüfung!

Schöne Doku: "Using Optionals".

Challenges

Katzen-Café

In den Vorgaben finden Sie eine Implementierung für ein Katzencafé.

Verändern Sie die Vorgaben so, dass möglich wenig null verwendet wird. Setzen Sie dazu gezielt und sinnvoll Exception-Handling und Optional<T> ein. Ergänzen Sie die Vorgaben um ein ausführliches Beispiel und bevölkern Sie das Café mit verschiedenen Katzen und geben Sie diese mit Hilfe der verschiedenen Methoden aus.

Begründen Sie die Relevanz der verbleibenden null-Vorkommen im Code.

String-Handling

Können Sie den folgenden Code so umschreiben, dass Sie statt der if-Abfragen und der einzelnen direkten Methodenaufrufe die Stream-API und Optional<T> nutzen?

String format(final String text, String replacement) {
    if (text.isEmpty()) {
        return "";
    }

    final String trimmed = text.trim();
    final String withSpacesReplaced = trimmed.replaceAll(" +", replacement);

    return replacement + withSpacesReplaced + replacement;
}

Ein Aufruf format(" Hello World ... ", "_"); liefert den String "_Hello_World_..._".

Quellen

Record-Klassen

TL;DR

Häufig schreibt man relativ viel Boiler Plate Code, um einfach ein paar Daten plus den Konstruktor und die Zugriffsmethoden zu kapseln. Und selbst wenn die IDE dies zum Teil abnehmen kann - lesen muss man diesen Overhead trotzdem noch.

Für den Fall von Klassen mit final Attributen wurden in Java14 die Record-Klassen eingeführt. Statt dem Schlüsselwort class wird das neue Schlüsselwort record verwendet. Nach dem Klassennamen kommen in runden Klammern die "Komponenten" - eine Auflistung der Parameter für den Standardkonstruktor (Typ, Name). Daraus wird automatisch ein "kanonischer Konstruktor" mit exakt diesen Parametern generiert. Es werden zusätzlich private final Attribute generiert für jede Komponente, und diese werden durch den kanonischen Konstruktor gesetzt. Außerdem wird für jedes Attribut automatisch ein Getter mit dem Namen des Attributs generiert (also ohne den Präfix "get").

Beispiel:

public record StudiR(String name, int credits) {}

Der Konstruktor und die Getter können überschrieben werden, es können auch eigene Methoden definiert werden (eigene Konstruktoren müssen den kanonischen Konstruktor aufrufen). Es gibt außer den über die Komponenten definierten Attribute keine weiteren Attribute. Da eine Record-Klasse intern von java.lang.Record ableitet, kann eine Record-Klasse nicht von weiteren Klassen ableiten (erben). Man kann aber beliebig viele Interfaces implementieren. Record-Klassen sind implizit final, d.h. man nicht von Record-Klassen erben.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Record-Klassen sind final
  • (K2) Record-Klassen haben einen kanonischen Konstruktor
  • (K2) Die Attribute von Record-Klassen sind final und werden automatisch angelegt und über den Konstruktor gesetzt
  • (K2) Die Getter in Record-Klassen haben die Namen und Typen der Komponenten, also keinen Präfix 'get'
  • (K2) Der kanonische Konstruktor kann ergänzt werden
  • (K2) Es können weitere Methoden definiert werden
  • (K2) Record-Klassen können nicht von anderen Klassen erben, können aber Interfaces implementieren
  • (K3) Einsatz von Record-Klassen

Motivation; Klasse Studi

public class Studi {
    private final String name;
    private final int credits;

    public Studi(String name, int credits) {
        this.name = name;
        this.credits = credits;
    }

    public String getName() {
        return name;
    }

    public int getCredits() {
        return credits;
    }
}

Klasse Studi als Record

public record StudiR(String name, int credits) {}
  • Immutable Klasse mit Feldern String name und int credits => "(String name, int credits)" werden "Komponenten" des Records genannt

  • Standardkonstruktor setzt diese Felder ("Kanonischer Konstruktor")

  • Getter für beide Felder:

    public String name() { return this.name; }
    public int credits() { return this.credits; }

Record-Klassen wurden in Java14 eingeführt und werden immer wieder in neuen Releases erweitert/ergänzt.

Der kanonische Konstruktor hat das Aussehen wie die Record-Deklaration, im Beispiel also public StudiR(String name, int credits). Dabei werden die Komponenten über eine Kopie der Werte initialisiert.

Für die Komponenten werden automatisch private Attribute mit dem selben Namen angelegt.

Für die Komponenten werden automatisch Getter angelegt. Achtung: Die Namen entsprechen denen der Komponenten, es fehlt also der übliche "get"-Präfix!

Eigenschaften und Einschränkungen von Record-Klassen

  • Records erweitern implizit die Klasse java.lang.Record: Keine andere Klassen mehr erweiterbar! (Interfaces kein Problem)

  • Record-Klassen sind implizit final

  • Keine weiteren (Instanz-) Attribute definierbar (nur die Komponenten)

  • Keine Setter definierbar für die Komponenten: Attribute sind final

  • Statische Attribute mit Initialisierung erlaubt

Records: Prüfungen im Konstruktor

Der Konstruktor ist erweiterbar:

public record StudiS(String name, int credits) {
    public StudiS(String name, int credits) {
        if (name == null) { throw new IllegalArgumentException("Name cannot be null!"); }
        else { this.name = name; }

        if (credits < 0) { this.credits = 0; }
        else { this.credits = credits; }
    }
}

In dieser Form muss man die Attribute selbst setzen.

Alternativ kann man die "kompakte" Form nutzen:

public record StudiT(String name, int credits) {
    public StudiT {
        if (name == null) { throw new IllegalArgumentException("Name cannot be null!"); }

        if (credits < 0) { credits = 0; }
    }
}

In der kompakten Form kann man nur die Werte der Parameter des Konstruktors ändern. Das Setzen der Attribute ergänzt der Compiler nach dem eigenen Code.

Es sind weitere Konstruktoren definierbar, diese müssen den kanonischen Konstruktor aufrufen:

public StudiT() {
    this("", 42);
}

Getter und Methoden

Getter werden vom Compiler automatisch generiert. Dabei entsprechen die Methoden-Namen den Namen der Attribute:

public record StudiR(String name, int credits) {}

public static void main(String... args) {
    StudiR r = new StudiR("Sabine", 75);

    int x = r.credits();
    String y = r.name();
}

Getter überschreibbar und man kann weitere Methoden definieren:

public record StudiT(String name, int credits) {
    public int credits() { return credits + 42; }
    public void wuppie() { System.out.println("WUPPIE"); }
}

Die Komponenten/Attribute sind aber final und können nicht über Methoden geändert werden!

Beispiel aus den Challenges

In den Challenges zum Thema Optional gibt es die Klasse Katze in den Vorgaben.

Die Katze wurde zunächst "klassisch" modelliert: Es gibt drei Eigenschaften name, gewichtund lieblingsBox. Ein Konstruktor setzt diese Felder und es gibt drei Getter für die einzelnen Eigenschaften. Das braucht 18 Zeilen Code (ohne Kommentare Leerzeilen). Zudem erzeugt der Boilerplate-Code relativ viel "visual noise", so dass der eigentliche Kern der Klasse schwerer zu erkennen ist.

In einem Refactoring wurde diese Klasse durch eine äquivalente Record-Klasse ersetzt, die nur noch 2 Zeilen Code (je nach Code-Style auch nur 1 Zeile) benötigt. Gleichzeitig wurde die Les- und Wartbarkeit deutlich verbessert.

Wrap-Up

  • Records sind immutable Klassen:
    • final Attribute (entsprechend den Komponenten)
    • Kanonischer Konstruktor
    • Automatische Getter (Namen wie Komponenten)
  • Konstruktoren und Methoden können ergänzt/überschrieben werden
  • Keine Vererbung von Klassen möglich (kein extends)

Schöne Doku: "Using Record to Model Immutable Data".

Quellen
  • [LernJava] Learn Java
    Oracle Corporation, 2022.
    Tutorials \> Using Record to Model Immutable Data

Interfaces: Default-Methoden

TL;DR

Seit Java8 können Methoden in Interfaces auch fertig implementiert sein: Sogenannte Default-Methoden.

Dazu werden die Methoden mit dem neuen Schlüsselwort default gekennzeichnet. Die Implementierung wird an die das Interface implementierenden Klassen (oder Interfaces) vererbt und kann bei Bedarf überschrieben werden.

Da eine Klasse von einer anderen Klasse erben darf, aber mehrere Interfaces implementieren kann, könnte es zu einer Mehrfachvererbung einer Methode kommen: Eine Methode könnte beispielsweise in verschiedenen Interfaces als Default-Methode angeboten werden, und wenn eine Klasse diese Interfaces implementiert, steht eine Methode mit der selben Signatur auf einmal mehrfach zur Verfügung. Dies muss (u.U. manuell) aufgelöst werden.

Auflösung von Mehrfachvererbung:

  • Regel 1: Klassen gewinnen
  • Regel 2: Sub-Interfaces gewinnen
  • Regel 3: Methode explizit auswählen

Aktuell ist der Unterschied zu abstrakten Klassen: Interfaces können keinen Zustand haben, d.h. keine Attribute/Felder.

Videos (HSBI-Medienportal)
Lernziele
  • (K2) Interfaces mit Default-Methoden, Unterschied zu abstrakten Klassen
  • (K2) Problem der Mehrfachvererbung
  • (K3) Erstellen von Interfaces mit Default-Methoden
  • (K3) Regeln zum Auflösen der Mehrfachvererbung

Problem: Etablierte API (Interfaces) erweitern

interface Klausur {
    void anmelden(Studi s);
    void abmelden(Studi s);
}

=> Nachträglich noch void schreiben(Studi s); ergänzen?

Wenn ein Interface nachträglich erweitert wird, müssen alle Kunden (also alle Klassen, die das Interface implementieren) auf die neuen Signaturen angepasst werden. Dies kann viel Aufwand verursachen und API-Änderungen damit unmöglich machen.

Default-Methoden: Interfaces mit Implementierung

Seit Java8 können Interfaces auch Methoden implementieren. Es gibt zwei Varianten: Default-Methoden und statische Methoden.

interface Klausur {
    void anmelden(Studi s);
    void abmelden(Studi s);

    default void schreiben(Studi s) {
        ...     // Default-Implementierung
    }

    default void wuppie() {
        throw new java.lang.UnsupportedOperationException();
    }
}

Methoden können in Interfaces seit Java8 implementiert werden. Für Default-Methoden muss das Schlüsselwort default vor die Signatur gesetzt werden. Klassen, die das Interface implementieren, können diese Default-Implementierung erben oder selbst neu implementieren (überschreiben). Alternativ kann die Klasse eine Default-Methode neu deklarieren und wird damit zur abstrakten Klasse.

Dies ähnelt abstrakten Klassen. Allerdings kann in abstrakten Klassen neben dem Verhalten (implementierten Methoden) auch Zustand über die Attribute gespeichert werden.

Problem: Mehrfachvererbung

Drei Regeln zum Auflösen bei Konflikten:

  1. Klassen gewinnen: Methoden aus Klasse oder Superklasse haben höhere Priorität als Default-Methoden
  2. Sub-Interfaces gewinnen: Methode aus am meisten spezialisiertem Interface mit Default-Methode wird gewählt Beispiel: Wenn B extends A dann ist B spezialisierter als A
  3. Sonst: Klasse muss Methode explizit auswählen: Methode überschreiben und gewünschte (geerbte) Variante aufrufen: X.super.m(...) (X ist das gewünschte Interface)

Auf den folgenden Folien wird dies anhand kleiner Beispiele verdeutlicht.

Auflösung Mehrfachvererbung: 1. Klassen gewinnen

interface A {
    default String hello() { return "A"; }
}
class C {
    public String hello() { return "C"; }
}
class E extends C implements A {}


/** Mehrfachvererbung: 1. Klassen gewinnen */
public class DefaultTest1 {
    public static void main(String... args) {
        String e = new E().hello();
    }
}

Die Klasse E erbt sowohl von Klasse C als auch vom Interface A die Methode hello() (Mehrfachvererbung). In diesem Fall "gewinnt" die Implementierung aus Klasse C.

1. Regel: Klassen gewinnen immer. Deklarationen einer Methode in einer Klasse oder einer Oberklasse haben Vorrang von allen Default-Methoden.

Auflösung Mehrfachvererbung: 2. Sub-Interfaces gewinnen

interface A {
    default String hello() { return "A"; }
}
interface B extends A {
    @Override default String hello() { return "B"; }
}
class D implements A, B {}


/** Mehrfachvererbung: 2. Sub-Interfaces gewinnen */
public class DefaultTest2 {
    public static void main(String... args) {
        String e = new D().hello();
    }
}

Die Klasse D erbt sowohl vom Interface A als auch vom Interface B die Methode hello() (Mehrfachvererbung). In diesem Fall "gewinnt" die Implementierung aus Klasse B: Interface B ist spezialisierter als A.

2. Regel: Falls Regel 1 nicht zutrifft, gewinnt die Default-Methode, die am meisten spezialisiert ist.

Auflösung Mehrfachvererbung: 3. Methode explizit auswählen

interface A {
    default String hello() { return "A"; }
}
interface B {
    default String hello() { return "B"; }
}
class D implements A, B {
    @Override public String hello() { return A.super.hello(); }
}


/** Mehrfachvererbung: 3. Methode explizit auswählen */
public class DefaultTest3 {
    public static void main(String... args) {
        String e = new D().hello();
    }
}

Die Klasse D erbt sowohl vom Interface A als auch vom Interface B die Methode hello() (Mehrfachvererbung). In diesem Fall muss zur Auflösung die Methode in D neu implementiert werden und die gewünschte geerbte Methode explizit aufgerufen werden. (Wenn dies unterlassen wird, führt das selbst bei Nicht-Nutzung der Methode hello() zu einem Compiler-Fehler!)

Achtung: Der Aufruf der Default-Methode aus Interface A erfolgt mit A.super.hello(); (nicht einfach durch A.hello();)!

3. Regel: Falls weder Regel 1 noch 2 zutreffen bzw. die Auflösung noch uneindeutig ist, muss man manuell durch die explizite Angabe der gewünschten Methode auflösen.

Quiz: Was kommt hier raus?

interface A {
    default String hello() { return "A"; }
}
interface B extends A {
    @Override default String hello() { return "B"; }
}
class C implements B {
    @Override public String hello() { return "C"; }
}
class D extends C implements A, B {}


/** Quiz Mehrfachvererbung */
public class DefaultTest {
    public static void main(String... args) {
        String e = new D().hello(); // ???
    }
}

Die Klasse D erbt sowohl von Klasse C als auch von den Interfaces A und B die Methode hello() (Mehrfachvererbung). In diesem Fall "gewinnt" die Implementierung aus Klasse C: Klassen gewinnen immer (Regel 1).

Statische Methoden in Interfaces

public interface Collection<E> extends Iterable<E> {
    boolean add(E e);
    ...
}
public class Collections {
    private Collections() { }
    public static <T> boolean addAll(Collection<? super T> c, T... elements) {...}
    ...
}

Typisches Pattern in Java: Interface plus Utility-Klasse (Companion-Klasse) mit statischen Hilfsmethoden zum einfacheren Umgang mit Instanzen des Interfaces (mit Objekten, deren Klasse das Interface implementiert). Beispiel: Collections ist eine Hilfs-Klasse zum Umgang mit Collection-Objekten.

Seit Java8 können in Interfaces neben Default-Methoden auch statische Methoden implementiert werden.

Die Hilfsmethoden können jetzt ins Interface wandern => Utility-Klassen werden obsolet ... Aus Kompatibilitätsgründen würde man die bisherige Companion-Klasse weiterhin anbieten, wobei die Implementierungen auf die statischen Methoden im Interface verweisen (SKIZZE, nicht real!):

public interface CollectionX<E> extends Iterable<E> {
    boolean add(E e);
    static <T> boolean addAll(CollectionX<? super T> c, T... elements) { ... }
    ...
}
public class CollectionsX {
    public static <T> boolean addAll(CollectionX<? super T> c, T... elements) {
        return CollectionX.addAll(c, elements);  // Verweis auf Interface
    }
    ...
}

Interfaces vs. Abstrakte Klassen

  • Abstrakte Klassen: Schnittstelle und Verhalten und Zustand

  • Interfaces:

    • vor Java 8 nur Schnittstelle
    • ab Java 8 Schnittstelle und Verhalten

    Unterschied zu abstrakten Klassen: Kein Zustand, d.h. keine Attribute

  • Design:

    • Interfaces sind beinahe wie abstrakte Klassen, nur ohne Zustand
    • Klassen können nur von einer (abstrakten) Klasse erben, aber viele Interfaces implementieren

Wrap-Up

Seit Java8: Interfaces mit Implementierung: Default-Methoden

  • Methoden mit dem Schlüsselwort default können Implementierung im Interface haben
  • Die Implementierung wird vererbt und kann bei Bedarf überschrieben werden
  • Auflösung von Mehrfachvererbung:
    • Regel 1: Klassen gewinnen
    • Regel 2: Sub-Interfaces gewinnen
    • Regel 3: Methode explizit auswählen
  • Unterschied zu abstrakten Klassen: Kein Zustand
Challenges

Erklären Sie die Code-Schnipsel in der Vorgabe und die jeweils entstehenden Ausgaben.

Quellen
  • [Urma2014] Java 8 in Action: Lambdas, Streams, and Functional-Style Programming
    Urma, R.-G. und Fusco, M. und Mycroft, A., Manning Publications, 2014. ISBN 978-1-6172-9199-9.
    Kapitel 9: Default Methods