Exportieren (0) Drucken
Alle erweitern
Erweitern Minimieren

Ressourcenbereinigung

Veröffentlicht: 14. Aug 2006
Von Stanley B. Lippman

In meinem letzten Artikel haben wir erfolgreich einen Wrapper für meine native Anwendung Text Query Language (TQL) zur Verarbeitung natürlichsprachlicher Texte erstellt. Die Anwendung wurde zwar richtig ausgeführt, aber ich hatte einige kleine Fehler ins Programm eingebaut, die ich in diesem Artikel gerne korrigieren möchte. Für all diejenigen, die sich den Code nicht eingeprägt haben, zeigt Abbildung 1 , wie er zuletzt aussah.

Das erste Problem ist recht trivial, es wäre aber peinlich, wenn die Klasse allgemein verfügbar gemacht würde, weil der Gültigkeitsbereich aller Typen innerhalb eines Moduls standardmäßig auf das betreffende Modul beschränkt ist. Das heißt, sie sind für Elemente außerhalb des Moduls nicht sichtbar. Diese Standardeinstellung ist sinnvoll, weil sie verhindert, dass der globale Namespace der Anwendung durch rein modulbezogene Typen korrumpiert wird. Sie müssen alle Klassen, die modulübergreifend sichtbar sein sollen, explizit als public deklarieren:

public ref class TQL {
public:
    // no change ...
};

Dies gilt auch für native Klassen, die unter C++/CLI kompiliert werden und deren Sichtbarkeit daher der C++-Sichtbarkeit nativer Typen entgegengesetzt ist. Wenn eine native Klasse modulübergreifend verfügbar sein soll, müssen Sie diese Klasse daher ebenfalls als public deklarieren. Dies ist eine Erweiterung von ISO-C++ in Visual C++® 2005.

Das zweite Problem betrifft die Bereinigung von Ressourcen, ausgenommen den Speicher, der für den CLR- (Common Language Runtime-)Heap reserviert ist und vom Garbage Collector bereinigt wird. Wenn ein Heap vom Garbage Collector bereinigt wird, hat das schwerwiegende Auswirkungen für Destruktoren, und an diesem Punkt setzen wir an. Unter Microsoft® .NET Framework gibt es das Konzept der Destruktoren nicht, und daher haben wir die Destruktion künstlich auf .NET übertragen, wie ich gleich erläutern werde.

Denken Sie erstens daran, dass die Destruktion ein zweistufiger Prozess ist. Wenn Sie beispielsweise einen Zeiger löschen, prüft der Compiler zuerst intern, ob der Zeiger nicht gleich Null ist. Wenn er Null ist, geschieht nichts weiter. Andernfalls ruft der Compiler den Destruktor auf, der zum fraglichen Objekt gehört. Wenn dies abgeschlossen wird, ohne dass eine Ausnahme ausgelöst wird, wird der eigentliche Heapspeicher freigegeben. Bei CLR-Typen wird diese zweistufige Operation von zwei verschiedenen Komponenten ausgeführt. Der Destruktor wird vom Compiler aufgerufen werden. Die tatsächliche Freigabe des Speichers wird stets vom Garbage Collector gehandhabt. Wenn Sie den Destruktor in .NET aufrufen, wurde der mit dem Verweisobjekt verbundene Speicher noch nicht freigegeben, und er wird auch erst dann zurückgefordert, wenn der Garbage Collector seine Arbeit aufnimmt.

Problematisch an meiner ersten Implementierung ist daher, dass jedes TQL-Objekt zwar schlussendlich vom Garbage Collector freigegeben wird (die zweite Phase der Destruktion), aber dass der zugehörige Destruktor nicht aufgerufen wird, also die erste (und aus meiner Sicht) wichtigere Phase der Destruktion. Bevor der mit einem Objekt verbundene Speicher vom Garbage Collector zurückgefordert wird, wird die zugehörige Finalize-Methode, sofern vorhanden, aufgerufen. Stellen Sie sich diese Methode als eine Art Superdestruktor vor, da sie nicht an die Nutzungsdauer eines Objekts im Progamm gebunden ist. Dies wird als Beendigung bezeichnet. Der Zeitpunkt, zu dem eine Finalize-Methode aufgerufen wird oder nicht, bleibt undefiniert. Das bedeutet, dass der Garbage Collector eine nicht deterministische Beendigung darstellt.

