AUDACIA Software

Warum Delphi-Klassen auf dem Heap erstellt werden müssen

Kurzer Abriß der technischen Unterschiede zwischen C++- und Delphi-Klassen.
Moritz Beutel, 14.10.2008, 06.07.2009



  1. TObject
  2. Konstruktion und Destruktion
  3. Virtuelle Funktionen in Konstruktor und Destruktor
  4. Exceptions im Konstruktor
  5. Initialisierung
  6. Mehrfachvererbung
  7. Virtuelle Konstruktoren
  8. Klassenfunktionen
  9. Freigabe
  10. Semantik
  11. Benutzung
  12. Syntax für temporäre Stack-Objekte
  13. Allokation und Deallokation
  14. RTTI
  15. Implizit generierte Methoden
  16. Kommentare



C++-Programmierer, die noch wenig Erfahrung mit Delphi und C++Builder haben, wundern sich gerne darüber, daß der Compiler die Konstruktion von Delphi-Klassen auf dem Stack verhindert:
void foo (void)
{
    TStringList sl;
    ...
}

[BCC32 Fehler] main_unit.cpp(16): E2459 Klassen im Delphi-Stil müssen mit dem Operator new erstellt werden


Dies ist eine der Situationen, da die weniger offensichtlichen Unterschiedlichkeiten von C++- und Delphi-Klassen unerwartete Probleme verursachen. In diesem Artikel will ich versuchen, einen Überblick über die grundsätzlichen Eigenschaften von Delphi-Klassen in C++Builder zu geben:


TObject


Alle von TObject abgeleiteten Klassen sind Klassen im Delphi-Stil, vereinfacht: Delphi-Klassen.


Konstruktion und Destruktion


Delphi-Klassen verhalten sich während der Konstruktions- und Destruktionsphase anders als C++-Klassen. Die Konstruktoren werden in der umgekehrten Reihenfolge - von der konkret instantiierten Klasse zur Basisklasse - aufgerufen, und jeder Konstruktor ist selbst dafür verantwortlich, den Basisklassenkonstruktor aufzurufen. (Der C++-Compiler erzwingt das allerdings in der Initialisierungsliste.)


Virtuelle Funktionen in Konstruktor und Destruktor


Werden virtuelle Funktionen, die von einer abgeleiteten Klasse überschrieben werden, im Basisklassenkonstruktor aufgerufen, so wird die virtuelle Funktion der abgeleiteten Klasse aufgerufen. In C++-Klassen riefe man damit die Implementation der Basisklasse, so vorhanden, auf - und falls die Funktion als rein virtuell deklariert war, wird das Programm mit einer "Pure virtual function called"-Assertion terminiert:
struct Base
{
    virtual void foo (void) = 0;
    Base (void) { foo (); }
};
struct Derived : Base
{
    virtual void foo (void) { ShowMessage ("It's me!"); }
};

struct TBase : TObject
{
    virtual void foo (void) = 0;
    TBase (void) { foo (); }
};
struct TDerived : TBase
{
    virtual void foo (void) { ShowMessage ("It's me!"); }
};

void bar (void)
{
    TDerived* td = new TDerived; // zeigt "It's me!" an
    Derived d; // zeigt "Pure virtual function called" an
}

Das Verhalten während der Destruktion ist analog dazu.


Exceptions im Konstruktor


Falls in einem Konstruktor eine Exception geworfen wird, erfolgt der Aufruf des Destruktors, und alle Member-Objekte werden, soweit sie bereits konstruiert waren, zerstört. In einer C++-Klasse wird der Destruktor nicht aufgerufen.


Initialisierung


Alle Datenfelder einer frisch allozierten, noch nicht konstruierten Delphi-Klasse sind mit 0 initialisiert. Das ist beim Aufruf von virtuellen Funktionen in Konstruktoren zu berücksichtigen, denn in genau diesem Zustand befinden sich die Datenfelder einer abgeleiteten Klasse, wenn eine von ihr überschriebene virtuelle Funktion vom Basisklassenkonstruktor aufgerufen wird.


