Smart-Pointer

ein intelligenter Umgang mit Zeigern...

abstract

In C++ ist der Umgang mit dynamischem Speicher nicht unproblematisch. Nicht freigegebener Speicher (Memory leaks) und Zugriffsverletzung können jederzeit einen Terminprobleme erzeugen und Probleme beim Kunden verursachen. Die Verwendung von Smart-Pointern kann diese Problematik entschärfen, in dem sie die Verwendung von dynamisch angefordertem Speicher vereinfacht.

Was sind Smart-Pointer?

Smart-Pointer sind C++-Klassen,

  • die sich wie Zeiger (Pointer) verhalten und
  • für die Freigabe von dynamischem Speicher verantwortlich sind, den sie verwalteten. (Smart)

Mit Hilfe von Klassen, die sich wie Zeiger verhalten, kann der Zugriff auf Speicher geprüft werden. Da sich das Programm selbst um die Freigabe des dynamischen Speichers kümmert, kann die Freigabe viel zuverlässiger durchgeführt werden.

Wie kann sich eine Klasse wie ein Zeiger verhalten?

C++-Klassen können wie Zeiger verwendet werden, wenn die Operatoren * und -> überschrieben worden sind.

Das Überladen der Operatoren * und -> sind keine außergewöhnliche C++-Tricks. Diese Technik wird z.B. auch bei den Iteratoren der STL angewandt. Damit können Algorithmen so formuliert werden, dass sie mit verschieden Container-Klassen und Feldern im C-Stils verwendet werden können.

So können die Objekte einer Smart-Pointer Klasse, überall dort eingesetzt werden, wo bisher Zeiger verwendet wurden.

Wie "smart" ist ein Smart-Pointer?

Mit dem Begriff smart ist bei den Smart-Pointer, die dahinter liegende Speicherverwaltungsroutine gemeint.

Die Smart-Pointer verwenden das Resource Acqusiation is initialisation (RAII)-Prinzip für die Speicherverwaltung. Im Construktor einer Klasse wird eine Ressource angefordert und im Destructor wird sie wieder freigegeben.

Bei den Smart-Pointer liegt der Schwerpunkt im 2. Teil von RAII: Der Destructor sorgt für die Freigabe des Speichers!

Die meisten Smart-Pointer konzentrieren sich auf das Freigeben. Sie bekommen den (hoffendlich) frisch angeforderten Speicher im Construktor übergeben. Nur so kann garantiert werden, dass der Speicher immer freigegeben wird.

Um den Speicher korrekt verwaltet zu kann, müssen alle Smart-Pointer klar definierte Regeln für das Kopieren besitzen (s.u.).

Wofür brauche ich Smart-Pointer?

Smart-Pointer können den Umgang mit dynamischem Speicher vereinfachen und den Zugriff darauf absichern.

Speicherverwaltung

Computer erledigen stupide Aufgaben zuverlässiger als Menschen. Eine der stupiden und gleichzeitig schwierigsten Aufgaben beim Programmieren ist die korrekte Verwaltung von externen Ressource wie z.B. dynamischer Speicher. Die Regel dazu sind im Prinzip ganz einfach: Jeder Speicher der angefordert wurde, muss auch wieder freigegeben werden. Was hier so einfach anhört ist im Detail gar nicht so einfach. Der Programmverlauf muss nicht immer so ablaufen wie erwartet. Werden Smart-Pointer verwendet trägt der Compiler die Freigabe einfach überall ein. Dadurch, dass der Programmier den Speicher nicht mehr selbst freigeben muss, kann er es auch nicht mehr vergessen.

Ein weitere stupide und fehlerträchtige Programmieraufgabe sind die korrekten AddRef und Release- Aufrufe für COM-Pointer. Hier müssen passende Anzahl von AddRef- und Release-Aufrufen programmiert werden - oder man verwendet einfach Smart-Pointer. Es existieren Smart-Pointern Klassen, die die AddRef und Release-Aufrufe korrekt kapseln.

Exception- Sicherheit

