Initialisierungsdilemma bei Programmiersprachen

Babel-Bulletin  –  35 Kommentare

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.

In vielen objektorientierte Programmiersprachen werden Objekte mit new erzeugt. Bei der Gelegenheit wird dann der sogenannte Konstruktor aufgerufen, der für die Initialisierung dieses frisch erzeugten Objekts zuständig ist. In den meisten dieser Sprachen ist der Konstruktor aber keine vollwertige Methode. Das ist sehr schade, denn sonst könnte man wirklich nützliche Sachen damit machen.

Grundsätzlich ist ein Konstruktor ja gar nicht nötig, weshalb es einen solchen beispielsweise auch nicht explizit in der UML gibt. Dass Objekte irgendwie erzeugt werden, ist wohl unvermeidbar, aber dass sie einen Konstruktor dazu benötigen, ist nur eine Frage des Geschmacks. In Prototyp-basierten Sprachen etwa werden Objekte durch Klonen erzeugt. Zunächst gibt es ein einziges Objekt, das man nach Belieben duplizieren kann. Fehlen einem solchen Objekt Eigenschaften, kann man einfach neue Methoden und Felder definieren. Klont man nun dieses Objekt, so übernehmen dessen Kopien natürlich alle Eigenschaften, also auch die neuen Felder und Methoden. Auf diese Weise kann obige Konstruktion vollständig entfallen.

Gibt es aber einen Konstruktor, wäre es doch schön, wenn man ihn wie eine Methode überall benutzen könnte. Aber warum ist ein Konstruktor so anders? Ein Unterschied ist sicherlich die besondere Rolle, die der Konstruktor spielt: Er ist die einzige "Methode", die bei einem uninitialisierten Objekt aufgerufen werden darf.
Darüber hinaus muss gewährleistet sein, dass bereits vor einem Zugriff eines Objekts auf die eigenen Komponenten sämtliche Komponenten der Oberklasse initialisiert wurden. Das ist allerdings eine Eigenschaft, die sich auch von beliebigen anderen überschriebenen Methoden fordern ließ. Während der Compiler das für Konstruktoren sicherstellt, gibt es kaum eine Sprache, die das auch für andere Methoden tut. Apropos überschreiben: Ein Konstruktor kann meist nicht überschrieben werden. Aber das liegt ja nun auch nur daran, dass er keine Methode sein soll.

Wäre ein Konstruktor eine Methode, dann könnte man ihn auch in einer Schnittstelle fordern. Das wäre beispielsweise sehr nützlich im Zusammenhang mit der Serialisierung von Objekten. In Java etwa gibt es die Schnittstelle Serializable, die eine Klasse derart markiert, dass sie serialisiert werden darf. Warum auch immer hat diese Schnittstelle keine Methoden. Das ist schade, denn sonst könnte sie dokumentieren, wie die Serialisierung vonstatten geht, ohne die Spezifikation bemühen zu müssen.

Eine solche Schnittstelle könnte für die Serialisierung exemplarisch Methoden der folgenden Form vorsehen:

interface Serializable {
void readObject(ObjectInputStream in);
void writeObject(ObjectOutputStream out);
}

Die Serialisierung über einen Datenstrom ist damit trivial formuliert. Beim deserialisierenden Lesen ergibt sich aber leider ein Problem: Methoden lassen sich bekanntermaßen nur an Objekten aufrufen. Objekte müssen aber vor ihrer Benutzung initialisiert worden sein. Das bedeutet, dass sich ein Objekt nur deserialisieren lässt, nachdem es bereits mit dem Konstruktor initialisiert worden ist. Damit der Deserialisierer also ein Objekt erzeugen kann, muss er es zuerst initialisieren und anschließend überschreiben. Damit er dies machen kann, braucht er – weil er das zu deserialisierende Objekt ja nicht zu genüge kennt – meist einen parameterlosen Konstruktor. In Java führt das sogar dazu, dass ein serialisierbares Objekt einen solchen Konstruktor generiert bekommt, obwohl man für sein Objekt keinen solchen vorgesehen hat. Damit ist die Verletzung von Invarianten eigentlich schon vorprogrammiert, weil man ein Objekt beim Deserialisieren so faktisch zweimal initialisiert.

Geht doch – mit Swift

Die Lösung des Problems ist eigentlich ganz einfach. Man könnte die Schnittstelle einfach wie folgt ändern:

interface Serializable {
Serializable(ObjectInputStream in);
void writeObject(ObjectOutputStream out);
}

Auf diese Weise könnte man die Schnittstelle des Konstruktors – analog zur seiner Definition – für die Deserialisierung festlegen. Damit fiele das "zweimalige Initialisieren" weg, und man würde genau sehen, wie das Objekt die geforderte Serialisierung meistert.

Die Programmiersprache Swift erlaubt die Definition solcher Schnittstellenverträge. Dort heißen diese aber nicht interface sondern protocol, und der Konstruktor hat dort auch ein eigenes Konstrukt, sodass nicht der Name der Klasse, sondern einfach init (ohne Rückgabewert) verwendet werden kann. Diese Konstruktoren lassen sich dann wie die anderen Methoden in einem Schnittstellenvetrag angeben.

Das Beispiel Swift zeigt erfreulicherweise, dass nicht nur der Bedarf besteht, bei einem Objekt einem bestimmten Konstruktor zu fordern, sondern auch, dass es möglich ist, das technisch umzusetzen. Mindestens in diesem Punkt ist Swift also ein würdiges Mitglied in der großen Familie der Programmiersprachen.