Ist eine Schnittstelle einmal verbreitet, so sollte es dennoch möglich sein, Operationen hinzuzufügen. Java 8 bringt dafür eine Sprachänderung mit, die es Entwicklern erlaubt, neue Operationen einzuführen, ohne dass Unterklassen verpflichtet werden, diese Methoden zu implementieren. Damit das möglich ist, muss die Schnittstelle eine Standard-Implementierung mitbringen. Auf diese Weise ist das Problem gelöst, denn wenn eine Implementierung vorhanden ist, haben die implementierenden Klassen nichts zu meckern, und wenn sie das Standardverhalten überschreiben möchten, können sie das gerne machen. Oracle nennt diese Methoden in Schnittstelle mit vordefinierter Implementierung Default-Methoden[1]. Schnittstellen mit Default-Methoden heißen erweiterte Schnittstellen.
Eine Default-Methode unterscheidet sich syntaktisch in zwei Aspekten von herkömmlichen implizit abstrakten Methoden-Deklarationen.
· Die Deklaration einer Default-Methode beginnt mit dem Schlüsselwort default.
· Statt dass ein Semikolon das Ende der Deklaration anzeigt, steht bei einer Default-Methode stattdessen in geschweiften Klammen ein Block mit Implementierung. Die Implementierung wollen wir Default-Code nennen.
Sonst verhalten sich erweiterte Schnittstellen wie normale Schnittstellen. Eine Klasse, die eine Schnittstelle implementiert, erbt alle Operationen, sei 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 gegeben.
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 Spielobjektklasse implementieren können. Version 1 der Schnittstelle sieht also aus:
interface GameLifecycle {
void start();
void finish();
}
Klassen wie Player, Room, Door können die Schnittstellen 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 sie aus dem Spiel entfernt wurden.
Je länger Software lebt, desto mehr bedauern Entwickler Designentscheidungen. Die Umstellung einer ganzen Architektur ist eine Mammutaufgabe, einfache Änderungen wie das Umbenennen sind über ein Refactoring schnell erledigt. Nehmen wir an, auch bei unserer Schnittstelle gibt es einen Änderungswunsch – nur die Initialisierung und das Ende zu melden reicht nicht. Geht das Spiel in einen Pausenmodus, 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 die Default-Methoden perfekt in die Hand, 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; Version 2 der nun erweiterten Schnittstelle GameLifecycle:
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 weiter entwickeln, aber alles bleibt binärkompatibel und nichts muss neu compiliert werden. Vorhandener Code kann auf die neue Methode zurückgreifen, die automatisch mit der Implementierung vorhanden ist. Weiterhin 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 den Timer pausieren. Eine Tür dagegen hat nichts zu stoppen und kann pause() mit dem Default-Code so übernehmen.
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. 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 isBuyable() 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 isBuyable() { return price() > 0; }
}
Implementierende Klassen erben die Methode isBuyable() und beim Aufruf geht der interne Aufruf von price() an genau die Klasse, die Buyable und die Methode implementiert.
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, mit Default-Code Methoden wie toString() oder hashCode() vorzubelegen.
Neben der Möglichkeit auf Methoden zuzugreifen, steht auch die this-Referenz zur Verfügung. Das ist sehr wichtig, denn so kann der Default-Code an Utility-Methoden weiterreichen und einen Verweis auf sich selbst übergeben. Hätten wir zum Beispiel schon eine isBuyable(Buyable)-Methode in einer Utiltiy-Klasse PriceUtils implementiert, so könnte der Default-Code aus einer einfachen Weiterleitung bestehen:
class PriceUtils {
public static boolean isBuyable( Buyable b ) { return b.price() > 0; }
}
interface Buyable {
double price();
default boolean isBuyable() { return PriceUtils.isBuyable( this ); }
}
Dass die Methode PriceUtils.isBuyable(Buyable) für den Parameter den Typ Buyable vorsieht und sich der Default-Code mit this auf genau so 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 dessen Objektexemplar gebildet wurde.
Haben die Default-Methoden weitere Parameter, so lassen sie auch diese weiter an die statische Methode reichen:
class PriceUtils {
public static boolean isBuyable( Buyable b ) { return b.price() > 0; }
public static double defaultPrice( Buyable b, double defaultPrice ) {
if ( b != null && b.price() > 0 )
return b.price();
return defaultPrice;
}
}
interface Buyable {
double price();
default boolean isBuyable() { return PriceUtils.isBuyable( this ); }
default double defaultPrice( double defaultPrice ) {
return PriceUtils.defaultPrice( this, defaultPrice ); }
}
Es ist vorzuziehen, die Implementierung auszulagern, um die Schnittstellen nicht so Code-lastig werden zu lassen. Nutzt das JDK Default-Code, so gibt es in der Regel immer eine statische Methode in einer Utility-Klasse.
Neue Möglichkeiten 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 Standard-Implementierung als Default-Code in Schnittstellen wandert, oder wie bisher, Standard-Implementierungen als abstrakte Klasse bereitgestellt wird, von dem wiederum andere Klassen ableiten. Als Beispiel sei auf die Datenstrukturen verwiesen: Eine Schnittstelle Collection schreibt Standardverhalten vor, AbstractCollection gibt eine Implementierung soweit 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 Zustand über Objektvariablen einführen, was eine Schnittstelle nie könnte.
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 bzw. Trait bekannt.
[1] 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).