Konstruktionslose Initialisierung und das Meta-Problem

Wie viel ein guter Name wert ist, erkennt man leider erst, wenn man einen nicht ganz so passenden vor sich hat. Wenn dieser Begriff dann noch für jedermann "falsch" konnotiert ist, hat man ein echtes Problem. Wie etwa beim Konstruktor, der in vielen Fällen doch eher "nur" ein Initialisierer ist.

vorlesen Druckansicht 6 Kommentare lesen
Lesezeit: 18 Min.
Von
  • Michael Wiedeking
Inhaltsverzeichnis

Wie viel ein guter Name wert ist, erkennt man leider erst, wenn man einen nicht ganz so passenden vor sich hat. Wenn dieser Begriff dann noch für jedermann "falsch" konnotiert ist, hat man ein echtes Problem. Wie etwa beim Konstruktor, der in vielen Fällen doch eher "nur" ein Initialisierer ist.

Eine Instanz ist laut Duden eine für einen Fall, eine Entscheidung zuständige Stelle (bes. eine Behörde o. Ä.). Für uns Programmierer ist es etwas ganz anderes. Wir meinen damit, was ein Englisch Sprechender instance nennt, was im Oxford Dictionary an erster Stelle als example, also Beispiel, erklärt wird. Unser (Gerichts-)Instanz-Begriff wird schon auch erwähnt, aber das ist im Zusammenhang mit Objekten sicherlich nicht hilfreich. Inzwischen haben wir uns aber daran gewöhnt. Eine Instanz ist also ein konkretes Exemplar einer Klasse und hat mit dem hiesigen Rechtsweg nichts zu tun.

Genauso wissen wir, was ein Konstruktor tut: Er erzeugt derartige Instanzen. Das Problem dabei ist allerdings, dass er leider in den meisten Programmiersprachen alles Mögliche macht – nur nichts konstruiert. Nicht ohne Grund taucht der Begriff des Konstruktors weder in der Beschreibung von Simula noch von Smalltalk auf. Es scheint, als wäre es C++ zu verdanken, dass es diesen Begriff überhaupt gibt.

Im Grunde kann ja jeder alles benennen, wie er will. So bezeichnen wir dieses Ding, das morgens auf- und abends untergeht, sich also tagsüber scheibenartig am Himmel präsentiert und oft extrem warm und hell ist, als Sonne (wenngleich unsere Vorstellung von dem Geschlecht unserer Sonne nicht mit der der Engländer oder Spanier übereinstimmt). Wenn allerdings in einer Gruppe keine Einigkeit über die Bedeutung der Namen herrscht, mag es dem einen oder anderen so ergehen wie dem alten Mann in Peter Bichsels Erzählung "Ein Tisch ist ein Tisch".

Herrn Bjarne Stroustrup scheint es also verdanken zu sein, dass wir den Konstruktor kennen. In dem gemeinsam mit Margaret A. Ellis geschriebenen Buch The Annotated C++ Reference Manual (1991) steht zum Thema Konstruktor:

"A member function with the same name as its class is called a constructor, it is used to construct values of its class type. If a class has a constructor, each object of that class will be initialized before any use is made of the object."

Nach Auflistung der Orte, an denen der Konstruktor auftauchen kann, steht da noch:

"In each case the job of the constructor is to create the basic structure of the object; that is, to initialize any virtual function tables, to construct the objects representing base classes (if any), to construct objects representing nonstatic members (if any). […] In other words, a constructor turns raw memory into an object for which the rules of the type system hold."

Eine Definition ist bekanntlich eine willkürliche Festlegung, und so kann ein Konstruktor machen, was Stroustrup will. Allerdings verbirgt sich hinter einem Konstruktor beeindruckend viel. Was aber viel wichtiger ist: Auf das meiste, was in C++ definiert wurde, hat man innerhalb des Konstruktors überhaupt keinen Zugriff. C++ legt in einem für den Programmierer unsichtbaren Konstruktor-Ding selbst fest, wie viel Speicher tatsächlich benötigt wird, wie dieser erzeugt wird, wie dann die virtuelle Funktionstabelle aufgebaut und wie schließlich dieser Speicher initialisiert wird.

