Zum Inhalt springen

shared_ptr, weak_ptr & Besitzgraphen

Verstehe referenzgezählten geteilten Besitz mit shared_ptr, brich Zyklen mit weak_ptr auf, lerne Kontrollblöcke und den Aliasing-Konstruktor kennen und erkenne, wann shared_ptr die falsche Wahl ist.

shared_ptr: geteilter Besitz

std::shared_ptr ermöglicht geteilten Besitz — mehrere shared_ptr-Instanzen können dasselbe Objekt besitzen. Das Objekt wird zerstört, sobald der letzte besitzende shared_ptr zerstört oder zurückgesetzt wird.

Intern verwaltet shared_ptr einen Kontrollblock, der Folgendes enthält:
- Einen starken Referenzzähler (Anzahl der shared_ptr-Besitzer)
- Einen schwachen Referenzzähler (Anzahl der weak_ptr-Beobachter)
- Den Deleter und den Allokator

shared_ptr ist größer als ein roher Zeiger (typischerweise 2 Zeiger: einer auf das Objekt, einer auf den Kontrollblock), und die Referenzzählung verursacht bei jedem Kopieren und Zerstören atomaren Overhead. Setze ihn nur ein, wenn der Besitz tatsächlich geteilt wird.

make_shared und Referenzzählung

std::make_shared(args...) allokiert das Objekt UND den Kontrollblock in einer einzigen Allokation (statt in zweien). Das ist cache-freundlicher und umgeht das Problem der Ausnahmesicherheit von rohem new.

shared_ptr_basics.cpp
#include <iostream>
#include <memory>
#include <string>

struct Logger {
    std::string name;
    Logger(std::string n) : name(std::move(n)) {
        std::cout << name << " created\n";
    }
    ~Logger() { std::cout << name << " destroyed\n"; }
    void log(const std::string& msg) {
        std::cout << "[" << name << "] " << msg << '\n';
    }
};

int main() {
    auto logger = std::make_shared<Logger>("AppLogger");
    std::cout << "use_count: " << logger.use_count() << '\n';  // 1

    {
        auto copy = logger;  // shared ownership
        std::cout << "use_count: " << logger.use_count() << '\n';  // 2
        copy->log("inside scope");
    }  // copy destroyed, count drops to 1

    std::cout << "use_count: " << logger.use_count() << '\n';  // 1
    logger->log("still alive");
}  // logger destroyed — count drops to 0, Logger deleted

weak_ptr: Zyklen aufbrechen

std::weak_ptr beobachtet ein von shared_ptr verwaltetes Objekt, ohne dessen Lebensdauer zu verlängern. Um auf das Objekt zuzugreifen, rufst du lock() auf: Das liefert einen shared_ptr — oder nullptr, falls das Objekt bereits zerstört wurde. weak_ptr ist unverzichtbar, um zirkuläre Referenzen aufzubrechen, die sonst lecken würden.

weak_ptr_example.cpp
#include <iostream>
#include <memory>
#include <string>

struct Employee;
struct Team {
    std::string name;
    std::vector<std::shared_ptr<Employee>> members;
    Team(std::string n) : name(std::move(n)) {}
    ~Team() { std::cout << "Team " << name << " destroyed\n"; }
};

struct Employee {
    std::string name;
    std::weak_ptr<Team> team;  // weak — does NOT own the team
    Employee(std::string n) : name(std::move(n)) {}
    ~Employee() { std::cout << "Employee " << name << " destroyed\n"; }

    void print_team() {
        if (auto t = team.lock()) {  // try to get shared_ptr
            std::cout << name << " belongs to " << t->name << '\n';
        } else {
            std::cout << name << " has no team\n";
        }
    }
};

int main() {
    auto team = std::make_shared<Team>("Engineering");
    auto alice = std::make_shared<Employee>("Alice");
    auto bob = std::make_shared<Employee>("Bob");

    team->members.push_back(alice);
    team->members.push_back(bob);
    alice->team = team;  // weak_ptr — no circular ownership
    bob->team = team;

    alice->print_team();  // "Alice belongs to Engineering"

    team.reset();  // destroy team
    alice->print_team();  // "Alice has no team" — weak_ptr expired
}
Stolperfalle

Halten zwei Objekte jeweils einen shared_ptr aufeinander, kann keiner der Referenzzähler je null erreichen — sie halten sich gegenseitig für immer am Leben. Das ist ein Speicherleck, obwohl du Smart Pointer verwendest.

