Werbung

  1. Diese Seite verwendet Cookies. Wenn du dich weiterhin auf dieser Seite aufhältst, akzeptierst du unseren Einsatz von Cookies. Weitere Informationen

[C++] Konstruktoren richtig schreiben

Jeder schreibt sie - aber wie schreibt man sie richtig?

  1. -AB-
    Programmiersprache(n):
    C++
    Wer objektorientiert coded, hat sicher schon viele Konstruktoren geschrieben. Wer das RAII verwendet, nutzt Konstruktoren und Destruktoren, um Operationen an die Lebenszeit von Objekten zu binden.

    Aber zunächst - was ist anders in C++, so dass man einen Konstruktor überhaupt "falsch" schreiben kann?

    1) Was man oft sieht: Der "Java-Style" - Konstruktor
    Nehmen wir eine einfache geometrische Vector-Klasse für 2D an, mit 2 Membervariablen x und y. In Java würde man die Klasse und den Konstruktor wohl wie folgt schreiben:

    Code (Java):
    Quelltext kopieren
    1. class Vec2 {
    2.     public Vec2(float px, float py) {
    3.         x = px;
    4.         y = py;
    5.     }
    6.     public float x;
    7.     public float y;
    8. }
    Leider sieht man solche Konstruktoren in C++ viel zu häufig. Leider deshalb, weil er zeigt, dass man die interne Funktionsweise der Sprache nicht versteht.

    2) Was macht C++ "besonders"?
    Wie auch schon C erlaubt C++ Variablen, nicht initialisiert zu sein. Ursprünglich u.A. aus Performancegründen eingeführt, wurde zugunsten der Abwärtskompatibilitaät zu C dieses Verhalten übernommen.

    Prinzipiell bedeutet es nur, dass intrinsische Datentypen nach der Deklaration nicht automatisch initialisiert werden. So werden im Beispiel
    Code (C++):
    Quelltext kopieren
    1. int a;
    2. float b;
    3. char* c;
    a, b oder c keine Werte zugewiesen - sie enthalten also die Werte, die gerade zufällig dort im Speicher standen, wo die Variable angelegt wurde. Java funktioniert da anders - ob man will oder nicht, wird jede Variable von Java initialisiert, bevor man sie überhaupt in die Finger bekommt. Zahlen haben in Java grundsätzlich einen Wert von 0, Pointer/Referenzen (Wie immer man das in Java nennen will) zeigen auf NULL.
    Dadurch, dass man in C++ völlige Kontrolle über die Initialisierung von Objekten hat, braucht man auch kein separates readonly-Konstrukt - man nutzt einfach const.

    Code (C++):
    Quelltext kopieren
    1. const int a = 5; //so geht es
    2. const float b; //so geht es nicht
    3. b = 5;
    In Java beispielsweise wird jede Variable erst initialisiert - und wenn sie final ist, genau einmal zugewiesen. Eine mehrfache Zuweisung ist untersagt. Dadurch, dass Java die Initialisierung und Zuweisung in dieser Hinsicht nicht so ernst nimmt, erlaubt es, folgenden Code zu schreiben:

    Code (Java):
    Quelltext kopieren
    1. class UnmodifiableVec2 {
    2.     public UnmodifiableVec2(float px, float py) {
    3.         x = px;
    4.         y = py;
    5.     }
    6.     public final float x;
    7.     public final float y;
    8. }
    x und y können, obwohl sie final sind, einmalig zugewiesen werden. Den Zugriff auf x und y vor Zuweisung verbietet Java aber geflissentlich.

    Dieser Code lässt sich in C++ nicht schreiben. Statt const x und y zuzuweisen, *müssen* sie initialisiert werden. Folgender Code führt zum Fehler:
    Code (C++):
    Quelltext kopieren
    1. struct UnmodifiableVec2
    2. {
    3.     UnmodifiableVec2(float px, float py)
    4.     {
    5.         x = px; //Zuweisung auf const - Fehler
    6.         y = py;
    7.     }
    8.     const float x;
    9.     const float y;
    10. };
    3) Die Memberinitialisierungsliste
    Wer einen Konstruktor schreibt, will meistens nur Membervariablen bestimmte Werte geben. Hierfür ist aber die Memberinitialisierungsliste gedacht - nicht der Codeblock der *nach* der Initialisierung der Daten ausgeführt wird.

    Mithilfe der Memberinitialisierungsliste kann man const-Membern Werte geben, Referenzen initialisieren oder speziellen Klassen, sie sich nicht zuweisen, wohl aber initialisieren (konstruieren) lassen, einen Wert verleihen. Klassen, die keinen Default-Konstruktor haben, können logischerweise nicht default-konstruiert werden. Da Klassen aber im Gegensatz zu primitiven Datentypen immer initialisiert werden müssen, können Membervariablen, deren Typ keinen default-Konstruktor hat, auch nur per Memberinitialisierungsliste zugewiesen werden. Und natürlich lassen sich auch alle gewöhnlichen Membervariablen initialisieren.

    const und Referenzen:
    Code (C++):
    Quelltext kopieren
    1. struct UnmodifiableVec2
    2. {
    3.     UnmodifiableVec2(float px, float py) : x(px), y(py), largerComponent(x>y?x:y)
    4.     {
    5.     }
    6.     const float x;
    7.     const float y;
    8.     const float& largerComponent;
    9. };
    Oder ein Beispiel mit nicht default-konstruierbarem Member:
    Code (C++):
    Quelltext kopieren
    1. struct Nodefault
    2. {
    3.     Nodefault(int val) : mValue(val)
    4.     {
    5.     }
    6.  
    7.     int mValue;
    8. };
    9.  
    10. struct NoDefUser
    11. {
    12.     NoDefUser(int number) : mMember(number)
    13.     {
    14.     }
    15.  
    16.     Nodefault mMember;
    17. };
    18.  
    19. struct Broken
    20. {
    21.     Broken(int number)
    22.     {
    23.         //vor der Zuweisung muss mMember schon initialisiert sein
    24.         //aber mit welchem Wert? Der Konstruktor verlangt einen int.
    25.         //Diese Klasse produziert einen Compilerfehler.
    26.         mMember = Nodefault(number);
    27.     }
    28.     Nodefault mMember;
    29. };
    Die meisten Konstruktoren behandeln rein die Initialisierung von Objekten. In diesen Fällen kann der Codeblock gleich leer bleiben. In den seltensten Fällen braucht man Code nach der Initialisierung.

    Nur über die Initialisierungsliste hat man die Möglichkeit, const-Member zu verwenden oder Referenzen zu nutzen. Auch lassen sich die in der RAII-Philosophie verpönten invaliden Objekte, die zwar initialisiert sind, aber keinen sinnvollen Zustand haben, durch Verwendung der Memberinitialisierungsliste verhindern.

    4) Was aber, wenn....
    Hier ein paar Hinweise, wie man trotz widriger Umstände sauberen Code schreiben kann.

    ...wenn ich Werte erst ausrechnen muss?
    Auch in der Memberinitialisierungsliste lässt sich Code ausführen. Daten könnten auch von externen Funktionen berechnet werden:

    Code (C++):
    Quelltext kopieren
    1. struct WeirdVec2
    2. {
    3.     WeirdVec2(float px, float py) : x(px * 42), y(transmogrifyData(py))
    4.     {
    5.     }
    6.     const float x;
    7.     const float y;
    8. };
    Wenn eine Berechnung für mehrere Member exakt wiederholt werden müsste, dann liegt die Vermutung nahe, dass man am Klassendesign etwas ändern sollte.

    ...wenn ich mit new Speicher allokieren will?
    Obiges gilt hier genauso - anstatt einen Pointer erst default (oder mit NULL) zu initialisieren, kann man ihn auch gleich mit dem Rückgabewert von new initialisieren.

    ...wenn ich bestimmte Werte konditional zuweisen will?
    Zum Beispiel soll ein Pointer auf ein Objekt oder auf NULL zeigen, je nachdem, was ein anderer Parameter angibt:
    Code (C++):
    Quelltext kopieren
    1. struct MyClass
    2. {
    3.     MyClass(int * data, bool isDataValid)
    4.     {
    5.         if(isDataValid)
    6.             mData = data;
    7.         else
    8.             mData = nullptr; //vor C++11: NULL
    9.     }
    10.     int* mData;
    11. };
    Vorhin wurde schon eine Möglichkeit genannt - der ternäre ?-Operator. Dann kann mData auch const sein.
    Code (C++):
    Quelltext kopieren
    1. struct MyClass
    2. {
    3.     MyClass(int * data, bool isDataValid)
    4.         : mData(isDataValid ? data : nullptr)
    5.     {
    6.     }
    7.     const int* mData;
    8. };
    Auch hier ist natürlich eine Alternative, die Struktur gleich zu verändern, so dass gleich der richtige Pointer an MyClass übergeben wird, oder, wenn das nicht möglich ist, eine Funktion zu schreiben, die die Daten auf Validität prüft und entweder ein bool returned - für den ?-Operator - oder gleich den korrekten Pointer zurück gibt.
    Code (C++):
    Quelltext kopieren
    1. struct MyClass
    2. {
    3.     MyClass(int * data, bool isDataValid)
    4.         : mData(validateData(data, isDataValid))
    5.     {
    6.     }
    7.     const int* mData;
    8.  
    9. private:
    10.     static int* validateData(int * data, bool isDataValid)
    11.     {
    12.         if(isDataValid)
    13.                 return data;
    14.             else
    15.                 return nullptr;
    16.     }
    17. };
    ...wenn eine Initialisierung fehlschlagen kann, ich aber die Exception fangen und weiter werfen möchte?
    Man kann einen try/catch Block um die gesamte Initialisierungsliste setzen, in etwa:
    Code (C++):
    Quelltext kopieren
    1. struct Stringwrapper
    2. {
    3.     Stringwrapper(const std::string& value)
    4.         try : mValue(register_string(value)) //String wird kopiert, new kann fehlschlagen
    5.         { //leerer Konstruktor-Rumpf
    6.         }
    7.         catch(std::bad_alloc&)
    8.         {
    9.             undo_register_string(value);
    10.             //throw; //kann weggelassen werden, wird implizit gesetzt
    11.         }
    12.     std::string mValue;
    13. };
    Meist will man eine Exception ja fangen, um eventuelle Nebeneffekte wieder rückgängig zu machen, in unserem Beispiel eben das, was auch immer register_string() gerade tut.

    Natürlich erfährt man nicht, an welcher Stelle der Initialisierungsliste etwas schief gelaufen ist. Auch wenn man erst per default initialisiert, und dann Aufruf für Aufruf im Konstruktor Zuweisungen mit try/catch absichert, so muss man sich doch immer noch um jeden einzelnen Seiteneffekt einzeln kümmern. Das produziert unglaublich hässlichen Code. Stattdessen sollte man sich an die RAII halten, einen Effekt im Konstruktor, den entsprechenden Undo-Effekt im Destruktor halten, und immer nur eine Ressource pro Klasse handlen.
    "Ressourcen" bezieht sich hier auf Member, deren Funktionsweise auf Seiteneffekten basiert, bzw einem open()/close() oder lock/unlock Aufruf, ohne die das Programm nicht sauber läuft. Sind diese jeweils bereits im Konstruktor/Destruktor einer Klasse verborgen, lassen sich natürlich beliebig viele solcher "gemanagten" Ressourcen pro Klasse halten.

    ...wenn ich auf *this zugreifen will?
    Antwort: Es kommt darauf an. Der Compiler warnt zu Recht, dass *this noch nicht voll initialisiert wurde, man also den Pointer nicht immer guten Gewissens dereferenzieren (und nutzen) kann.
    In einigen Fällen - zum Beispiel, wenn einem Member eine Callback-Referenz auf das beinhaltende Objekt mitgegeben werden soll, diese Referenz aber während der Initialisierung nicht benutzt wird, ist das kein Problem. Sobald aber Parallelismus oder Vererbung ins Spiel kommt, sollte man wohl eher darauf verzichten. (Auch this im Konstruktorblock zu verwenden kann dann aber schon unerwartete Nebeneffekte haben.)

    Code (C++):
    Quelltext kopieren
    1. struct Container; //forward-declaration um Container& nutzen zu können
    2.  
    3. struct Action
    4. {
    5.     Action(Container& ctnr) : mContainer(ctnr)
    6.     {}
    7.  
    8.     void doAwesome();
    9.  
    10.     Container& mContainer; //Referenz auf das beinhaltende Objekt
    11. };
    12.  
    13. struct Container
    14. {
    15.     Container(int value) : mValue(value), mAction(*this) //*this an dieser Stelle kann eine Warnung geben
    16.     //ist aber in diesem Fall harmlos
    17.     {}
    18.  
    19.     void transmogrify();
    20.  
    21.     int mValue;
    22.     Action mAction;
    23. };
    24.  
    25. // greift auf das äußere Objekt zu und ändert dessen Wert
    26. void Action::doAwesome()
    27. {
    28.     mContainer.mValue = 42;
    29. }
    30.  
    31. //führt die gespeicherte Aktion aus
    32. void Container::transmogrify()
    33. {
    34.     mAction.doAwesome();
    35. }
    In diesem Fall kann man sich sicher sein, dass auf *this nicht zugegriffen wird, bevor die Initialisierung zu Ende ist (auch, wenn die Referenz kopiert wird). Daher ist die Verwendung sicher.

    ...wenn ich nach der Initialisierung Werte prüfen / ausgeben möchte?
    Nun, dann haben wir den legitimen Fall, für den der Codeblock des Konstruktors gedacht ist. :)


    So viel zu Konstruktoren für den Moment. Wenn es Fragen zum Thema Konstruktoren gibt, oben links unter der Überschrift auf "Diskussion" gehen und losplaudern; ich ergänze den Artikel dann entsprechend.
    2bki07 gefällt das.

Letzte Rezensionen

  1. 2bki07
    2bki07
    5/5,
    nicht schlecht, Herr Specht!