Der Speicher wird auch im Fehlerfall freigegeben. Auch wenn eine Funktion durch eine Exception oder einen anderen Notausgang (return; im Fehlerfall) vorzeitig verlassen wird, wird der Speicher zuverlässig freigegeben, da der Compiler in diesem Fall alle Destructoren (in diesem Fall der Smart-Pointer) aufruft.

Verwalten von Objekten

Manchmal kann der richtige Zeitpunkt für die Freigabe von Objekten (welche dynamisch angelegt wurden) nicht eindeutig bestimmt werden. Wenn z.B. der Speicher an der einen Stelle im Programm erzeugt wird und an einer anderen Stelle freigegeben werden soll, ist dies oft mit einem nicht unerheblichen Verwaltungsaufwand verbunden. Oder wenn mehrere Programmteile das gleiche Objekt benötigen kann es ebenfalls schwierig zu bestimmen sein wann das Objekt wieder freigegeben werden kann. Hier darf das Objekt erst dann freigegeben werden, wenn es von niemandem mehr benötigt wird.

Smart-Pointer sind immer (mit NULL) initialisiert

Bei der Verwendung von Speicher muss der Speicher auf Gültigkeit geprüft werden.

Ein Pointer in C++ sollte immer in einem der zwei folgenden Zustände befinden:

  • auf eine gültige Adresse im Speicher zeigen oder
  • den Wert NULL besitzen.

Nur durch das Befolgen dieser Regeln (und den zugehörenden Abfragen auf NULL) können Zugriffsverletzungen in C++ vermieden werden.

Einfache Zeiger werden in C++ jedoch nicht zwingend initialisiert. Wird im Programmcode die Initialisierung vergessen, steht der Zeiger auf einem zufälligen Wert. Damit kann nicht auf die Gültigkeit des Zeigers geprüft werden. Im Gegensatz dazu sind Smart-Pointer immer initialisiert.

Achtung:
Die Prüfung auf NULL kann durch die Verwendung von Smart-Pointer nicht ersetzt werden.
Anmerkung (C++0x):
Mit dem künftigen C++-Standard erhält C++ das neue Schlüsselwort null_ptr, welches statt dem Macro NULL verwendet werden kann. Das neue Schlüsselwort wird bereits in den neuesten Compilern des Visual Studios (VS2010, VC10) und der GNU Compiler Collection (GCC 4.6) unterstützt.

Wo findet man Smart-Pointer?

Oder anders gefragt: Muss sich jetzt jeder seine eigene Smart-Pointer programmieren?

Auch wenn Smart-Pointer eigentlich ganz einfach programmiert werden könnten, liegen die Feinheiten im Detail. Daher sollte man einen fertigen Smart-Pointer verwenden.

Viele moderne (C++) Klassen Bibliotheken bringen Smart-Pointer mit:

  • [boost boost.html],
  • ATL, COM
  • G3D und viele andere…

Oder man verwendet einen aktuelle C++-Compiler: - Visual Studio 2008 SP1 / VC9sp1 (TR1) - Visual Studio 2010 / VC10 (C++0x) - GCC 4.X

Im zukünftigen C++-Standard (C++0x) sind verschiedene Smart-Pointer Teil der Standard-Bibliothek. Da der Standardisierungsprozess sehr langwierig ist, wurde ein so genannter //Technical Report// (TR1) veröffentlicht, der neue Bibliotheken für aktuelle Compiler beschreibt. Im TR1 ist eine Untermenge der C++0x Smart-Pointer enthalten.

Welche Smart-Pointer gibt es?

Die Smart-Pointer können nach ihrem Kopierverhalten unterschieden werden. Dies sind:

  • 3 klassische Typen

  • keine Kopie (Scoped-Pointer)

  • (interner) Referenz-Zählung (Shared-Pointer)
  • externe Referenz-Zählung (COM-Pointer)

  • 1 speziellen Typ

  • Besitzerwechsel beim kopieren (std::auto_ptr)

  • Felder

Keine Kopie (Scoped-Pointer)

Smart-Pointer die keine Kopie des Zeigers zulassen.

  • boost::scoped_ptr
  • std::unique_ptr (C++0x)