Die nicht deterministische Beendigung eignet sich besonders für die dynamische Speicherverwaltung. Wenn der verfügbare Speicher knapp wird, kommt der Garbage Collector ins Spiel, und die Dinge regeln sich praktisch von selbst. In einer Umgebung, die vom Garbage Collector bereinigt wurde, sind keine Destruktoren zur Freigabe des Speichers erforderlich.

Die nicht deterministische Beendigung funktioniert allerdings nicht so gut, wenn ein Objekt eine kritische Ressource hält, z. B. eine Datenbankverbindung, nativen Heapspeicher oder eine Art von Sperre. In einem solchen Fall muss diese Ressource so bald wie möglich freigegeben werden. In native C++ geschieht dies durch die Paarung von Konstruktor und Destruktor. Sobald das Objekt nicht mehr verwendet wird, was entweder durch den Abschluss des lokalen Blocks, in dem das Objekt definiert ist, oder durch die Auflösung des Stack wegen der Auslösung einer Aufnahme angezeigt wird, kommt der Destruktor zum Zug, und die Ressource wird automatisch freigegeben. Dieser Ansatz funktioniert sehr gut, fehlte aber leider in der CLR.

Es wurde bald klar, dass Programmierer, die mit .NET arbeiten, auf eine kanonische Weise angeben können müssen, dass potenziell knappe Ressourcen, die von Objekten eines Typs belegt werden, rasch freigegeben werden müssen. Die Designlösung ist die Schnittstelle System::IDisposable, die eine einzige Dispose-Methode mit dem Bereinigungscode enthält. Der Hauptnachteil dieser Lösung besteht darin, dass Dispose vom Benutzer explizit aufgerufen werden muss. Dies ist potenziell fehlerträchtig und daher ein Rückschritt. Die Sprache C# stellt durch eine spezielle using-Anweisung eine bescheidene Form der Automatisierung bereit, die bei ordnungsgemäßem Einsatz für die in der Anweisung enthaltenen Objekte einen Aufruf der Dispose-Methode erzeugt.

Seit Visual Studio® 2005 wird in Visual C++ stattdessen der Destruktor einer Klasse in die Dispose-Methode der Klasse übersetzt. Der Destruktor wird intern in die Dispose-Methode umbenannt, und die Verweisklasse wird automatisch um eine Implementierung der IDispose-Schnittstelle erweitert:

// internal transformation of destructor under C++/CLI
public ref class TQL : IDisposable 
{
...
void Dispose() { 
    // suppress finalize method for this object 
    // then generate the user code ...
    System::GC::SuppressFinalize(this);
    delete pTQuery;
}
};

Die zugrunde liegende Dispose-Methode wird aufgerufen, wenn in C++/CLI ein Destruktor explizit aufgerufen wird oder wenn ein Trackinghandle gelöscht wird. Wenn es sich um eine abgeleitete Klasse handelt, wird am Schluss der künstlichen Methode ein Aufruf der Dispose-Methode der Basisklasse eingefügt.

Diese Umformung ist zwar eine gute Sache, für sich genommen aber nicht gut genug. Erstens gelten für Verweisobjekte keine Beschränkungen hinsichtlich des Gültigkeitsbereichs. Wenn der Programmierer das Verweisobjekt also nicht explizit löscht, wird der Destruktor nicht aufgerufen. Zweitens hat der Garbage Collector keine Methode, die er aufrufen kann, weil der Destruktor jetzt in die Dispose-Methode statt die Finalize-Methode übersetzt wird. Auf den ersten Blick sieht es daher so aus, als wäre diese Designänderung ein Fehler gewesen!

