Initialisierungsdilemma bei Programmiersprachen (2)
Wenn man von einer Klasse abgeleitet hat, verlangt beispielsweise Java, dass in einem Konstruktor zuerst ein Konstruktor der Oberklasse aufgerufen wird. Aber warum eigentlich?
- Michael Wiedeking
Wenn man von einer Klasse abgeleitet hat, verlangt beispielsweise Java, dass in einem Konstruktor zuerst ein Konstruktor der Oberklasse aufgerufen wird. Aber warum eigentlich?
Initialisierungsdilemma bei Programmiersprachen (1)
In vielen Programmiersprachen ist ein Konstruktor keine normale Methode und wird deshalb ein bisschen stiefmütterlich behandelt. Beispielsweise können sie nicht in Schnittstellen vorkommen. Schade eigentlich.
Dass auch Objekte abgeleiteter Klassen wohldefiniert sein wollen, steht außer Frage. Viele objektorientierte Programmiersprachen erreichen dies dadurch, dass sie in einem Konstruktor verlangen, dass zunächst der Teil der Oberklasse initialisiert wird. Stellt man sich etwa eine Person vor, von der hier beispielhaft nur der Name interessiert, könnte die wie folgt aussehen:
class Person {
String name;
Person(String name) {
this.name = name;
}
}
Um nun ein Objekt dieser Art erzeugen zu können, verlangt der Konstruktor, dass man einen Namen übergeben muss.
Person p = new Person("Jonathan Swift");
Will man nun basierend auf dieser Person einen Angestellten definieren, verlangen viele objektorientierte Sprachen, dass in einem Konstruktor dieser ableitenden Klasse der Konstruktor der Oberklasse explizit aufzurufen ist.
class Employee extends Person {
int id;
Employee(String name, int id) {
super(name);
this.id = id;
}
}
Java fordert dabei, dass der Konstruktor der Oberklasse aufgerufen wird, bevor irgendetwas anderes im Konstruktor der ableitenden Klasse gemacht wird.
Aber warum eigentlich?
Natürlich wird auf diese Weise gewährleistet, dass eine Nutzung von Methoden der Oberklasse nicht auf nicht initialisierten Daten basiert. Leider hat diese Garantie auch ihren Preis. So kann eine Oberklasse nur schwer mit abstrakten Methoden arbeiten, die erst von der ableitenden Klasse mit Leben gefüllt werden.
Will man etwa einen Datenstrom mit einem Lookahead-Zeichen definieren, bietet sich dafĂĽr folgende, sehr effiziente Implementierung an:
abstract class AbstractLookaheadStream {
int next;
AbstractLookaheadStream() {
next = readRaw();
}
abstract int readRaw();
int lookahead() {
return next;
}
int read() {
int result = next;
next = readRaw();
return result;
}
}
Wie man sieht, lässt sich auf eine Reihe von Tests verzichten, wenn das Lookahead immer bekannt ist. Allerdings muss es dazu schon bei der Initialisierung gelesen werden. Möchte man nun, dass das initiale Lesen wie dargestellt bereits im Konstruktor stattfindet, um dieses Implementierungsdetail zu verbergen, bekommt man ein Problem: Beim Aufruf der abstrakten readRaw-Methode greift man nämlich auf eine Methode zu, die eventuell auf Daten zugreift, die noch nicht initialisiert sind.
Um das zu verdeutlichen, soll hier die folgende Oberklasse definiert werden.
class A {
A() {
print(getCount())
}
int getCount() {
return 5;
}
}
A a = new A(); // Ausgabe: 5
print(a.getCount()) // Ausgabe: 5
Eine ableitende Klasse hat nun die Möglichkeit, die Methode getCount zu überschreiben und damit eine andere Anzahl zu liefern.
class B extends A {
int count;
B() {
super();
count = 1;
}
@Override
int getCount() {
return count;
}
}
Leider hat das bei obiger Anwendung nicht ganz den gewĂĽnschten Effekt. Denn durch das Ăśberschreiben greift man nun auf Elemente der ableitenden Klasse zu, die aber im Konstruktor der Oberklasse noch nicht definiert sind.
A a = new B(); // Ausgabe: 0
print(a.getCount()) // Ausgabe: 1
Das ist ein relativ beliebter Fehler, nach dem man unter Umständen sehr lange suchen muss. Aus diesem Grund wird empfohlen, dass in einem Konstruktor nur statische, private oder nicht überschreibbare Methoden aufgerufen werden sollen. Alternativ könnte man den Anwender noch darauf hinweisen, dass eine überschriebene Methode aufgerufen wird und die Methode nur eingeschränkten Zugriff auf den Zustand der ableitenden Klasse haben darf. Aber so etwas liest ja bekanntermaßen niemand.
Übrigens ändert etwa in Java auch ein Umformulieren nichts an diesem "Fehler".
class B extends A {
int count = 1;
B() {
super();
}
…
Das mag ja suggerieren, dass jetzt die Initialisierung von count schon vor dem Aufruf des Konstruktors stattfindet, aber auch das ist leider nicht der Fall. Daran ändert sich auch nichts, wenn man count final macht. Dass in diesem speziellen Fall verwirrenderweise doch das gewünschte Ergebnis erzielt wird, ist dem Compiler zu verdanken, dem es erlaubt ist, primitive Konstanten auch direkt zu verwenden.
class B extends A {
final int count = 1;
…
Weil der Compiler nun den Wert von count als primitive Konstante versteht, kann er diese ohne Umweg ĂĽber die Instanzvariable direkt in den Rumpf der getCount-Methode einbauen. "Primitiv" heiĂźt hier, dass es sich um einen der primitiven Datentypen (double, int, char, etc.) oder einen String handeln muss. Also hat auch das final keinen einen Einfluss auf die Initialisierungsreihenfolge.
Vielleicht sei an dieser Stelle noch erwähnt, dass es bei Nichtbeachtung der Initialisierungsreihenfolge zumindest bei den virtuellen Maschinen nicht zu gänzlich undefinierten Werten kommen kann. Erfreulicherweise wird ja hier neuer Speicher immer mit Nullen vorbelegt, sodass der Fehlerfall doch gelegentlich erkennbar ist oder eine Ausnahme geworfen wird.
Das ist natürlich eine sehr starke Einschränkung, denn unter diesen Bedingungen sind Bibliotheksklassen wie der AbstractLookaheadStream unmöglich oder nur mit Abstrichen implementierbar. Selbstverständlich könnte man den Anwender dazu zwingen, die Initialisierung in einem Extraschritt zu machen.
abstract class AbstractLookaheadStream {
…
AbstractLookaheadStream() {
;
}
void init() {
next = readRaw();
}
...
}
AbstractLookaheadStream s;
s = new ConcreteLookaheadStream();
s.init();
Aber wer will das schon!
Gibt es denn nun einen Weg aus diesem Dilemma?
Ja, gibt es, indem man die Initialisierungsreihenfolge einfach umdreht – so es einem erlaubt ist. Anstatt also erst die Elemente der Superklasse zu initialisieren, kümmert man sich zuerst um die eigenen.
class Employee extends Person {
int id;
Employee(String name, int id) {
this.id = id;
// vorher
super(name);
// nachher
}
}
Das hat den Vorteil, dass die Oberklasse getrost auch auf normale Methoden zugreifen kann, die durchaus auch überschrieben worden sein dürfen. Ob sich schon vor dem Aufruf des Oberklassenkosntruktors eine eigene Methode verwenden lässt, hängt vom jeweiligen Zustand ab, der ja zu jedem Zeitpunkt bekannt ist. Eine Methode der Oberklasse sollte nicht aufgerufen werden (können), da dieser Teil noch nicht initialisiert ist.
Sollte der Zustand eines abgeleiteten Elements tatsächlich von Eigenschaften der Oberklasse abhängig sein, lässt sich auch das bewerkstelligen, in dem man dessen Initialisierung hinter die Initialisierung der Oberklasse schiebt. Das befreit einen aber nicht davon, das Element vorher schon irgendwie zu initialisieren. Der Unterschied ist nun aber, dass einem der Zustand bekannt ist, den man der Oberklasse anvertraut.
class ConcreteLookaheadStream extends AbstractLookaheadStream {
InputStream in;
ConcreteLookaheadStream(…) {
this.in = new XxxInpuStream(…);
super.init();
}
@Override
int readRaw() {
return in.read();
}
}
Jetzt lässt sich der AbstractLookaheadStream bequem implementieren, und auch bei ableitenden Klassen gibt es keine Zustandsprobleme mehr.
Auch hier unterscheidet sich die Programmiersprache Swift von vielen ihrer objektorientierten Kolleginnen, die genau dieses Verfahren erlaubt und mithilfe des Compilers eine korrekte Initialisierung sicherstellt. Vielleicht ist dieser Ansatz ein bisschen gewöhnungsbedürftig, aber er löst einige Probleme auf bequeme Art und Weise. ()