1. Warum „smart pointers“ trotzdem leaken können
Wenn man zum ersten Mal von shared_ptr hört, denkt man fast automatisch: „Alles klar, Leaks sind erledigt, man kann das Wort delete aus dem Vokabular streichen und der Speicher regelt sich von selbst“. Und ja, in 90 % der Fälle ist das auch so. Aber es gibt ein fieses Szenario, in dem sich shared_ptr wie ein überhöflicher Gast verhält: er geht einfach nicht weg, weil ihn andere shared_ptr „an der Hand halten“.
Um zu verstehen, was kaputt geht, müssen wir uns den Mechanismus von shared_ptr ins Gedächtnis rufen: es ist Besitz mittels eines Zählers. Jeder neue Besitzer erhöht den Zähler, jeder Verlassende verringert ihn. Und das Objekt wird nur dann zerstört, wenn der Zähler auf null fällt. Stellen Sie sich jetzt vor, zwei Objekte besitzen einander. Ihre Zähler fallen nie auf null — selbst wenn Sie in main() „alles loslassen“.
Zyklus des Besitzes: die Erklärung in Kurzform
Ein Zyklus des Besitzes ist eine Situation, in der es eine geschlossene Kette von Objekten gibt und jedes das nächste über eine besitzende Referenz hält (in unserem Fall ist das std::shared_ptr). Am Ende „muss“ jedes Objekt leben, weil jemand es als Besitzer referenziert. Und so geht die Runde weiter.
Wichtig ist die feine Unterscheidung: das Problem sind nicht „einfach nur Referenzen“. Referenzen sind normal. Das Problem sind gerade die besitzenden Referenzen. Sie verlängern die Lebenszeit eines Objekts. Wenn diese Verlängerung sich zu einem Ring schließt, kann das Referenzzählungssystem diesen Ring nicht automatisch „abschneiden“: die Zähler werden niemals 0.
Unten eine sehr anschauliche Visualisierung:
flowchart LR
A[Task] -- shared_ptr --> B[Column]
B -- shared_ptr --> A
%% Ergebnis: use counts werden nicht 0
2. Wie man ein Leak sieht: der Destruktor als Detektor
Der Ausdruck „Speicherleck“ bleibt abstrakt, bis man es live sieht: „ich habe den Gültigkeitsbereich verlassen, aber der Destruktor wurde nicht aufgerufen“. Daher machen wir heute etwas sehr Ingenieurmäßiges: wir fügen dem struct einen Destruktor hinzu, der eine Nachricht ausgibt. Das ist fast wie eine Alarmanlage: wenn nichts gepiept hat, wurde das Objekt nicht zerstört.
Beginnen wir mit einer Mini-Struct, nur um die Technik zu verinnerlichen:
#include <iostream>
struct Loud {
int id{};
~Loud() {
std::cout << "~Loud(" << id << ")\n";
}
};
int main() {
Loud x{1};
std::cout << "End of main\n"; // End of main
} // ~Loud(1)
Hier ist alles erwartungsgemäß: x ist ein gewöhnliches Stack-Objekt, beim Verlassen von main wird der Destruktor aufgerufen.
Jetzt dasselbe, aber „unter Verwaltung“ eines shared_ptr, damit wir sehen, dass auch das korrekt endet:
#include <iostream>
#include <memory>
struct Loud {
int id{};
~Loud() { std::cout << "~Loud(" << id << ")\n"; }
};
int main() {
auto p = std::make_shared<Loud>(Loud{7});
std::cout << "End of main\n"; // End of main
} // ~Loud(7)
Solange es nur einen Besitzer gibt, ist alles gut: p wird zerstört, der Zähler fällt auf 0 und das Objekt wird freigegeben.
3. Zyklus in der Praxis: Task und Column in einer Mini-App
Stellen wir uns vor, wir entwickeln eine kleine Konsolen-Übungsanwendung (arbeitstitel MiniBoard): wir haben Aufgaben (Task) und Spalten (Column) im Stil „Todo / Doing / Done“. Die Column speichert eine Liste von Tasks, und die Task möchte plötzlich auch wissen, in welcher Column sie liegt, um schnell ihren Status ausgeben zu können.
Auf der Ebene „einfach praktisch“ liegt die Hand nahe, zu schreiben: „lass Column shared_ptr<Task> speichern und Task ein shared_ptr<Column>“. Klingt logisch: hier wird Besitz ausgeübt, dort wird Besitz ausgeübt. Gerade das ist ein fertiger Zyklus des Besitzes.
Lassen Sie uns Schritt für Schritt vorgehen. Zuerst die Typdeklarationen (beachten Sie das Forward Declaration — wir können Typen „vorwärts deklarieren“, um Abhängigkeitsprobleme beim Namen zu vermeiden):
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Column; // Vorwärtsdeklaration
struct Task {
int id{};
std::string title;
std::shared_ptr<Column> column; // "zurueck" (und hier beginnt das Risiko)
~Task() { std::cout << "~Task(" << id << ")\n"; }
};
Nun die Column, die die Tasks besitzt:
struct Column {
std::string name;
std::vector<std::shared_ptr<Task>> tasks; // "vorwaerts" — wir besitzen die Tasks
~Column() { std::cout << "~Column(" << name << ")\n"; }
};
Und jetzt der interessante Teil: Erzeugung und Verknüpfung.
int main() {
auto todo = std::make_shared<Column>();
todo->name = "Todo";
auto t = std::make_shared<Task>();
t->id = 1;
t->title = "Write C++ code";
todo->tasks.push_back(t);
t->column = todo; // Zyklus: Column -> Task -> Column
std::cout << "Leaving main...\n"; // Leaving main...
}
Sie starten das Programm und sehen: „Leaving main...“ wurde ausgegeben, aber ~Task(1) und ~Column(Todo) wurden nicht ausgegeben. Aus Sicht unserer Alarmanlage wurden die Objekte nicht zerstört. Das Leak „ohne delete“ ist passiert, weil niemand verpflichtet ist, delete zu schreiben, aber auch niemand verpflichtet ist, den Kreis für Sie zu durchtrennen.
4. Was mit den Zählern passiert und warum das keine Magie ist
Jetzt ist es wichtig, die Mechanik zu verstehen und sich nichts als Zauberspruch zu merken. Zerlegen wir die Ereignisse in Zeitpunkte, nicht formal, aber ehrlich:
Im Moment nach der Erzeugung von todo hat die Column einen Besitzer — die Variable todo.
Im Moment nach der Erzeugung von t hat die Task einen Besitzer — die Variable t.
Wenn wir todo->tasks.push_back(t) aufrufen, kopieren wir das shared_ptr in den Vektor. Das fügt einen weiteren Besitzer der Task hinzu: jetzt referenzieren sowohl die Variable t als auch das Element im Vektor todo->tasks[0] die Task.
Wenn wir t->column = todo setzen, kopieren wir das shared_ptr der Column in die Task. Das fügt einen weiteren Besitzer der Column hinzu: jetzt referenzieren die Variable todo und das Feld t->column die Column.
Dann kommt das Ende von main(). Die lokalen Variablen todo und t werden zerstört und die Zähler verringern sich. Aber sie fallen nicht komplett auf null, weil die Column noch von der Task über t->column gehalten wird und die Task weiterhin von der Column über tasks gehalten wird.
Das ergibt einen geschlossenen Kreis.
Man kann natürlich use_count() ausgeben und die Zahlen anschauen, aber denken Sie daran: use_count() ist eher eine Taschenlampe zum Debuggen als ein Mittel, um robuste Geschäftslogik aufzubauen. Die Idee, dass use_count() in allen Fällen starke Garantien liefert, ist trügerisch; betrachten Sie es als Diagnoseinstrument.
5. Formen von Zyklen und der Unterschied zwischen Besitz und Navigation
Häufigste Zyklen: „hin und zurück“ und „Nachbarn“
In Anwendungscode sehen Zyklen selten so aus, als hätte man sie absichtlich gebaut. Meist wirken sie banal: „Objekt A kennt Objekt B, und B kennt A“. Besonders häufig passiert das in doppelt verketteten Strukturen und in Parent–Child-Modellen.
Klassisches Beispiel mit „Nachbarn“ — eine doppelt verkettete Liste. Dafür braucht man nicht einmal einen Vector: nur zwei Zeiger.
#include <memory>
struct Node {
int value{};
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev; // wenn dies shared_ptr ist — Zyklus fast garantiert
};
Wenn Sie zwei Nodes erstellen und a->next auf b und b->prev auf a setzen, haben Sie bereits einen Besitzkreis zu zweit. Wieder werden die Destruktoren nicht aufgerufen, selbst wenn Sie aus main austreten.
Eine ähnliche Geschichte gilt für Parent–Child. Zum Beispiel ein Ordner, der Dateien besitzt, und eine Datei, die eine Referenz auf den Ordner speichert, um schnell den vollständigen Pfad auszugeben. Wenn beide Seiten besitzend sind, haben Sie praktisch im Alltag einen Zyklus gebaut.
„Navigationsreferenz“ vs „Besitzreferenz“
Jetzt kommt der nützlichste (und reifste) Gedanke der Vorlesung. In echtem Design muss man zwei Bedeutungen einer Verbindung unterscheiden.
Eine Verbindung zum Besitz der Lebenszeit beantwortet die Frage: „Wer ist verantwortlich dafür, das Objekt am Leben zu erhalten?“. In unserem Kontext ist das typischerweise ein shared_ptr.
Eine Verbindung zur Navigation beantwortet die Frage: „Wie finde ich das zugehörige Objekt, falls es noch existiert?“. Und hier ist es nicht immer nötig, Besitz auszuüben. Manchmal reicht ein einfacher Blick.
Als wir Task::column gefertigt haben, wollten wir eigentlich Navigation: die Task „möchte wissen“, wo sie ist. Stattdessen haben wir ihr Besitz gegeben: die Task wurde Miteigentümerin der Column. Und daraus entstand der Zyklus.
Daher eine sehr praktische Regel: wenn die Rückverbindung oder eine Seitwärtsverbindung nur für bequemen Zugriff gedacht ist, sollte sie oft nicht besitzend sein. Wie man sie nicht-besitzend macht, behandeln wir in der nächsten Vorlesung, wenn wir std::weak_ptr kennenlernen — genau dieses Konzept ist als „Referenz ohne Besitz“ gedacht.
Dass weak_ptr in der Standardbibliothek existiert und eine eigene Entwicklung hat, ist kein Zufall: es ist ein Werkzeug zur Behebung solcher architektonischer Engstellen.
6. Warum „den Zyklus per reset aufbrechen“ nur eine temporäre Flickschusterei ist
Wenn man einen Zyklus des Besitzes zum ersten Mal entdeckt, möchte man schnell handeln: „okay, ich rufe vor dem Programmende einfach reset() und gut ist“. Manchmal funktioniert das... aber genau das macht den Ansatz gefährlich: er funktioniert nur bis zum ersten Fall, in dem der Code nicht dort endet, wo Sie es erwarten.
Zum Beispiel kann man manuell so den Zyklus aufbrechen:
// ... nach Erzeugung und Verknuepfung:
t->column.reset(); // Besitz der Column von der Task entfernt
todo->tasks.clear(); // Besitz der Tasks von der Column entfernt
Danach können die Zähler tatsächlich auf null fallen und die Destruktoren ausgeführt werden. Der Preis ist jedoch, dass Sie sich merken müssen, vor dem „Aufräumen“ das Ritual in der richtigen Reihenfolge und in allen Codepfaden durchzuführen. Das wird schnell zum Spiel „rate, wo ich das Aufräumen vergessen habe“.
Gutes Design behebt den Zyklus nicht durch „Tänze mit reset()“, sondern durch Typen und Verträge: wo ist Besitz, wo ist Beobachtung. Und deshalb ist der nächste Schritt des Kurses weak_ptr.
7. Typische Fehler mit shared_ptr und Besitzzyklen
Fehler Nr. 1: „Wenn ich kein delete geschrieben habe, kann es kein Leak geben“.
Das ist eine sehr menschliche Falle: delete wird mit Speicher assoziiert, und es scheint, als gäbe es ohne ihn keine Probleme. Aber ein Zyklus des Besitzes ist eine Situation, in der Speicher nicht freigegeben wird, nicht wegen Vergesslichkeit, sondern wegen Logik der Zähler: sie erreichen ehrlich niemals 0.
Fehler Nr. 2: Beide Seiten einer Beziehung vorsorglich besitzend machen.
Psychologisch möchte man alles absichern: alles soll shared_ptr sein, dann stürzt nichts ab. In Wirklichkeit legt man damit keinen Schutz aus, sondern einen Ring: A hält B, B hält A, und beide leben ewig. „Vorsorglich“ ist einer der Hauptlieferanten für Zyklen.
Fehler Nr. 3: Den Zyklus an zufälligen Stellen per reset() heilen.
Manchmal wird der Zyklus so „behoben“: „irgendwo im Destruktor/aufraeumen mache ich ein reset()“. Das verwandelt den Code in ein Minenfeld: das Aufräumen hängt von Reihenfolge ab, davon, wer zuerst stirbt, und davon, welche Referenzen noch leben. Die richtige Heilung ist, das Besitzmodell zu ändern, nicht magische Nullsetzungen hinzuzufügen.
Fehler Nr. 4: Korrektheit der Anwendung auf use_count() stützen.
use_count() ist eine nützliche Lampe, um zu verstehen, „warum es nicht gelöscht wurde“, aber kein solides Fundament für Entscheidungen wie „darf ich löschen“, „wer ist der Hauptbesitzer“ usw. Diese Methode hat Limitierungen in ihren Garantien; betrachten Sie sie als Diagnoseausgabe im Log, nicht als Bedingung in einem if.
Fehler Nr. 5: Den Zyklus im Code nicht sehen, weil „das sind doch nur Felder“.
Ein Zyklus des Besitzes sieht oft harmlos aus: nur zwei Felder mit shared_ptr in zwei Strukturen. Genau das sollte Misstrauen wecken: wenn Sie ein „vorwärts“ shared_ptr und ein „zurueck“ shared_ptr haben, halten Sie inne und fragen Sie sich: „Müssen wirklich beide Seiten die Lebenszeit besitzen?“
GO TO FULL VERSION