An dieser Stelle soll uns mal nicht interessieren, dass in C++ trotz alledem die Möglichkeit besteht, den benötigten Speicher selbst zu verwalten und anderen "Blödsinn" machen kann. So steht dem normal sterblichen Programmierer nur sein Konstruktor zur Verfügung, mit dem er – seien wir mal ehrlich – den Speicher faktisch nur initialisieren kann. Und das ist auch in C#, Java oder einer ähnlichen mit einem Konstruktor bewaffneten Sprache so: Es ist eigentlich ein Initialisierer.

In der (statisch typisierten) Programmiersprache Swift beispielsweise benutzt man fĂĽr den "Konstruktor" nicht den Namen der Klasse, sondern den Namen init; und in Swift heiĂźt es auch nicht Constructor sondern Initializer! (Dementsprechend spricht man dort auch nicht von einem Destructor, sondern einem Deinitializer, der den Namen deinit und wie der Initialisierer keinen RĂĽckgabewert hat.) Vergleichbares gilt ĂĽbrigens auch fĂĽr (dynamische) Sprachen wie Ruby & Co.

Nun gut, der nun zum Initialisierer degenerierte "Konstruktor", wird also immer dann aufgerufen, wenn ein neues Objekt erzeugt wurde. Neben der Möglichkeit, ein Objekt mit new zu erzeugen (sprich: kreieren), kann man es in C++ auch ohne new direkt aufrufen.

Point  p = Point(1, 2);        /* C++ Value */
Point* p = new Point(1, 2); /* C++ dynamic Pointer/Object*/
Point p = new Point(1, 2); /* C#, Java etc. Reference/Object */

In C++ können Objekte ja auch auf dem Stack abgelegt werden. Der Aufruf des Konstruktors ist damit eigentlich nur syntaktischer Zucker für das Beschaffen vom benötigten Platz auf dem Stack und dem anschließenden Initialisieren von ebendiesem. Wird der Speicher dynamisch erzeugt, dann wird der benötigte Speicher alloziert, mit den virtuellen Tabellen versorgt und dann der Objektbereich des Speichers initialisiert. In Sprachen wie C# oder Java wird ebenfalls der Speicher erzeugt und ungeachtet irgendwelcher Vorbereitungen für virtuelle Funktionstabellen noch vor der Initialisierung mit Nullen vorbelegt. Somit sind die Daten wenigstens hier auf jeden Fall (wohldefiniert) initialisiert. Im Gegensatz dazu gibt man sich bei C++, wo kein explizites Definieren eines Konstruktors nötig ist, gelegentlich auch mit Datenmüll zufrieden.

Wer ist denn nun für die eigentliche Konstruktion zuständig, wenn das, was wir – wenn nicht Initialisierer – Konstruktor nennen, nicht dafür zuständig ist? Na ja, eigentlich die Meta-Klasse. Die regelt nämlich, wie die virtuelle Funktionstabelle aussieht, wie sie Anwendung findet, wie Referenzen auf überladene und überschriebene Methoden aufgelöst werden und wann und wie die Initialisierung stattfindet. Letzteres wird in der Regel auch vom Compiler unterstützt, der etwa (auf Wunsch der Meta-Klasse) erzwingt, das erst die Oberklassenbestandteile einer Klasse initialisiert werden müssen.

Dieser nicht initialisierende Teil des Konstruktors, der um Missverständnisse zu vermeiden ab jetzt Kreator genannt werden soll, stellt also den Pointer (oder die Referenz) zur Verfügung, über das das zukünftige Objekt erreicht werden kann. Das Objekt ist uninitialisiert, weil der Kreator nur Kenntnis über das Layout des Objektes hat, aber nicht, was die einzelnen Elemente bedeuten und wie sie zu initialisieren sind. Dass die Elemente im Zweifelsfall mit Nullen initialisiert werden, ist zwar ein netter, wohldefinierter Zug, kann aber fatale Folgen haben.

Beispielhaft soll hier nur ein Enum erwähnt werden, dass die Werte 1, 2 und 3 annehmen kann. Ist der Initialisierer "fehlerhaft", kann es nun passieren, dass eine derartige Instanzvariable den Wert 0 zugewiesen bekommt (wobei in C++ erschwerend dazu kommt, dass man 0 wegen des eigenwilligen C++-Typsystems fast allem zuweisen kann, ob man das will oder nicht). Wo diese Value-artigen Enums nicht existieren, kann analog die Variable unerwünscht null sein. Mit anderen Worten: Ein fehlerhafter Initialisierer ist dank einer Default-Initialisierung eine wunderbare Möglichkeit, um Invarianten zu verletzen und das Typsystem auszuhebeln.