Es ist sicher der Fehler, den ich in meiner Implementierung gemacht habe. Als Programmierer von native C++ erkannte ich nicht, dass wegen der Funktionsweise des Garbage Collectors unter .NET ein Destruktor keine vollständige Lösung für die Objektverwaltung war. Daher hatte ich nicht daran gedacht, explizit eine Beendigungsfunktion bereitzustellen, die vom Garbage Collector aufgerufen werden kann. Wahrscheinlich würde dies niemand bemerken, wenn nur ein Objekt vorhanden ist. In einem laufenden System, das in jeder Abfragesitzung ein neues TQL-Objekt erzeugt, wäre es wohl ziemlich peinlich. Der Einfachheit halber stelle ich noch einmal die Deklaration des TQL-Objekts dar:

// the tq object never invokes the destructor ...
// and we have not provided a finalize method ...
// so the native memory held by tq is never freed ...
int main()
{
    TQL ^tq = gcnew TQL;

    tq->build_up_text();
    tq->query_text();

    return 0;
}

Sie müssen einen Finalizer bereitstellen. Wie das geht, zeige ich gleich. Zuerst wollen wir uns ansehen, wie die deterministische Beendigung durch die CLR-Features in Visual C++ simuliert wird: durch die syntaktische Bindung eines Verweisobjekts an einen lokalen Gültigkeitsbereich oder Gültigkeitsbereich auf Klassenebene, die jeweils eine deterministische Lebensdauer repräsentieren. Knifflig ist dabei, dass .NET dies an sich nicht unterstützt, und daher mussten wir raffiniert vorgehen.

Visual C++ unterstützt die Deklaration eines Objekts einer Verweisklasse auf dem lokalen Stack oder als Member einer Klasse durch eine Objektdeklaration mit dem Typnamen, aber ohne das formale Caretzeichen (^). Bei jeder Verwendung des Objekts, z. B. zum Aufruf einer Memberfunktion, wird stets der Punktoperator für die Memberauswahl (.) statt des Pfeils (->) eingesetzt. Am Ende des Blocks wird automatisch der zugehörige Destruktor aufgerufen, der in Dispose umgeformt wurde.

// OK, this invokes our destructor ...
int main()
{
    TQL tq;

    tq.build_up_text();
    tq.query_text();

    // destructor is invoked here ...

    return 0;
}

Wer eine syntaxlastige Bibliothek hat, für den empfiehlt sich eine gleichwertige auto_handle<>-Vorlage. (Ich arbeite in meinen Entwüfen lieber ohne Spitzklammern.) Wie die using-Anweisung in C# ist dies eher ein syntaktisches Schmuckstück als eine Missachtung der zugrunde liegenden .NET-Beschränkung, dass für alle Verweistypen Speicher auf dem CLR-Heap reserviert werden muss. Die zugrunde liegende Semantik ändert sich nicht, abgesehen davon, dass der Aufruf des Destruktors automatisch erfolgt. Im Endeffekt wird der Destruktor wieder mit Konstruktoren gepaart und zwar durch einen automatisierten Beschaffungs-/Freigabemechanismus, der an die Lebensdauer von Objekten gebunden ist.

An dieser Lösung ist problematisch, dass Programmierer zu ihrer Verwendung nicht gezwungen werden können, und daher ist es nicht möglich, einen Destruktor ohne zugehörigen Finalizer gefahrlos bereitzustellen. So gehen Sie hierzu vor:

public ref class TQL {
public:
    // constructor creates a TextQuery object on native heap
    TQL()  { pTQuery = new TextQuery; }

    // destructor frees it 
    ~TQL() { delete pTQuery;          }
    
    // finalizer frees it, called by garbage collector if 
    // destructor is not invoked ...
    !TQL() { delete pTQuery;          }
};

Das Präfix ! soll das analoge Tildezeichen (~) symbolisieren, mit dem Klassendestruktoren eingeleitet werden; d. h. bei den beiden Methoden, die aufgerufen werden, nachdem ein Objekt nicht mehr verwendet wird, wird dem Klassennamen jeweils ein Präfix vorangestellt. Wenn die künstliche Finalize-Methode in einer abgeleiteten Klasse steht, wird an ihrem Ende ein Aufruf der Finalize-Methode der Basisklasse eingefügt. Wenn der Destruktor explizit aufgerufen wird, dann wird der Finalizer unterdrückt.