In dieser, der einfachsten Variante des Smart-Pointers, wird einfach das Kopieren und die Zuweisung verboten. Ohne Zuweisung und Kopie muss auch nichts verwaltet werden. Damit kann sich der scoped_ptr auf seine Grundaufgabe konzentrieren. Ohne den Verwaltungsaufwand ist ein scoped_ptr so effektiv wie Hand-Programmierter Code.

Verwendung

Der scoped_ptr wird zum Aufbewahren von Speicher verwendet, welcher beim Verlassen des Scopes wieder freigeben werden soll. Z.B.:

  • Lokale Variablen in Funktionen und
  • Membervariablen (Stichwort Pimpl idiom bzw. Opaque pointer)

Beispiel

boost::scoped_ptr

Die boost-Version des scoped_ptr bringt eine nette Erweiterung mit. Mit der reset-Funktion kann der Speicher ausgetauscht werden. Der vorhandene Speicher wird dabei freigegeben. Mit reset(NULL) kann der Pointer zurückgesetzt werden.

Vereinfachter Code:

template<class T>
class scoped_ptr : noncopyable {
public:
  explicit scoped_ptr(T * p = 0); // Constructor
  ~scoped_ptr();                  // Destructor - Free the memory
  void reset(T * p = 0);          // Reset the pointer to other pointer or NULL (Free the memory)
  T & operator*() const;
  T * operator->() const;
  T * get() const;                // Zugriff auf rohen (raw) Speicher
private:
  // private data
};

Beispiel für die Benutzung:

#include <boost/scoped_ptr.hpp>
class MyClass
{
public:
  int f();
};
void main()
{
  if(true) // Einen "scope" erzeugen
  {
    boost::scoped_ptr<MyClass> poA(new MyClass);
    poA->f();
    (*poA).f();
  } // automatic free the memory
  // MyClass-Instanz wurde freigegeben.
}

shared_ptr

Smart-Pointer mit interner Referenz-Zählung

  • boost::shared_ptr
  • std::tr1::shared_ptr (TR1 z.B. VC9sp1)
  • std::shared_ptr (C++0x z.B. VC10)

Bei den Smart-Pointer mit Referenz-Zählung können mehrere Smart-Pointer auf den gleichen Speicherbereich zeigen. Durch eine entsprechende Verwaltung wird sichergestellt, dass der Speicher freigegeben wird, sobald kein Zeiger mehr den Speicherbereich verwendet bzw. nicht freigegeben wird solange noch ein Zeiger auf den Speicher zeigt. ("Der letzte macht das Licht aus")

Die übliche Implementierungstechnik verwendet einen internen Zähler. Wegen dieser Implementierungstechnik werden sie üblicherweise als "Smart-Pointer mit (interner) Referenz-Zählung" bezeichnet. Dabei kann dieser Smart-Pointer-Typ auch ohne Zähler implementiert werden. (z.B. zyklische Kette, siehe Modern C++ Design Kapitel "Reference Linking".)

Im künftigen C++- Standard (C++0x) wird es ein Smart-Pointer mit dem Namen shared_ptr auf der Basis des gleichnamigen Boost-Pointers geben. Da die shared_ptr-Klasse keine C++0x-Erweiterungen benötigt, ist sie auch im TR1 enthalten. (In diesem Fall im Namespace std::tr1.)

Ebenfalls im künftigen C++- Standard (C++0x) ist die Funktion make_shared. Mit dieser Funktion kann die interne Verwaltungsstruktur sehr effizient erzeugt werden. Für diese Funktion wird eine C++0x-Erweiterungen benötigt, daher ist diese Funktion nicht im TR1 enthalten.

Verwendung

Die shared_ptr können vielfältig verwendet werden:

  • Funktions-Parameter,
  • Funktionsrückgabe,
  • Speicher wird an einer anderen Stelle, zu einem anderen Zeitpunkt erzeugt als er wieder freigegeben werden soll.
  • uvm.

Eigentlich können shared_ptr fast überall dort verwendet werden, wo bisher herkömmliche Zeiger Verwendung. Die einzige Ausnahmen sind Zeiger auf Felder.

Mit einem shared_ptr könnte auch ein scoped_ptr ersetzt werden. Hier kann es es jedoch semantisch hilfreich sein, wenn man mit der Verwendung eines scoped_ptr klar ausdrückt, dass dieser Speicher diesen scope (z.B. Funktion, Klasse) nie verläst.