Der Kreator wird also durch die Meta-Klasse definiert. Eine Instanz (sic!) dieser Meta-Klasse ist die "Klasse", ein (Klassen-)Objekt, das alle Methoden und Felder hat, die durch die Meta-Klasse beschrieben werden. Zu dessen Methoden gehört etwa addMethod, mit der sich eine neue Methode für die Objekte der Klasse definieren lässt, oder getMethods, mit der man sich auflisten lassen kann, welche Methoden den Objekten der Klasse zur Verfügung stehen. In Java und .NET etwa findet der Aufruf addMethod zur Übersetzungszeit statt und führt dazu, dass die binären Klassendateien bzw. Assemblies um den kompilierten, ausführbaren Code der Methode erweitert werden; in dynamischen Sprachen ist es das Ergänzen der Methoden zu den Klassenobjekten, während die Klassen-Source eingelesen und (aber noch nicht im Sinne einer Ausführung) interpretiert werden.

Eine Methode fĂĽr die Objekte jener Klasse beschreibt auf dieser Meta-Ebene so etwas wie ein Name-Signatur-Maschinencode-Tripel, ĂĽber das jenes Objekt beim Schicken einer entsprechenden Nachricht (einem Methodenaufruf mit bestimmter Signatur) via wohldefiniertem dynamischen Dispatching ein Lesen oder Manipulieren des Objektzustands erlaubt.

Ein Klassen-Objekt ist in der Regel ein Singleton, dass einer globalen, konstanten Referenz mit dem Namen der Klasse zugewiesen wird. Das kann man sich in etwa so vorstellen, dass sowohl bei statischen als auch dynamischen Sprachen aus einer Klassenbeschreibung

class C {
... // Methoden und Felddefinitionen
}

etwas in der Form

static const C = compile("quelle von C");

im globalen Namensraum gemacht wird. Dabei machen wir keine Aussagen darĂĽber, wann dies und eventuell in welchen Teilen das gemacht wird: zur Ăśbersetzungszeit, Linkzeit, Laufzeit etc. Nutzt man nun die Anweisung

new C(p1, ... , pk)

weiß der Compiler, welche Klasse für das Kreieren zuständig ist, in diesem Fall die Klasse C. Übrigens sind alle statischen Methoden einer Klasse eigentlich Methoden dieses einen Objektes. Weshalb auch die Schreibweise

C.doThis()

so treffend ist. Dass sich diese statische Methoden auch über Instanzen der Klassen aufrufen lassen, ist in vielen Sprachen nur eine Sache des Namensraums; aber schön ist das nicht. Korrekt wäre es, wenn man in diesem Fall bei einem Objekt o

o.getClass().doThis()

schreiben mĂĽsste. Aber das bringt noch eine Reihe anderer Probleme mit sich (ĂĽber die vielleicht ein andermal gesprochen werden will).

Wie dem auch sei: Zu diesen statischen Methoden gehört also auch der Kreator. Dieser macht zwei Dinge:

T creator() {
T object = (T) construct(); // 1.)
object.init(); // 2.)
return object;
}

Der Kreator erzeugt mit seiner construct-Methode

  1. ein neues Stück Speicher passender Größe und macht aus ihm ein Objekt mit dem gewünschten Typ, den benötigten Feldern und Methoden, die er sämtlich der Klassendefinition entnommen hat.
  2. initialisiert er dann das SpeicherstĂĽck mit dem passenden Initialisierer.

Jetzt können diese Initialisierer aber in unterschiedlichen, überladenen Varianten mit verschiedenen Parameterlisten auftreten. Also gibt es für jeden Initialisierer einen korrespondierenden Kreator, der die unterschiedlichen Initialisierungen vornehmen kann.

T creator(P1 p1, ... , Pn pn) {
T object = (T) construct(); // 1.)
object.init(p1, ... , pn); // 2.)
return object;
}

In Ruby ist das übrigens genau so: der Kreator heißt für den Programmierer new, und der ruft die in der Klasse definierte initialize-Methode mit den passenden Parametern auf. Der Aufruf erfolgt dann erwartungsgemäß über

C.new(p1, ... , pn)

