Subsections of Modern Java: Funktionaler Stil und Stream-API
Lambda-Ausdrücke und funktionale Interfaces
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.
- (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
oderextends
!
- Achtung Schreibweise: ohne
- 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:
- Parameterliste (in runden Klammern),
- Pfeil
- 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?
() -> {}
() -> "wuppie"
() -> { return "fluppie"; }
(Integer i) -> return i + 42;
(String s) -> { "foo"; }
(String s) -> s.length()
(Studi s) -> s.getCredits() > 300
(List<Studi> sl) -> sl.isEmpty()
(int x, int y) -> { System.out.println("Erg: "); System.out.println(x+y); }
() -> new Studi()
s -> s.getCps() > 100 && s.getCps() < 300
s -> { return s.getCps() > 100 && s.getCps() < 300; }
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 vonjava.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; }
}
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:
- In welchem Kontext habe ich den Lambda-Ausdruck gesehen?
- OK, der Zieltyp ist hier
Comparator<Studi>
. - Wie lautet die eine abstrakte Methode im
Comparator<T>
-Interface? - OK, das ist
int compare(T o1, T o2);
- Da
T
hier anStudi
gebunden ist, muss der Lambda-Ausdruck der Methodeint compare(Studi o1, Studi o2);
entsprechen: 2xStudi
als Parameter und als Ergebnis einint
- Ergebnis:
a) Cool, passt zum Lambda-Ausdruck
c1
. Fertig. b) D.h. inc2
müsseno1
undo2
vom TypStudi
sein. Cool, passt zum Lambda-Ausdruckc2
. 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; }
- Form 1:
- 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
- Zwei mögliche Formen:
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.
-
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()
). -
Erweitern Sie die Klasse
Student
um eine statische Methode, die zweiStudent
-Objekte anhand des Alters miteinander vergleicht. Die Methode soll die Signaturstatic 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 insort_2a()
. Nutzen Sie dabei einen Lambda-Ausdruck. -
Erweitern Sie die Klasse
Student
um eine Instanz-Methode, die dasStudent
-Objekt mit einem anderen (als Parameter übergebenen)Student
-Objekt vergleicht. Die Methode soll die Signaturint 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 insort_3a()
. Nutzen Sie dabei einen Lambda-Ausdruck. -
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()
).
- [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
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).
- (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 wieint compare(Studi s1, Studi s2)
ausComparator<Studi>
- Kann deshalb wie eine Instanz von
Comparator<Studi>
genutzt werden - Name der Methode spielt dabei keine Rolle
- Die statische Methode
Überblick: Arten von Methoden-Referenzen
-
Referenz auf eine statische Methode
- Form:
ClassName::staticMethodName
- Wirkung: Aufruf mit
(args) -> ClassName.staticMethodName(args)
- Form:
-
Referenz auf Instanz-Methode eines bestimmten Objekts
- Form:
objectref::instanceMethodName
- Wirkung: Aufruf mit
(args) -> objectref.instanceMethodName(args)
- Form:
-
Referenz auf Instanz-Methode eines bestimmten Typs
- Form:
ClassName::instanceMethodName
- Wirkung: Aufruf mit
(arg0, rest) -> arg0.instanceMethodName(rest)
(arg0
ist vom TypClassName
)
- Form:
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
- Iteration über die Collection, analog zu externer Iteration mit
- 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)
)
- Form 1: Referenz auf statische Methode:
-
Im jeweiligen Kontext muss ein passendes funktionales Interface verwendet werden (d.h. ein Interface mit genau einer abstrakten Methode)
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.
-
Erweitern Sie die Klasse
Student
um eine statische Methode, die zweiStudent
-Objekte anhand des Alters miteinander vergleicht. Die Methode soll die Signaturstatic 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 insort_2b()
. Nutzen Sie dabei eine Methodenreferenz. -
Erweitern Sie die Klasse
Student
um eine Instanz-Methode, die dasStudent
-Objekt mit einem anderen (als Parameter übergebenen)Student
-Objekt vergleicht. Die Methode soll die Signaturint 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 insort_3b()
. Nutzen Sie dabei eine Methodenreferenz.
- [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
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 mitstream.toList()
(ab Java16))collect(Collectors.toSet())
collect(Collectors.toCollection(LinkedList::new))
(alsSupplier<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 :-)
- (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:
-
Mappe über alle Elemente des Eingabe-Streams mit der Funktion. Im Beispiel würde also aus einem
Stream<Studiengang>
jeweils einStream<Stream<Studi>>
, also alleStudiengang
-Objekte werden durch je einStream<Studi>
-Objekt ersetzt. Wir haben jetzt also einen Stream vonStream<Studi>
-Objekten. -
"Klopfe den Stream wieder flach", d.h. nimm die einzelnen
Studi
-Objekte aus denStream<Studi>
-Objekten und setze diese stattdessen in den Stream. Das Ergebnis ist dann wie gewünscht einStream<Studi>
(Stream mitStudi
-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:
toList()
sammelt die Elemente in einList
-Objekt (bzw. direkt mitstream.toList()
(ab Java16))toSet()
sammelt die Elemente in einSet
-ObjekttoCollection()
sammelt die Elemente durch Anwendung der MethodeT get()
des übergebenenSupplier<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()
oderStream.of()
... -
Intermediäre Operationen:
peek()
,map()
,flatMap()
,filter()
,sorted()
... -
Terminale Operationen:
count()
,forEach()
,allMatch()
,collect()
...collect(Collectors.toList())
collect(Collectors.toSet())
collect(Collectors.toCollection())
(mitSupplier<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".
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.
- [LernJava] Learn Java
Oracle Corporation, 2022.
Tutorials \> The Stream API - [Ullenboom2021] Java ist auch eine Insel
Ullenboom, C., Rheinwerk-Verlag, 2021. ISBN 978-3-8362-8745-6.
Kap. 17.3 - 17.6: Java Stream-API
Optional
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!
- (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 (stattnull
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ürnull
-Checks!null
ist kein Ersatz für vernünftiges Error-Handling! Das häufig zu beobachtende "Irgendwas Unerwartetes ist passiert, hier istnull
" 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 nichtnull
sein. Die ersten beiden Statements in der Methode rufen auf dieser Referenz Methoden auf, was bei einernull
-Referenz zu einerNullPointer
-Exception führen würde. Hier wärenull
ein Fehlerzustand. entity.getComponent()
kann offenbarnull
zurückliefern, wenn die gesuchte Component nicht vorhanden ist. Hier wirdnull
als "kein Wert vorhanden" genutzt, was dann nachfolgendenull
-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 offenbarnull
zurückliefern, wenn kein Feld an der Position vorhanden ist. Hier wirdnull
wieder als "kein Wert vorhanden" genutzt, was dann nachfolgendenull
-Checks notwendig macht (Entfernen allernull
-Referenzen aus der Liste).level.findPath()
kann auch wiedernull
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 Argumentnull
!) -
Verpacken eines "unsicheren"/beliebigen Elements:
Optional.ofNullable()
- Liefert verpacktes Element, oder
Optional.empty()
, falls Elementnull
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
-
Nutze
Optional
nur als Rückgabe für "kein Wert vorhanden"Optional
ist nicht als Ersatz für einenull
-Prüfung o.ä. gedacht, sondern als Repräsentation, um auch ein "kein Wert vorhanden" zurückliefern zu können. -
Nutze nie
null
für eineOptional
-Variable oder einenOptional
-RückgabewertWenn man ein
Optional
als Rückgabe bekommt, sollte das niemals selbst einenull
-Referenz sein. Das macht das gesamte Konzept kaputt!Nutzen Sie stattdessen
Optional.empty()
. -
Nutze
Optional.ofNullable()
zum Erzeugen einesOptional
Diese Methode verhält sich "freundlich" und erzeugt automatisch ein
Optional.empty()
, wenn das Argumentnull
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 vonOptional.of()
undOptional.empty()
. -
Erzeuge keine
Optional
als Ersatz für die Prüfung aufnull
Wenn Sie auf
null
prüfen müssen, müssen Sie aufnull
prüfen. Der ersatzweise Einsatz vonOptional
macht es nur komplexer - prüfen müssen Sie hinterher ja immer noch. -
Nutze
Optional
nicht in Attributen, Methoden-Parametern und SammlungenNutzen Sie
Optional
vor allem für Rückgabewerte.Attribute sollten immer direkt einen Wert haben oder
null
, analog Parameter von Methoden o.ä. ... Hier hilftOptional
nicht, Sie müssten ja trotzdem einenull
-Prüfung machen, nur eben dann über denOptional
, wodurch dies komplexer und schlechter lesbar wird.Aus einem ähnlichen Grund sollten Sie auch in Sammlungen keine
Optional
speichern! -
Vermeide den direkten Zugriff (
ifPresent()
,orElseThrow()
...)Der direkte Zugriff auf ein
Optional
entspricht dem Prüfen aufnull
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.
Interessante Links
- "Using Optionals"
- "What You Might Not Know About Optional"
- "Experienced Developers Use These 7 Java Optional Tips to Remove Code Clutter"
- "Code Smells: Null"
- "Class Optional"
Wrap-Up
Optional
als Rückgabe für "kein Wert vorhanden"
-
Optional.ofNullable()
: Erzeugen einesOptional
- Entweder Objekt "verpackt" (Argument !=
null
) - Oder
Optional.empty()
(Argument ==null
)
- Entweder Objekt "verpackt" (Argument !=
-
Prüfen mit
isEmpty()
undifPresent()
-
Direkter Zugriff mit
ifPresent()
,orElse()
undorElseThrow()
-
Stream-API:
map()
,filter()
,flatMap()
, ... -
Attribute, Parameter und Sammlungen: nicht
Optional
nutzen -
Kein Ersatz für
null
-Prüfung!
Schöne Doku: "Using Optionals".
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_..._
".
- [LernJava] Learn Java
Oracle Corporation, 2022.
Tutorials \> The Stream API \> Using Optionals - [Ullenboom2021] Java ist auch eine Insel
Ullenboom, C., Rheinwerk-Verlag, 2021. ISBN 978-3-8362-8745-6.
Kap. 12.6: Optional ist keine Nullnummer
Record-Klassen
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.
- (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
undint 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
,
gewicht
und 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".
- [LernJava] Learn Java
Oracle Corporation, 2022.
Tutorials \> Using Record to Model Immutable Data
Interfaces: Default-Methoden
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.
- (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:
- Klassen gewinnen: Methoden aus Klasse oder Superklasse haben höhere Priorität als Default-Methoden
- Sub-Interfaces gewinnen:
Methode aus am meisten spezialisiertem Interface mit Default-Methode wird gewählt
Beispiel: Wenn
B extends A
dann istB
spezialisierter alsA
- 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
Erklären Sie die Code-Schnipsel in der Vorgabe und die jeweils entstehenden Ausgaben.
- [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