Wenn in einem STL-Constainer Pointer gespeichert werden sollten nur Smart-Pointer mit Referenz-Zählung verwendet werden! (Empfehlung: boost::shared_ptr) Alles andere kann gut muss aber nicht. Sobald ein Algorithmus aus der STL z.B. std::sort auf diesem Container angewandt wird kann es zu einer Zugriffsverletzung kommen. (Zukünftige Alternative (C++0x): unique_ptr durch die move-Erweiterung.)

Performance

Da C++ oftmals wegen der Performance eingesetzt wird, sollte die Performance hier ebenfall betrachtet werden. Die shared_ptr werden über einen interne Verwaltungsstruktur (meistens Referenz-Zähler) verwaltet. Diese Verwaltung kostet Zeit. Wenn der Speicher von verschieden Zeigern unabhängig voneinander verwendet werden soll, ist eine Verwaltung nötig.

Wird die Verwaltung nicht benötig, kann ein scoped_ptr verwendet werden - dieser Smart-Pointer ist genauso performant wie Handgeschriebener C++-Code.

Es bleibt die Frage ob die interne Verwaltung durch einen shared_ptr oder eine Selbstgeschriebene Verwaltung durch der Programmierer verwendet werden soll.

Hier würde ich immer für die shared_ptr entscheiden. - Jede Selbstprogrammierte Zeile ist in diesem Fall ein potenzieller Fehler. - Jede nicht programmiert Zeile ist gesparte Zeit beim entwickeln. Dies kann durch den geringen Performance-Vorteil nicht wettgemacht werden. - Nur über das RAII-Prinzip kann in C++ garantiert werden, dass im Fehlerfall der Speicher wieder freigegeben wird. Dies kann keine externe Verwaltung leisten.

Beispiel

boost::shared_ptr

class A {};
class B : public A {};
int main()
{
  using namespace std;
  if(true)
  {
    boost::shared_ptr<A> pA;
    if (! pA)
    {
      cout << "pA is not initialized" << endl;
    }
    if(true)
    {
      boost::shared_ptr<B> pB(new B);
      pA = pB;
    }
    // pB does not exist any more, but the memory is still accessible over pA
    if (pA)
    {
      cout << "pA is still valid" << endl;
    }
  } // leaving the scope. pA was the last pointer, pointing to the the memory, so the memory will be freed.
  return 0;
}

Die boost-Variante des shared_ptr bringt ein paar netter Zusätze mit:

  • boost::weak_ptr: Hilfe beim aufbrechen von zyklischen Referenzen.
  • dynamische casts von shared_ptr.

Bekannte Probleme

Zyklische Referenzen können von shared_ptr nicht automatisch aufgelöst werden. Hier müssen die verwendeten Strukturen angepasst geändert werden. Durch die Verwendung von weak_ptr, einfacher Zeiger oder C++-Referenzen für Rückwärts-Referenzen können diese Kreisbeziehungen aufgelöst werden.

Smart-Pointer mit externer Referenz-Zählung

  • Smart-Pointer für Com-Pointer
  • Addref und Release müssen unterstützt werden.
  • Der Smart-Pointer gibt den Speicher selbst nicht frei – er ruft Addref und Release korrekt auf.

Beispiel

  • Z.B. CComPtr (MFC & ATL)
  • boost::intrusive_ptr als allgemeine Variante. Hier müssen die aufgerufenen Funktionen müssen nicht Addref und Release heißen.

std::auto_ptr

  • Besitzerwechsel beim kopieren
  • Ist bei jedem C++-Compiler dabei
  • Besitz-Semantik: Der Besitzer gibt Speicher frei
  • Beim Kopieren/ Zuweisungen wechselt der Besitz. Achtung: Wenn der Besitzer zerstört wird können andere auto_ptr auf ungültigen Speicher zeigen.
  • Als const auto_ptr sehr ähnlich zum Scoped-Pointer.
  • Der kommende C++-Standard (C++0x) wird diese Klasse als //deprecated// erklären.