Mehrfachvererbung


Das VMT-Layout ist im Delphi-Stil gehalten und erlaubt daher keine Mehrfachvererbung. Das Ableiten von beliebig vielen Interfaces ist jedoch möglich.


Virtuelle Konstruktoren


Konstruktoren können virtuell sein. (Allerdings können virtuelle Konstruktoren nur in Delphi-Code aufgerufen werden.)


Klassenfunktionen


Delphi-Klassen unterstützen sowohl statische Memberfunktionen (in Delphi: "statische Klassenfunktion") als auch virtuelle statische Memberfunktionen ("virtuelle Klassenfunktion"). Ab C++Builder 2009 können virtuelle statische Memberfunktionen mithilfe des neuen __classmethod-Schlüsselwortes auch in C++ deklariert und überschrieben werden. (Es gibt allerdings noch einige Beschränkungen bei der Verwendung von Metaklassen-Polymorphismus; so ist es beispielsweise derzeit nicht möglich, einen virtuellen Konstruktor über einen Metaklassenzeiger aufzurufen. Dies kann aber mit einer kleinen Delphi-Wrapperfunktion erreicht werden.)


Freigabe


Für Delphi-Klassen existiert ein einziger wohldefinierter Weg zur Destruktion eines Objekts: der Aufruf der Free()-Methode. Diese Methode kümmert sich um die Destruktion des Objektes und die Freigabe des Speichers über den Heap-Memory-Manager. Sämtlicher Delphi-Code gibt Delphi-Klassen auf diesem Weg frei, und auch das Zerstören mittels delete in C++ veranlaßt den Compiler lediglich, so etwas wie ein inline-Äquivalent zu TObject::Free() zu generieren. Im Gegensatz dazu kann eine C++-Klasse auf dem Heap, auf dem Stack oder im statischen Speichersegment alloziert werden. Der Standard erfordert, daß der Destruktor ggf. die Freigabe des Speichers übernimmt, daher muß dieser wissen, in welchem dieser Speichersegmente sich das Objekt befindet. Das implementieren C++-Compiler gewöhnlich über einen versteckten zusätzlichen Destruktorparameter, dessen Wert davon abhängt, ob das Objekt freigegeben (delete obj), explizit (obj->T ()) oder implizit (beim Verlassen des Gültigkeitsbereiches) zerstört wird. Diesen versteckten Parameter gibt es auch bei Destruktoren von Delphi-Klassen, doch ist er nicht ohne weiteres zugänglich.


Semantik


Delphi-Code wie
procedure foo;
var
  sl1, sl2: TStringList;
begin
  sl1 := TStringList.Create;
  sl2 := sl1; // diese Zeile
  ...
kopiert nur die Objektreferenz, nicht das Objekt selbst; in C++ entspräche das einer Kopie des Zeigers. Es gibt für Delphi-Objekte keine intrinsische Kopier- und Zuweisungssemantik (erst ab TPersistent gibt es die Assign()-Methode). Man hätte also nicht die gewohnten Vorteile von stackbasierten VCL-Objekten: da sie auch keinen Kopierkonstruktor haben, wären sie nicht in Containern oder als Funktionsrückgabewerte verwendbar, auch wenn der Compiler die Allokation von Delphi-Klassen in statischem oder Stack-Speicher erlaubte.
Das ist nicht notwendigerweise ein Nachteil. Wie Barry Kelly hier diskutiert, ist der C++-Ansatz, der Typen mit Wertsemantik zum Quasi-Standard macht, für die meisten Arten von Klassen unangebracht. Weiterhin muß sich ein C++-Programmierer, der Objekte per Wert zurückgeben oder die Größe dynamischer Arrays von Werttypen verändern möchte, ohne spürbare Performanceeinbußen hinzunehmen, auf Dinge wie Named Return Value Optimization (eine Optimierung, die leider noch nicht selbstverständlich geworden ist) und die Move-Semantik (ein Vorschlag für den kommenden C++-Standard, den C++Builder 2009 bereits implementiert) verlassen. Ein Delphi-Programmierer gäbe einfach einen Zeiger zurück und übertrüge damit das Besitztum des Objektes an den Aufrufenden.


