| title | Vererbung und abstrakte Klassen |
|---|---|
| permalink | /vererbung-und-abstrakte-klassen/ |
Zunächst gibt es Klassen (class) und Schnittstellen (interfaces, interface).
Klassen können Attribute und Methoden enthalten, welche jeweils die Sichtbarkeiten öffentlich (public), privat (protected), geschützt (protected, paketweit sichtbar und in abgeleiteten Klassen) und paketweit sichtbar (package, ohne Schlüsselwort).
Methoden sind entweder implementiert, oder als abstract markiert, was wiederum die Klasse zu einer abstrakten Klasse macht.
Von abstrakten Klassen können keine Instanzen erstellt werden, die Verwendung des new Operators mit einer abstrakten Klasse ist ein Syntaxfehler.
Attribute und Methoden können auch statisch (static) sein, diese sind dann für alle Instanzen die selben (sic!) und können deshalb ohne Instanzen verwendet werden.
Schnittstellen enthalten Methodensignaturen, in der Regel ohne Implementierungsblock und Sichtbarkeit (default: public).
Seit Java 8 können mit dem Schlüsselwort default Implementierungen eingefügt werden, welche dann nicht zwingend überschrieben werden müssen.
Soll eine Klasse eine Schnittstelle erfüllen, so definiert man die Klasse mit class K implements I, und implementiert die erforderlichen Methoden.
Da die Klasse K mindestens die Methoden implementiert, welche das Interface I vorgibt, kann man Instanzen der Klasse K einer Referenz vom Typ I zuweisen:
interface I {
void methode(); // keine Implementierung
}class K2 implements I {
public void methode() {
System.out.println("Hallo, Welt!");
}
}I inst = new K2();Soll eine Klasse eine andere erweitern, so definiert man sie mit class K extends B.
Da K nun als Erweiterung alle Attribute und Methoden von B hat, kann man entsprechend Instanzen der Klasse K einer Referenz vom Typ B zuweisen:
class B {
void methode() {
System.out.println("Hallo, Welt!");
}
}class K1 extends B {
// methode() ist bereits verfügbar, und kann überschrieben werden
}B inst = new K1();Klassen können von höchstens einer Klasse Erben, aber mehrere Schnittstellen implementieren (getrennt durch ,).
Definiert eine abgeleitete Klasse eine Methode deren Signatur bereits in der Basisklasse vorhanden ist, so wird diese Methode überschrieben.
class B {
void methode1() { System.out.println("Ich bin Klasse B.methode1()"); }
void methode2() { System.out.println("Ich bin Klasse B.methode2()"); }
}class K extends B {
void methode2() { System.out.println("Ich bin Klasse K.methode2()"); }
}B b = new K();
b.methode1(); // "Ich bin Klasse B.methode1()"
b.methode2(); // "Ich bin KLasse K.methode2()"Innerhalb einer Klasse kann die Methode einer Basisklasse mit dem super Operator (statt this) aufgerufen werden.
Ist eine Klasse als abstract definiert, so können keine Instanzen erstellt werden; eine abstrakte Klasse ohne ableitende Klassen macht also keinen Sinn.
Abstrakte Klassen können -- ähnlich wie Schnittstellen -- dazu verwendet werden, um gewisse Methoden zu erzwingen aber trotzdem bereits Funktionalität bereit zu stellen.
Folgendes Beispiel: Eine Klasse AutoManuell modelliere ein Auto mit Gangschaltung und stelle die Methoden anlassen(), anfahren(), abstellen() und fahren(int km) bereit:
class AutoManuell {
Pedal gas, bremse, kupplung;
Schaltung schaltung;
Zuendung zuendung;
private void anlassen() {
bremse.druecken();
kupplung.druecken();
zuendung.an();
zuendung.start();
}
private void anfahren() {
kupplung.druecken();
schaltung.einlegen(1);
kupplung.loslassen();
gas.druecken();
}
private void abstellen() {
bremse.druecken();
kupplung.druecken();
zuendung.aus();
}
public void fahren(int km) {
anlassen();
anfahren();
// km fahren...
abstellen();
}
public int anzahlGaenge() {
return schaltung.gaenge();
}
}Anders aber bei einer Automatik, hier würde der Vorgang wohl eher so aussehen:
class AutoAutomatik {
private Pedal gas, bremse;
private AutomatikGetriebe automatik;
private Zuendung zuendung;
private void anlassen() {
bremse.druecken();
zuendung.an();
zuendung.start();
}
private void anfahren() {
bremse.druecken();
automatik.drive();
gas.druecken();
}
private void abstellen() {
bremse.druecken();
automatik.park();
}
public void fahren(int km) {
anlassen();
anfahren();
// km fahren...
abstellen();
}
}Man sieht: Die Funktionalität ist in weiten Teilen die gleiche, warum also nicht einfach erben und nur den tatsächlich verschiedenen Teil implementieren?
Nachdem man die Sichtbarkeiten von private auf protected gesetzt hat, funktioniert das im Prinzip.
class AutoAutomatik extends Auto {
private AutomatikGetriebe automatik;
protected void anlassen() {
bremse.druecken();
zuendung.an();
zuendung.start();
}
protected void anfahren() {
bremse.druecken();
automatik.drive();
gas.druecken();
}
protected void abstellen() {
bremse.druecken();
automatik.park();
}
}Aber Vorsicht: Ein AutomatikAuto hat jetzt sowohl eine Schaltung als auch ein AutomatikGetriebe, und oben drein auch noch ein Kupplungspedal!
Darüber hinaus ist die Semantik jetzt: Jedes AutomatikAuto ist ein AutoManuell, was natürlich an der Realität vorbei geht.
AutoManuell a = new AutoAutomatik();
a.anzahlGaenge(); // das ist natürlich quatsch!Hier helfen abstrakte Klassen: Sie erlauben es zum einen Methoden in abgeleiteten Klassen vorzuschreiben (Schlüsselwort abstract), zum anderen aber Programmlogik, welche für alle abgeleiteten Klassen gleich ist, bereits zu implementieren.
abstract class Auto {
protected Pedal gas, bremse; // jedes Auto hat das
protected Zuendung zuendung;
// das ist für manuell und automatik anders!
protected abstract void anlassen();
protected abstract void anfahren();
protected abstract void abstellen();
// wieder gleich fuer alle Autos:
public void fahren(int km) {
anlassen();
anfahren();
// km fahren...
abstellen();
}
}class AutoManuell extends Auto {
protected Pedal kupplung;
protected Schaltung schaltung;
protected void anlassen() {
bremse.druecken();
kupplung.druecken();
zuendung.an();
zuendung.start();
}
protected void anfahren() {
kupplung.druecken();
schaltung.einlegen(1);
kupplung.loslassen();
gas.druecken();
}
protected void abstellen() {
bremse.druecken();
kupplung.druecken();
zuendung.aus();
}
}class AutoAutomatik extends Auto {
protected AutomatikGetriebe automatik;
protected void anlassen() {
bremse.druecken();
zuendung.an();
zuendung.start();
}
protected void anfahren() {
bremse.druecken();
automatik.drive();
gas.druecken();
}
protected void abstellen() {
bremse.druecken();
automatik.park();
}
}Auto manuell = new AutoManuell();
Auto automatik = new AutoAutomatik();
manuell.fahren(10);
automatik.fahren(10);Die abstrakte Klasse Auto kann also die Methoden anlassen(), anfahren() und abstellen() vorschreiben, und intern bereits benutzen.
∎
Ein deterministischer ("nicht-zufälliger") Zustandsautomat besteht aus einem Startzustand, einer beliebigen Anzahl Zuständen und, wenn er endlich ist, mindestens einem Endzustand. Zustandsübergänge als Tripel von Von-Zustand, Eingabe, Zu-Zustand definieren dabei, auf welchen Wegen man durch diesen Graphen gehen kann. Hier ein einfaches Beispiel aus dem Studentenleben:
Die naive Herangehensweise so einen Zustandsautomaten zu implementieren ist die Verwendung eines enums für die Zustände, und switch-case für die Übergänge:
class Automat {
enum Zustand { schlafend, aufgewacht, geduscht, ... }
Zustand aktuell = schlafend;
void weiterschalten(String s) {
if (s == null)
return;
switch (aktuell) {
case schlafend:
if (s.equals("aufwecken")) aktuell = wach;
else throw new IllegalStateException();
break;
case aufgewacht:
if (s.equals("snooze")) aktuell = schlafend;
else if (s.equals("duschen")) aktuell = geduscht;
else if (s.equals("fruehstuecken")) aktuell = wach;
else throw new IllegalStateException();
break;
// ...
}
}
}Diese Implementierung ist umständlich, und vermischt die Ablaufmechanik (in welchem Zustand bin ich) mit der eigentlichen Logik (welcher Zustand kommt als nächstes).
Eine bessere Implementierung ist es, diese beiden Dinge zu trennen.
Anders betrachtet hat ein Automat Zustände, wobei sich der Zustand nur ändert, wenn es eine Eingabe gibt (z.B. "aufwecken").
Jeder Zustand weiss für sich, auf welche Eingaben welcher Zustand folgt, z.B. der Zustand schlafend bleibt ohne Eingabe unverändert, aber bei "aufwecken" wird der Zustand zu aufgewacht.
Sowie weitere Zustände analog.
Der Automat kann also mit accept ein Array von Strings entgegen nehmen, und gibt true zurück, wenn für die Sequenz von Eingaben ein Pfad in dem Automaten gefunden wurde, oder false, wenn die Eingabe ungültig war.
Die Eingabe: [null, null, "aufwecken", "snooze", "aufwecken", "fruehstuecken", "lernen", "lernen", "hinlegen"] entspricht der Zustandsfolge schlafend, aufgewacht, schlafend, wach, fleissig, muede, schlafend, und gibt true zurück.
Die Eingabe ["aufwecken", "duschen", "lernen"] hat keinen gültigen Pfad (ohne Mampf kein Kampf!), und gibt daher false zurück.
Gegeben sind die Klassen State und Automaton:
abstract class State {
public State accept(String input) {
if (input == null)
return this;
else
return successor(input);
}
/**
* Gibt den Nachfolgezustand in Abhängigkeit vom Input
* zurück, oder wirft eine IllegalStateException.
*/
protected abstract State successor(String input);
}class Automaton {
private State currentState = new Schlafend();
public boolean accept(String[] input) {
try {
for (String in : input) {
currentState = currentState.accept(in);
}
return true;
} catch (IllegalStateException e) {
return false;
}
}
}- Machen Sie sich mit der Funktionsweise der
Automaton.accept()Methode vertraut. - Implementieren Sie die Klassen
Schlafend,Aufgewacht,Geduscht,Wach,Fleissig,Faul,Muede, undBettfertigwelche die entsprechenden Zustände des Automaten modellieren sollen. Als Beispiel: Der Nachfolgezustand vonAufgewachtist vom TypGeduscht, wenn die Eingabe"duschen"war, oderWachwenn die Eingabe "fruehstuecken" war; bei anderen Eingaben wird eineIllegalStateExceptiongeworfen. - Testen Sie Ihre Zustände für einige Inputarrays.