Es sollen in Zukunft statt dem std::auto_ptr die oben genannten Klassen (std::shared_ptr & std::unique_ptr) verwendet werden.

Smart-Pointer für Felder

Smart-Pointer für Felder heißen z.B.: std::string, std::vector, CString, boost::array usw.

Naja, nicht ganz. Natürlich sind die aufgezählten Typen Klassen für Felder und keine Smart-Pointer. Jedoch sollte man sich überlegen ob man von einem Zeiger auf ein Feld gleich auf eine Feld-Klasse umsteigt, wenn bereits die Mühe macht und die Speicherverwaltung in eine intelligente Klasse kapselt.

Selbstverständlich gibt es spezielle Smart-Pointer für Felder z.B. boost::scoped_array und boost::shared_array. Diese Smart-Pointer geben den verwalteten Speicher mit 'delete[]' statt 'delete' frei. Intern halten diese Smart-Pointer nur den verwalteten Speicher, aber nicht die Größe des verwalteten Speichers.

Es wäre jedoch schön wenn die Smart-Pointer für Felder neben dem Speicher auch noch die Größe des allokierten Speichers und eventuell die Größe des belegen Speichers sich merken könnten.

Wenn man dies möchte, braucht man jedoch keine neuen Smart-Pointer erfinden. Klassen für Felder erfüllen genau diese Anforderungen.

  • Sie verwalten selbst den Speicher. Dabei geben sie nicht nur den verwalteten Speicher frei, sondern allokieren den Speicher auch bei Aufforderung z.B. mit std::vector::push_back oder std::vector::resize.
  • Sie stellen Funktionen zu Verfügung, mit denen man die Größe des aktuell belegten Speicher abfragen kann z.B. std::vector::size.
  • Man kann den allokierten Speicher abfragen und setzen(capacity).
  • Es gibt die Möglichkeit eines geprüfen Zugriffen z.B. std::vector::at. Wenn die Funktion std::vector::at mit einem unglütigen Index aufgerufen wird, wird eine entsprechende Exception ausgelöst.
Anmerkung 1:
Die Klasse std::vector eignet besonders gut sich für 1:1 Umstellungen, da die Adresse des 1. Element dem Zeiger auf den Buffer entspricht. API-Aufrufe, die einen Zeiger auf Felder erwarten, können so aufgerufen werden.
Anmerkung 2:
Zeiger auf ein char-Feld, das einen String repräsentieren, ersetzt man am sinnvollsten mit der Klasse std::string oder einer vergleichbaren String-Klasse.
char* fn = "a.txt";
int fh0 = open( fn, _O_RDONLY ); // open erwartet einen Zeiger auf ein Feld von char.
//vector vor Zeichen
std::vector<char> vecFilename(fn, fn+strlen(fn)); // Initialisieren mit den Anfang und Ende eines Feldes.
int fh1 = open( &vecFilename[0], _O_RDONLY ); // Aufruf mit der Adresse des ersten Elementes
// Für ein Feld von Zeichen ist die String-Klasse die bessere Alternative:
std::string strFilename(fn) ;
int fh2 = open( strFilename.c_str(), _O_RDONLY ); // c_str() gibt das Feld im "C"-Stil zurück

Wie werden Smart-Pointer eingesetzt?

  • Voraussetzungen
  • Grenzen und Gefahren
  • Empfehlungen

Voraussetzungen

muss:
  • Der Speicher liegt auf dem Heap. (klassisch)
  • oder spezielle Member-Funktion sind implementiert (z.B. addref / release) (COM-Smart-Pointer, externe Referenz-Zählung)
sollte:
  • Echte Objekte oder Strukturen. Das Überladen des * und -> Operators sollte Vorteile bringen.

Einzelwerte integraler Datentypen sollten nicht auf dem Heap abgelegt werden.

Für Felder integraler Datentypen sind Container-Klassen sinnvoll.

Grenzen und Gefahren

  • Export über DLL-Grenzen ist problematisch. Smart-Pointer sind template Klassen und diese sollten nicht über DLL-Grenzen weitergereicht werden.
  • zyklische Referenzen (A zeigt auf B; B zeigt auf A)
  • Speicher darf nicht zweimal an Smart-Pointer übergeben werden. (-> würde 2x freigegeben werden)

