7.7 Abstrakte Klassen und abstrakte Methoden
Nicht immer soll eine Klasse sofort ausprogrammiert werden, zum Beispiel dann nicht, wenn die Oberklasse lediglich Methoden für die Unterklassen vorgeben möchte, aber nicht weiß, wie sie diese implementieren soll. In Java gibt es dazu zwei Konzepte: abstrakte Klassen und Schnittstellen (engl. interfaces). Während final im Prinzip die Klasse abschließt und Unterklassen unmöglich macht, sind abstrakte Klassen das Gegenteil: Ohne Unterklassen sind abstrakte Klassen nutzlos.
Es ergeben sich daher drei Szenarien:
Klassentyp |
Bedeutung |
---|---|
normale nichtabstrakte und nichtfinale Klasse |
Eine Unterklasse kann gebildet werden, muss aber nicht. |
finale Klasse |
Eine Unterklasse kann nicht gebildet werden. |
abstrakte Klasse |
Eine Unterklasse muss gebildet werden. |
7.7.1 Abstrakte Klassen
Bisher konnten wir von jeder Klasse mit new ein Objekt bilden. Das Bilden von Exemplaren ist allerdings nicht immer sinnvoll, zum Beispiel soll es untersagt werden, wenn eine Klasse nur als Oberklasse in einer Vererbungshierarchie existieren soll. Sie kann dann als Modellierungsklasse eine Ist-eine-Art-von-Beziehung ausdrücken und Signaturen für die Unterklassen vorgeben. Eine Oberklasse besitzt dabei Vorgaben für die Unterklasse. Das heißt, alle Unterklassen erben die Methoden. Ein Exemplar der Oberklasse selbst muss nicht existieren.
Um dies in Java auszudrücken, setzen wir den Modifizierer abstract an die Typdeklaration der Oberklasse. Von dieser Klasse können dann keine Exemplare gebildet werden, und der Versuch einer Objekterzeugung führt zu einem Compilerfehler. Ansonsten verhalten sich die abstrakten Klassen wie normale Klassen, enthalten die gleichen Eigenschaften und können auch selbst von anderen Klassen erben. Abstrakte Klassen sind das Gegenteil von konkreten Klassen.
Wir wollen die Klasse Event als Oberklasse für alle Ereignisse abstrakt machen, da Exemplare davon nicht existieren müssen:
abstract class Event {
String about;
int duration;
}
Mit dieser abstrakten Klasse Event drücken wir aus, dass es eine allgemeine Klasse ist, von der keine konkreten Objekte existieren und gebildet werden können. Der Versuch ergibt einen Compilerfehler:
Event flight = new Event(); // 'Event' is abstract; cannot be instantiated
Der Sinn dahinter ist einfach: Es gibt in der realen Welt keine allgemeinen und unspezifizierten Ereignisse, sondern nur spezielle Unterarten, zum Beispiel ein Nickerchen, ein Fußballspiel oder das Naschen von Süßigkeiten. Es ergibt also keinen Sinn, ein Exemplar der Klasse Event zu bilden. Die Klasse soll nur in der Hierarchie auftauchen und Ereignisse sozusagen kategorisieren und ihnen Eigenschaften geben.
[+] Tipp
Abstrakte Klassen lassen sich auch nutzen, um zu verhindern, dass ein Exemplar der Klasse gebildet wird. Der Modifizierer abstract sollte aber dazu nicht eingesetzt werden. Besser ist es, die Sichtbarkeit des Konstruktors auf private oder protected zu setzen.
Basistyp abstrakte Klasse
Abstrakte Klassen werden immer in Verbindung mit Vererbung eingesetzt. (Abstrakte) Oberklassen sind allgemein gehalten, und Unterklassen müssen den Basistyp weiter spezialisieren. Eine Klasse wird die abstrakte Klasse erweitern, und von dieser Klasse kann – wenn die Unterklasse nicht selbst abstrakt ist – eine Instanz gebildet werden.
Auch gilt die Ist-eine-Art-von-Beziehung weiterhin, sodass sich bei den Event-Unterklassen Nap und Workout schreiben lässt:
Event sleep = new Nap();
Event running = new Workout();
Event[] events = { new Nap(), new Nap(), new Workout(), new Nap() };
Die Deklaration Event[] events kennzeichnet nur den Typ des Arrays. Das ist unabhängig davon, ob die Klasse Event abstrakt ist oder nicht; das Array enthält Referenzen auf Unterklassen von Event.
[»] Hinweis
Abstrakte Klassen können natürlich auch wiederum abstrakte Unterklassen haben:
abstract class Event { }
abstract class Hackathon extends Event { }
Auch von Hackathon lässt sich keine Instanz mit new bilden.
7.7.2 Abstrakte Methoden
Der Modifizierer abstract vor dem Schlüsselwort class leitet die Deklaration einer abstrakten Klasse ein. Doch auch eine Methode kann abstrakt sein. Sie gibt lediglich die Signatur vor, und eine Unterklasse implementiert irgendwann diese Methode. Die abstrakte Klasse ist somit für den Kopf der Methode zuständig, während die Implementierung an anderer Stelle erfolgt. Das ist eine klare Trennung von »Was kann ich?« und »Wie mache ich es?«. Während die Oberklasse mit der Deklaration der abstrakten Methode ausdrückt, dass sie etwas kann, realisieren die Unterklassen, wie der Code dazu aussieht. Überspitzt gesagt: Abstrakte Methoden drücken aus, dass sie keine Ahnung von der Implementierung haben und dass sich die Unterklassen darum kümmern müssen. Das Ganze muss natürlich im Rahmen der Spezifikation geschehen.
Da eine abstrakte Klasse abstrakte Methoden enthalten kann, aber nicht enthalten muss, unterscheiden wir:
-
Rein (pure) abstrakte Klassen: Die abstrakte Klasse enthält ausschließlich abstrakte Methoden.
-
Partiell abstrakte Klassen: Die Klasse ist abstrakt, enthält aber auch konkrete Implementierungen, also nichtabstrakte Methoden. Das bietet den Unterklassen ein Gerüst, das sie nutzen können.
Bei der Reise kann ein Ereignis auftreten
In unserem kleinen Spiel soll es möglich sein, dass der Spieler die Stadt wechselt. Bei der Reise können unglücklicherweise verschiedene Ereignisse vorkommen.
-
Der Spieler könnte überfallen und ihm könnten seine Süßigkeiten geklaut werden.
-
Der Spieler könnte hungrig werden, und dann isst er einige Süßigkeiten auf.
-
Oder vielleicht werden durch einen glücklichen Zufall dem Spieler neue Süßigkeiten geschenkt.
Die Ereignisse sind zufällig, und vielleicht passiert bei der Reise auch überhaupt nichts.
Für ein Ereignis hatten wir schon eine Klasse deklariert. Jetzt soll in der Oberklasse Event eine abstrakte Methode für Programmcode deklariert werden, die bei dem Eintreten des Ereignisses ausgeführt werden kann. Damit die einzelnen Ereignisse Zugriff auf den Spieler haben, wird der Methode der aktuelle Spieler übergeben. Damit können die Ereignismethoden auf den Spieler zurückgreifen. Wir nennen so etwas auch Kontextobjekt.
Die abstrakte Methode sieht so aus:
Da abstrakte Methoden immer ohne Implementierung sind, steht statt des Methodenrumpfs in geschweiften Klammern ein Semikolon. Ist mindestens eine Methode abstrakt, so ist es automatisch die ganze Klasse. Deshalb müssen wir das Schlüsselwort abstract ausdrücklich vor den Klassennamen schreiben. Vergessen wir das Schlüsselwort abstract bei einer solchen Klasse, erhalten wir einen Compilerfehler. Eine Klasse mit einer abstrakten Methode muss abstrakt sein, da sonst irgendjemand ein Exemplar konstruieren und genau diese Methode aufrufen könnte. Versuchen wir, ein Exemplar einer abstrakten Klasse zu erzeugen, so bekommen wir ebenfalls einen Compilerfehler. Natürlich kann eine abstrakte Klasse nichtabstrakte Eigenschaften haben, so wie es Event mit den Objektvariablen about und duration zeigt. Konkrete Methoden sind auch erlaubt, die brauchen wir jedoch hier nicht. Eine toString()-Methode hatten wir in früheren Beispielen ja auch schon programmiert.
Prinzipiell wäre es auch möglich gewesen, einen leeren Rumpf zu haben und dann darauf zu hoffen, dass die Unterklassen die Methode überschreiben. Doch es ist eine bewusste Designentscheidung, die Methode abstrakt zu machen, weil ja unklar ist, was genau bei diesem speziellen Ereignis passieren soll. Jedes Ereignis ist ein wenig anders, und es ist einfach unmöglich, hier eine gewisse Implementierung anzubieten. Abstrakte Methoden drücken aus, dass diese Implementierung zwingend nötig sein muss.
Vererben von abstrakten Methoden
Wenn wir von einer Klasse abstrakte Methoden erben, so haben wir zwei Möglichkeiten:
-
Wir überschreiben alle abstrakten Methoden und implementieren sie. Dann muss die Unterklasse nicht mehr abstrakt sein (wobei sie es auch weiterhin sein kann). Von der Unterklasse kann es ganz normale Exemplare geben.
-
Wir überschreiben die abstrakte Methode nicht, sodass sie normal vererbt wird. Das bedeutet: Eine abstrakte Methode bleibt in unserer Klasse, und die Klasse muss wiederum abstrakt sein.
Kommen wir zurück zum Beispiel: Die abstrakte Methode aus der Oberklasse soll von den Unterklassen (unterschiedlich) implementiert werden, denn jedes Ereignis macht mit den Spielern etwas anderes.
Beginnen wir mit dem Überfall. Dabei verliert der Spieler alle Süßigkeiten.
class Mugging extends Event {
@Override void process( Player player ) {
player.candy.quantity = 0;
}
}
Ein Geschenk ist da schon viel besser: Der Spieler bekommt eine willkürliche Anzahl von neuen Süßigkeiten geschenkt:
class Gift extends Event {
@Override void process( Player player ) {
player.candy.quantity += ThreadLocalRandom.current().nextInt( 1, 10 );
}
}
Im Testprogramm wollen wir den Spieler initialisieren, ihm eine Süßigkeit geben, dann ihn überfallen und anschließend beschenken:
Candy liquorice = new Candy();
liquorice.quantity = 10;
liquorice.name = "Die salzige Leckmuschel";
Player peter = new Player();
peter.candy = liquorice;
Event mugging = new Mugging();
Event gift = new Gift();
System.out.println( peter.candy.quantity ); // 10
mugging.process( peter );
System.out.println( peter.candy.quantity ); // 0
gift.process( peter );
System.out.println( peter.candy.quantity ); // 7
An der Ausgabe lässt sich gut ablesen, wie die Ereignisbehandlung auf den Spieler peter einwirkt und die Anzahl Süßigkeiten verändert.
[»] Hinweis
Wenn Methoden einmal mit Rumpf existieren, so können sie nicht später abstrakt überschrieben werden und somit noch tieferen Unterklassen vorschreiben, sie zu überschreiben. Wenn eine Implementierung einmal vorhanden ist, kann sie nicht wieder versteckt werden. So existiert z. B. toString() in Object und könnte nicht in Event abstrakt überschrieben werden, um etwa den Unterklassen Gift oder Mugging vorzuschreiben, dass sie toString() überschreiben müssen.
Implementiert eine Klasse nicht alle geerbten abstrakten Methoden, so muss die Klasse selbst wieder abstrakt sein. Ist unsere Unterklasse einer abstrakten Basisklasse nicht abstrakt, so bietet IntelliJ mit (Alt)+(¢) an, entweder die eigene Klasse abstrakt zu machen oder alle geerbten abstrakten Methoden mit einem Dummy-Rumpf zu implementieren.
Das Schöne an abstrakten Methoden ist, dass sie auf jeden Fall von konkreten Exemplaren realisiert werden. Hier finden also immer polymorphe Methodenaufrufe statt.
Zu Ende gespielt
Bisher haben wir noch nicht den Wechsel der Städte implementiert, das vorherige Beispiel sollte nur die Typbeziehungen verdeutlichen. Jetzt wollen wir das Spiel erweitern, dass der Spieler die Stadt wechseln kann und dann zufällige Dinge passieren können.
Zunächst noch einmal die drei Event-Unterklassen, die jetzt eine Konsolenausgabe bekommen.
class Mugging extends Event {
@Override void process( Player player ) {
System.out.println( "Hilfe! Jemand klaut dir alle Süßigkeiten." );
player.candy.quantity = 0;
}
}
class Gift extends Event {
@Override void process( Player player ) {
System.out.println( "Du Glückspilz! Jemand schenkt dir Süßigkeiten." );
player.candy.quantity += ThreadLocalRandom.current().nextInt( 1, 10 );
}
}
class Eating extends Event {
@Override void process( Player player ) {
System.out.println( "Du hast Hunger und isst einige deiner Süßigkeiten." );
player.candy.quantity *= Math.random();
}
}
Neu ist eine Event-Unterklasse, die nichts macht. Wir nennen solche Objekte auch Nullobjekte:
class NoopEvent extends Event {
@Override void process( Player player ) { }
}
Da die Ereignisse zufällig auftreten können, soll eine statische Methode random() ein Zufallsereignis liefern.
class RandomGameEvents {
private RandomGameEvents() {}
static Event next() {
double random = Math.random();
if ( random < 0.5 ) return new NoopEvent();
if ( random < 0.8 ) return new Gift();
if ( random < 0.9 ) return new Eating();
return new Mugging();
}
}
Aus der Methode lässt sich ablesen, dass die Ereignisse nicht gleich wahrscheinlich sind. Zu 50 % soll beim Reisen nichts passieren, zu 30 % soll es Geschenke geben, zu 10 % hat der Spieler Hunger, und zu 10 % wird er überfallen.
Die Klasse Candy ist vereinfacht und enthält nur Namen und Anzahl:
public class Candy {
String name;
int quantity;
}
Interessant wird es beim Spieler, da er zu einem neuen Ziel reisen kann:
class Player {
Candy candy;
String location;
void moveTo( String cityName ) {
if ( location.equalsIgnoreCase( cityName ) ) {
System.out.println( "Du bist doch schon da! (_)" );
return;
}
RandomGameEvents.next().process( this );
location = cityName;
}
}
Die Methode moveTo(String) bekommt einen beliebigen Städtenamen übergeben, und falls sich der Spieler schon in der Stadt befindet, ist eine Reise unnötig. Der interessante Teil folgt, wenn die Reise angetreten wird: Ein zufälliges Ereignis wird ermittelt und der Spieler der process(Player)-Methode übergeben.
Candy liquorice = new Candy();
liquorice.quantity = 10;
liquorice.name = "Salzige Leckmuschel";
Player peter = new Player();
peter.candy = liquorice;
peter.location = "Dortmund";
System.out.println( "Gib das Ziel ein, wohin du reisen möchtest. " +
"Eine Leereingabe beendet das Spiel." );
while ( true ) {
System.out.printf( "Du bist aktuell in %s und besitzt %d '%s'%n",
peter.location, peter.candy.quantity, peter.candy.name );
System.out.print( "Ziel: " );
String input = new Scanner( System.in ).nextLine();
if ( input.trim().isEmpty() ) break;
peter.moveTo( input );
}
Das eigentliche Hauptprogramm ist jetzt nicht mehr groß. Eine Süßigkeit wird wie der Spieler aufgebaut und die Endlosschleife betreten. Das Programm bittet um eine Eingabe, und wenn keine Eingabe erfolgt, beendet das die Schleife, und das Programm ist zu Ende. Falls die Eingabe nicht leer war, ist das ein Reiseziel, und Peter reist an diesen Ort, mit allen Überraschungen.
[+] Warum?
Abstrakte Methoden sind ein sehr wichtiges Werkzeug, denn sie geben ein Versprechen ab, dass eine Implementierung später vorhanden sein wird. Es ist weniger so, dass wir selbst diese Methoden aufrufen, sondern andere. Wir erlauben somit anderen Parteien, unsere Implementierungen aufzurufen. Das wird oft von Frameworks genutzt: Die Bibliothek deklariert eine abstrakte Klasse mit einer abstrakten Methode, und wir implementieren diese Methode, bauen ein Exemplar von dieser Implementierung und übergeben das Objekt wieder dem Framework. Das weiß nun, dass die Methode implementiert wurde, und kann sie aufrufen. Ohne dynamisches Binden würde das alles überhaupt nicht funktionieren.