Da C nichts anderes als ein Objekt ist, kann die new-Methode im Rahmen der Meta-Programmierung über geeignete Methoden überschrieben und erweitert werden, sodass der Klasse C etwa neue Methoden hinzugefügt werden oder Methoden abgeändert werden können. Dabei ist zu beachten, dass die Referenz von C auf das Klassenobjekt selbst nicht verändert werden kann, nur dessen Inhalt.

So sind alle statischen Methoden eigentlich normale Methoden des Klassenobjektes, also kann man nun durch ein Interface für die Meta-Klasse erzwingen, das deren Meta-Objekte – also die "normalen" Klassen – eine entsprechende (statische) Methode haben müssen. Fordert ein solches Interface CI etwa eine Methode f(), die ein T liefert, wird steht die auch den Meta-Objekten (also den Klassenobjekten) C und C' zur Verfügung, wenn deren (Meta-)Klassen dieses Interface implementieren.

CI c = ...
T o = c.f()

Jetzt kann o natürlich sowohl mit o = C als auch o = C' initialisiert werden, da es sich ja um Objekte mit dem geforderten Interface handelt. Liefert f nun Objekte vom Typ T, können wir f eine Fabrikmethode nennen, da sie ja neue Objekte fabriziert. Der Methode ist es nun überlassen, ob es immer dasselbe Objekt liefert oder bei jedem Aufruf ein neues erzeugt – oder möglicherweise jedes dritte Mal ein neues Objekt. (Im Falle eines Kreators könnte man übrigens über die Meta-Meta-Klasse sicherstellen, wie dessen Semantik aussehen muss; aber das würde hier jetzt wirklich zu weit führen …)

Anstatt f könnte die Methode selbstverständlich auch new heißen (insbesondere dann, wenn es sich bei new nicht um ein Schlüsselwort handelt). Dann hätte man nun Zugriff auf den Kreator wie auf eine normale Methode, und eigentlich spricht auch nichts dagegen. Es ist eigentlich sehr schade, dass das nicht in jeder OO-Sprache so ist und meist nur in dynamischen Sprachen wie Ruby, Python, Groovy etc. gemacht wird.

Auf diese Weise lassen sich nun auch (ohne Introspektion oder Reflektion) Objekte erzeugen, die in Abhängigkeit von ihrer Klasse erzeugt werden, die so echte "Einwohner erster Klasse" (engl.: first class citizen) wären. Um eines der Beispiele aus den Kommentaren zum letzten Blog-Eintrag zu bemühen, könnte nun eine Methode geschrieben werden, die einem Kind ein neues Haustier zuordnet, ohne dessen Art kennen zu müssen.

interface PetAnimal {
PetAnimal(String name); // (1)
static PetAnimal init(String name); // (2)
static String getKind(); // (3)
String getName(); // (4)
}

In Anlehnung an den letzten Blog-Eintrag verlangt diese Klasse einen Initialisierer, dem man einen Namen ĂĽbergeben kann (entweder einen "Konstruktor" (1) oder alternativ eine echte init-Methode (2), die in der implementierenden Klasse als "Konstruktor" realisiert werden muss); dieser Name soll sich dann auch mit getName (4) abfragen lassen. DarĂĽber hinaus soll eine getKind (3) allgemein Auskunft darĂĽber geben, um welche Kategorie Haustier es sich handelt, was fĂĽr alle Instanzen dieser Art gleich sein soll. Was das Haustier sonst noch vermag, scheint fĂĽr unsere Applikation nicht relevant.

class Dog implements PetAnimal {

final String name;
    Dog(String name) {
this.name = name;
}
    String getName() {
return name;
}
    static String getKind() {
return "Dog";
}
    void bark() {
...
}
    Chunk bite() {
return ...;
}

}

Damit sich das PetAnimal-Interface oder die darin enthaltenen Methoden auch auf das Klassenobjekt beziehen und nicht auf die Objekte der Klasse, könnte man sich analog zu statischen Variablen und Methoden – wie auch schon oben verwendet – das Prefix static ebenso für Schnittstellen und Klassen und die darin enthaltenen Felder und Methoden vorstellen. Mit nachfolgender Variante ließe sich schließlich erreichen, dass nur statische bzw. Meta-Methoden möglich wären.

static interface PetAnimal {
PetAnimal(String name);
PetAnimal init(String name);
String getKind();
}
class Dog implements static PetAnimal {
...
}

Dann wĂĽrde das Interface allerdings nicht ĂĽber die Klasse Dog (sondern nur ĂĽber die Klasse Class<Dog>) sichtbar sein, was aber durchaus erwĂĽnscht ist.

