namespace cpp

C++ lernen, kennen, anwenden

Benutzer-Werkzeuge

Webseiten-Werkzeuge


lernen:eigentum

Eigentum verpflichtet - Der Umgang mit dynamischem Speicher in C++

Eigentum verpflichtet. Sein Gebrauch soll zugleich dem Wohle der Allgemeinheit dienen.
— Grundgesetz, Art. 14 (2)

Zeiger und dynamischer Speicher erfordern solides Handwerk bei ihrem Einsatz. Zeiger in Prozeduren und in Objekten, auch unter Ausnahmebedingungen werden betrachtet.

Sterne

Es ist mal wieder Weihnachten. Zeit, Sterne zu betrachten. Der folgende Quelltextabschnitt enthält auch welche. Leider stürzt das Programm ab:

/*1*/  int *p = new int(1); 
/*2*/  int *q = new int(2); 
/*3*/  p = q;               
/*4*/  delete q;            
/*5*/  std::cout << *p << '\n'; // evtl. hier
/*6*/  delete p;                // spätestens hier. 

Wieviel Fehler sind im Quelltext? Wo? Haben Sie sie entdeckt? Falls nicht, hier die Fakten:

Grundlagen: Zeiger und dynamischer Speicher

Zeiger werden mit Sternen nach der Typangabe vereinbart. Sie sollten auf Speicher verweisen, wo die erwarteten Inhalte stehen und mit Sternen auf den Inhalt zugegriffen wird:

  std::cout << *p << '\n'; // Deklaration als Muster: *p ist vom Typ int

Zeiger müssen dazu vorher eine gültige Speicheradresse erhalten, auf die zugegriffen werden kann. Herumirrende, baumelnde Zeiger sind gefährliche Fehlerquellen in Programmen, die schwer einzugrenzen sind. Bleibt ein Zeiger längere Zeit unbenutzt, sollte er mit NULL belegt werden, zum Zeichen dafür, dass er ungültig ist und nirgendwo hin zeigt:

/*7*/  q = p = NULL; // Vorschlag für Zeile 7

Leider nützt das nichts, das Programm ist ja vorher abgeschmiert. Nur wo, schon gefunden?

Bei Bedarf kann dynamisch Speicher mit dem Operator new vom System angefordert und nach Gebrauch wieder mit delete freigegeben werden. Die Zeilen 1 und 2 fordern jeweils eine Speichereinheit für einen int-Wert an und belegen diese mit den Werten 1 und 2. Preisfrage: Welcher Wert hätte in Zeile 5 ausgegeben werden sollen? Vermutlich 2? Warum?

Schon die Speicheranforderung kann scheitern. Je nach Compiler liefert new dann NULL, einen ungültigen Zeiger, oder in new wird eine bad_alloc-Ausnahme geworfen und der normale Programmlauf unterbrochen. (Mehr zu Ausnahmen weiter unten. Hier reicht zu wissen, dass dann folgende Anweisungen überhaupt nicht mehr abgearbeitet werden.) Eine Debugger-Ausschrift, nach Zeile 2 eingefügt, überzeugt uns davon, dass keine Ausnahme eingetreten ist und p und q keine NULL-Zeiger sind. Wo liegt also der Fehler?

Na, dämmert es? Es können nur die ursprünglichen Zeilen 3 und 4 sein. Falls Sie den Fehler noch nicht gefunden haben, lesen Sie diese Seite noch mal aufmerksam (goto Grundlagen;).

Speicher: Lecks, Leichen und Korruption

Jeder angeforderte Speicherplatz muss wieder freigegeben werden. Speicherbereiche, die dem Betriebssystem nicht zurückgegeben werden, fressen langsam, aber stetig die Systemresourcen auf. Programme, die immer wieder Speicherleichen (Speicherlecks) erzeugen, können nicht ewig arbeiten. Irgendwann ist ein Neustart des Systems notwendig. Nicht einmal der Bildschirm kann dann noch korrekt dargestellt werden, weil Speicherplatz für Stifte, Pinsel und Fenster nicht bereitgestellt werden kann.

Das Programmbeispiel stürzt aber viel schneller ab. Es enthält zwei new und zwei delete. Trotzdem reicht das nicht: Es ist genau der angeforderte Speicher freizugeben, und zwar genau je einmal. Was tut es stattdessen?

Sie haben den Fehler schon gefunden, sonst würden sie hier nicht lesen. Falls nicht, zurück auf Seite 1 (malen Sie sich auf, was passiert)! Wenn Sie die Lösung kennen, überspringen Sie den nächsten Absatz, denn er ist nur langweilig…