Benutzung


Beim Erstellen eines lokalen Objekts einer Delphi-Klasse sollte man auf die Exception-Sicherheit achten. Der manuelle Aufruf von delete ist nicht exception-sicher; dieser Aufruf muß dazu zumindest von einem try/finally-Konstrukt umgeben werden, wie es in Delphi üblich wäre:
void foobar (void)
{
    TStringList* sl = 0;
    try
    {
        sl = new TStringList;
        ...
    }
    __finally
    {
        delete sl;
    }
}


Nun ist dieses Konstrukt zwar einfach nachvollziehbar und erfüllt seinen Zweck, aber es sieht nicht besonders schön aus, und wenn mehrere Objekte, die an unterschiedlichen Stellen oder gar in einem inneren Gültigkeitsbereich konstruiert werden, dazukommen, wird es noch viel schlimmer. Der in C++ übliche Ansatz wäre die Verwendung eines RAII-Wrappers wie std::auto_ptr:
#include <memory>

void foobar (void)
{
    std::auto_ptr <TStringList> sl (new TStringList);
    ...
}


std::auto_ptr darf allerdings nicht als Rückgabewert einer Funktion oder in Containern verwendet werden, da diese Klasse keine Kopiersemantik bereitstellt. An deren Stelle wäre ein referenzzählender Smart-Pointer, z.B. std::tr1::shared_ptr, passend:
#include <memory> // TR1-Unterstützung seit C++Builder 2009
#include <vector>

typedef std::tr1::shared_ptr <TStringList> TStringListPtr;

TStringListPtr stringListFactory (void)
{
    TStringListPtr result (new TStringList);
    result->Add ("Initial string");
    return result;
}

void foobaz (void)
{
    std::vector <TStringListPtr> slv;
    slv.push_back (stringListFactory ());
    ...
}


Falls der Rückgabetyp den Benutzer nicht an die Verwendung eines bestimmten Smart-Pointers binden soll, ist auch folgender Ansatz erwägenswert:
#include <memory>

TStringList* stringListFactory (void)
{
    std::auto_ptr <TStringList> result (new TStringList);
    result->Add ("Initial string");
    return result.release ();
}

void foobaz (void)
{
    std::auto_ptr <TStringList> theStringList (stringListFactory ());
}



Addenda vom 06.07.2009:

Syntax für temporäre Stack-Objekte


Wie in External Exception EEFFACE erläutert wurde, gestattet der Compiler die Konstruktion eines Delphi-Objektes auch ohne new, wenn die Syntax für die Erstellung namenloser temporärer Objekte angewandt wird. Obgleich als Grund für diese Ausnahme das erwähnte paradigmatische Zugeständnis beim Werfen von Exceptions gesehen werden kann, limitiert der Compiler diese Ausnahme nicht auf die throw-Anweisung. Folgender Code ist also zulässig:
class TMyThread : public TThread
{
protected:
    virtual void __fastcall Execute (void) {}

public:
    TMyThread (void) : TThread (false) {}
};

void startMyThreadTheFreakyWay (void)
{
    TMyThread ().FreeOnTerminate = true;
}
Der vom Compiler generierte Code ist jedoch äquivalent zur Allokation mit new:
void startMyThreadTheNormalWay (void)
{
    new TMyThread ()->FreeOnTerminate = true;
}
Es besteht keine Gewißheit darüber, ob diese "semantische Lücke" beabsichtigt ist. Außer beim Werfen von Exceptions sollte man daher von der Nutzung absehen, zumal diese Variante bei Objekten, deren Freigabe dem Ersteller obliegt, entgegen dem Anschein ein Speicherleck verursacht.