Wie immer sind bei dieser Art von Sprachentwürfen einige andere Dinge zu berücksichtigen. Im Allgemeinen sind Finalizer ineffizient. (Dieses Thema wird von Jeffrey Richter in der zweiten Ausgabe seines Buchs CLR via C# ausführlich behandelt. Wenn möglich, sollte auf die Definition eines Finalizers verzichtet werden. Gegenwärtig gibt es aber keine Möglichkeit, eine Klasse, die einen Destruktor enthält, derart zu beschränken, dass Objekte dieser Klasse so definiert werden, dass die deterministische Beendigung stets gewährleistet ist. Ich kann die Benutzer meiner Klasse nicht dazu zwingen, immer lokale Verweisobjekte zu deklarieren:

void f()
{
    TQL t;     // ok, guaranteed to be disposed
    TQL ^ht;   // oops. No guarantee. Need a finalizer, then ...
    ...
}

Eine Möglichkeit wäre, durch die Verfügbarkeit des Finalizers oder eine entsprechende Zugriffsbeschränkung anzuzeigen, ob Sie den Benutzern den Zugriff auf den Finalizer ermöglichen möchten. Das heißt, durch die Platzierung des Finalizers in einem privaten Abschnitt würden Sie anzeigen, dass Sie die Verwendung von Objekten außerhalb des gebundenen Gültigkeitsbereichs untersagen. Im gegenwärtigen Entwurf der Spracherweiterungen sind jedoch alle Finalizer public, also öffentlich zugänglich, ungeachtet des vom Benutzer angegebenen Gültigkeitsbereichs.

Daher scheint der Finalizer immer als ausfallsichere Komplementärdefinition in einer Referenzklasse erforderlich zu sein, ebenso wie im Allgemeinen prinzipiell ein Kopierkonstruktor mit einem Kopierzuweisungsoperator oder der new-Operator mit dem delete-Operator kombiniert werden muss.

Das zweite verbliebene, zugegebenermaßen triviale Problem besteht darin, dass Destruktorcode und Finalizercode in der Regel gleich sind und sich einige von uns entweder darüber ärgern, doppelt vorhandenen Code zu haben, oder sich darüber aufregen, wie dieses Problem kanonisch gelöst werden sollte. Michael Vanier, ein Professor für Programmiersprachen am CalTech, schlug die Syntax ~! vor, um dem Compiler anzugeben, dass er den betreffenden Code sowohl für den Destruktor als auch den Finalizer verwenden soll. Ich finde diesen Vorschlag gut, und vielleicht greift ihn die ECMA C++/CLI-Kommission in einer künftigen Version einmal auf!

Dieses kleine Programm enthielt also eine ganze Menge Fehler. Warum? Ich denke, der Grund hierfür ist, dass wir diese Probleme – Sichtbarkeit der Typen, nicht deterministische Beendigung – in .NET nicht ausreichend berücksichtigen, weil sie in der Programmierung mit native C++ nicht gegeben sind. Nächstes Mal beschäftigen wir uns mit regulären Ausdrücken und versuchen dabei, wortreiches C++/CLI in prägnantes Perl umzuwandeln. Bis dann möge Ihr Code fehlerfrei kompiliert und bis zum Schluss schnell und richtig ausgeführt werden.

Senden Sie Ihre Fragen und Kommentare für Stanley an purecpp@microsoft.com.

Stanley B. Lippman begann 1984 in den Bell Laboratories seine Arbeit an C++, gemeinsam mit dem Erfinder dieser Programmiersprache, Bjarne Stroustrup. Später war er bei Disney und DreamWorks im Bereich "Feature Animation" tätig und hatte die Position eines Software Technical Director bei Fantasia 2000 inne. Seit dem hat er als Distinguished Consultant bei JPL und als Architekt im Visual C++-Team bei Microsoft gearbeitet. Vielen Dank an Jim Hogg und Michael Vanier für Ihre Unterstützung bei diesem Artikel.

Aus der Ausgabe August 2006 des MSDN Magazine.


Anzeigen:
© 2015 Microsoft