Für alle Undisziplinierten hier die Auflösung. In Zeile 3 sollte vielleicht der Wert 2 aus dem Speicher, auf den q zeigt, in den Speicher, auf den p zeigt, übertragen werden. Allerdings wurden die Sternchen vor dem p und dem q vergessen: *p = *q. Dieser Fehler ist hier offensichtlich, in komplizierteren Ausdrücken aber wahrscheinlicher. Wer mit den Typen und der Regel "Deklaration ist Muster für die Nutzung" unsicher ist, probiert bei unpassenden Typen solange, bis der Compiler keine Fehlermeldung mehr liefert. Leider akzeptiert er auch inhaltlich falsche Anweisungen: p = q setzt den Zeiger p ebenfalls auf die Adresse, auf die q schon zeigt. Die zwei vergessenen Sternchen erzeugen zwei Fehler auf einmal.

  • Fehler 1: Der Speicher mit der Zahl 1 kann nicht mehr angesprochen werden, weil seine Adresse überschrieben wurde, er wurde zur Speicherleiche.
  • Fehler 2: Dafür wird der Speicher q doppelt referenziert. Zwischen einzelnen Programmteilen (zwischen Zeile 3 und 4) können jetzt Daten auf merkwürdige Weise hin und her wandern. Gewöhnlich fragt der Fehlersuchende dann: "Wieso steht hier ein anderer Wert drin? Den habe ich doch gar nicht angefasst!" Viel Spass beim Suchen…
  • Fehler 3 steckt in Zeile 4. Der doppelt referenzierte Speicher mit der 2, auf den q zeigt, wird gelöscht. Damit zeigt p auf einen nicht mehr gültigen Speicherbereich, p ist zum baumelnden Zeiger geworden, der nur lauert, bis er den größten Schaden anrichten kann.
  • Fehler 4 in Zeile 5: lesender Zugriff auf ungültige Speicheradresse. Ein ordentlicher Betriebssystem bemerkt den Fehler und beendet das Programm: Schutzverletzung / Segmentierungsfehler. Ein unordentliches System lässt uns weiter ins Verderben laufen.
  • Fehler 5 in Zeile 6: Der selbe, schon zurückgegebene Speicher wird ein zweites Mal zurückgebracht. Ein guter Bibliothekar merkt, das etwas nicht stimmt, wenn Sie zweimal hintereinander dasselbe Buch zurückbringen, ohne es zwischendurch wieder ausgeliehen zu haben. Ein ordentliches Betriebssystem beendet das betrügende Programm. Sonst besteht die Gefahr, die Speicherverwaltung des Systems völlig durcheinander zu bringen (Speicherkorruption).

Eine weitere Chance, den Hauptspeicher durcheinander zu bringen, gibt es beim Verwechseln der Speicherverwaltung für Einzelobjekte und Felder von Objekten.

  p = new int(3); // eine Ganzzahl mit Wert 3
  q = new int[4]; // ein Feld mit 4 int-Werten
  // ...
  delete p;       // Freigabe Einzelobjekt
  delete [] q;    // Freigabe Feld          ... nie verwechseln!

Verwechslungen dabei sind schwierig zu entdecken, weil es sich nur um eine harmlose Klammer handelt. Auf die Gefahr, mich als unfähig bloßzustellen: ich spreche aus leidvoller Erfahrung. Ich habe einmal ein ganzes Wochenende wegen eines solchen Fehlers gesucht. Ich hatte blind drauflosgeschrieben und die ALT-Gr-Taste bei new nicht richtig niedergehalten. (Das kann mit amerikanischer Tastatur so nicht passieren) Speicherverwaltung "zu Fuß" ist in C++, wie in der Sprache C, zugegebenermaßen mühsam. Alternative "Lösungen" von Anfängern bestehen darin, delete zu entfernen und subtilere Fehler im Programm zu hinterlassen: "Sieh mal, jetzt gehts doch!" … bis zur Speichererschöpfung.

Ausnahmezustand: Wie werde ich schnell den Speicher wieder los?

Komplizierter wird die Kiste, wenn in C++ auch noch Ausnahmen ins Spiel kommen. Wenn irgendwo in den Tiefen des Programms etwas nicht klappt, kann man eine Ausnahme werfen:

  if (nenner == 0) throw DivByZeroError();
  bruch = zaehler / nenner;

Der normale Programmlauf wird abgebrochen, Funktionen verlassen, lokale Variablen abgebaut (stack unwinding), bis irgendwo weiter oben, vielleicht im Hauptprogramm, ein Programmierer weiß, wie im Fehlerfall weiterzuverfahren ist:

  try
  {
    // unsicherer Abschnitt, 
    // enthält Unterprogrammrufe mit Fehlermöglichkeit
  }
  catch(DivByZeroError& e)
  {
    cerr << e.what() << endl; // Fehlerausschrift
  }
  // normal weiter

Nur, was machen wir mit dem zwischendurch dynamisch angeforderten Hauptspeicher? Wegwerfen führt zu Speicherlecks. Wir müssen darauf achten, ihn doch noch freizugeben.

  q = new int[100];
  try
  {
    // unsicherer Programmteil
  }
  catch(...)      // ... heißt bei jedem möglichen Fehler
  {
    delete [] q;  // Speicher zurückgeben
    throw;        // Ausnahme weiter werfen, wie heiße Kartoffel
  }
  // ...
  delete [] q;  // Normalfall

Der Quelltext wird unleserlich, Dinge müssen mehrfach hingeschrieben werden. Wie leicht wird ein Teil vergessen, besonders bei schnellen Änderungen. Java kennt für diese Situation das finally-Knstrukt.

Programmierer, die C++ hassen, finden hier genügend Angriffsfläche, ihren Frust abzuladen. Das alles zeigt doch nur, was für eine holprige Sprache C++ ist. Andere Sprachen sind da viel besser, in denen es keine Zeiger gibt, mit denen man nach Belieben herumschießen kann, über Feldgrenzen hinausgehen usw., in denen Speicher nicht ausdrücklich freigegeben werden muss: Smalltalk, Java, C#, language wars …

Müllabfuhr

Ein Müllsammler (garbage collector) kümmert sich um nicht mehr benutzte Speicherbereiche und führt sie den System wieder zu. Das ist eine feine Sache, wenn sie funktioniert. In einigen Sprachen ist ein solches System eingebaut. Neben Vorteilen gibt es aber auch Nachteile. Der Müllsammler nimmt dem Programmierer einen Teil der Arbeit ab und bürdet sie der Laufzeit-Umgebung auf. Von Zeit zu Zeit wuselt im Hintergrund der Müllsammler und frisst Rechenzeit, gemeinerweise immer dann, wenn viel Arbeit zu erledigen ist und der Speicher heftig genutzt wird (nur dann wird er so knapp, dass der garbage collector aktiv wird). Echtzeitanwendungen sind damit schwer realisierbar: Der Flugzeug-Steuercomputer setzt im Landeanflug für 10 Sekunden aus wegen garbage collection, nix mit Mallorca.

Der Ansatz von C++ ist ein anderer. Der Programmierer weiß (hoffentlich) am besten, wann ein Objekt nicht mehr benutzt wird. Ein Gleichnis: Wer in die Sächsische Schweiz boofen (übernachten) geht, nimmt den Müll wieder mit, um nicht dem nächsten den Schlafplatz als Müllhalde zu hinterlassen. (Verlasse den Ort so, wie du ihn vorzufinden wünschst.) Systeme, die sich auf garbage collection verlassen, ähneln den Wiesen um die Elbbrücken nach dem Jahreswechsel. Die Stadtreinigung braucht ungefähr drei Wochen im Januar, um Sektflaschen, Glühweinbecher, Böller und anderen Müll wegzuräumen, den die Massen im Rausch ausklinken. Bei Konzertveranstaltungen, die ich mal in einem Verein mit organisiert habe, haben wir sehr schnell Pfand auch auf Weinflaschen und Plastebecher eingeführt. Dadurch blieb der Saal relativ sauber, wir waren zwei Stunden eher mit Aufräumen fertig als ohne Pfand. Fast jeder wollte sein Pfandgeld wieder haben. Und wir hatten fleißige Helfer, die Liegengebliebenes in Bares und Flüssiges umsetzten. Es ist erstaunlich, dass unser Land so lange braucht, um die Erkenntnis aus dem Kleinen ins Große umzusetzen.

XXX geht nicht in Sprache YYY, ist ein beliebtes Argument. Es zeigt meist nur die Inkompetenz derer, die sich streiten. Kaum einer kennt mehrere Systeme gleich gut, um ein faires Urteil zu treffen. Müllsammler gibt es als Bibliotheken sowohl für C als auch für C++. Man muss sie nur aus dem Internet besorgen und einbinden, wenn man das will. Einen fest eingebauten Müllsammler abschalten, scheint mir schwieriger (Beweis meiner Inkompetenz).

Ein Teil der Philosophie von C++ ist, den Programmierer von Routineaufgaben zu befreien. Mein Ziel ist in diesem Text, Techniken zu zeigen, die den Umgang mit dynamischem Speicher erleichtern und sicherer machen, die Lesbarkeit des Quelltextes erhöhen und auch beim Auftreten von Ausnahmen sicher funktionieren.

Grundidee aller Ansätze ist, die Zeiger in Objekte zu kapseln. Konstruktoren allokieren Speicher, der Destruktor gibt den Speicher frei, sobald das Objekt selbst vernichtet wird, also auch bei Ausnahmen. Einmal festgelegt, laufen die Prozesse automatisiert ab. Das Wunderbare daran ist, dass das mit wenigen Zeilen Quelltext erreichbar ist.

Standardcontainer für Objektmengen

Containerklassen vector<T>, deque<T>, list<T> mit identischer Schnittstelle, aber unterschiedlichem Speicherlayout für verschiedene Anwendungsgebiete, befreien uns von manueller Speicherarbeit für Mengen von Objekten und dem Nachdenken über delete []:

{
  std::vector<double> v(anzahl, startwert); 
  // ... Arbeit mit v[i], i = 0..anzahl
} // automatische Freigabe durch Destruktor

Einzelne Zeiger einkapseln

Die in der Standard-Bibliothek <memory> vorhandene Klassenschablone std::unique_ptr<T> erscheint nach außen fast wie ein Zeiger, ist aber keiner. Ein unique_ptr kümmert sich selbst um die Freigabe des gekapselten Zeigers auf ein einzelnes Objekt:

{
  std::unique_ptr<int> p = new int(5);
  std::cout << *p << std::endl;
} // automatische Freigabe, auch bei Ausnahmen zwischendrin

Eigentum verpflichtet

Mit der Zuweisung des neu erschaffenen Objektes an einen unique_ptr geht dieses Objekt in den Besitz des unique_ptr über, damit ist dieser auch für die Speicherfreigabe verantwortlich. Das heißt auch, niemals die Adresse einer lokalen Variable einem unique_ptr zu übertragen!

Bei der Nutzung dynamischen Speichers muss immer klar sein, wer für die Freigabe des Speichers verantwortlich ist. Bei normalen Zeigern ist das der Programmierer, der den Speicher holt. Eigentum verpflichtet. Eigentum an dynamisch allokiertem Speicher bürdet die Aufgabe des gemeinnützigen Umgangs und die Freigabe nach der Nutzung dem Besitzer auf. Werden in Objekte Zeiger eingebaut, ist zu klären, ob das Objekt den Speicher verwaltet oder die Verantwortung außerhalb liegt.

Bengel: Klassen mit Zeigern

Zuerst ein Beispiel, an dem deutlich werden soll, was bei bestem Wollen schiefgehen kann. Die dynamisch angelegten Ganzzahlen vom Anfang werden gekapselt:

class Bengel
{
public:
  Bengel(int n)    { p = new int(n); } // Konstruktor: Speicher anfordern
  ~Bengel()        { delete p; }       // Destruktor:  Speicher freigeben
  int wert() const { return *p; }      // abfragen
private:
  int* p;
};
 
int main()
{
  Bengel a(9);
  Bengel b(10);
  std::cout << a.wert() << '\n';
  // ...

Es ist alles in bester Ordnung, der erhaltene Speicher wird wieder zurückgegeben. Bis einer auf die Idee kommt, einen Bengel an eine Funktion zu übergeben, die mit einem Bengel umgehen kann oder eine Zu(recht)weisung versucht:

  baendige(a);                        // Kopie
  std::cout << a.wert() << '\n';      // Crash?
  a = b;                              // Crash?
  Bengel c = b;                       // noch eine Bengel-Kopie 
  // ...
  return 0;
} // Crash! 

Was ist passiert? Beim Kopieren (Wertübergabe erfolgt durch Kopie) wie auch bei der Zuweisung wird der Inhalt des Originals bitweise in das andere Objekt geschrieben. Auch der Zeiger! Wir haben dasselbe Problem wie im Anfangsbeispiel, nur etwas versteckter. Es gibt wieder doppelte Referenzen und Speicherleichen. Treffen wir keine andere Festlegung, wird eine flache Kopie angelegt.

Was sollten wir sinnvoll bei Kopien und Zuweisungen tun? Bei eingehender Überlegung gibt es keine allgemeingültige Antwort, keinen Standard. Statt dessen kommen mehrere Möglichkeiten in Betracht:

  • Den alten Speicher wegwerfen und neuen an sich reißen?
  • Eine tiefe Kopie anlegen? Solange der durch Zeiger referenzierte Typ bekannt ist, kein Problem. Bei polymorphen Zeigern wäre eine clone()-Methode nötig, wie bei Java-Objekten definierbar. (Alle Java-Klassen stammen von Object ab.)
  • Zeiger gemeinsam nutzen? Nur, wer macht dann das Licht aus? Wie bestimmt man, wer der Letzte ist? Referenzzähler.
  • Verzögertes Kopieren? Eine Kombination der vorigen beiden Methoden. Die Klasse std::string der Standard-Bibliothek nutzte bisher bei mehreren Kopien den Inhalt gemeinsam, solange sich am Inhalt nichts ändert. Erst beim Schreiben wird eine Kopie gezogen (copy on write). In neueren Implementierungen werden std::string-Objekte wegen zunehmender Nutzung in nebenläufigen Programmen wieder tief kopiert.

Die Varianten 1 und 4 sind in älteren Versionen von <memory> und <string> nachlesbar: Selbststudium! Zur Vorbereitung darauf will ich mich auf Variante 2 und 3 konzentrieren.

Engel: Tiefes Kopieren

Will man richtig mit Zeigern in einer Klasse umgehen, sind außer Konstruktor und Destruktor auch Kopierkonstruktor und Zuweisungsoperator besonders zu definieren.

class Engel
{
public:
  Engel(int n)     { p = new int(n); } // Konstruktor: Speicher anfordern
  ~Engel()         { delete p; }       // Destruktor:  Speicher freigeben
 
  Engel(const Engel& original)         // Kopie, Original unverändert
  {
    p = new int(original.wert());
  }
 
  Engel& operator=(const Engel& rhs)   // Zuweisung von rechter Seite (rhs)
  {
    if (this != &rhs)                  // Selbstzuweisung a = a verhindern
    {
      int *temp = new int(rhs.wert()); // neues Objekt anlegen (kann scheitern!)
      std::swap(p, temp.p);            // Zeiger übernehmen 
      delete temp;                     // altes Objekt freigeben
    }
    return *this;                      // weitergeben 
  }
 
  int wert() const { return *p; }      // abfragen
private:
  int* p;
};

Der Kopierkonstruktor muss bei tiefer Kopie neuen Speicher besorgen und initialisieren.

Bei der Zuweisung sind ein paar Dinge mehr zu tun. Zumeist ist eine Zuweisung an sich selbst gefährlich, denn wird nachfolgend das vermeintlich alte Objekt gelöscht, ist das identische Original-Objekt auch futsch. Deshalb muss Selbstzuweisung unbedingt vermeiden werden. (Keiner ruft a = a ausdrücklich auf. Es kann jedoch in komplizierten Programmstücken unabsichtlich geschehen.) Statt des alten Speichers kann eine Kopie des Originals aufgenommen werden. Wichtig ist, dass die alten Daten des linksseitigen Objektes nicht zerstört werden, solange nicht sicher ist, wodurch sie ersetzt werden sollen (sog. starke Garantie).

Da C fortlaufende Zuweisungen a = b = c erlaubt, sollten neue Klassen dies auch erlauben (Prinzip der kleinsten Überraschung). Deswegen muss b sich selbst (*this) zuletzt an a weiterreichen.

Die Zuweisung lässt sich elegant und schmerzfrei als Kopier-Zuweisung (copy & swap) umsetzen, wobei der Kopierkonstruktor nachgenutzt wird:

  Engel& operator=(const Engel& rhs)   // Zuweisung von rechter Seite (rhs)
  {
    Engel temp(rhs);                   // Kopie anlegen  
    std::swap(p, temp.p);              // alle (!) Daten-Attribute austauschen  
    return *this;                      // weitergeben 
  }                                    // Freigabe alter Daten im temp-Destruktor

Anstelle einer const-Referenz kann der Zuweisungsoperator auch einen Wertparameter als Kopie übernehmen. Das spart eine weitere Zeile, sieht aber ungewöhnlich aus. An manchen Stellen kann der Compiler dadurch weitere temporäre Kopien einsparen, die er sonst erzeugen müsste, um eine const-Referenz zu bilden.

Diese Regeln stellen das allgemeine Schema dar, das für die meisten Klassen sinnvoll ist. Speziell bei diesem Engel wäre etwas weniger Aufwand ausreichend gewesen:

  Engel& operator=(const Engel& rhs)
  {
    *p = rhs.wert();       
    return *this;                     
  }

Das Löschen des Speichers und Wiederbesorgen kann sich sparen, wenn nur ein Wert zu übernehmen ist. Wichtig ist nur, sich zu überlegen, welches Verhalten Objekte mit Zeigern haben sollen. Lege ich das nicht fest, verhält sich das System trotzdem irgendwie (fehlerhaft), nur habe ich dann die Kontrolle darüber aufgegeben.

(Halb-)Dynamische Felder

C++ bietet std::vector<T> als voll-dynamischen Container an, bei dem sich auch Elemente zwischenrein drängeln oder aus der Mitte verschwinden dürfen. Machmal ist er vielleicht überdimensioniert. In C++0x lassen sich statische Felder fester Größe auch als std::array<T, size> festlegen, die im Gegensatz zu T[size]-Feldern auch als Rückgabewerte einsetzbar sind. Wie sieht es in anderen Sprachen aus? Der Java-Vector nimmt alles mögliche auf (Basistyp Object). Beim Entnehmen von Objekten ist ein typecast erforderlich. Eine gefährliche Sache:

  Vector sheep = new Vector();
  sheep.addElement(new Sheep());
  sheep.addElement(new Sheep());
  sheep.addElement(new Wolf()); // kein Übersetzungsfehler in Java!
 
  Sheep one_sheep = (Sheep) sheep.elementAt(2); // Wolf im Schafspelz?
                    // wirft ClassCastException - zur Laufzeit
<code cpp>
 
In Javascript gibt es einen Array-Container, dessen Größe zur Laufzeit bestimmbar und dann unveränderlich ist, deshalb halb-dynamisch, allerdings kümmert sich Javascript gar nicht um Typen. In C++ sollte ein Array für alle Typen funktionieren, ohne die Typprüfung außer Kraft zu setzen. Schablonen (templates) sind dafür das geeignete Mittel. Wieviel Quelltext braucht man in C++, um einen solchen Container zu erzeugen? Nicht viel:
 
<code cpp>
#include <algorithm>
 
template <class T>
class Array
{
public:
  Array(int size, const T& initvalue)         // Konstruktor
  : size_( size )
  , data_( new T[size] )
  {
    std::fill_n(data_, size_, initvalue);
  }
 
  Array(const Array<T>& rhs)                  // Kopie
  : size_( rhs.size_ )
  , data_( new T[size_] )
  {
    std::copy(rhs.data_, rhs.data_+size_, data_);
  }
 
  Array& operator=(Array<T> rhs)              // Zuweisung 
  {
    swap(rhs);                                // rhs ist Kopie!
    return *this;
  }
 
  void swap(Array<T>& rhs) 
  {
    std::swap(size_, rhs.size_);              
    std::swap(data_, rhs.data_);             
  }
 
  ~Array() { delete [] data_; }               // Destruktor
 
  // Größe, Elementzugriff
  int size() const { return size_; }
  const T& operator[](int index) const { return data_[safe(index)]; }
        T& operator[](int index)       { return data_[safe(index)]; }
private:
  int size_;
  T* data_;
  int safe(int index) const;
};

Die Feldgrenzen lassen sich auch noch überwachen:

class ArrayRangeError  // evtl. ableitbar von std::range_error
{
public:
  virtual const char* what() const { return "Array range error"; }
};
 
template <class T>
int Array<T>::safe(int index)
{
  if (index<0 || index>=size()) throw ArrayRangeError();
  return index;
}

Mit nur 50 Zeilen Quelltext lassen sich so nützliche Container bauen. Damit besteht keine Notwendigkeit mehr, gefährliche C-Felder mit ungeprüften Feldgrenzen einzubauen.

  Array<double> arr(10, 3.14); // mit 10 mal Wert 3.14
  double sum = 0;
  for (int i = 0; i < arr.size(); i++)
  {
    sum += arr[i];
  }
  std::cout << sum << std::endl; // Ergebnis: 31.4

Mit der Array-Klasse (auch mit std::vector<T>) löst sich folgendes Problem in Luft auf:

  std::cout << "Wieviel Daten sind zu verarbeiten?";
  std::cin >> anzahl;
  int C_array[anzahl]; // Fehler: Feldgröße muss eine Konstante sein
  // ... Datenerfassung

Die Lösung erfordert dynamischen Speicher. Zu Fuß sieht die Lösung so aus:

  // ...
  int *dyn_array = new int[anzahl];
  // ... Datenerfassung
  delete [] dyn_array;

Mit Containern besteht die Lösung in einer einzigen Zeile:

  // ...
  Array<int> arr(anzahl, 0);
  // ... oder
  std::vector<int> vec(anzahl);
  // ... Datenerfassung

Die Container können auch zur Datenhaltung in Objekten genutzt werden. Da die Container sich selbsttätig um Speicheranforderung, Kopie, Zuweisung und Speicherfreigabe kümmern, kann sich der professionelle Programmierer auf die inhaltliche Problemstellung konzentrieren, weil er von fehlerträchtigen Routineaufgaben entlastet ist:

class Briefkasten
{
// ...
private:
  std::vector<Message> briefe;
};

Merkregel: Wer direkt dynamischen Speicher anfordert und besitzt, muss ihn auch freigeben. Wer einen Destruktor definiert, um dynamischen Speicher freizugeben, muss auch das Wechseln und Nachmachen neu regeln. Wer das nicht beachtet und solche unvollständigen Klassen herstellt, benutzt oder in Verkehr bringt, wird mit regelmäßigen Programmabstürzen bestraft. Der Spruch klingt bekannt? Er stammt (abgewandelt) von alten Geldscheinen. Interessanterweise findet er sich nicht auf Euro-Scheinen. Er wurde abgekürzt in: © BCE ECB EZB EKT EKP. Das reicht auch. Alle Kopierrechte vorbehalten. Möglicherweise kopiert die Europäische Zentralbank die Scheine auch nur?

Kopierschutz

Sie möchten ihre Objekte auch kopierschützen? Erklären Sie Kopierkonstruktor und Zuweisung als privat. Sie brauchen sie nicht zu implementieren, denn keiner kann sie nutzen:

class Copyrighted
{ // ...
private:
  Copyrighted(const Copyrighted&); // = delete; in C++11
  void operator=(const Copyrighted&); // = delete;
};

Klonen

Das Klonen von Menschen kommt gerade in Mode. Sollten wir unsere Gene kopierschützen lassen? Es würde mich nicht überraschen, wenn bei der vollständigen Sequenzierung irgendwo in Enden der DNS folgendes auftaucht:

...GGGAAATTT(C)GOD_DAY_SIX_VER_ADAM_GPL___GGGAAATTTGGGAAATTT...

Es gibt gewisse Sekten, die gerne Klone herstellen möchten, obwohl es angenehmere Arten der Vervielfältigung gibt. Warum einfach, wenn es auch umständlich geht.

Klonen in C++ (oder Java) setzt klonfähige Objekte voraus, Erben einer Klasse, die selbst (!) Klone erzeugt. Dann kann ein Besitzer der Klone diese zur Vervielfachung veranlassen:

class Cloneable
{
public:
  virtual ~Cloneable();                 // Basis-Destruktor bei Polymorphie
  virtual Cloneable* clone() const = 0; // wird überschrieben
};
 
class Besitzer
{
  Cloneable* p;                         // Objekt kann auch abgeleitet sein 
public:
  Besitzer(const Besitzer& original) 
  {
    p = original.p->clone();
  }
 
  Besitzer& operator=(Besitzer rhs)
  {
    swap(rhs);
    return *this;
  } 
 
  void swap(Besitzer& rhs)
  {
    std::swap(p, rhs.p);    
  }  
  // ...
};

Automatisch klonen

So unangenehme Aufgaben wie das Klonen kann man delegieren:

class CloneablePtr
{
public:
  CloneablePtr(Cloneable* neuesObjekt)
  : p(neuesObjekt)
  {
  } 
  CloneablePtr(const CloneablePtr& original) 
  {
    p = original.p ? original.p->clone() : 0;
  }
  CloneablePtr& operator=()
  {
    if (this != &original)
    {
      delete p;
      p = original.p ? original.p->clone() : 0;
    }
    return *this;
  }  
  // Zeigerverhalten:
  Cloneable& operator*() { return *safe_ptr(); }
  Cloneable* operator->() { return safe_ptr (); }
 
  const Cloneable& operator*() const { return *safe_ptr (); }
  const Cloneable* operator->() const { return safe_ptr (); }
 
  operator bool() const { return p!=0; }
private:
  Cloneable* p;                        
  Cloneable* safe_ptr();
};

Der Zugriff auf den Zeiger sollte überwacht werden:

class NullPointerException
{
public:
  virtual const char* what() const { return "access to NULL pointer"; }
};
 
Cloneable* CloneablePtr::safe_ptr() const
{
  if (!p) throw NullPointerException();
  return p;
}

Besitzer solcher Zeiger brauchen sich um nichts zu kümmern außer:

class Besitzer
{
// ...
private:
  CloneablePtr p;
};

Gute Referenzen

Besonders bequem ist der Umgang mit Zeigertypen, die von mehreren Objekten gemeinsam genutzt werden können und die sich selbst aufräumen, wenn sie nicht mehr gebraucht werden. Das Java-Laufzeitsystem nutzt solche "Referenzen" (referenzzählende Zeiger) im Hintergrund außer für einfache Typen. Eine Klasse mit (fast) genau demselben Verhalten lässt sich auch in C++ implementieren. Versuchen Sie es doch mal! Sie werden weniger als 100 Zeilen Quelltext brauchen. Zur Anregung der Entwurf der öffentlichen Schnittstelle:

template <class T>
class RefPtr
{
public:
  RefPtr(); 
  RefPtr(T* neuesObjekt);
  RefPtr& operator=(const RefPtr& rhs);
  template <class T2>
  RefPtr(const RefPtr<T2>& rhs); // für RefPtr auf abgeleitete Objekte
  RefPtr(const RefPtr& rhs);
  ~RefPtr();
        T& operator*();
  const T& operator*() const;
        T* operator->();
  const T* operator->() const;
  bool operator==(const RefPtr& rhs) const;
  bool operator!=(const RefPtr& rhs) const;
  operator bool() const; 
  // ...
};

Sie werden zwei Hürden zu überwinden haben:

  • Wie ist der Standardkonstruktor zu implementieren, d.h. wohin zeigen nicht initialisierte RefPtr<T>? Gibt es ein null-Objekt (für alle RefPtr aller Typen)?
  • Wie können RefPtr auf abgeleitete Objekte auch von RefPtr auf die Basisklasse übernommen werden? Die Schwierigkeit: Instanziierte Schablonen unterschiedlicher Typen sind typverschieden, daher der Kopierkonstruktor mit RefPtr<T2>. Nur bei Typgleichheit haben Sie Zugriff auf die private Implementation. Können Sie sichern, dass der Versuch, nicht abgeleitete Objekte zu referenzieren, vom Compiler zurückgewiesen wird oder müssen Sie die Typprüfung aufweichen?

Der Rest ist einfach, um nicht zu sagen, elementar. Wenn Sie es geschafft haben, können Sie auch in C++ folgendes schreiben:

struct Punkt { int x, y; };
 
void demo()  // fast wie Java
{
  RefPtr<Punkt> p = new Punkt(1,2);
  RefPtr<Punkt> array[10]; // zehn Nullreferenzen
 
  p->x = 1;
  p->y = 2;
 
  array[0] = array[1] = p;
  if (!array[2])
  {
  // std::cout << array[2]->x << std::endl; // würde NullPointerException werfen
  }
}

Noboby is perfect

Schaffen Sie es nicht auf Anhieb, trösten Sie sich; Sie sind in guter Gesellschaft. Ein Beispiel.

Der inzwischen geächtete std::auto_ptr<T> wurde geschaffen, um Speicherlecks beim Umgang mit dynamischem Speicher zu verhindern. Der mit dem Borland-Compiler BC++ 5.01 1996 ausgelieferte Standard-Header <memory> enthielt einen Fehler an delikater Stelle. Finden Sie ihn?

/*
 * Copyright (c) 1991, 1996 by Borland International
 * Copyright (c) 1994 Hewlett-Packard Company
 * (c) Copyright 1994, 1995 Rogue Wave Software, Inc.
 * ALL RIGHTS RESERVED
 *****************************************************/
 
// ... leicht gekürzt
 
template<class X> class auto_ptr
{
public:
  explicit auto_ptr (X* p = 0) : the_p(p)           {}
  auto_ptr (auto_ptr<X>& a)    : the_p(a.release()) {}
  void operator= (auto_ptr<X>& rhs) { reset(rhs.release()); }
  ~auto_ptr () { delete the_p; }
 
  X& operator*  ()   const { return *the_p;   }
  X* operator-> ()   const { return the_p;    }
  X* get        ()   const { return the_p;    }
  X* release    ()         { return reset(0); }
  X* reset      (X* p = 0) { X* tmp = the_p; the_p = p; return tmp; }
 
private:
  X* the_p;
};

Im Zuweisungsoperator fehlt das Zauberwörtchen delete. Ausgerechnet die Klasse, die Speicherlecks verhindern soll, erzeugt eines. Das alte Objekt wird vor Schreck fallengelassen und nicht aufgeräumt. Will man das Objekt löschen, muss man sich aber auch um die Selbstzuweisung sorgen; ein Fehler kommt selten allein:

  void operator= (auto_ptr<X>& rhs) 
  { 
    if (this != &rhs) delete reset(rhs.release()); 
  }

Über dem Quelltext befinden sich drei Copyrights, die ich hiermit gebrochen habe. Die Frage ist nur: Wer hat wann von wem falsch abgeschrieben, ohne es zu bemerken?

Der Zuweisungsoperator des std::auto_ptr<T> zeigt noch eine andere Abweichung vom allgemeinen Muster. Er liefert nichts zurück und wie im Kopierkonstruktor bleibt die rechte Seite nicht konstant. Dies ist aber kein Fehler. Eine Mehrfachzuweisung macht keinen Sinn, da der rechteste Zeiger nur bis ganz links durchgereicht würde, alle Objekte links von ihm würden weggekehrt. Die rechte Seite kann nicht unverändert bleiben, sie muss den Zeiger loslassen. Beide Abweichungen sind also Absicht und sinnvoll. Ein Muster ist kein Dogma.

Holmes: Ich kombiniere ...

Smarte Pointer (Zeigerklassen) kümmern sich nur um Einzelobjekte, Container um viele Objekte. Was ist, wenn man mehrere Objekte in mehreren Klassen gemeinsam nutzen will? Man könnte auf den Gedanken kommen, referenzgezählte Felder und Felder von tief kopierenden Zeigern als weitere Klassen zu definieren. Jetzt, wo man auf den Trichter gekommen ist und es gerade so schön lauft… Falsche Richtung! Wir haben schon benötigten Werkzeuge. Sie müssen nur sinnvoll kombiniert eingesetzt werden:

  typedef RefPtr<Array<int> > Shared_int_array;
 
  Shared_int_array shared = new Array<int>(10,0);
  (*shared)[1] = 2;
 
  typedef Array<CloneablePtr> Array_of_clones;
 
  Array_of_clones stammzellen(10, new Stammzelle()); 
 
  for (int i = 0; i < stammzellen.size(); i++)
  {
    stammzellen[3]->mutieren();
  }

Es ist möglich, ein Feld gemeinsam zu nutzen, über einen referenzzählenden Zeiger. Es ist auch möglich, in einem Feld Zeiger einzusetzen, so dass mehrere, aber vielleicht nicht alle Elemente des Feldes auf dasselbe Objekt verweisen:

  Array<RefPtr<Rad> > fahrwerk(5, new Rad());
  fahrwerk[0] = new Rad(); // Ersatzrad
  fahrwerk[1]->drehen();   // Räder 1 bis 4 drehen sich!

Schlussbemerkung

Zeiger sind gefährlich. Vielleicht sollte man gar keine benutzen? Besser ist, sie in Klassen zu verstecken, die sich um die niederen Arbeiten der Speicherbereitstellung und -bereinigung kümmern. Hat man diese Klassen, ist der Umgang mit dynamischem Speicher einfacher. Allerdings reichen Programmier-Grundkenntnisse allein nicht, diese Klassen zu schaffen. Zum Glück gibt es für die grundlegenden Aufgaben schon Standard-Container und Standard-Algorithmen. Wer Klassen mit Zeigern baut, muss sich um die Speichernutzung kümmern. Mindestens einmal. Ist man über diesen Berg, wird es einfacher. Nur mit umfassendem Verständnis läßt sich C++ effizient programmieren. Nur, in welcher Sprache ist das nicht so?

Weiterführende Literatur:

  • [Josuttis] Nikolai Josuttis: Die C++-Standardbibliothek. Addison-Wesley, Bonn (1996). ISBN 3-8273-1023-7
  • [Effective C++] Scott D. Meyers: Effective C++. Addison-Wesley, Reading (Mass.) (1992). ISBN 0-201-56364-9
  • [Horstmann] Cay S. Horstmann: Practical Object-Oriented Development in C++ and Java. Wiley, New York (1997). ISBN 0-471-14767-2
lernen/eigentum.txt · Zuletzt geändert: 2014-07-13 16:14 (Externe Bearbeitung)