Allokation und Deallokation


Die Erstellungsphase einer C++-Klasse ist strikt unterteilt in Allokation und Konstruktion: für die Allokation ist der Aufrufer verantwortlich, für die Konstruktion der Konstruktor der jeweiligen Klasse; analoges gilt für Destruktion und Deallokation (mit einer Ausnahme, die ich weiter unten nenne). Die meisten syntaktischen Varianten der Konstruktion implizieren zugleich eine bestimmte Variante der Allokation:
#include <string>

void constructSomeObjects (void)
{
        // Allokation auf dem Stack
    std::string myStackString ("foo");
    myStackString += std::string ("bar");

        // Allokation auf dem Heap
    std::string* myHeapString = new std::string ("baz");
    delete myHeapString;

        // Explizite Konstruktion und Destruktion
    char buffer[sizeof (std::string)];
    std::string* anotherStackString = new (buffer) std::string ("wtf?");
    anotherStackString->~string ();
}
Darüber hinaus gestattet die Sprachspezifikation die Überladung der für die Allokation und Freigabe von Speicher zuständigen Operatoren sowohl global als auch spezifisch für eine Klasse:
class MyCppClass
{
public:
    void* operator new (unsigned long size)
    { return std::malloc (size); }
    void operator delete (void* ptr)
    { std::free (ptr); }

    virtual ~MyCppClass (void) {}
};
Bei einem virtuellen Destruktor allerdings kommt besagte Ausnahme ins Spiel:
ISO 14882, §12.4.11:
At the point of definition of a virtual destructor (including an implicit definition (12.8)), non-placement operator delete shall be looked up in the scope of the destructor’s class (3.4.1) and if found shall be accessible and unambiguous. [Note: this assures that an operator delete corresponding to the dynamic type of an object is available for the delete-expression (12.5).]

Für Delphi-Klassen verläuft die Konstruktionsphase etwas anders. Hallvard Vassbotn hat den Ablauf hier ausführlich beschrieben. Der wesentliche Unterschied besteht darin, daß Allokation und Deallokation Aufgabe von Konstruktoren und Destruktor sind.
Die Allokationsfunktion für Delphi-Klassen, NewInstance(), ist eine virtuelle Klassenfunktion; um also obiges Allokationsverhalten für Delphi-Klassen zu erreichen, müssen wir NewInstance() und FreeInstance() überschreiben:
class TMyCustomAllocatedClass : public TObject
{
public:
#ifdef __CODEGEARC__ // C++Builder 2009 unterstützt Klassenmethoden
    __classmethod virtual TObject* __fastcall NewInstance (void)
    {
        void* memory = std::malloc (InstanceSize ());
        return InitInstance (memory);
    }
#else
    virtual TObject* __fastcall NewInstance (TMetaClass* Self)
    {
        void* memory = std::malloc (Self->InstanceSize ());
        return Self->InitInstance (memory);
    }
#endif
    virtual void __fastcall FreeInstance (void)
    { std::free (this); }
};

InitInstance() ist dafür zuständig, den übergebenen Speicher mit 0 zu initialisieren und den VMT-Zeiger einzutragen. Die Funktion InstanceSize() liest die Größe des zu konstruierenden Objektes, die in dessen RTTI vermerkt ist, aus. Zu beachten ist, daß sizeof(TMyCustomAllocatedClass) aus zwei Gründen keinen adäquaten Ersatz darstellt: erstens, weil unsere Allokationsfunktion auch für von TMyCustomAllocatedClass abgeleitete Klassen aufgerufen wird, und zweitens, weil CodeGear in Delphi/C++Builder 2009 ein sogenanntes "Hidden Field" eingeführt hat:
  // System.pas, 263ff
  { Hidden TObject field info }
  hfFieldSize          = 4;
  hfMonitorOffset      = 0;

