7.7 Schnittstellen
Schnittstellen sind eine gute Ergänzung zu abstrakten Klassen/Methoden, denn im objektorientierten Design wollen wir das »Was« vom »Wie« trennen. Abstrakte Methoden sagen wie Schnittstellen etwas über das »Was« aus, aber erst die konkreten Implementierungen realisieren das »Wie«.
7.7.1 Schnittstellen sind neue Typen
Da Java nur Einfachvererbung kennt, ist es schwierig, Klassen mehrere Typen zu geben. Da es aber möglich sein soll, dass in der objektorientierten Modellierung eine Klasse mehrere Typen annimmt, gibt es das Konzept der Schnittstelle (engl. interface). Eine Klasse kann dann von einer Klasse erben und eine beliebige Anzahl Schnittstellen implementieren und auf diese Weise weitere Typen annehmen.
Eine Schnittstelle ist wie eine Klasse ein Typ und hat viele Gemeinsamkeiten, nur die Intention ist eine andere. Eine Schnittstelle kann enthalten:
abstrakte Methoden
private und öffentliche konkrete Methoden (sogenannte Default-Methoden)
private und öffentliche statische Methoden
Konstanten
geschachtelte Typen, wie Aufzählungen
Eine Schnittstelle darf keinen Konstruktor deklarieren. Das ist auch klar, da Exemplare von Schnittstellen nicht erzeugt werden können, sondern nur von den konkreten implementierenden Klassen. Auch kann eine Schnittstelle keine Objektvariablen deklarieren.
Werden wir konkret. Vererbung ist immer linear, etwa so: GameObject erbt von Object, Building erbt von GameObject, Castle erbt von Building usw. Es wird schwierig, an einer Stelle zu sagen, dass ein Building ein GameObject ist, aber zusätzlich den Typ Buyable annehmen soll. Denn soll eine Klasse auf einer Ebene von mehreren Typen erben, geht das durch die Einfachvererbung nicht.
7.7.2 Schnittstellen deklarieren
Die Deklaration einer Schnittstelle erinnert an eine abstrakte Klasse, nur steht anstelle von class das Schlüsselwort interface:
interface Buyable {
}
Die Schnittstelle kann nun von Klassen implementiert werden.
7.7.3 Abstrakte Methoden in Schnittstellen
Die wichtigsten Elemente in Schnittstellen sind abstrakte Methoden. Wir kennen das schon von abstrakten Klassen: Eine abstrakte Methode hat keine Implementierung, sondern deklariert nur den Kopf einer Methode (also Modifizierer), den Rückgabetyp und die Signatur (ohne Rumpf). Deklariert wird also nur eine Vorschrift – die Implementierung einer Objektmethode übernimmt später eine Klasse.[ 166 ](Oder ein Lambda-Ausdruck, doch dazu später mehr in Kapitel 12, »Lambda-Ausdrücke und funktionale Programmierung« )
Sollen in einem Spiel gewisse Dinge käuflich sein, haben sie einen Preis. Eine Schnittstelle Buyable soll allen Klassen die Methode price() vorschreiben:
interface Buyable {
double price();
}
Da Objektmethoden in Schnittstellen standardmäßig abstrakt und öffentlich sind, können die Modifizierer abstract und public entfallen und sind redundant. Die von den Schnittstellen deklarierten Operationen sind – wie auch bei abstrakten Methoden – mit einem Semikolon abgeschlossen. Eine Implementierung ist möglich, wie wir später sehen werden.
Existiert eine Klasse, in der Methoden in einer neuen Schnittstelle deklariert werden sollen, lässt sich Refactor • Extract Interface… einsetzen. Es folgt ein Dialog, der uns Methoden auswählen lässt, die später in der neuen Schnittstelle deklariert werden. Eclipse legt die Schnittstelle automatisch an und lässt die Klasse die Schnittstelle implementieren. Dort, wo es möglich ist, erlaubt Eclipse, dass die konkrete Klasse durch die Schnittstelle ersetzt wird.
[»] Hinweis
Der Name einer Schnittstelle endet oft auf -ble (Accessible, Adjustable, Runnable). Er beginnt üblicherweise nicht mit einem Präfix wie »I«, obwohl die Eclipse-Entwickler diese Namenskonvention nutzen und sie auch in .NET üblich ist.
7.7.4 Implementieren von Schnittstellen
Möchte eine Klasse eine Schnittstelle verwenden, so folgt hinter dem Klassennamen das Schlüsselwort implements und dann der Name der Schnittstelle. Die Ausdrucksweise ist dann: »Klassen werden vererbt und Schnittstellen implementiert.«
Für unsere Spielwelt sollen die Klassen Chocolate und Magazine die Schnittstelle Buyable implementieren (siehe Abbildung 7.14). Eine Schokolade soll dabei immer einen sozialistischen Einheitspreis von 0,69 haben.
public class Chocolate implements Buyable {
@Override public double price() {
return 0.69;
}
}
Die Annotation @Override zeigt wieder eine überschriebene Methode (hier die implementierte Methode einer Schnittstelle) an.
Während Chocolate nur die Schnittstelle Buyable implementiert, soll Magazine zusätzlich ein GameObject sein:
public class Magazine extends GameObject implements Buyable {
double price;
@Override public double price() {
return price;
}
}
Es ist also kein Problem – und bei uns so gewünscht –, wenn eine Klasse eine andere Klasse erweitert und zusätzlich Operationen aus Schnittstellen implementiert.
Es gelten dann folgende Typbeziehungen (die sich auch mit instanceof testen lassen):
GameObject ist ein GameObject.
GameObject ist ein Object.
Magazine ist ein Magazine.
Magazine ist ein GameObject.
Magazine ist ein Object.
Magazine ist ein Buyable.
Chocolate ist ein Chocolate.
Chocolate ist ein Buyable.
Chocolate ist ein Object.
Fordert eine Methode ein Objekt eines gewissen Typs, haben wir viele Möglichkeiten:
Methode fordert Typ | Ein gültiger Argumenttyp ist |
---|---|
Object | Object (also beliebig), Magazine, Chocolate, GameObject, Buyable |
GameObject | GameObject, Magazine |
Buyable | Buyable, Magazine, Chocolate |
Magazine | Magazine |
Chocolate | Chocolate |
Wir lesen ab: Wenn ein konkreter Typ wie Magazine oder Chocolate gefordert ist, haben wir wenig Optionen. Bei Basistypen gibt es üblicherweise immer mehrere Varianten – wer wenig will, kann eben viel bekommen.
[»] Hinweis
Sind die in Schnittstellen deklarierten Operationen public, müssen auch die implementierten Methoden in den Klassen immer öffentlich sein. protected ist als Sichtbarkeit nicht erlaubt. Und private Schnittstellenmethoden sind in implementierenden Klassen sowie nicht sichtbar.
Implementiert eine Klasse nicht alle Operationen aus den Schnittstellen, so erbt sie damit abstrakte Methoden und muss selbst wieder als abstrakt gekennzeichnet werden.
Eclipse zeigt bei der Tastenkombination (Strg)+(T) eine Typhierarchie an; Oberklassen stehen oben und Unterklassen unten. Wird in dieser Ansicht erneut (Strg)+(T) gedrückt, dreht sich die Ansicht um, und Obertypen stehen unten; implementierte Schnittstellen tauchen mit unter den Obertypen auf.
7.7.5 Ein Polymorphie-Beispiel mit Schnittstellen
Obwohl Schnittstellen auf den ersten Blick nichts »bringen« – Programmierer wollen gerne etwas vererbt bekommen, damit sie Implementierungsarbeit sparen können –, sind sie eine enorm wichtige Erfindung. Über Schnittstellen lassen sich ganz unterschiedliche Sichten auf ein Objekt beschreiben. Jede Schnittstelle ermöglicht eine neue Sicht auf das Objekt, eine Art Rolle. Implementiert eine Klasse diverse Schnittstellen, können ihre Exemplare in verschiedenen Rollen auftreten. Hier wird erneut das Substitutionsprinzip wichtig, bei dem ein mächtiges Objekt zum Beispiel als Argument einer Methode verwendet wird, obwohl je nach Kontext der Parametertyp einer Methode nur die kleine Schnittstelle ist.
Mit Magazine und Chocolate haben wir zwei Klassen, die Buyable implementieren. Damit existieren zwei Klassen, die einen gemeinsamen Typ und eine gemeinsame Methode price() besitzen:
Buyable b1 = new Magazine();
Buyable b2 = new Chocolate();
System.out.println( b1.price() );
System.out.println( b2.price() );
Für Buyable wollen wir eine statische Methode calculateSum(….) schreiben, die den Preis einer Sammlung zum Verkauf stehender Objekte berechnet. Damit calculateSum(…) eine beliebige Anzahl Argumente, aber mindestens eins, annehmen kann, realisieren wir die Methode mit einem Vararg:
class PriceUtils {
static double calculateSum( Buyable first, Buyable... more ) {
double result = first.price();
for ( Buyable buyable : more )
result += buyable.price();
return result;
}
}
Die Methode nimmt käufliche Dinge an, wobei es ihr völlig egal ist, um welche Methode es sich dabei handelt. Was zählt, ist die Tatsache, dass die Elemente die Schnittstelle Buyable implementieren.
Die dynamische Bindung tritt schon in der ersten Anweisung, first.price(), auf. Auch später rufen wir auf jedem Objekt, das Buyable implementiert, die Methode price() auf. Indem wir die unterschiedlichen Werte summieren, bekommen wir den Gesamtpreis der Elemente aus der Parameterliste.
Sie soll wie folgt aufgerufen werden:
Magazine madMag = new Magazine();
madMag.price = 2.50;
Buyable schoki = new Chocolate();
Magazine maxim = new Magazine();
maxim.price = 3.00;
System.out.printf( "%.2f", PriceUtils.calculateSum( madMag, maxim, schoki ) );
// 6,19
[+] Tipp
Wie schon erwähnt, sollte der Typ einer Variablen immer der kleinste nötige sein. Dabei sind Schnittstellen als Variablentypen nicht ausgenommen. Entwickler, die alle ihre Variablen vom Typ einer Schnittstelle deklarieren, wenden das Konzept Programmieren gegen Schnittstellen an. Sie binden sich also nicht an eine spezielle Implementierung, sondern an einen Basistyp.
Im Zusammenhang mit Schnittstellen bleibt zusammenfassend zu sagen, dass hier bei Methodenaufrufen dynamisches Binden pur auftaucht.
7.7.6 Die Mehrfachvererbung bei Schnittstellen
Eine Klasse kann höchstens eine Basisklasse haben – egal, ob sie abstrakt ist oder nicht. Der Grund ist, dass Mehrfachvererbung zu dem Problem führen kann, dass eine Klasse von zwei Oberklassen die gleiche Methode erbt und dann nicht weiß, welche sie aufnehmen soll. Ohne Schwierigkeiten kann eine Klasse jedoch mehrere Schnittstellen implementieren. Das liegt daran, dass von einer Schnittstelle kein Code kommt, sondern nur eine Vorschrift zur Implementierung – im schlimmsten Fall gibt es die Vorschrift, eine Operation umzusetzen, mehrfach.
Dass in Java eine Klasse mehrere Schnittstellen implementieren kann, wird gelegentlich als Mehrfachvererbung in Java bezeichnet. Auf diese Weise besitzt die Klasse ganz unterschiedliche Typen. Ist Unter eine solche Klasse mit der Oberklasse Ober und implementiert sie die Schnittstellen I1 und I2, so liefert für ein Exemplar u vom Typ Unter der Test u instanceof Ober ein wahres Ergebnis genauso wie u instanceof I1 und u instanceof I2.
[»] Begriffe
Wenn es um das Thema Mehrfachvererbung geht, dann müssen wir Folgendes unterscheiden: Geht es um Klassenvererbung, sogenannte Implementierungsvererbung, ist Mehrfachvererbung nicht erlaubt. Geht es hingegen um Schnittstellenvererbung, so ist in diesem Sinne Mehrfachvererbung erlaubt, denn eine Klasse kann beliebig viele Schnittstellen implementieren. Typvererbung ist hier ein gebräuchliches Wort. Üblicherweise wird der Begriff Mehrfachvererbung in Java nicht verwendet, da er sich traditionell auf Klassenvererbung bezieht.
Beginnen wir mit einem Beispiel. GameObject soll die Markierungsschnittstelle Serializable implementieren, sodass alle Unterklassen von GameObject ebenfalls vom Typ Serializable sind. Die Markierungsschnittstelle schreibt nichts vor, daher gibt es keine spezielle überschriebene Methode:
public abstract class GameObject implements Serializable {
protected String name;
protected GameObject( String name ) {
this.name = name;
}
}
Damit gibt es schon verschiedene Ist-eine-Art-von-Beziehungen: GameObject ist ein java. lang.Object, GameObject ist ein GameObject, GameObject ist Serializable.
Ein Magazine soll zunächst ein GameObject sein. Dann soll es nicht nur die Schnittstelle Buyable und damit die Methode price() implementieren, sondern sich auch mit anderen Magazinen vergleichen lassen. Dazu gibt es schon eine passende Schnittstelle in der Java-Bibliothek: java.lang.Comparable. Die Schnittstelle Comparable fordert, dass unser Magazin die Methode int compareTo(Magazine) implementiert. Der Rückgabewert der Methode zeigt an, wie das eigene Magazin zum anderen aufgestellt ist. Wir wollen definieren, dass das günstigere Magazin vor einem teureren steht (eigentlich sollten mit Comparable auch equals(…) und hashCode() aus Object überschrieben werden, doch das spart das Beispiel aus[ 167 ](Wenn compareTo(…) bei zwei gleichen Objekten 0 ergibt, so sollte equals(…) auch true liefern. Doch wird equals(…) nicht überschrieben, so führt die in Object implementierte Methode nur einen Referenzvergleich durch. Bei zwei im Prinzip gleichen Objekten würde die equals(…)-Standardimplementierung also false liefern. Bei hashCode() gilt das Gleiche: Zwei gleiche Objekte müssen auch den gleichen Hashwert haben. Ohne Überschreiben der Methode ist das jedoch nicht gegeben; nur zwei identische Objekte haben den gleichen Hashcode. )):
public class Magazine extends GameObject implements Buyable, Comparable<Magazine> {
private double price;
public Magazine( String name, double price ) {
super( name );
this.price = price;
}
@Override public double price() {
return price;
}
@Override public int compareTo( Magazine that ) {
return Double.compare( this.price(), that.price() );
}
@Override public String toString() {
return name + " " + price;
}
}
Die Implementierung nutzt Generics mit Comparable<Magazine>, was wir genauer erst in Kapitel 11, »Generics<T>«, lernen, aber an dieser Stelle schon einmal nutzen wollen. Der Hintergrund ist, dass Comparable dann genau weiß, mit welchem anderen Typ der Vergleich stattfinden soll.
Durch diese »Mehrfachvererbung« bekommt Magazine mehrere Typen, sodass sich je nach Sichtweise Folgendes schreiben lässt:
Magazine m1 = new Magazine( "Mad Magazine", 2.50 );
GameObject m2 = new Magazine( "Mad Magazine", 2.50 );
Object m3 = new Magazine( "Mad Magazine", 2.50 );
Buyable m4 = new Magazine( "Mad Magazine", 2.50 );
Comparable<Magazine> m5 = new Magazine( "Mad Magazine", 2.50 );
Serializable m6 = new Magazine( "Mad Magazine", 2.50 );
Die Konsequenzen davon sind:
Im Fall m1 sind alle Methoden der Schnittstellen verfügbar, also price() und compareTo(…) sowie das Attribut name.
Über m2 ist keine Schnittstellenmethode verfügbar, und nur die geschützte Variable name ist vorhanden.
Mit m3 sind alle Bezüge zu Spielobjekten verloren. Aber ein Magazine als Object ist ein gültiger Argumenttyp für System.out.println(Object).
Die Variable m4 ist vom Typ Buyable, sodass es price() gibt, jedoch kein compareTo(…). Das Objekt könnte daher in PriceUtils.calculateSum(…) eingesetzt werden.
Mit m5 gibt es ein compareTo(…), aber keinen Preis.
Da Magazine die Klasse GameObject erweitert und darüber auch vom Typ Serializable ist, lässt sich keine besondere Methode auf m6 aufrufen – Serializable ist eine Markierungsschnittstelle ohne Operationen. Damit könnte das Objekt allerdings von speziellen Klassen der Java-Bibliothek serialisiert und so persistent gemacht werden.
Ein kleines Beispiel zeigt abschließend die Anwendung der Methode compareTo(…) der Schnittstelle Comparable und der Methode price() unserer Schnittstelle Buyable:
Magazine spiegel = new Magazine( "Spiegel", 3.50 );
Magazine madMag = new Magazine( "Mad Magazine", 2.50 );
Magazine maxim = new Magazine( "Maxim", 3.00 );
Magazine neon = new Magazine( "Neon", 3.00 );
Magazine ct = new Magazine( "c't", 3.30 );
Da wir einem Magazin so viele Sichten gegeben haben, können wir unsere Methode calculateSum(…) mit Magazine-Argumenten aufrufen, da jedes Magazine ja Buyable ist:
System.out.println( PriceUtils.calculateSum( spiegel, madMag, ct ) ); // 9.3
Und die Magazine können wir vergleichen:
System.out.println( spiegel.compareTo( ct ) ); // 1
System.out.println( ct.compareTo( spiegel ) ); // -1
System.out.println( maxim.compareTo( neon ) ); // 0
So wie es der Methode calculateSum(…) egal ist, was für Buyable-Objekte konkret übergeben werden, so gibt es auch für Comparable einen sehr nützlichen Anwendungsfall: das Sortieren. Einem Sortierverfahren ist es egal, was für Objekte genau es sortiert, solange die Objekte sagen, ob sie vor oder hinter einem anderen Objekt liegen:
Magazine[] mags = { spiegel, madMag, maxim, neon, ct };
Arrays.sort( mags );
System.out.println( Arrays.toString( mags ) );
// [Mad Magazine 2.5, Maxim 3.0, Neon 3.0, c't 3.3, Spiegel 3.5]
Die statische Methode Arrays.sort(…) erwartet ein Array, dessen Elemente Comparable sind. Der Sortieralgorithmus macht Vergleiche über compareTo(…), muss aber sonst über die Objekte nichts wissen. Unsere Magazine mit den unterschiedlichen Typen können also sehr flexibel in unterschiedlichen Kontexten eingesetzt werden. Es muss somit für das Sortieren keine Spezialsortiermethode geschrieben werden, die nur Magazine sortieren kann, oder eine Methode zur Berechnung einer Summe, die nur auf Magazinen arbeitet. Wir modellieren die unterschiedlichen Anwendungsszenarien mit jeweils unterschiedlichen Schnittstellen, die Unterschiedliches von dem Objekt erwarten.
7.7.7 Keine Kollisionsgefahr bei Mehrfachvererbung *
Bei der Mehrfachvererbung von Klassen besteht die Gefahr, dass zwei Oberklassen die gleiche Methode mit zwei unterschiedlichen Implementierungen den Unterklassen vererben. Die Unterklasse wüsste dann nicht, welche Logik sie erbt, also wäre eine spezielle Syntax in Java nötig, die das Dilemma auflösen würde. Das wollen die Sprachdesigner nicht einbauen.
Bei den Schnittstellen gibt es das Problem nicht, denn auch wenn zwei implementierende Schnittstellen die gleiche Operation vorschreiben würden, gäbe es keine zwei verschiedenen Implementierungen von Anwendungslogik. Die implementierende Klasse bekommt sozusagen zweimal die Aufforderung, die Operation zu realisieren. So wie bei folgendem Beispiel: Ein Politiker muss verschiedene Dinge vereinen – er muss sympathisch sein, aber auch durchsetzungsfähig handeln können.
interface Likeable {
void act();
}
interface Assertive {
void act();
}
public class Politician implements Likeable, Assertive {
@Override public void act() {
// Implementation
}
}
Zwei Schnittstellen schreiben die gleiche Operation vor. Eine Klasse implementiert diese beiden Schnittstellen und muss beiden Vorgaben gerecht werden (siehe Abbildung 7.16).
[»] Hinweis
Ein Rückgabetyp gehört in Java nicht zur Signatur einer Methode. Wenn eine Klasse zwei Schnittstellen implementiert und die Signaturen der Operationen aus den Schnittstellen gleich sind, müssen auch die Rückgabetypen gleich sein. Es funktioniert bei der Implementierung nicht, wenn die Signaturen der Methoden aus den Schnittstellen gleich sind (also gleicher Methodenname, gleiche Parameterliste), aber die Rückgabetypen nicht typkompatibel sind. Der Grund ist einfach: Eine Klasse kann nicht zwei Methoden mit gleicher Signatur, aber unterschiedlichen Rückgabetypen implementieren. Würde Assertive ein boolean act() besitzen, müsste Politician dann void act() und boolean act() gleichzeitig realisieren – das geht nicht.
7.7.8 Erweitern von Interfaces – Subinterfaces
Ein Subinterface ist die Erweiterung eines anderen Interfaces. Diese Erweiterung erfolgt – wie bei der Vererbung – durch das Schlüsselwort extends.
interface Disgusting {
double disgustingValue();
}
interface Stinky extends Disgusting {
double olf();
}
Die Schnittstelle modelliert Stinkiges, das besonders abstoßend ist. Zusätzlich soll die Stinkquelle die Stärke der Stinkigkeit in der Einheit Olf angeben. Eine Klasse, die nun Stinky implementiert, muss die abstrakten Methoden aus beiden Schnittstellen implementieren, demnach die Methode disgustingValue() aus Disgusting sowie die Operation olf(), die in Stinky selbst angegeben wurde. Ohne die Implementierung beider Methoden wird eine implementierende Klasse abstrakt sein müssen.
[+] Tipp
Eine Unterschnittstelle kann eine Operation der Oberschnittstelle »überschreiben«. Auf den ersten Blick ist das nicht sinnvoll, erfüllt aber zwei Zwecke.
In der Unterschnittstelle kann die API-Dokumentation präzisiert werden.[ 168 ](Leser können das bei java.util.Collection und java.util.Set einmal nachschauen. )
Wegen kovarianter Rückgaben kann eine Operation in der Unterschnittstelle einen spezielleren Rückgabetyp bekommen.
7.7.9 Konstantendeklarationen bei Schnittstellen
Schnittstellen können keine Objektvariablen haben und folglich keinen Zustand speichern, aber sie dürfen static final-Variablen (benannte Konstanten) deklarieren.
[zB] Beispiel
Die Schnittstelle Buyable soll eine Konstante für einen Maximalpreis deklarieren:
interface Buyable {
int MAX_PRICE = 10_000_000;
double price();
}
Auch wenn die Variablen selbst nach der Initialisierung keine Änderung mehr zulassen, besteht bei mutabel referenzierten Objekten immer noch das Problem, dass eine spätere Änderung an den Objekten möglich ist. Alle Attribute einer Schnittstelle sind immer implizit public static final. Das verhindert, dass die Variable neu belegt wird, aber es verhindert keine Objektmanipulation.
[zB] Beispiel und Tipp
Die Schnittstelle Volcano referenziert ein veränderbares StringBuilder-Objekt:
interface Volcano {
StringBuilder EYJAFJALLAJÖKULL = new StringBuilder( "Eyjafjallajökull" );
}
Da EYJAFJALLAJÖKULL eine öffentliche StringBuilder-Variable und StringBuilder ein veränderbarer Container ist, modifiziert eine Anweisung wie Volcano.EYJAFJALLAJÖKULL.replace(0, Volcano.EYJAFJALLAJÖKULL.length(), "Vesuvius"); den Inhalt, was der Idee einer Konstanten absolut widerspricht. Besser ist es, immer immutable Objekte zu referenzieren, also etwa Strings. Problematisch sind Arrays, in denen Elemente ausgetauscht werden können, sowie alle veränderbaren Objekte wie Date, StringBuilder oder mutable Datenstrukturen.
Vererbung und Überschattung von statischen Variablen *
Die Konstanten einer Schnittstelle können einer anderen Schnittstelle vererbt werden. Dabei gibt es einige kleine Einschränkungen. Wir wollen an einem Beispiel sehen, wie sich die Vererbung auswirkt, wenn gleiche Bezeichner in den Unterschnittstellen erneut verwendet werden. Die Basis unseres Beispiels ist die Schnittstelle BaseColors mit ein paar Deklarationen von Farben. Zwei Unterschnittstellen erweitern BaseColors, und zwar CarColors und PlaneColors, die für Farbdeklarationen für Autos und Flugzeuge stehen. Eine besondere Schnittstelle FlyingCarColors erweitert die beiden Schnittstellen CarColors und PlaneColors, denn es gibt auch fliegende Autos, die eine Farbe haben können.
interface BaseColors {
int WHITE = 0;
int BLACK = 1;
int GREY = 2;
}
interface CarColors extends BaseColors {
int WHITE = 1;
int BLACK = 0;
}
interface PlaneColors extends BaseColors {
int WHITE = 0;
int GREY = 2;
}
interface FlyingCarColors extends CarColors, PlaneColors { }
public class Colors {
public static void main( String[] args ) {
System.out.println( BaseColors.GREY ); // 2
System.out.println( CarColors.GREY ); // 2
System.out.println( BaseColors.BLACK ); // 1
System.out.println( CarColors.BLACK ); // 0
System.out.println( PlaneColors.BLACK ); // 1
System.out.println( FlyingCarColors.WHITE );
// field FlyingCarColors.WHITE is ambiguous
System.out.println( FlyingCarColors.GREY );
// field FlyingCarColors.GREY is ambiguous
}
}
Die erste wichtige Tatsache ist, dass unsere drei Schnittstellen ohne Fehler übersetzt werden können, aber nicht die Klasse Colors. Das Programm und der Compiler zeigen folgendes Verhalten:
Schnittstellen vererben ihre Eigenschaften an die Unterschnittstellen. CarColors und auch PlaneColors erben die Farben WHITE, BLACK und GREY aus BaseColors.
Konstanten dürfen überdeckt werden. CarColors vertauscht die Farbdeklarationen von WHITE und BLACK und gibt ihnen neue Werte. Wird jetzt der Wert CarColors.BLACK verlangt, liefert die Umgebung den Wert 0, während BaseColors.BLACK 1 ergibt. Auch PlaneColors überdeckt die Konstanten WHITE und GREY, obwohl die Farben mit dem gleichen Wert belegt sind.
Erbt eine Schnittstelle von mehreren Oberschnittstellen, so ist es zulässig, dass die Oberschnittstellen jeweils ein gleichlautendes Attribut haben. So erbt etwa FlyingCarColors von CarColors und PlaneColors die Einträge WHITE, BLACK und GREY.
Unterschnittstellen können aus zwei Oberschnittstellen die Attribute gleichen Namens übernehmen, auch wenn die Konstanten einen unterschiedlichen Wert haben. Das testet der Compiler nicht. FlyingCarColors bekommt aus CarColors ein WHITE mit 1, aber aus PlaneColors das WHITE mit 0. Daher ist in dem Beispiel Colors auch der Zugriff FlyingCarColors.WHITE nicht möglich und führt zu einem Compilerfehler. Bei der Benutzung muss ein unmissverständlich qualifizierter Name verwendet werden, der deutlich macht, welches Attribut gemeint ist, also zum Beispiel CarColors.WHITE oder PlaneColors.WHITE. Ähnliches gilt für die Farbe GREY. Obwohl Grau durch die ursprüngliche Deklaration bei BaseColors und auch bei der Überschattung in PlaneColors immer 2 ist, ist die Nutzung durch FlyingCarColors.GREY nicht zulässig. Das ist ein guter Schutz gegen Fehler, denn wenn der Compiler dies durchließe, könnte sich im Nachhinein die Belegung von GREY in BaseColors oder PlaneColors ohne Neuübersetzung aller Klassen ändern und zu Schwierigkeiten führen. Diesen Fehler – die Oberschnittstellen haben für eine Konstante unterschiedliche Werte – müsste die Laufzeitumgebung erkennen. Doch das ist nicht möglich, und in der Regel setzt der Compiler die Werte auch direkt in die Aufrufstelle ein, und ein Zugriff auf die Konstantenwerte der Schnittstelle findet nicht mehr statt.
7.7.10 Nachträgliches Implementieren von Schnittstellen *
Implementiert eine Klasse eine bestimmte Schnittstelle nicht, so kann sie auch nicht am dynamischen Binden über diese Schnittstelle teilnehmen, auch wenn sie eine Methode hat, über die eine Schnittstelle abstrahiert. Besitzt zum Beispiel die nichtfinale Klasse FIFA eine öffentliche Methode price(), implementiert aber Buyable mit einer gleich benannten Methode nicht, so lässt sich zu einem Trick greifen: Wir schaffen eine neue Klasse, die die existierende Methode aus der Klasse und die Methode der Schnittstelle in die Typhierarchie bringt.
class FIFA {
public double price() { ... }
}
interface Buyable {
double price();
}
class FIFAisBuyable extends FIFA implements Buyable { }
Eine neue Unterklasse FIFAisBuyable erbt von der Klasse FIFA und implementiert die Schnittstelle Buyable, sodass der Compiler die existierende price()-Methode mit Vorgabe der Schnittstelle vereinigt. Nun lässt sich FIFAisBuyable als Buyable nutzen, und dahinter steckt die Implementierung von FIFA. Als Unterklasse bleiben auch alle sichtbaren Eigenschaften der Oberklasse erhalten. Die Lösung hilft uns allerdings nicht, wenn wir von anderer Stelle ein FIFA-Objekt bekommen.
7.7.11 Statische ausprogrammierte Methoden in Schnittstellen
In der Regel deklariert eine Schnittstelle Operationen, also abstrakte Objektmethoden, die eine Klasse später implementieren muss. Die in Klassen implementierte Schnittstellenmethode kann später wieder überschrieben werden, nimmt also ganz normal an der dynamischen Bindung teil. Einen Objektzustand kann die Schnittstelle nicht deklarieren, denn Objektvariablen sind in Schnittstellen tabu – jede deklarierte Variable ist automatisch statisch, also eine Klassenvariable.
In Schnittstellen sind statische Methoden erlaubt und lassen sich als Utility-Methoden neben Konstanten stellen. Es gibt also statische Klassenmethoden und statische Schnittstellenmethoden; beide werden nicht dynamisch gebunden.
[zB] Beispiel
In Abschnitt 7.7.2 hatten wir eine Schnittstelle Buyable deklariert. Die Idee ist, dass alles, was käuflich ist, diese Schnittstelle implementiert und einen Preis hat. Zusätzlich gibt es eine Konstante für einen Maximalpreis:
interface Buyable {
int MAX_PRICE = 10_000_000;
double price();
}
Hinzufügen lässt sich nun eine statische Methode isValidPrice(double), die prüft, ob sich ein Kaufpreis im gültigen Rahmen bewegt:
interface Buyable {
int MAX_PRICE = 10_000_000;
static boolean isValidPrice( double price ) {
return price >= 0 && price < MAX_PRICE;
}
double price();
}
Von außen ist dann der Aufruf Buyable.isValidPrice(123) möglich.
Alle deklarierten Eigenschaften sind standardmäßig public, können aber seit Java 9 auch privat sein. Konstanten sind implizit immer statisch. Statische Methoden müssen den Modifizierer static tragen, andernfalls gelten sie als abstrakte Methode.
[»] Hinweis
Statische Schnittstellenmethoden erlauben eine neue Möglichkeit zur Deklaration der main(…)-Methode:
interface HelloWorldInInterfaces {
static void main( String[] args ) {
System.out.println( "Hallo Welt einmal anders!" );
}
}
Das Schlüsselwort interface ist vier Zeichen länger als class, doch mit der Einsparung von public und einem Trenner ergibt sich eine Kürzung von drei Zeichen – wieder eine neue Möglichkeit zum Längefeilschen.
Der Zugriff auf eine statische Schnittstellenmethode ist ausschließlich über den Namen der Schnittstelle möglich, bzw. die Eigenschaften können statisch importiert werden. Bei statischen Methoden von Klassen ist im Prinzip auch der Zugriff über eine Referenz erlaubt (wenn auch unerwünscht), etwa wie bei new Integer(12).MAX_VALUE. Allerdings ist das bei statischen Methoden von Schnittstellen nicht zulässig. Implementiert etwa Car die Schnittstelle Buyable, würde new Car().isValidPrice(123) zu einem Compilerfehler führen. Selbst Car.isValidPrice(123) ist falsch, was doch ein wenig verwundert, da statische Methoden normalerweise vererbt werden.
Fassen wir die erlaubten Eigenschaften einer Schnittstelle zusammen:
Attribut | Methode | |
---|---|---|
Objekt- | nein, nicht erlaubt | ja, üblicherweise abstrakt |
Statische(s) | ja, als Konstante | ja, immer mit Implementierung |
Gleich werden wir sehen, dass Schnittstellenmethoden durchaus eine Implementierung besitzen können, also nicht zwingend abstrakt sein müssen.
[»] Design
Eine Schnittstelle mit nur statischen Methoden ist ein Zeichen für ein Designproblem und sollte durch eine finale Klasse mit privatem Konstruktor ersetzt werden. Schnittstellen sind immer als Vorgaben zum Implementieren gedacht. Wenn nur statische Methoden in einer Schnittstelle vorkommen, erfüllt die Schnittstelle nicht ihren Zweck, Vorgaben zu machen, die unterschiedlich umgesetzt werden können.
7.7.12 Erweitern und Ändern von Schnittstellen
Sind Schnittstellen einmal deklariert und in einer großen Anwendung verbreitet, so sind Änderungen nur schwer möglich, da sie schnell die Kompatibilität brechen. Wird der Name einer Parametervariablen geändert, ist das kein Problem. Bekommt aber eine Schnittstelle eine neue Operation, führt das zu einem Compilerfehler, wenn nicht bereits alle implementierenden Klassen diese neue Methode implementieren. Framework-Entwickler müssen also sehr darauf achten, wie sie Schnittstellen modifizieren. Doch sie haben es in der Hand, wie weit die Kompatibilität gebrochen wird.
[»] Geschichtsstunde
Schnittstellen später zu ändern, wenn schon viele Klassen die Schnittstelle implementieren, ist eine schlechte Idee. Denn erneuert sich die Schnittstelle, etwa wenn nur eine Operation hinzukommt oder sich ein Parametertyp ändert, dann sind plötzlich alle implementierenden Klassen kaputt. Sun selbst riskierte dies bei der Schnittstelle java.sql.Connection. Beim Übergang von Java 5 auf Java 6 wurde die Schnittstelle erweitert, und keine Treiberimplementierung konnte mehr compiliert werden.
Codekompatibilität und Binärkompatibilität *
Fügen wir in einer Schnittstelle eine Konstante (public static final-Variable) ein oder ändern wir den Namen eines Parameters, so ist das für die implementierenden Klassen in Ordnung, und es führt zu keinem Compilerfehler. Wir sprechen in diesem Fall von Änderungen, die codekompatibel sind.
Fügen wir eine neue Operation in eine Schnittstelle ein, führt das sofort zu einem Compilerfehler bei allen implementierenden Klassen. Würden wir jedoch nur die Schnittstelle neu in Bytecode übersetzen, wäre dies zur Laufzeit in Ordnung, denn bekommt eine Schnittstelle eine neue Methode, so ist das für die JVM überhaupt kein Problem. Die Laufzeitumgebung arbeitet auf den Klassendateien selbst, und sie interessiert es nicht, ob eine Klasse brav alle Methoden der Schnittstelle implementiert; sie löst nur Methodenverweise auf. Wenn eine Schnittstelle plötzlich »mehr« vorschreibt, hat die JVM damit kein Problem.
Während also fast alle Änderungen an Schnittstellen zu Compilerfehlern führen, sind einige Änderungen für die JVM in Ordnung. Wir nennen das Binär-Kompatibilität. Wenn zum Beispiel die Schnittstelle verändert, neu übersetzt und in den Modulpfad gesetzt wird, ist Folgendes in Ordnung:
neue Methoden in Schnittstelle hinzufügen
Die Schnittstelle erbt von einer zusätzlichen Schnittstelle.
eine throws-Ausnahme hinzufügen oder löschen
den letzten Parametertyp von T[] in T... ändern
neue Konstanten, also statische Variablen hinzufügen
Es gibt allerdings Änderungen, die nicht binärkompatibel sind und zu einem JVM-Fehler führen:
Ändern des Methodennamens
Ändern der Parametertypen und Umsortieren der Parameter
einen formalen Parameter hinzunehmen oder entfernen
Strategien zum Ändern von Schnittstellen
Falls die Schnittstelle nicht weit verbreitet wurde, so lassen sich einfacher Änderungen vornehmen. Ist der Name einer Operation zum Beispiel schlecht gewählt, wird ein Refactoring in der IDE den Namen in der Schnittstelle genauso ändern wie auch alle Bezeichner in den implementierenden Klassen. Problematischer ist es, wenn externe Nutzer sich auf die Schnittstelle verlassen. Dann müssen Klienten ebenfalls Anpassungen durchführen, oder Entwickler müssen auf »Schönheitsänderungen« wie das Ändern des Methodennamens einfach verzichten.
Kommen Operationen hinzu, hat sich eine Konvention etabliert, die im Java-Universum oft anzutreffen ist: Soll eine Schnittstelle um Operationen erweitert werden, so gibt es eine neue Schnittstelle, die die alte erweitert und deren Name auf »2« endet; java.awt.LayoutManager2 ist ein Beispiel aus dem Bereich der grafischen Oberflächen, Attributes2, EntityResolver2, Locator2 für XML-Verarbeitung sind weitere.[ 169 ](Ein Blick auf die API des Eclipse-Frameworks zeigt, dass bei mehr als 3.700 Typen dieses Muster mehr als sechzigmal angewendet wurde (http://help.eclipse.org/oxygen/topic/org.eclipse.platform.doc.isv/reference/api/index.html?overviewsummary). )
Default-Methoden sind eine weitere Möglichkeit zur späteren Erweiterung von Schnittstellen. Sie erweitern die Schnittstelle, bringen aber gleich schon eine vorgefertigte Implementierung mit, sodass Unterklassen nicht zwingend eine Implementierung anbieten müssen. Das schauen wir uns jetzt an.
7.7.13 Default-Methoden
Ist eine Schnittstelle einmal verbreitet, so sollte es dennoch möglich sein, Operationen hinzuzufügen. Entwicklern sollte es erlaubt sein, neue Operationen einzuführen, ohne dass Unterklassen verpflichtet werden, diese Methoden zu implementieren. Damit das möglich ist, muss die Schnittstelle eine Standardimplementierung mitbringen. Auf diese Weise ist das Problem der »Pflicht-Implementierung« gelöst, denn wenn eine Implementierung vorhanden ist, haben die implementierenden Klassen nichts zu meckern und können bei Bedarf das Standardverhalten überschreiben. Oracle nennt diese Methoden in Schnittstellen mit vordefinierter Implementierung Default-Methoden[ 170 ](Der Name hat sich während der Planung für dieses Feature mehrfach gewandelt. Ganz am Anfang war der Name »defender methods« im Umlauf, dann lange Zeit »virtuelle Erweiterungsmethoden« (engl. virtual extension methods). ). Schnittstellen mit Default-Methoden heißen erweiterte Schnittstellen.
Eine Default-Methode unterscheidet sich syntaktisch in zwei Aspekten von herkömmlichen implizit abstrakten Methodendeklarationen:
Die Deklaration einer Default-Methode beginnt mit dem Schlüsselwort default.[ 171 ](Am Anfang sollte default hinter dem Methodenkopf stehen, doch die Entwickler wollten default so wie einen Modifizierer wirken lassen; da Modifizierer aber am Anfang stehen, rutschte auch default nach vorne. Eigentlich ist ein Modifizierer auch gar nicht nötig, denn wenn es eine Implementierung, also einen Codeblock, in {} gibt, ist klar, dass es eine Default-Methode wird. Doch die Entwickler wollten eine explizite Dokumentation, so wie auch abstract eingesetzt wird – auch dieser Modifizierer bei Methoden wäre eigentlich gar nicht nötig, denn es gibt keinen Codeblock, wenn eine Methode abstrakt ist. )
Statt eines Semikolons markiert bei einer Default-Methode ein Block mit der Implementierung in geschweiften Klammern das Ende der Deklaration. Die Implementierung wollen wir Default-Code nennen.
Sonst verhalten sich erweiterte Schnittstellen wie normale Schnittstellen. Eine Klasse, die eine Schnittstelle implementiert, erbt alle Operationen, seien es die abstrakten Methoden oder die Default-Methoden. Falls die Klasse nicht abstrakt sein soll, muss sie alle von der Schnittstelle geerbten abstrakten Methoden realisieren. Sie kann die Default-Methoden überschreiben, muss das aber nicht, denn eine Vorimplementierung ist ja schon in der Default-Methode der Schnittstelle gegeben.
[»] Hinweis
Erweiterte Schnittstellen bringen »Code« in eine Schnittstelle, doch das ging vorher auch schon, indem zum Beispiel eine implizite öffentliche und statische Variable auf eine Realisierung verweist:
interface Comparators {
Comparator<String> TRIM_COMPARATOR = new Comparator<String>() {
@Override public int compare( String s1, String s2 ) {
return s1.trim().compareTo( s2.trim() );
} };
}
Die Realisierung nutzt hier eine innere anonyme Klasse, ein Konzept, das genauer in Kapitel 9, »Geschachtelte Typen«, beleuchtet wird.
7.7.14 Erweiterte Schnittstellen deklarieren und nutzen
Realisieren wir dies in einem Beispiel. Für Spielobjekte soll ein Lebenszyklus möglich sein; der besteht aus start() und finish(). Der Lebenszyklus ist als Schnittstelle vorgegeben, die Spielobjektklassen implementieren können. Version 1 der Schnittstelle sieht also so aus:
interface GameLifecycle {
void start();
void finish();
}
Klassen wie Player, Room, Door können die Schnittstelle erweitern, und wenn sie dies tun, müssen sie die beiden Methoden implementieren. Bei Spielobjekten, die diese Schnittstelle implementieren, kann unser Hauptprogramm, das Spiel, diese Methoden aufrufen und den Spielobjekten Rückmeldung geben, ob sie gerade in das Spiel gebracht wurden oder ob sie aus dem Spiel entfernt wurden.
Je länger Software lebt, desto mehr offenbaren sich Fehlentscheidungen beim Design. Die Umstellung einer ganzen Architektur ist eine Mammutaufgabe, einfache Änderungen wie das Umbenennen sind über ein Refactoring schnell erledigt. Nehmen wir an, dass es auch bei unserer Schnittstelle einen Änderungswunsch gibt – nur die Initialisierung und das Ende zu melden, reicht nicht. Geht das Spiel in einen Pausemodus, soll ein Spielobjekt die Möglichkeit bekommen, im Hintergrund laufende Programme anzuhalten. Das soll durch eine zusätzliche pause()-Methode in der Schnittstelle realisiert werden. Hier spielen uns Default-Methoden perfekt in die Hände, denn wir können die Schnittstelle erweitern, aber eine leere Standardimplementierung mitgeben. So müssen Unterklassen die pause()-Methode nicht implementieren, können dies aber, wie Version 2 der nun erweiterten Schnittstelle GameLifecycle zeigt:
interface GameLifecycle {
void start();
void finish();
default void pause() {}
}
Klassen, die GameLifecycle schon genutzt haben, bekommen von der Änderung nichts mit. Der Vorteil: Die Schnittstelle kann sich weiterentwickeln, aber alles bleibt binärkompatibel, und nichts muss neu compiliert werden. Vorhandener Code kann auf die neue Methode zurückgreifen, die automatisch mit der »leeren« Implementierung vorhanden ist. Außerdem verhalten sich Default-Methoden wie andere Methoden von Schnittstellen auch: Es bleibt bei der dynamischen Bindung, wenn implementierende Klassen die Methoden überschreiben. Wenn eine Unterklasse wie Flower zum Beispiel bei der Spielpause nicht mehr blühen möchte, so überschreibt sie die Methode und lässt etwa den Timer pausieren. Eine Tür dagegen hat nichts zu stoppen und kann mit dem Default-Code in pause() gut leben. Das Vorgehen ist ein wenig vergleichbar mit normalen nichtfinalen Methoden: Sie können, müssen aber nicht überschrieben werden.
[»] Hinweis
Statt des leeren Blocks könnte der Rumpf auch throw new UnsupportedOperationException ("Not yet implemented"); beinhalten, um anzukündigen, dass es keine Implementierung gibt. So führt eine hinzugenommene Default-Methode zwar zu keinem Compilerfehler, aber zur Laufzeit führen nicht überschriebene Methoden zu einer Ausnahme. Erreicht ist das Gegenteil vom Default-Code, weil eben keine Logik standardmäßig ausgeführt wird – das Auslösen einer Ausnahme zum Melden eines Fehlers wollen wir nicht als Logik ansehen.
Kontext der Default-Methoden
Default-Methoden verhalten sich wie Methoden in abstrakten Klassen und können alle Methoden der Schnittstelle (inklusive der geerbten Methoden) aufrufen.[ 172 ](Und damit lässt sich das bekannte Template-Design-Pattern realisieren. ) Die Methoden werden später dynamisch zur Laufzeit gebunden.
Nehmen wir eine Schnittstelle Buyable für käufliche Objekte:
interface Buyable {
double price();
}
Leider schreibt die Schnittstelle nicht vor, ob Dinge überhaupt käuflich sind. Eine Methode wie hasPrice() wäre in Buyable ganz gut aufgehoben. Was kann aber die Default-Implementierung sein? Wir können auf price() zurückgreifen und testen, ob die Rückgabe ein gültiger Preis ist. Das soll gegeben sein, wenn der Preis echt größer 0 ist:
interface Buyable {
double price();
default boolean hasPrice() { return price() > 0; }
}
Implementieren Klassen die Schnittstelle Buyable, müssen sie price() implementieren, da die Methode keine Default-Methode ist. Doch es ist ihnen freigestellt, hasPrice() zu überschreiben, mit eigener Logik zu füllen und nicht die Default-Implementierung zu verwenden. Wenn implementierende Klassen keine neue Implementierung wählen, bekommen sie den Default-Code und erben eine konkrete Methode hasPrice(). In dem Fall geht ein Aufruf von hasPrice() intern weiter an price() und dann genau an die Klasse, die Buyable und die Methode price() implementiert. Die Aufrufe sind dynamisch gebunden und landen bei der tatsächlichen Implementierung.
[»] Hinweis
Eine Schnittstelle kann die Methoden der absoluten Oberklasse java.lang.Object ebenfalls deklarieren, etwa um mit Javadoc eine Beschreibung hinzuzufügen. Allerdings ist es nicht möglich, mittels Default-Code Methoden wie toString() oder hashCode() vorzubelegen.
Neben der Möglichkeit, auf Methoden der eigenen Schnittstelle zurückzugreifen, steht auch die this-Referenz zur Verfügung. Das ist sehr wichtig, denn so kann der Default-Code an Utility-Methoden delegieren und einen Verweis auf sich selbst übergeben. Hätten wir zum Beispiel schon eine hasPrice(Buyable)-Methode in einer Utility-Klasse PriceUtils implementiert, so könnte der Default-Code aus einer einfachen Delegation bestehen:
class PriceUtils {
public static boolean hasPrice( Buyable b ) { return b.price() > 0; }
}
interface Buyable {
double price();
default boolean hasPrice() { return PriceUtils.hasPrice( this ); }
}
Dass die Methode PriceUtils.hasPrice(Buyable) für den Parameter den Typ Buyable vorsieht und sich der Default-Code mit this auf genauso ein Buyable-Objekt bezieht, ist natürlich kein Zufall, sondern bewusst gewählt. Der Typ der this-Referenz zur Laufzeit entspricht dem der Klasse, die die Schnittstelle implementiert hat und deren Objektexemplar gebildet wurde.
Haben die Default-Methoden weitere Parameter, so lassen sich auch diese an die statische Methode weiterreichen:
class PriceUtils {
public static boolean hasPrice( Buyable b ) { return b.price() > 0; }
public static double priceOr( Buyable b, double defaultPrice ) {
if ( b != null && b.price() > 0 )
return b.price();
return defaultPrice;
}
}
interface Buyable {
double price();
default boolean hasPrice() { return PriceUtils.hasPrice( this ); }
default double priceOr( double defaultPrice ) {
return PriceUtils.priceOr( this, defaultPrice );
}
}
Da Schnittstellen statische Utility-Methoden mit Implementierung enthalten können, kann der Default-Code auf diese statischen Methoden delegieren. Allerdings ist zu überlegen, ob in einer Schnittstelle wirklich viel Code untergebracht werden sollte oder ob dieser nicht besser in eine paketsichtbare Implementierung wandern sollte. Es ist vorzuziehen, die Implementierung auszulagern, damit die Schnittstellen nicht so codelastig werden. Nutzt das JDK Default-Code, so gibt es in der Regel immer eine statische Methode in einer Utility-Klasse.
7.7.15 Öffentliche und private Schnittstellenmethoden
Seit Java 9 müssen die statischen und die Default-Methoden nicht mehr public sein; sie können auch private sein. Das ist gut, denn das beugt Codeduplikaten vor; mit privaten Methoden können Programmteile innerhalb der Schnittstelle ausgelagert werden. Private Methoden bleiben in der Schnittstelle und werden nicht in die implementierenden Klassen vererbt.
7.7.16 Erweiterte Schnittstellen, Mehrfachvererbung und Mehrdeutigkeiten *
Die Default-Methoden mussten eingeführt werden, um Schnittstellen im Nachhinein ohne nennenswerte Compilerfehler mit neuen Operationen ausstatten zu können. Ideal ist, wenn neue Default-Methoden hinzukommen und Standardverhalten definieren und es dadurch zu keinem Compilerfehler für implementierende Klassen kommt oder zu Fehlern bei Schnittstellen, die erweiterte Schnittstellen erweitern.
Erweiterte Schnittstellen mit Default-Code nehmen ganz normal an der objektorientierten Modellierung teil, können vererbt und überschrieben werden und werden dynamisch gebunden. Nun gibt es einige Sonderfälle, die wir uns anschauen müssen. Es kann vorkommen, dass zum Beispiel
eine Klasse von einer Oberklasse eine Methode erbt, aber gleichzeitig von einer Schnittstelle Default-Code für die gleiche Methode erhält, oder dass
eine Klasse von zwei erweiterten Schnittstellen unterschiedliche Implementierungen angeboten bekommt.
Gehen wir verschiedene Fälle durch.
Überschreiben von Default-Code
Eine Schnittstelle kann andere Schnittstellen erweitern und neuen Default-Code bereitstellen. Mit anderen Worten: Default-Methoden können andere Default-Methoden aus Oberschnittstellen überschreiben und mit neuem Verhalten implementieren.
Führen wir eine Schnittstelle Priced mit einer Default-Methode ein:
interface Priced {
default boolean hasPrice() { return true; }
}
Eine andere Schnittstelle kann die Default-Methode überschreiben:
interface NotPriced extends Priced {
@Override default boolean hasPrice() { return false; }
}
public class TrueLove implements NotPriced {
public static void main( String[] args ){
System.out.println( new TrueLove().hasPrice() ); // false
}
}
Implementiert die Klasse TrueLove die Schnittstelle NotPriced, so ist alles in Ordnung und es entsteht kein Konflikt. Die Vererbungsbeziehung ist linear TrueLove → NotPriced → Priced.
Klassenimplementierung geht vor Default-Methoden
Implementiert eine Klasse eine Schnittstelle und erbt sie außerdem von einer Oberklasse, kann Folgendes passieren: Die Schnittstelle hat Default-Code für eine Methode, und die Oberklasse vererbt ebenfalls die gleiche Methode mit Code. Dann bekommt die Unterklasse von zwei Seiten eine Implementierung. Zunächst muss der Compiler entscheiden, ob so etwas überhaupt syntaktisch korrekt ist. Ja, das ist es!
interface Priced {
default boolean hasPrice() { return true; }
}
class Unsaleable {
public boolean hasPrice() { return false; }
}
public class TrueLove extends Unsaleable implements Priced {
public static void main( String[] args ) {
System.out.println( new TrueLove().hasPrice() ); // false
}
}
TrueLove erbt die Implementierung hasPrice() von der Oberklasse Unsaleable und auch von der erweiterten Schnittstelle Priced. Der Code compiliert und führt zu der Ausgabe false – die Klasse mit dem Code »gewinnt« also gegen den Default-Code. Merken lässt sich das ganz einfach an der Reihenfolge class … extends … implements … – hier steht extends am Anfang, also haben Methoden aus Implementierungen hier eine höhere Priorität als die Methoden aus erweiterten Schnittstellen.
Default-Methoden aus speziellen Oberschnittstellen ansprechen *
Eine Unterklasse kann eine konkrete Methode der Oberklasse überschreiben, aber dennoch auf die Implementierung der überschriebenen Methode zugreifen. Allerdings muss der Aufruf über super erfolgen, da sich sonst ein Methodenaufruf rekursiv verfängt.
Default-Methoden können andere Default-Methoden aus Oberschnittstellen ebenfalls überschreiben und mit neuem Verhalten implementieren. Doch genauso wie normale Methoden können sie mit super auf Default-Verhalten aus dem übergeordneten Typ zurückgreifen.
Nehmen wir für ein Beispiel unsere bekannte Schnittstelle Buyable und eine neue erweiterte Schnittstelle PeanutsBuyable an:
interface Buyable {
double price();
default boolean hasPrice() { return price() > 0; }
}
interface PeanutsBuyable extends Buyable {
@Override default boolean hasPrice() {
return Buyable.super.hasPrice() && price() < 50_000_000;
}
}
In der Schnittstelle Buyable sagt der Default-Code von hasPrice() aus, dass alles einen Preis hat, was größer als 0 ist. PeanutsBuyable dagegen nutzt eine erweiterte Definition und implementiert daher das Default-Verhalten neu. Nach den berühmten kopperschen Peanuts[ 173 ](https://de.wikipedia.org/wiki/Hilmar_Kopper#.E2.80.9EPeanuts.E2.80.9C) ist alles unter 50 Millionen problemlos käuflich und verursacht – zumindest für die Deutsche Bank – keine Schmerzen. In der Implementierung von hasPrice() greift PeanutsBuyable auf den Default-Code von Buyable zurück, um vom Obertyp eine Entscheidung über die Preiseigenschaft zu bekommen, die aber mit der Und-Verknüpfung noch spezialisiert wird.
Default-Code für eine Methode von mehreren Schnittstellen erben *
Wenn eine Klasse aus zwei erweiterten Schnittstellen den gleichen Default-Code angeboten bekommt, führt das zu einem Compilerfehler. Die Klasse RockAndRoll zeigt dieses Dilemma:
interface Sex {
default boolean hasPrice() { return false; }
}
interface Drugs {
default boolean hasPrice() { return true; }
}
public class RockAndRoll implements Sex, Drugs { } // Compilerfehler
Selbst wenn beide Implementierungen identisch wären, müsste der Compiler das ablehnen, denn der Code könnte sich ja jederzeit ändern.
Mehrfachvererbungsproblem mit super lösen
Die Klasse RockAndRoll lässt sich so nicht übersetzen, weil die Klasse aus zwei Quellen Code bekommt. Das Problem kann aber einfach gelöst werden, indem in RockAndRoll die hasPrice()-Methode überschrieben und dann an eine Methode delegiert wird:
interface Sex {
default boolean hasPrice() { return false; }
}
interface Drugs {
default boolean hasPrice() { return true; }
}
public class RockAndRoll implements Sex, Drugs {
@Override public boolean hasPrice() { return Sex.super.hasPrice(); }
}
Im Rumpf der Methode hasPrice() können wir nicht einfach hasPrice() schreiben, denn dann hätten wir einen rekursiven Aufruf. Auch können wir nicht Sex.hasPrice() schreiben, da diese Syntax für den Aufruf von statischen Methoden reserviert ist. Es kommt daher super mit der neuen Schreibweise ins Spiel: Sex.super.hasPrice().
Abstrakte überschriebene Schnittstellenoperationen nehmen Default-Methoden weg
Default-Methoden haben die interessante Eigenschaft, dass Untertypen den Status von »hat Implementierung« in »hat keine Default-Implementierung« ändern können:
interface Priced {
default boolean hasPrice() { return false; }
}
interface Buyable extends Priced {
@Override boolean hasPrice();
}
Die Schnittstelle Priced bietet eine Default-Methode. Buyable erweitert die Schnittstelle Priced, aber überschreibt die Methode – jedoch nicht mit Code! Dadurch wird sie in Buyable abstrakt. Eine abstrakte Methode kann also durchaus eine Default-Methode überschreiben. Klassen, die Buyable implementieren, müssen also nach wie vor eine hasPrice()-Methode implementieren, wenn sie nicht selbst abstrakt sein wollen. Es ist schon ein interessantes Java-Feature, dass die Implementierung einer Default-Methode in einem Untertyp wieder »weggenommen« werden kann. Bei der Sichtbarkeit ist das zum Beispiel nicht möglich: Ist eine Methode einmal öffentlich, kann eine Unterklasse die Sichtbarkeit nicht einschränken.
Das Verhalten des Compilers hat einen großen Vorteil: Bestimmte Veränderungen der Oberschnittstelle sind erlaubt und haben keine Auswirkungen auf die Untertypen. Nehmen wir an, hasPrice() hätte es in Priced vorher nicht gegeben, sondern nur abstrakt in Buyable. Default-Code ist ja nur eine nette Geste, und diese sollte schmerzlos in Priced integriert werden können. Anders gesagt: Entwickler können in den Basistyp so eine Default-Methode ohne Probleme aufnehmen, ohne dass es in den Untertypen zu Fehlern kommt. Obertypen lassen sich also ändern, ohne die Untertypen anzufassen. Im Nachhinein kann aber zur Dokumentation die Annotation @Override an die Unterschnittstelle gesetzt werden.
Nicht nur eine Unterschnittstelle kann die Default-Methoden »wegnehmen«, sondern auch eine abstrakte Klasse:
abstract class Food implements Priced {
@Override public abstract double price();
}
Die Schnittstelle Priced bringt eine Default-Methode mit, doch die abstrakte Klasse Food nimmt diese wieder weg, sodass erweiternde Food-Klassen auf jeden Fall price() implementieren müssen, wenn sie nicht selbst abstract sein wollen.
7.7.17 Bausteine bilden mit Default-Methoden *
Default-Methoden geben Bibliotheksdesignern ganz neue Möglichkeiten. Heute ist noch gar nicht richtig abzusehen, was Entwickler damit machen werden und welche Richtung die Java-API einschlagen wird. Auf jeden Fall wird sich die Frage stellen, ob eine Standardimplementierung als Default-Code in eine Schnittstelle wandert oder wie bisher eine Standardimplementierung als abstrakte Klasse bereitgestellt wird, von der wiederum andere Klassen ableiten. Als Beispiel sei auf die Datenstrukturen verwiesen: Eine Schnittstelle Collection schreibt Standardverhalten vor, AbstractCollection gibt eine Implementierung so weit wie möglich vor, und Unterklassen wie Listen setzen dann noch einmal auf diese Basisimplementierung auf. Erweiterte Schnittstellen können Hierarchien abbauen, denn auf eine abstrakte Basisimplementierung kann verzichtet werden. Auf der anderen Seite kann aber eine abstrakte Klasse einen Zustand über Objektvariablen einführen, was eine Schnittstelle nicht kann.
Default-Methoden können aber noch etwas ganz anderes: Sie können als Bauelemente für Klassen dienen. Eine Klasse kann mehrere Schnittstellen mit Default-Methoden implementieren und erbt im Grunde damit Basisfunktionalität von verschiedenen Stellen. In anderen Programmiersprachen ist das als Mixin oder Trait bekannt. Das ist ein Unterschied zur Mehrfachvererbung, die in Java nicht zulässig ist. Schauen wir uns diesen Unterschied jetzt einmal genauer an.
Default-Methoden zur Entwicklung von Traits nutzen
Was ist das Kernkonzept der objektorientierten Programmierung? Wohl ohne zu zögern können wir Klassen, Kapselung und Abstraktion nennen. Klassen und Klassenbeziehungen sind das Gerüst eines jeden Java-Programms. Bei der Vererbung wissen wir, dass Unterklassen Spezialisierungen sind und das liskovsche Substitutionsprinzip (siehe Abschnitt 7.3.2, »Das Substitutionsprinzip«) gilt: Falls ein Typ gefordert ist, können wir auch einen Untertyp übergeben. So sollte perfekte Vererbung aussehen: Eine Unterklasse spezialisiert das Verhalten, aber erbt nicht einfach von einer Klasse, weil diese nützliche Funktionalität hat. Aber warum eigentlich nicht? Als Erstes ist zu nennen, dass das Erben aufgrund der Nützlichkeit oft gegen die Ist-eine-Art-von-Beziehung verstößt und dass uns Java zweitens nur Einfachvererbung mit nur einer einzigen Oberklasse erlaubt. Wenn eine Klasse etwas Nützliches wie Logging anbietet und unsere Klasse davon erbt, kann sie nicht gleichzeitig von einer anderen Klasse erben, um zum Beispiel Zustände in Konfigurationsdaten festzuhalten. Eine unglückliche Vererbung verbaut also eine spätere Erweiterung. Das Problem bei der »Funktionalitätsvererbung« ist also, dass wir uns nur einmal festlegen können.
Wenn eine Klasse eine gewisse Funktionalität einfach braucht, woher soll diese denn dann kommen, wenn nicht aus der Oberklasse? Eigentlich gibt es hier nur eine naheliegende Variante: Die Klasse greift auf andere Objekte per Delegation zurück. Wenn ein Punkt mit Farbe nicht von java.awt.Point erben soll, kann ein Farbpunkt einfach in einer internen Variablen einen Point referenzieren. Das ist eine Lösung, aber dann nicht optimal, wenn eine Ist-eine-Art-von-Beziehung besteht. Und Schnittstellen wurden ja gerade eingeführt, damit eine Klasse mehrere Typen besitzt. Abstraktionen über Schnittstellen und Oberklassen sind wichtig, und Delegation hilft hier nicht. Gewünscht ist eine Technik, die einen Programmbaustein in eine Klasse setzen kann – im Grunde so etwas wie Mehrfachvererbung, aber doch anders, weil die Bausteine nicht als komplette Typen auftreten; der Baustein selbst ist nur ein Implantat und allein uninteressant. Auch ein Objekt kann von diesem Bausteintyp nicht erzeugt werden.
Am ehesten sind die Bausteine mit abstrakten Klassen vergleichbar, doch das wären Klassen, und Nutzer könnten nur einmal von diesem Baustein erben. Mit den erweiterten Schnittstellen gibt es ganz neue Möglichkeiten: Sie bilden die Bausteine, von denen Klassen Funktionalität bekommen können.[ 174 ](Siehe etwa http://scg.unibe.ch/archive/papers/Scha02aTraitsPlusGlue2002.pdf. ) Diese Bausteine sind nützlich, denn so lässt sich ein Algorithmus in eine Extra-Compilationseinheit setzen und leichter wiederverwenden. Ein Beispiel: Nehmen wir zwei erweiterte Schnittstellen an, PersistentPreference und Logged. Die erste erweiterte Schnittstelle soll mit store() Schlüssel-Wert-Paare in die zentrale Konfiguration schreiben, und get() soll sie auslesen:
import java.util.prefs.Preferences;
interface PersistentPreference {
default void store( String key, String value ) {
Preferences.userRoot().put( key, value );
}
default String get( String key ) {
return Preferences.userRoot().get( key, "" );
}
}
Die zweite erweiterte Schnittstelle ist Logged und bietet uns drei kompakte Logger-Methoden:
import java.util.logging.*;
interface Logged {
default void error( String message ) {
Logger.getLogger( getClass().getName() ).log( Level.SEVERE, message );
}
default void warn( String message ) {
Logger.getLogger( getClass().getName() ).log( Level.WARNING, message );
}
default void info( String message ) {
Logger.getLogger( getClass().getName() ).log( Level.INFO, message );
}
}
Eine Klasse kann diese Bausteine nun einbauen:
class Player implements PersistentPreference, Logged {
// ...
}
Die Methoden sind nun Teil vom Player und können auch von Unterklassen überschrieben werden. Als Aufgabe für den Leser bleibt, die Implementierung von store() im Player zu verändern, sodass der Schlüssel immer mit player. beginnt. Die Frage, die der Leser beantworten sollte, ist, ob store() von Player auf das store() von der erweiterten Schnittstelle zugreifen kann.
Default-Methoden weitergedacht
Für diese Bausteine, also die erweiterten Schnittstellen, gibt es viele Anwendungsfälle. Da die Java-Bibliothek schon 20 Jahre alt ist, würden heute einige Typen anders aussehen. Dass sich Objekte mit equals(…) vergleichen lassen können, könnte heute zum Beispiel in einer erweiterten Schnittstelle stehen, etwa so:[ 175 ](Die Schnittstelle compiliert mit der jetzigen Java SE nicht, da eine Default-Methode keine Methode aus Object überschreiben kann. )
interface Equals {
default boolean equals( Object that ) {
return this == that;
}
}
So müsste java.lang.Object die Methode nicht für alle vorschreiben, wobei das sicherlich kein Nachteil ist. Natürlich gilt das Gleiche für die hashCode()-Methode, die heutzutage aus einer erweiterten Schnittstelle Hashable stammen könnte.
java.lang.Number ist ein weiteres Beispiel. Die abstrakte Basisklasse für Werte repräsentierende Objekte deklariert die abstrakten Methoden doubleValue(), floatValue(), intValue(), longValue() und die konkreten Methoden byteValue() und shortValue(). Bisher erben AtomicInteger, AtomicLong, BigDecimal, BigInteger, Byte, Double, Float, Integer, Long und Short von dieser Oberklasse. Auch diese Funktionalität ließe sich mit einer erweiterten Schnittstelle umsetzen.
Zustand in den Bausteinen?
Nicht jeder wünschenswerte Baustein ist mit erweiterten Schnittstellen möglich. Ein Grund ist, dass die Schnittstellen keinen Zustand einbringen können. Nehmen wir zum Beispiel einen Container als Datenstruktur, der Elemente aufnimmt und verwaltet. Einen Baustein für einen Container können wir nicht so einfach implementieren, da ein Container Kinder verwaltet, und hierfür ist eine Objektvariable für den Zustand nötig. Schnittstellen haben nur statische Variablen, und die sind für alle sichtbar. Und selbst wenn die Schnittstelle eine modifizierbare Datenstruktur referenzieren würde, wäre jeder Nutzer des Container-Bausteins von den Veränderungen betroffen. Da es keinen Zustand gibt, existieren auch für Schnittstellen keine Konstruktoren und folglich auch nicht für solche Bausteine. Denn wo es keinen Zustand gibt, gibt es auch nichts zu initialisieren. Wenn eine Default-Methode einen Zustand benötigt, muss sie selbst diesen Zustand erfragen. Hier lässt sich eine Technik einsetzen, die Oracles Java Language Architect Brian Goetz »virtual field pattern«[ 176 ](http://mail.openjdk.java.net/pipermail/lambda-dev/2012-July/005171.html) nennt. Wie sie funktioniert, zeigt das folgende Beispiel.
Referenziert ein Behälter eine Menge von Objekten, die sortierbar sind, können wir einen Baustein Sortable mit einer Methode sort() realisieren. Die Schnittstelle Comparable soll die Klasse nicht direkt implementieren, da ja nur die referenzierten Elemente sortierbar sind, nicht aber Objekte der Klasse selbst; zudem soll eine neue Methode sort() in Sortable hinzukommen. Damit das Sortieren gelingt, muss die Implementierung irgendwie an die Daten gelangen – und hier kommt ein Trick ins Spiel: Zwar ist sort() eine Default-Methode, doch die erweiterte Schnittstelle Sortable besitzt eine abstrakte Methode getValues(), die die Klasse implementieren muss und dem Sortierer die Daten gibt. Im Quellcode sieht das so aus:
import java.util.*;
interface Sortable<T extends Comparable<?>> {
T[] getValues();
void setValues( T[] values );
default void sort() {
T[] values = getValues();
Arrays.sort( values );
setValues( values );
};
}
Fassen wir zusammen: Damit sort() an die Daten kommt, erwartet Sortable von den implementierenden Klassen eine Methode getValues(), und damit die Daten nach dem Sortieren wieder zurückgeschrieben werden können, eine zweite Methode setValues(…). Der Clou ist, dass die spätere Implementierung von Sortable mit den beiden Methoden dem Sortierer Zugriff auf die Daten gewährt – allerdings auch jedem anderem Stück Code, da die Methoden öffentlich sind. Da bleibt ein unschönes »Geschmäckle« zurück.
Ein Nutzer von Sortable soll RandomValues sein; die Klasse erzeugt intern Zufallszahlen.
class RandomValues implements Sortable<Integer> {
private List<Integer> values = new ArrayList<>();
public RandomValues() {
Random r = new Random();
for ( int i = r.nextInt( 20 ) + 1; i > 0; i-- )
values.add( r.nextInt(10000) );
}
@Override public Integer[] getValues() {
return values.toArray( new Integer[values.size()] );
}
@Override public void setValues( Integer[] values ) {
this.values.clear();
Collections.addAll( this.values, values );
}
}
Damit sind die Typen vorbereitet, und eine Demo schließt das Beispiel ab:
public class SortableDemo {
public static void main( String[] args ) {
RandomValues r = new RandomValues();
System.out.println( Arrays.toString( r.getValues() ) );
r.sort();
System.out.println( Arrays.toString( r.getValues() ) );
}
}
Wird das Demoprogramm aufgerufen, kommt auf die Konsole zum Beispiel:
[2732, 4568, 4708, 4302, 4315, 5946, 2004]
[2004, 2732, 4302, 4315, 4568, 4708, 5946]
So interessant diese Möglichkeit auch ist, ein Problem wurde schon angesprochen: Jede Methode in einer Schnittstelle ist public oder private. Es wäre schön, wenn die Datenzugriffsmethode protected und somit nur sichtbar für die implementierende Klasse wäre, aber das geht nicht.
[ ! ] Warnung!
Natürlich lässt sich mit Rumgetrickse ein Speicherort finden, der Exemplarzustände speichert. Es lässt sich zum Beispiel in der Schnittstelle ein Assoziativspeicher referenzieren, der eine this-Instanz mit einem Objekt assoziiert. Ein Container-Baustein, der mit add() Objekte in eine Liste setzt und sie mit iterable() herausgibt, könnte so aussehen:
interface ListContainer<T> {
Map<Object,List<Object>> $ = new HashMap<>();
default void add( T e ) {
if ( ! $.containsKey( this ) )
$.put( this, new ArrayList<Object>() );
$.get( this ).add( e );
}
default public Iterable<T> iterable() {
if ( ! $.containsKey( this ) )
return Collections.emptyList();
return (Iterable<T>) $.get( this );
}
}
Nicht nur die öffentliche Konstante $ ist ein Problem, sondern auch, dass die Variable ein übles doppeltes Speicherloch ist. Ein Exemplar der Klasse, die diese erweiterte Schnittstelle nutzt, kann nicht so einfach entfernt werden, denn in der Sammlung ist noch eine Referenz auf das Objekt vorhanden, und diese Referenz verhindert eine automatische Speicherbereinigung. Selbst wenn dieses Objekt weg wäre, hätten wir noch all die referenzierten Kinder der Sammlung in der Map. Das Problem ist nicht wirklich zu lösen, und hier müsste mit schwachen Referenzen tief in die Java-Voodoo-Kiste gegriffen werden. Alles in allem ist es keine gute Idee, und Java-Chefentwickler Brian Goetz macht auch klar:
»Please don’t encourage techniques like this. There are a zillion ›clever‹ things you can do in Java, but shouldn’t. We knew it wouldn’t be long before someone suggested this, and we can’t stop you. But please, use your power for good, and not for evil. Teach people to do it right, not to abuse it.«[ 177 ](http://mail.openjdk.java.net/pipermail/lambda-dev/2012-July/005166.html)
Daher: Diese Implementierung ist eine schöne »Spielerei«, aber der Zustand sollte eine Aufgabe der abstrakten Basisklassen oder des Delegates sein.
Zusammenfassung
Was wir in den letzten Beispielen zu den Bausteinen gemacht haben, war, ein Standardverhalten in Klassen einzubauen, ohne dass dabei der Zugriff auf die nur einmal existierende Basisklasse nötig war und ohne dass die Klasse an Hilfsklassen delegierte. In dieser Arbeitsweise können Unterklassen in jedem Fall die Methoden überschreiben und spezialisieren. Wir haben es also mit üblichen Klassen zu tun und mit erweiterten Schnittstellen, die nicht selbst eigenständige Entitäten bilden. In der Praxis wird es immer Fälle geben, in denen für eine Umsetzung eines Problems entweder eine abstrakte Klasse oder eine erweiterte Schnittstelle infrage kommt. Wir sollten uns dann noch einmal an die Unterschiede erinnern: Eine abstrakte Klasse kann Objektvariablen haben und Methoden aller Sichtbarkeiten und sie auch final setzen, sodass sie nicht mehr überschrieben werden können. Eine Schnittstelle dagegen ist ohne Zustand und mit puren virtuellen und öffentlichen Methoden darauf ausgelegt, dass die Implementierung überschrieben werden kann.
7.7.18 Initialisierung von Schnittstellenkonstanten *
Eine Schnittstelle kann Attribute deklarieren, aber das sind dann immer initialisierte public static final-Konstanten. Nehmen wir eine eigene Schnittstelle PropertyReader an, die in einer Konstanten ein Properties-Objekt für Eigenschaften referenziert und eine Methode getProperties() für implementierende Klassen vorschreibt:
import java.util.Properties;
public interface PropertyReader {
Properties DEFAULT_PROPERTIES = new Properties();
Properties getProperties();
}
Würden wir DEFAULT_PROPERTIES nicht mit new Properties() initialisieren, gäbe es einen Compilerfehler, da ja jede Konstante final ist, also einmal belegt werden muss.
[»] Hinweis
Referenziert eine Schnittstelle eine veränderbare Datenstruktur (wie Properties), dann muss uns die Tatsache bewusst sein, dass die Datenstruktur als statische Variable global ist. Das heißt, alle implementierenden Klassen teilen sich diese Datenstruktur.
Nun stellt sich ein Problem, wenn die statischen Attribute nicht einfach mit einem Standardobjekt initialisiert werden sollen, sondern wenn zusätzlicher Programmcode zur Initialisierung gewünscht ist. Für unser Beispiel soll das Properties-Objekt unter dem Schlüssel date die Zeit speichern, zu der die Klasse initialisiert wurde. Über statische Initialisierer ist dies jedenfalls nicht möglich, obwohl es statische Variablen und statische Methoden gibt:
import java.util.*;
public interface PropertyReader {
Properties DEFAULT_PROPERTIES = new Properties();
static { // Compilerfehler: "Interfaces can't have static initializers"
DEFAULT_PROPERTIES.setProperty( "date", LocalDate.now().toString() );
}
Properties getProperties();
}
Zwar sind statische Initialisierungsblöcke nicht möglich, aber mit drei Tricks kann die Initialisierung erreicht werden. Wir müssen dazu etwas auf anonyme Klassen vorgreifen, ein Thema, das Kapitel 9, »Geschachtelte Typen«, genauer aufgreift.
Konstanteninitialisierung über innere anonyme Klassen, Lösung A
Eine innere anonyme Klasse formt eine Unterklasse, sodass im Exemplarinitialisierer das Objekt (bei uns die Datenstruktur) initialisiert werden kann:
import java.util.*;
public interface PropertyReader {
Properties DEFAULT_PROPERTIES = new Properties() { {
setProperty( "date", LocalDate.now().toString() );
} };
Properties getProperties();
}
Ein Beispielprogramm zeigt die Nutzung:
import java.util.Properties;
public class SystemPropertyReaderDemo implements PropertyReader {
@Override public Properties getProperties() {
return System.getProperties();
}
public static void main( String[] args ) {
System.out.println( PropertyReader.DEFAULT_PROPERTIES ); // {date=Thu ...
}
}
Die vorgeschlagene Lösung funktioniert nur, wenn Unterklassen möglich sind; finale Klassen fallen damit raus.
Konstanteninitialisierung über statische geschachtelte Klassen, Lösung B
Mit einem anderen Trick lassen sich auch diese Hürden nehmen. Die Idee besteht in der Einführung zweier Hilfskonstrukte:
einer statischen geschachtelten Klasse, die wir $$ nennen wollen. Sie enthält einen statischen Initialisierungsblock, der auf DEFAULT_PROPERTIES zugreift und das Properties-Objekt initialisiert.
einer Konstanten $ vom Typ $$. Als public static final-Variable initialisieren wir sie mit new $$(), was dazu führt, dass die JVM beim Laden der Klasse $$ den static-Block abarbeitet und so das Properties-Objekt belegt.
Da leider statische geschachtelte Klassen und Konstanten von Schnittstellen nicht privat sein können (nur Methoden können privat sein) und so unglücklicherweise von außen zugänglich sind, geben wir ihnen die kryptischen Namen $ und $$, sodass sie nicht so attraktiv erscheinen:
import java.util.*;
public interface PropertyReader {
Properties DEFAULT_PROPERTIES = new Properties();
$$ $ = new $$();
static final class $$ {
static {
DEFAULT_PROPERTIES.setProperty( "date", LocalDate.now().toString() );
}
}
Properties getProperties();
}
Innerhalb des static-Blocks lässt sich auf das Properties-Objekt zugreifen, und somit lassen sich auch die Werte eintragen. Ohne die Erzeugung des Objekts $ geht es nicht, denn andernfalls würde die Klasse $$ nicht initialisiert werden. Doch es gibt eine weitere Variante, die sogar ohne die Zwischenvariable $ auskommt.
Konstanteninitialisierung über statische geschachtelte Klasse, Lösung C
Bei der dritten Lösung gehen wir etwas anders vor. Wir bauen kein Exemplar mit DEFAULT_PROPERTIES = new Properties() auf, sondern initialisieren DEFAULT_PROPERTIES mit einer Erzeugermethode einer eigenen internen Klasse, sodass die Initialisierung zu DEFAULT_PROPERTIES = $$.$() wird:
import java.util.*;
public interface PropertyReader2 {
Properties DEFAULT_PROPERTIES = $$.$();
static class $$ {
static Properties $() {
Properties p = new Properties();
p.setProperty( "date", LocalDate.now().toString() );
return p;
}
}
Properties getProperties();
}
Mit dieser Lösung kann prinzipiell auch das Aufbauen eines neuen Properties-Exemplars in $() entfallen und können etwa schon vorher aufgebaute Objekte zurückgegeben werden.
[»] Hinweis
Aufzählungen über enum können einfacher initialisiert werden.
7.7.19 Markierungsschnittstellen *
Auch Schnittstellen ohne Methoden sind möglich. Diese leeren Schnittstellen werden Markierungsschnittstellen (engl. marker interfaces) genannt. Sie sind nützlich, da mit instanceof leicht überprüft werden kann, ob ein Objekt einen gewollten Typ einnimmt.
Die Java-Bibliothek bringt einige Markierungsschnittstellen schon mit, etwa:
java.util.RandomAccess: Eine Datenstruktur bietet schnellen Zugriff über einen Index.
java.rmi.Remote: Identifiziert Schnittstellen, deren Operationen von außen aufgerufen werden können.
java.lang.Cloneable: Sorgt dafür, dass die clone()-Methode von Object aufgerufen werden kann.
java.util.EventListener: Diesen Typ implementieren viele Horcher in der Java-Bibliothek.
java.io.Serializable: Zustände eines Objekts lassen sich in einen Datenstrom schreiben – mehr dazu folgt in Kapitel 19, »Einführung in Dateien und Datenströme«.
[»] Hinweis
Seit es das Sprachmittel der Annotationen gibt, sind Markierungsschnittstellen bei neuen Bibliotheken nicht mehr anzutreffen.
7.7.20 (Abstrakte) Klassen und Schnittstellen im Vergleich
Eine abstrakte Klasse und eine Schnittstelle mit abstrakten Methoden sind sich sehr ähnlich: Beide schreiben den Unterklassen bzw. den implementierten Klassen Operationen vor, die implementiert werden müssen. Ein wichtiger Unterschied ist jedoch, dass beliebig viele Schnittstellen implementiert werden können, aber nur eine Klasse – sei sie abstrakt oder nicht – erweitert werden kann. Des Weiteren bieten sich abstrakte Klassen meist im Refactoring oder in der Designphase an, wenn Gemeinsamkeiten in eine Oberklasse ausgelagert werden sollen. Abstrakte Klassen können zudem Objektzustände enthalten, was Schnittstellen nicht können.
Beim Design gilt weiterhin der Grundgedanke für Schnittstellen: Wenn es um Vorschriften für Verhalten geht, ist eine Schnittstelle goldrichtig. Bei Basisimplementierungen kommen dann abstrakte Klassen ins Spiel, die in der Java-Bibliothek oft auf Abstract enden.
Wie wo was dynamisch binden
Es gibt bei Methoden von konkreten Klassen, abstrakten Klassen und Schnittstellen Unterschiede, wo der Aufruf letztendlich landet. Nehmen wir folgende Methode an:
void fun( T t ) {
t.m();
}
Fordert die Methode ein Argument vom Typ T und ruft auf der Parametervariablen t die Methode m() auf, so können wir Folgendes festhalten:
Ist T eine finale Klasse, so wird immer die Methode m() von T aufgerufen, da es keine Unterklassen geben kann, die m() überschreiben.
Ist T eine nichtfinale Klasse und m() eine finale Methode, wird genau m() aufgerufen, weil keine Unterklasse m() überschreiben kann.
Ist T eine nichtfinale Klasse und m() keine finale Methode, so könnten Unterklassen von T m() überschreiben, und t.m() würde dann dynamisch die überschriebene Methode aufrufen.
Ist T eine abstrakte Klasse und m() eine abstrakte Methode, so wird in jedem Fall eine Realisierung von m() in einer Unterklasse aufgerufen.
Ist T eine Schnittstelle und m() keine Default-Implementierung, so wird in jedem Fall eine Implementierung m() einer implementierenden Klasse aufgerufen.
Ist T eine Schnittstelle und m() eine Default-Implementierung, so kann t.m() bei der Default-Implementierung landen oder bei einer überschriebenen Version einer implementierenden Klasse.