Empfehlungen

Speicher im Constructor des Smart-Pointers anlegen.

Die Speicheranforderung sollte im Constructor des Smart-Pointers erfolgen.

Die bekannten und verbreiteten Smart-Pointer stellen keine Möglichkeiten für das Anlegen / Anfordern des Speichers zu Verfügung (z.B. mittels integriertes new im Contruktor.) Daher muss dem Smart-Pointer der Speicher übergeben werden. Damit sichergestellt wird, dass der Speicher immer freigegeben wird, sollte der Speicher direkt im Contruktor angelegt werden.

Beispiel:

class A
{
  public:
    A(int i); // Construktor
    ...
};
...
boost::scoped_ptr<A> pA(new A(9)); // richtig
// so nicht:
A* a = new A(11);
foo();
boost::scoped_ptr<A> pA2(a); // Wenn foo eine exception auslöst, wird A(11) nicht freigegeben.

In STL-Container Smart-Pointer mit Referenz-Zählung verwenden

In (STL-)Container sollten nur Smart-Pointer mit Referenz-Zählung verwenden (z.B. boost::shared_ptr). Alles andere ist gefährlich und nicht zukunftssicher. Sobald ein (STL-)Algorithmus auf diesen Container angewandt wird, wird es zu Problemen kommen.

Mit dem kommenden C++-Standard (C++0x) wird es auch möglich sein std::unique_ptr in STL-Containern zu verwenden.

Die semantisch richtigen Zeiger verwenden

Mit shared_ptr könnten eigendlich alle vorhandenden Pointer ersetzt werden. Durch die Verwendung von z.B. scoped_ptr kann man dem Pointer ein weiteres Attribut mit geben, das besagt dass dieser Zeiger diese Funktion / Klassen nie verlassen wird.

Damit hat man ein semantisches Mittel, dass die Verwendung des Zeigers beschreibt.

Keine ungekapselten Pointer verwenden

Die Verwendung von rohem Speicher (echte C++/C-Pointer) verzichten. Mit der Verwendung von Smart-Pointer kann auch in größere Projekt komplett auf den Aufruf von delete verzichtet werden.

Funktionsrückgabe

In C und C++ kann der Rückgabewert einer Funktion ignoriert werden. D.H. man ruft eine Funktion (Unterprogramm mit Rückgabewert) auf und behandelt die Funktion wie eine Prozedure bzw. Subroutine (Unterprogramm ohne Rückgabewert).

Mit diesem Wissen darf man eigentlich keine Funktion programmieren, welche dynamisch erzeugten Speicher als Rückgabewert zurückgibt. Wenn der Rückgabewert ignoriert wird, kann dieser Speicher nicht mehr freigegeben werden.

class MyClass {};
MyClass* createMyClass() {
  MyClass* p = new MyClass;
  return p;
}
...
createMyClass(); // ignoriert des Rückgabewert. Kein Fehler aber Memory Leak.

Eine saubere Lösung für dieses Problem können ebenfalls Smart-Pointer (mit Referenz-Zählung) sein. Der Smart-Pointer als Rückgabewert gibt den Speicher frei, auch wenn die Rückgabe ignoriert wird.

Auf diese Weise können Funktionen ohne Problem direkt in Abfragen eingesetzt werden.

// use a shared pointer as funtion return
shared_ptr<A> findObject(const std::string& name)
{
...
}
...
if(findObject("foo"))
{
  // Hugo existiert.
}

Niemand muss sich um den Speicher "foo" kümmern. Dies wird automatisch durch den Smart-Pointer erledigt.

Verwendung von STL-Container für Felder

Sobald Felder dynamische angelegt werden sollen, sollte dies mit Hilfe der STL-Container erfolgen.

  • Zukunftssicher: wenn der Code nicht mit übernommen werden kann, dass kann das Wissen über die Container übernommen werden.

Zusammenfassung

SmartPointer können nicht die Welt retten, aber die Verwendung von SmartPointer kann die Verwendung von dynamischen Speicher vereinfachen und sicherer manchen.

Weiterführende Literatur