struct Node {
    std::shared_ptr<Node> next;  // zeigt next zurück, gibt es ein LECK!
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a;  // zirkulär — weder a noch b werden je zerstört

Die Lösung: Mache eine der beiden Richtungen zu einem weak_ptr. In Baumstrukturen halten Kinder einen weak_ptr auf ihre Eltern (oder rohe Beobachterzeiger). In doppelt verketteten Listen verwendest du weak_ptr für den prev-Zeiger.

Der Aliasing-Konstruktor & enable_shared_from_this

Der Aliasing-Konstruktor erzeugt einen shared_ptr, der sich den Besitz mit einem bestehenden shared_ptr teilt, aber auf ein anderes Objekt zeigt (typischerweise auf ein Member). Das aliasierte Objekt bleibt am Leben, solange noch ein aliasierender shared_ptr existiert:

struct Pair { int first; int second; };
auto p = std::make_shared<Pair>(Pair{1, 2});
std::shared_ptr<int> sp_first(p, &p->first);  // teilt sich den Kontrollblock von p
// p darf seinen Gültigkeitsbereich verlassen — Pair bleibt am Leben, da sp_first es noch referenziert

enable_shared_from_this ist eine CRTP-Basisklasse, mit der ein von shared_ptr verwaltetes Objekt über shared_from_this() einen shared_ptr auf sich selbst bekommt. Ohne sie erzeugt ein neuer shared_ptr aus this einen zweiten Kontrollblock und damit doppeltes Freigeben.

Best Practice

shared_ptr wird zu oft verwendet. Greife nur dann dazu, wenn der Besitz tatsächlich über voneinander unabhängige Lebensdauern hinweg geteilt wird. Nutze shared_ptr NICHT als bequemen Ersatz dafür, über Besitz nachzudenken.

Vermeide shared_ptr, wenn:
- es genau einen Besitzer gibt — nimm stattdessen unique_ptr (kein Overhead).
- das Objekt auf dem Stack lebt — verwende das Objekt einfach direkt.
- du nur eine Referenz übergeben willst — übergib T& oder T*.
- du ihn nutzt, "weil er sicher ist" — shared_ptr kostet Laufzeit (atomarer Referenzzähler, zusätzliche Allokation) und verschleiert die Besitzverhältnisse.
- du in performancekritischen Pfaden bist — atomare Operationen auf dem Referenzzähler sind bei Konkurrenz überraschend teuer.

Legitime Fälle für geteilten Besitz: Caches (mehrere Abfragen liefern dasselbe Objekt), Publish-Subscribe-Systeme, gemeinsam genutzte Konfigurationsobjekte und Graph-Datenstrukturen mit tatsächlich geteilten Knoten.

Der Kontrollblock von innen

Wer den Kontrollblock versteht, versteht auch die Kosten von shared_ptr. make_shared fasst Objekt und Kontrollblock in einer Allokation zusammen. Die Konstruktion aus rohem new benötigt dagegen zwei getrennte Allokationen.

control_block.cpp
#include <memory>
#include <iostream>

struct Widget {
    int data[4];
};

int main() {
    // Two allocations: one for Widget, one for control block
    std::shared_ptr<Widget> p1(new Widget{});

    // Single allocation: Widget + control block together
    auto p2 = std::make_shared<Widget>();

    // Sizes (typical 64-bit platform)
    std::cout << "sizeof(Widget*):          " << sizeof(Widget*) << '\n';   // 8
    std::cout << "sizeof(unique_ptr<Widget>): " << sizeof(std::unique_ptr<Widget>) << '\n';  // 8
    std::cout << "sizeof(shared_ptr<Widget>): " << sizeof(std::shared_ptr<Widget>) << '\n';  // 16

    // shared_ptr is 2 pointers: one to object, one to control block
    // Control block contains: strong count, weak count, deleter, allocator

    std::cout << "p2 use_count: " << p2.use_count() << '\n';  // 1
    std::weak_ptr<Widget> w = p2;
    std::cout << "p2 use_count: " << p2.use_count() << '\n';  // still 1
    // weak_ptr does NOT increase strong count
}
Wichtigste Erkenntnisse
  • shared_ptr arbeitet mit Referenzzählung — das Objekt stirbt, wenn der letzte Besitzer verschwindet
  • make_shared allokiert Objekt + Kontrollblock in einer Allokation (bevorzuge es immer)
  • weak_ptr beobachtet, ohne zu besitzen — greife über lock() sicher zu; das bricht zirkuläre Referenzen auf
  • shared_ptr ist 2 Zeiger breit und kostet atomaren Referenzzähler-Overhead — er ist nicht gratis
  • Nimm standardmäßig unique_ptr; verwende shared_ptr nur, wenn der Besitz wirklich geteilt wird
  • Konstruiere nie zwei shared_ptr aus demselben rohen Zeiger — nutze enable_shared_from_this für Selbstreferenzen

Quiz — Teste dein Wissen

(15 XP)

1. Was passiert, wenn zwei Objekte jeweils einen `shared_ptr` aufeinander halten?

2. Warum ist `std::make_shared` besser als `shared_ptr<T>(new T(...))`?

3. Wie greifst du über einen `weak_ptr` sicher auf ein Objekt zu?