|
|||||||||||||||||||||||||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||||||||||||||||||||||||||
|
Polymorphic Methods and Constructors
|
||||||||||||||||||||||||||||||||||||||||||
Der Aufruf von polymorphen Methoden während der Initialisierungsphase
eines Objekts kann zu Überraschungen führen. Deshalb gibt es
die Regel: „Man soll keine non-final Methoden im Konstruktor aufrufen.“
In diesem Artikel wollen wir uns ansehen, warum diese Regel sinnvoll ist
und wie genau die Initialisierung von Objekten in Java funktioniert.
Ein problematisches BeispielBeginnen wir mit einem Fallbeispiel, an dem man sehen kann, daß die Konstruktion von Objekten in Klassenhierarchien bisweilen zu Überraschungen führen kann. Wir betrachten eine Hierarchie von Klassen, die das Personal einer Firma abbilden: eine Superklasse StaffMember für „normale“ Mitarbeiter und eine abgeleitete Klasse BoardMember für die Mitglieder des Vorstands. Der wesentliche Unterschied besteht darin, daß Mitglieder des Vorstands einen Bonus auf ihre Entlohnung bekommen; deshalb hat die Klasse BoardMember ein zusätzliches Feld. Hier eine verkürzte Form dieser Klassen:
public class StaffMember {
public StaffMember(Person p, long s, OrgUnit
o) {
public BoardMember(Person p, long s,long b, OrgUnit
o) {
Neben den hier gezeigten Klassen gibt es ein Klasse OrgUnit, welche die Abteilungen der Firma repräsentiert. Was diese Klasse genau tut, ist hier unerheblich. Wichtig ist in unserem Zusammenhang nur, daß jeder Mitarbeiter bei seiner Organisationseinheit registriert wird. Das geschieht im Konstruktor der Klasse StaffMember über die Methode OrgUnit.addMember(). Hier eine verkürzte Form dieser Klasse OrgUnit:
public class OrgUnit {
public final void addMember(StaffMember m) {
Nun ergibt sich folgender Effekt: wenn man Angestellte und Vorstandsmitglieder konstruiert und den verschiedenen Organisationseinheiten zuordnet, wird man feststellen, daß etwas mit den Gehaltsberechnungen nicht stimmt. Hier ein Testprogramm:
public final class Corporation {
public Corporation() {
dep = new OrgUnit("Board");
Hier die Ausgabe, die das Testprogramm produziert, unter der Annahme, daß entsprechende toString()-Methoden implementiert wurden:
corporation.OrgUnit:
corporation. OrgUnit:
Die Vorstandsmitglieder bekommen zwar ihren Gehaltsbonus, aber in den
Gesamtkosten der Organisationseinheit ist offenbar nur das Grundgehalt
berücksichtigt. Wie kann das passieren?
ProblemanalyseDas Problem entsteht bei der Konstruktion der Vorstandsmitglieder. Der Konstruktor der Klasse BoardMember ruft zunächst einmal einen Superklassenkonstruktor auf. Danach wird das Bonus-Feld initialisiert. Im Superklassenkonstruktor, d.h. im Konstruktor der Klasse StaffMember, werden die Felder der Klasse StaffMember initialisiert und danach wird die addMember()-Methode der Organisationseinheit aufgerufen. In der addMember()-Methode der Klasse OrgUnit wird das BoardMember-Objekt in den Set der Mitarbeiter eingehängt und die Gesamtkosten der Abteilung werden um das Gehalt des neuen Mitglieds erhöht.Damit sind ohne jeden Zweifel beim Verlassen des BoardMember-Konstruktors alle Felder des BoardMember-Objekts korrekt initialisiert. Die Methode getCompensation() der Klasse BoardMember wird korrekt das Gehalt inklusive Bonus berechnen. Warum stimmen dann die Gesamtkosten der Organisationseinheit nicht? Dort wurde doch auch die Methode getCompensation() der Klasse BoardMember aufgerufen. Das Problem liegt darin, daß die Methode getCompensation() während der Konstruktion des BoardMember-Objekts aufgerufen wird, und zwar zu einem Zeitpunkt, zu dem das BoardMember-Objekt noch gar nicht fertig ist. Ein Teil des Objekts, nämlich das Bonus-Feld, ist zu jenem Zeitpunkt noch nicht initialisiert und hat noch immer den Wert 0. Die Methode getCompensation() greift auf das noch nicht initialisierte Bonus-Feld zu und berechnet das Gehalt folgerichtig mit Bonus 0.
Um das nachzuvollziehen sehen wir uns einmal im Detail an, wie Objekte
in Java konstruiert werden.
Objektkonstruktion in JavaDie Konstruktion eines Objekts läuft in Java wie folgt ab:
class Elem2D {
class ColoredElem2D extends Elem2D {
...
Wenn ein Objekt vom Subtyp ColoredElem2D erzeugt wird, dann erfolgt die Konstruktion in folgenden Schritten:
class Elem3D {
class ColoredElem3D extends Elem3D {
ColoredElem3D(int x, int y, int z, int col) {
...
Wenn ein Objekt vom Subtyp ColoredElem3D erzeugt wird, dann erfolgt die Konstruktion in folgenden Schritten:
Es wird zuerst z mit 3 initialisiert, so wie im Initialisierungsausdruck angegeben. Dann wird der Instance Initializer ausgeführt; dabei wird x mit dem Inhalt von z initialisiert.
Kehren wir zu unserem Ausgangspunkt zurück. Unser Problem in
der Fallstudie stammt vom Aufruf einer polymorphen Methode während
der Konstruktion. Sehen wir uns also an, wie polymorphe Methoden
im Konstruktionsprozeß behandelt werden.
Aufruf polymorpher Methoden während der KonstruktionWährend der Konstruktion können Methoden aufgerufen werden. Dabei ist es auch erlaubt, nicht-statische Methoden der eigenen Klasse aufzurufen. Von Interesse für unser Problem sind dabei diejenigen Methoden eine Klasse, die in Subklassen redefiniert werden können, d.h. alle nicht-statischen Methoden, die weder private noch final sind. Solche redefinierbaren Methoden können in einem Superklassenkonstruktor aufgerufen werden, und das ist auch nicht weiter problematisch, solange nur Superklassenobjekte erzeugt werden. Wenn ein Superklassenobjekt konstruiert wird, dann wird während der Konstruktion die Superklassenvariante der fraglichen Methode aufgerufen, und es passiert genau das, was man erwartet.Der Superklassenkonstruktor wird aber nicht nur für die Konstruktion von Superklassenobjekten verwendet, sondern wird auch während der Konstruktion von Subklassenobjekten aufgerufen. Wenn man sich die Abfolge der Aktionen während der Konstruktion eines Subklassenobjekts noch einmal anschaut, dann stellt man fest, daß entweder explizit über super(...) oder implizit vom Compiler ein Konstruktor der Superklasse angestoßen wird. Dann ergibt sich eine interessante Situation, wenn dieser Superklassenkonstruktor eine redefinierte Methode aufruft: dem Compiler stehen für den Aufruf sowohl die Variante der Methode aus der Superklasse als auch die Variante der Methode aus der Subklasse zur Verfügung. Er entscheidet, welche der beiden Methoden gerufen wird, abhängig vom Typ des Objekts, auf dem die Methode angestoßen wird. Während der Konstruktion eines Subklassenobjekts wird er also die Subklassenvariante der redefinierten Methode aufrufen. Die Subklassenvariante greift unter Umständen auf subklassenspezifische Felder zu. Nun ist die Frage: in welchem Zustand sind diese Subklassenfelder zum Zeitpunkt des Aufrufs? Schließlich befinden wird uns gerade mitten im Prozeß der Initialisierung des Subklassenobjekts. Sind alle Felder schon ordnungsgemäß initialisiert, wenn die Subklassenvariante der Methode gerufen wird, oder nicht? Oder läßt sich das vielleicht gar nicht vorhersagen? Ob die möglicherweise benutzen Felder des Subklassenobjekts bereits ordnungsgemäß initialisiert sind, hängt davon ab, zu welchem Zeitpunkt während der Konstruktion die fragliche Methode aufgerufen wird. Sehen wir uns an, was da genau passiert. Betrachten wir dazu ein einfaches Beispiel:
class Elem2D {
class Elem3D extends Elem2D {
...
Wenn ein Objekt vom Subtyp Elem3D erzeugt wird, dann erfolgt die Konstruktion wie bereits zuvor erläutert in folgenden Schritten:
Die Überraschung rührt daher, daß die Verwendung von halbfertigen Objekten nicht den Konventionen der Objektorientierung entspricht. Die Grundidee in der Objektorientierung ist eigentlich, daß jede Methode eines Objekts das Objekt von einem konsistenten Zustand in einen anderen konsistenten Zustand überführt. Der Konstruktor hat dabei die besondere Aufgabe, den ersten konsistenten Zustand herzustellen. Alle Methode, außer dem Konstruktor, können immer davon ausgehen, daß sie auf einem konsistenten Objekt aufgerufen werden. Das genau ist aber in unserem Beispiel nicht der Fall. Die Methode calculateY() der Klasse Elem2D wird auf einem inkonsistenten, halbfertigen Objekt aufgerufen. Darauf ist die Methode nicht vorbereitet und das Ergebnis dürfte überraschend oder gar fehlerhaft, sein. Was kann man in solchen Fällen tun? Theoretisch könnte man die fragliche Methode darauf vorbereiten, mit inkonsistenten Objekte fertig zu werden. Das macht aber nur in seltenen Ausnahmefällen Sinn. Bestenfalls könnte man eine Konsistenzprüfung machen und eine Exception werfen, wenn das Objekt inkonsistent ist. Das hätte aber zur Folge, daß jede Konstruktion von Subklassenobjekten grundsätzlich fehlschlägt. Keine gute Idee. Aus diesen Überlegungen ergibt sich die Empfehlung, die in vielen Büchern zu finden ist (siehe / VER /, / HAG /, / BLO /): Man soll niemals überschreibbare Methoden in einem Konstruktor aufrufen .Die Regel ist sinnvoll und sollte auf jeden Fall beherzigt werden. Haben wir diese Regel in unserem problematischen Beispiel der Organisationseinheit mit ihren Mitarbeitern verletzt? Kehren wir zur Fallstudie zurück. Zurück zum problematischen BeispielSehen wir uns die Konstruktoren aus unserer Fallstudie noch einmal an.
public class StaffMember {
public StaffMember(Person p, long s, OrgUnit
o) {
public BoardMember(Person p, long s,long b, OrgUnit
o) {
Wenn ein Objekt vom Subtyp BoardMember erzeugt wird, dann erfolgt die Konstruktion wie bereits zuvor erläutert in folgenden Schritten:
public class OrgUnit {
public final void addMember(StaffMember m) {
Das Problem kommt daher, daß die Regel „Man soll niemals überschreibbare Methoden in einem Konstruktor aufrufen.“ etwas verkürzt ist. Eigentlich müßte es heißen: „Man soll niemals überschreibbare Methoden der eigenen Klasse in einem Konstruktor aufrufen – auch nicht indirekt.“ In unserer Fallstudie haben wird die this-Referenz als Argument an eine Methode einer anderen Klasse übergeben, die die this-Referenz verwendet hat, um eine überschreibbare Methode unserer Klasse aufzurufen. Wir haben also auf Umwegen eine überschreibbare Methode unserer eigenen Klasse während der Konstruktion aufgerufen, und das führt dann zu Problemen. Außerdem gibt die Methode getCompensation() die this-Referenz an die Methode TreeSet.add() weiter. Was macht die add()-Methode der TreeSet-Klasse eigentlich mit unserem unfertigen Objekt? Sie wird die compareTo()-Methode unserer Subklasse rufen, die wiederum das noch nicht initialisierte Bonus-Feld für den Vergleich heranziehen wird, und auch das wird zu Fehlern führen, die vielleicht nicht sofort, sondern erst irgendwann später auffallen werden. Was macht man also, wenn man eine solche problematische Situation entdeckt hat? Leider gibt es meistens keine einfache Lösung, die ohne ein Redesign der Klassenhierarchie und der an der Konstruktion beteiligten Methoden auskäme. Es empfiehlt sich also, bereits beim Design einer non-final-Klasse darauf zu achten, daß keine überschreibbaren Methoden der eigenen Klasse direkt oder indirekt aus dem Konstruktor heraus aufgerufen werden. Wenn man in Versuchung ist, es dennoch zu tun, dann sollte man sein Design sofort noch einmal überdenken. Nun ist die Weitergabe der this-Referenz während der Konstruktion durchaus gängige Praxis, typischerweise im Zusammenhang mit Registrierungen, die während der Konstruktion durchgeführt werden. In dynamischen System kommt es häufiger vor, daß sich Objekte während der Konstruktion bei einer anderen logischen Einheit im System anmelden, damit sie später unter bestimmten Umständen zurückgerufen werden; das bezeichnet man als Callback-Registrierung. Dabei wird, wie in unserem Beispiel, die this-Referenz auf ein noch unfertiges Objekt zur Registrierung weiter gegeben. Callback-Registrierung während der Konstruktion ist solange unproblematisch, wie keine Vererbung im Spiel ist. Dann kann man die Registrierung als letzte Anweisung im Konstruktor-Body machen, also dann, wenn das Objekt bereits fertig initialisiert ist. Sobald Subklassen existieren, kann es unter Umständen nicht mehr gewährleistet werden, daß die Registrierung erst ganz am Ende statt findet. Dann wird in der Tat ein halbfertiges Objekt registriert. Solange die this-Referenz nur herumgereicht und irgendwo gespeichert wird, ist das ungefährlich. Man muß aber darauf achten, daß die Methode, die die this-Referenz auf ein noch unfertiges Objekt bekommt, diese Referenz nicht zum Aufruf von Methoden oder zum Zugriff auf die Felder des Objekts verwendet. Bei einer typischen Callback-Registierung ist das auch nicht der Fall. Methoden des registrierten Objekts werden typischerweise erst sehr viel später, nach Beendigung der Konstruktion, gerufen.
In unserem Beispiel haben wir genau das mißachtet. Wir haben die
this-Referenz auf ein noch unfertiges Objekt an die Registrierungsmethode
OrgUnit.addMember() übergeben und die hat sofort eine Methode des
halbfertigen Objekts aufgerufen, was zu den erläuterten Problemen
geführt hat.
ZusammenfassungKonstruktoren arbeiten während der Konstruktion auf unfertigen Objekten und sollten deshalb äußerst vorsichtig sein beim Zugriff auf möglicherweise noch nicht initialisierte Teile des Objekts. Problematisch ist es insbesondere, wenn Superklassenkonstruktoren überschreibbare Methoden (d.h. non-final oder non-private Methoden) der eigenen Klasse direkt oder indirekt aufrufen. Die Superklassenkonstruktoren werden relativ früh während der Objekterzeugung gerufen und finden daher ein unfertiges Objekt vor. Wenn sie überschriebene Methoden aufrufen, dann greifen diese Methoden unter Umständen die auf die noch nicht initialisierten Felder des unfertigen Objekts zugreifen. Das ist normalerweise ein Problem, das man nur vermeiden kann, indem man während der Objekterzeugung weder direkt noch indirekt überschreibbare Methoden aufruft.LeserzuschriftGanz offensichtlich ist das ganze Problem keineswegs akademischer Natur, wie der folgende Leserbrief zeigt:
Literaturverweise
|
|||||||||||||||||||||||||||||||||||||||||||
© Copyright 1995-2008 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/11.PolyMethodsInCtor/11.PolyMethodsInCtor.html> last update: 26 Nov 2008 |