assert !(Dog instanceof PetAnimal);
assert Class<Dog> instanceof PetAnimal;

Unabhängig von der syntaktischen Form ließe sich nun eine Methode schreiben, die ohne konkrete Kenntnis der Haustierart ein solches erzeugen kann.

PetAnimal buyPetAnimal(Class<PetAnimal> animal, Owner owner) {

String name = owner.getPetName();

PetAnimal p = animal.new(name);
// oder nicht ganz so stimmig
p = new animal(name);

println("bying a " + animal.getKind();

assert p.getClass() == animal;
assert p.getClass().getKind() == animal.getKind()

return p;

}
PetAnimal myDaughtersAnimal;
if (...) {
myDaughtersAnimal = buyPetAnimal(Dog, myDaughter);
} else {
myDaughtersAnimal = buyPetAnimal(Unicorn, myDaughter);
}

Das ist syntaktisch nicht nur ein bisschen gewöhnungsbedürftig, sondern eventuell nicht ganz so einfach zu verstehen. Diese syntaktische Lösung erlaubt das Programmieren der ersten Meta-Ebene, so wie wir es bereits von vielen Programmiersprachen kennen. Anstatt static könnten wir auch meta hinschreiben, das hätte denselben Effekt (wenngleich es aber nicht immer dieselbe Semantik hätte).

So lassen sich beispielsweise im Zusammenhang mit Collections schöne Einsatzfelder liefern. Eine Collection könnte damit nicht nur über ihre Klasse den Typ der Elemente preisgeben, sondern diese, wenn es sinnvoll ist, auch beliebig neu erzeugen. Auch die Collection selbst könnte (wenn sie immutable ist) über ihr Klassenobjekt eine leere, typgleiche Variante mitführen und bei Bedarf liefern. Überhaupt könnten so viele Methoden derart ausgegliedert werden, dass sie für unterschiedliche Meta-Klassen entweder unterschiedlich implementiert oder für gleiche Meta-Klassen unterschiedlich konfiguriert werden können (was ja beispielsweise eigentlich bei den Generics der Fall ist).

Übrigens bietet sich aus praktischen Gründen noch an, den Kreator nicht new zu nennen, sondern dafür eine dedizierte create-Methode einzuführen, damit man das new nicht überschreiben und so dessen Semantik eineindeutig und unveränderlich definieren kann. Was auch der Grund zu sein scheint, dass in C++-orientieren Sprachen new T() geschrieben und eben nicht T.new() verwendet wird. Mit der create-Methode kann der kreative Entwickler dann nach Belieben Schindluder treiben (wie ja auch in C++ new und delete zur eigenen Speicherverwaltung umdefiniert werden können), um damit gegebenenfalls seine Kollegen in den Wahnsinn treiben. Allerdings spricht vieles dafür auch bei der create-Methode Einschränkungen zu machen, damit nicht etwa zu wenig Speicher geliefert oder das Initialisieren der erzeugten Objekte vergessen wird.

Hinter einem Kostruktor stehen eigentlich zwei unabhängige Konzepte: das der Objekt-Konstruktion, das für die Infrastruktur einen Objektes sorgt, und das der Objekt-Initialisierung, das für den ordnungsgemäßen, initialen Zustand eines Objekts zuständig ist. Mithilfe eines geeigneten (Meta-)Modells und einer dazu passende Umsetzung lässt sich diese Trennung sehr einfach, elegant und schön umsetzten.

Obige Beispiele haben bestimmt gezeigt, dass es unter Umständen nicht ganz so trivial ist, die Meta-Programmierung syntaktisch ansprechend und einleuchtend umzusetzen (besonders, wenn sie nicht von Anfang an Bestandteil der Sprache war). Nichtsdestoweniger kann aber eine Vorstellung von der Meta-Programmierung dazu beitragen, dass man das Programmiermodell seiner Lieblingssprache – insbesondere im Zusammenhang mit Kreator und Initialisierer – besser versteht.

Unabhängig von alledem glaube ich aber nicht, dass sich – aus den verschiedensten Gründen – der Begriff des Konstruktors aus "nicht-konstruktiven" Programmiersprachen entsorgen lässt. Aber vielleicht kann man ein bisschen zum Verständnis beitragen, indem man gelegentlich vom Initializer, Initialisierer (oder Initialisator?) spricht. ()