Jedes Objekt einer Delphi-Klasse enthält nach dem Ende des Klassenlayouts zusätzliche vier Bytes; mit deren Hilfe kann man mit den Methoden der Klasse System.TMonitor (analog zu System.Threading.Monitor in .NET) ein Objekt für einen kritischen Abschnitt sperren. InstanceSize() addiert vier Bytes zur Klassenlayoutgröße und sorgt so dafür, daß der benötigte Speicher auch stets alloziert wird; sizeof(TMyCustomAllocatedClass) tut dies nicht.

Eine offensichtliche Konsequenz dieser Allokationsmethode ist, daß das Überladen des globalen operator new auf Delphi-Klassen keine Auswirkung haben kann, denn der Destruktor einer Delphi-Klasse ist stets virtuell und benutzt demnach immer die virtuelle Funktion FreeInstance(). Aus demselben Grunde kann auch ein Objekt einer Delphi-Klasse nicht einfach auf dem Stack angelegt werden. Das bedeutet nicht, daß es technisch nicht möglich wäre. Auch Konstruktoren und Destruktoren von Delphi-Klassen haben einen versteckten Parameter, der beispielsweise beim Aufrufen des Basisklassenkonstruktors oder -destruktors verhindert, daß für das Objekt nochmals Speicherplatz alloziert oder dieser vor der Zeit freigegeben wird. Mit viel Trickserei kann auch tatsächlich ein Delphi-Objekt auf dem Stack alloziert werden - auch wenn das in der Praxis nur von sehr eingeschränkter Nützlichkeit ist.


RTTI


Da für Delphi-Klassen nicht (nur) die C++-, sondern auch die Delphi-RTTI erzeugt wird, ist es möglich, die sogenannte published-Sektion zu nutzen; dort deklarierte Methoden, Felder und Properties werden in die RTTI der Klasse eingetragen.


Implizit generierte Methoden


Der C++-Standard sieht vor, daß für Klassen und Strukturen Default-Konstruktor, Kopierkonstruktor, Zuweisungsoperator und Destruktor, sofern nicht explizit angegeben, implizit generiert werden. Für Delphi-Klassen werden diese Methoden bei Bedarf ebenfalls generiert.

Das Standardverhalten des Zuweisungsoperators ist die elementweise Zuweisung. Sobald aber eine Klasse (unabhängig davon, ob es eine Delphi- oder C++-Klasse ist) Properties benutzt und nichttriviale Member-Variablen hat, beanstandet der Compiler eine Zuweisung mit folgender Fehlermeldung:
class ClassWithProperties
{
private:
    int FFoo;
    String FBar;

public:
    __property int Foo = { read = FFoo, write = FFoo };
};
E2328: Klassen mit Eigenschaften dürfen nicht über ihren Wert kopiert werden

(Inkonsistenterweise läßt der Compiler den Aufruf des Kopierkonstruktors ungeachtet dessen zu.)



Referenzen


[1] Die C++Builder 6-Dokumentation, besonders das Kapitel Anwendungsentwicklung mit C++Builder / C++ Sprachunterstützung für die VCL
[2] Barry Kelly, Programming language design philosophy: C++'s value orientation, 08.08.2006
[3] Ayman B. Shoukry, Named Return Value Optimization in Visual C++ 2005, 10.2005
[4] The C++ Standards Committee, A Brief Introduction to Rvalue References, 12.06.2006
[5] Hallvard Vassbotn, The Rise and Fall of TObject, 07.1998

Dieser Artikel basiert auf Material, das ich zuvor im c++.de-Forum verwendet hatte.


Kommentare


Neuer Eintrag:

Name:
E-Mail:
Website:
Datum:
Anzahl der Zeichen in Ihrem Namen:
Text: