5 Dinge die man über die Speicherverwaltung von C++ wissen muss!

von Hubert Schmid vom 2012-11-11

Speicherlecks, hängende Zeiger und mehrfache Deallokationen sind ein großes Problem in C++. Solche oder so ähnliche Aussagen höre ich immer wieder von Leuten, die sich entweder mit C++ noch gar nicht ernsthaft auseinander gesetzt haben, oder die unter C++ etwas verstehen, was vor über 20 Jahren mal so genannt wurde.

Aus meiner Sicht gehören zu den großen Problemen von C++ die geringe Anzahl hochwertiger Bibliotheken und die verbesserungsfähige Tool-Unterstützung. Die Speicherverwaltung gehört hingegen nicht dazu. Denn entgegen den Vorurteilen funktioniert sie sehr gut. Ich kann mich nur an zwei Speicherlecks der Systeme erinnern, die ich in den letzten zehn Jahren entwickelt habe. Dabei konnten beide sehr schnell behoben werden. Das große Problem mit der Speicherverwaltung ist lediglich, dass viele Leute sich negativ dazu äußern – ohne sich anscheinend genauer damit auseinandergesetzt zu haben.

Vielleicht hilft es, die Mechanismen der C++‑Speicherverwaltung kurz und knapp in fünf Punkten zu erklären – beginnend mit dem folgenden Code-Fragment und einer einfachen Funktion, die – willkürlich – eine Art Kreuzprodukt erzeugt und zurückgibt.

vector<string> foobar(const vector<string>& values, const string& separator) { vector<string> result; for (auto&& first : values) { string head = first + separator; for (auto&& second : values) { result.push_back(head + second); } } return result; }

Diese Funktion konstruiert zahlreiche Objekte, benötigt Speicher für die String- und Container-Operationen, enthält keine expliziten Operationen für die Speicherverwaltung und ist dennoch frei von Speicherlecks – selbst im Falle von auftretenden Exceptions. Entscheidend dafür ist die folgende Eigenschaft von C++:

  1. Objekte werden zu einem genau definierten Zeitpunkt automatisch zerstört. Dabei wird unter anderem der Destruktor des Objekts aufgerufen.

Das gilt nur für Objekte, die nicht mit new erzeugt wurden – doch dazu gleich mehr. Wichtig ist: Diese Eigenschaft ist die technische Basis für die gesamte Ressourcen-Verwaltung – und fast alle Mechanismen bauen darauf auf. Insbesondere das folgende Idiom ist von zentraler Bedeutung.

  1. Die Verantwortung für die Freigabe von Ressourcen liegt bei demjenigen, der die Ressourcen allokiert.

So trivial wie es auch klingt – diese beiden Punkte sind spezifisch für Sprachen wie C++. In Progammiersprachen mit automatischer Speicherbereinigung wird die Verantwortung hingegen an eben diese übergeben, wohingegen für alle anderen Ressourcen die allokierenden Objekte auf die Zuarbeit des Aufrufers angewiesen sind – üblicherweise in Form eines finally-Blocks oder einer using-Anweisung.

Zurück zur Ausnahme: In einigen Fällen müssen Objekte mit new erzeugt werden – beispielsweise wenn die Lebenszeit der Objekte unabhängig vom erzeugenden Kontext ist, wie im folgenden Beispiel zu sehen:

thing* produce() { thing* foo = new thing; // do something with *foo return foo; } void consume(thing* foo) { // do something with *foo delete foo; } void bar() { consume(produce()); }

Solcher Code ist zweifelsohne anfällig für Speicherprobleme, da nicht direkt ersichtlich ist, ob das allokierte Objekt wirklich wieder zerstört wird. Doch der Rahmen für die Lösung wird bereits durch den zweiten Punkt bestimmt: Verantwortlich für die Freigabe sollte die Funktion produce sein. Der erste Punkt liefert dazu die technische Lösung.

  1. Die Verantwortung für die Freigabe von Ressourcen wird an Objekte delegiert.

Solche Objekte werden in vielen Situationen als Intelligente Zeiger bezeichnet. In der Standard-Bibliothek von C++11 finden sich dazu die beiden Klassen-Templates std::unique_ptr und std::shared_ptr, die gemeinsam fast alle Fälle optimal abdecken und viele der früher eingesetzten Implementierungen ablösen. Durch Ersetzung der Zeiger im vorherigen Beispiel mit unique_ptr wird das scheinbare Speicherprobleme einfach und effizient gelöst.

unique_ptr<thing> produce() { unique_ptr<thing> foo{new thing}; // do something with *foo return foo; } void consume(unique_ptr<thing> foo) { // do something with *foo // -- foo will be automatically deleted at the end // of this function, because it is no longer used. } void bar() { consume(produce()); }

Mit Berücksichtigung dieser drei Punkte hat man die Speicherverwaltung bereits weitgehend im Griff. Der Rest sind Details. Ein Detail ist, dass durch den Einsatz von std::unique_ptr und std::shared_ptr zwar alle expliziten delete-Aufrufe eliminiert wurden – weiterhin aber new verwendet wird. Diese Asymmetrie ist nicht nur unelegant, sondern auch in einigen Spezialfällen problematisch. Daher eliminiert man auch noch das new.

  1. Keine Verwendung von new und delete.

Stattdessen verwendet man generische Factory-Funktionen, die die Objekt-Konstruktion mit den Intelligenten Zeigern verknüpfen. Für die beiden Standard-Klassen sind das std::make_unique und std::make_shared. Im Beispiel ändert sich lediglich die erste Funktion.

unique_ptr<thing> produce() { unique_ptr<thing> foo = std::make_unique<thing>(); // do something with *foo return foo; }

Jetzt fehlt nur noch ein Punkt: Die beiden Klassen-Templates std::unique_ptr und std::shared_ptr basieren auf Referenzzählung. Doch dieser Mechanismus wird häufig in Zusammenhang mit Problemen durch zyklische Referenzen gebracht. Zu Unrecht, denn die Erfahrung zeigt:

  1. Referenzzählung funktioniert hinreichend gut durch Unterscheidung in starke und schwache Referenzen.

Schwache Referenzen werden in C++ üblicherweise mit std::weak_ptr realisiert. Sie können aus einem std::shared_ptr erzeugt werden, haben aber keinen Einfluss auf den Referenzzähler. Nur solange noch eine starke Referenz auf das Objekt existiert, kann aus dem std::weak_ptr wieder ein std::shared_ptr konstruiert werden. Damit können die Zyklen und Rückwärtskanten einfach aufgelöst werden.

Diese fünf einfachen Punkte reichen in C++ für die Speicherverwaltung fast immer aus und eliminieren die typischen Fehlerbilder, die man historisch mit C++ verbindet. Offen bleibt nur die Frage, ob das irgendwann auch Entwickler verstehen, die sich bisher nur oberflächlich damit auseinander gesetzt haben ...