21.4 shared_ptr, control block és weak_ptr

A megosztott tulajdonlás akkor indokolt, amikor egy erőforrás élettartama nem köthető egyetlen lexikai hatókörhöz vagy egyetlen tulajdonoshoz. Tipikus eset a több helyről elérhető, aszinkron callbackben élő objektum, egy gráf csomópontja, vagy egy gyorsítótár bejegyzése. Ilyenkor a std::shared_ptr több tulajdonost enged, és az objektum akkor semmisül meg, amikor az utolsó tulajdonos elengedi. Ez azonban nem alapértelmezés: ahol egyetlen tulajdonos kijelölhető, ott a [unique_ptr és egyedi tulajdonlás (21.3)](#s104) egyszerűbb, olcsóbb és kiszámíthatóbb. A std::weak_ptr nem tulajdonos, hanem olyan megfigyelő, amely biztonságosan meg tudja kérdezni, hogy a kezelt objektum még él-e. A három eszköz együtt adja a C++ determinisztikus erőforrás-kezelésének egyik fontos szeletét, de nem oldja meg automatikusan a ciklusokat, a dangling nyers pointereket vagy a szálbiztonságot.

A megosztott tulajdonlás szerződése szorosan épül a korábbi objektum-élettartam-szabályokra: a shared_ptr destruktora nem „szemétgyűjt”, hanem egy referenciaszámlálót csökkent, és az utolsó erős hivatkozás megszűnésekor determinisztikusan lefuttatja a deletetert vagy a destruktort. A [Tároló újrafelhasználása és C++ objektumszabályok (20.6)](#s101) részben tárgyalt objektumélettartam itt két külön élettartamra bomlik: a kezelt objektuméra és a control blockéra.

21.4.1 Control block, strong count és object lifetime

A std::shared_ptr<T> egy másolható tulajdonosi fogantyú. Két lényeges része van: a kezelt objektumra mutató pointer, amelyet get() ad vissza, és egy control blockra mutató pointer. A control block a shared_ptr létrehozásakor jön létre, és nyilvántartja az erős (strong) és gyenge (weak) hivatkozások számát, valamint a deletetert és az allocatort. A control block konkrét típusa, mérete és elrendezése nem része a nyelvi szerződésnek; a szabvány a megfigyelhető viselkedést írja le, nem a belső bájtszerkezetet.

Amikor az utolsó erős shared_ptr megszűnik, a kezelt objektum élettartama véget ér: lefut a destruktor vagy a egyedi deleter. A control block azonban még életben marad, ha arra weak_ptr mutat. A control block csak akkor szabadul fel, amikor a strong és a weak hivatkozások száma is nulla. A weak számláló pontos belső értéke implementációfüggő lehet; a lényeg az, hogy a weak_ptr ne érje el a már megsemmisült objektumot, de a control blockot életben tartsa.

Esemény Hatás a strong countra Hatás a weak countra Következmény
shared_ptr másolása +1 változatlan új tulajdonos
shared_ptr megszűnése -1 változatlan ha 0, az objektum megsemmisül
weak_ptr másolása változatlan +1 a control block életben marad
sikeres weak_ptr::lock() +1 változatlan új shared_ptr jön létre
utolsó weak_ptr megszűnése változatlan -1 ha a strong is 0, a control block felszabadul

A referenciaszámláló növelése és csökkentése atomikus, de ez csak a számlálót védi. Ugyanazon shared_ptr példány egyidejű módosítása továbbra is adatverseny, és a kezelt objektumot a shared_ptr nem teszi automatikusan szálbiztossá. Ha az objektumot több szál olvassa és írja, ahhoz külön szinkronizáció kell. Az objektum megsemmisítése abban a szálban történik, amelyik az utolsó erős hivatkozást elengedi; a többi szálnak ezt megfelelően szinkronizálnia kell.

21.4.2 make_shared, allokáció és weak tail

A std::make_shared<T>(args...) egyetlen allokációban hozza létre a control blockot és a T objektumot. Ez gyorsabb lehet, mint a külön new T és a külön control block-allokáció, és kivétel esetén is biztonságos: ha a T konstruktora dob, a már lefoglalt tároló felszabadul, és nem marad gazdátlan nyers pointer. A make_shared hátránya, hogy nem adható meg hozzá egyedi deleter, és a T konstruktorának elérhetőnek kell lennie.

A make_shared esetében az objektum és a control block ugyanabban az allokációban van. Amikor az erős hivatkozások elfogynak, az objektum destruktora lefut, de a memória csak akkor szabadul fel, amikor az utolsó weak_ptr is megszűnik. Ezt nevezik weak tail élettartamnak. Ha egy nagy objektumra sokáig élő weak_ptr mutat, a nagy allokáció a látszólagos megsemmisítés után is foglalva maradhat. Ilyen esetben lehet tudatosan külön allokációt használni, de a legtöbbször a helyes megoldás a weak_ptr élettartamának rövidre szabása.

A std::shared_ptr<T>(new T) külön allokációt használ. Ez önmagában nem hiba, de két allokációt jelent, és a nyers new egy nagyobb kifejezésben könnyen gazdátlanul maradhat, ha a kiértékelés közben kivétel keletkezik. Ezért a tulajdonjog átadását a lehető legközvetlenebbül, lehetőleg make_shared-del érdemes megtenni.

21.4.3 Nyers pointerből shared_ptr és a double-control-block hiba

A std::shared_ptr<T> nyers pointerből konstruálva átveszi a tulajdonjogot, és új control blockot hoz létre. Ha ugyanarra a nyers pointerre két külön shared_ptr-t építünk, két független control block keletkezik, amelyek ugyanazt az objektumot fogják felszabadítani:

// Tanítási példa: NE futtasd, nem definiált viselkedés.
auto raw = new Config{"localhost", 8080};
std::shared_ptr<Config> a(raw);
std::shared_ptr<Config> b(raw); // külön control block, dupla delete

A második konstruktor nem tud az első control blockjáról. A két control block külön számlálja a tulajdonosokat, és mindkettő azt hiszi, hogy ő szabadítja fel az objektumot. A következmény dupla felszabadítás, azaz nem definiált viselkedés. Ugyanez a hiba, ha egy meglévő shared_ptr-ből p.get()-tel készítünk újabbat, vagy ha egy tagfüggvényben shared_ptr<T>(this)-t írunk. A helyes megoldás a meglévő shared_ptr másolása vagy mozgatása, illetve az enable_shared_from_this használata.

21.4.4 Aliasing constructor és subobject lifetime

Az aliasing constructor egy meglévő shared_ptr control blockját használja, de a get() által visszaadott pointer egy másik címre mutat:

struct Config {
    std::string host;
    int port;
};

auto cfg = std::make_shared<Config>(Config{"localhost", 8080});
std::shared_ptr<const int> port_view(cfg, &cfg->port);

Itt a port_view erős tulajdonosa a teljes Config objektumnak, miközben *port_view a port tagra mutat. Amíg port_view él, a Config sem semmisülhet meg, tehát a cfg.reset() után is biztonságos lehet a port_view használata. Fontos: az aliasing shared_ptr nem non-owning view, hanem a teljes objektumot tulajdonló, de a subobjectre mutató fogantyú. A mutatott subobjectnek a tulajdonolt objektum élettartamán belül kell lennie. Ha a pointer olyan objektumra mutat, amelynek élettartama rövidebb, mint a tulajdonolt objektumé, a shared_ptr nem tudja megakadályozni a dangling állapotot.

Az aliasing constructor hasznos például akkor, ha egy nagyobb objektum egy tagját szeretnénk átadni egy API-nak, de a teljes objektum életben tartását is biztosítani akarjuk. Az owner_before összehasonlítás a control block tulajdonviszonya alapján rendez, nem a get() cím szerint; ez segít például asszociatív tárolóban.

21.4.5 weak_ptr, lock és referencia-ciklus

A std::weak_ptr<T> nem tulajdonos, hanem a control blockra mutató gyenge hivatkozás. Nem növeli a strong countot, ezért nem akadályozza meg az objektum megsemmisítését. A lock() metódus vagy egy érvényes shared_ptr-t ad vissza, amely biztosítja az objektum élettartamát a használat idejére, vagy üres shared_ptr-t, ha az objektum már megsemmisült. A lock() az egyetlen helyes „ellenőrzés és használat” minta; az expired() vagy a use_count() külön lekérdezése és utána a dereferálás versenyhelyzetet okozhat.

A leggyakoribb alkalmazás a referencia-ciklus megszakítása. Ha két objektum shared_ptr-rel kölcsönösen hivatkozik egymásra, egyik strong countja sem csökken nullára, ezért egyik sem semmisül meg. Az egyik irányt weak_ptr-re kell cserélni. Az alábbi példában a szülő erősen birtokolja a gyermeket, a gyermek viszont csak gyengén figyeli a szülőt:

#include <iostream>
#include <memory>
#include <string>
#include <vector>

struct Parent;

struct Child {
    std::string name;
    std::weak_ptr<Parent> parent; // nem tulajdonol
    ~Child() { std::cout << "Child " << name << " destroyed\n"; }
    void report() const;
};

struct Parent {
    std::string name;
    std::vector<std::shared_ptr<Child>> children; // tulajdonol
    ~Parent() { std::cout << "Parent " << name << " destroyed\n"; }
};

void Child::report() const {
    if (auto p = parent.lock()) {
        std::cout << name << " parent=" << p->name << "\n";
    } else {
        std::cout << name << " parent expired\n";
    }
}

int main() {
    auto p = std::make_shared<Parent>();
    p->name = "root";
    auto c = std::make_shared<Child>();
    c->name = "leaf";
    c->parent = p;               // weak hivatkozás
    p->children.push_back(c);    // erős tulajdonlás a szülő felől

    std::cout << "parent strong=" << p.use_count()
              << " child strong=" << c.use_count() << "\n";
    c->report();
    p.reset();                   // a szülő élettartama véget ér
    c->report();                 // a weak_ptr már expired
    std::cout << "done\n";
}

A laborban például ezzel a paranccsal fordítható:

g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o cycle_demo cycle_demo.cpp

A példában a p és a c a tulajdonosok, a Child::parent pedig nem tulajdonló view. A p.reset() invalidálja a szülő objektumot; a weak_ptr::lock() ezután üres shared_ptr-t ad, ezért a report() az „expired” ágat futja. Ha a gyermekben is shared_ptr<Parent> lenne, a két objektum kölcsönösen életben tartaná egymást, és a p.reset() után sem szabadulna fel egyik sem.

21.4.6 enable_shared_from_this inicializálási feltételei

Az std::enable_shared_from_this<T> egy bázisosztály-sablon, amely lehetővé teszi, hogy egy már shared_ptr által birtokolt objektum tagfüggvénye új shared_ptr-t szerezzen anélkül, hogy új control blockot hozna létre. A shared_from_this() egy meglévő ownerrel megosztott shared_ptr-t ad vissza. Feltétele, hogy az objektumot már egy shared_ptr tulajdonolja. C++17-től, ha nincs ilyen owner, a hívás std::bad_weak_ptr kivételt dob; korábbi szabványokban ez nem definiált viselkedés volt.

A bázis egy gyenge hivatkozást tárol, amely akkor inicializálódik, amikor egy shared_ptr átveszi az objektum tulajdonjogát. Ezért a konstruktorban még nincs érvényes owner: a shared_from_this() hívása a konstrukció közben bad_weak_ptr-hez vezet. Ugyanez a helyzet, ha az objektumot stacken vagy unique_ptr-rel hozták létre, mert nincs shared_ptr owner. Helyes minta: az objektumot make_shared-del létrehozzuk, majd egy külön init() lépésben, már létező owner mellett hívjuk a shared_from_this()-t. C++17-től a weak_from_this() is elérhető, ha nem akarunk azonnal erős hivatkozást szerezni.

21.4.7 Gyakori hibák

21.4.8 Feladatok

1. feladat (megértés). Mi lesz a kimenet, és mennyi a use_count() az egyes pontokon?

auto a = std::make_shared<int>(42);
std::weak_ptr<int> w = a;
auto b = a;
a.reset();
std::cout << b.use_count() << " " << w.expired() << "\n";
auto c = w.lock();
std::cout << (c ? *c : -1) << "\n";

2. feladat (kódolás). Készíts egy Subject–Observer mintát, ahol a Subject shared_ptr-ekkel tárolja a megfigyelőket, az Observer pedig weak_ptr-rel hivatkozik a Subject-re. A Subject értesítéskor hívja meg az observereket, az observer a lock() segítségével olvassa a Subject állapotát. Biztosítsd, hogy a Subject megsemmisülése után az observer ne érjen érvénytelen objektumhoz.

3. feladat (diagnosztika és javítás). Az alábbi töredékek közül melyek hibásak, és miért? Javítsd őket.

  1. auto p = std::make_shared<X>(); auto q = std::shared_ptr<X>(p.get());

  2. struct X : std::enable_shared_from_this<X> { X() { shared_from_this(); } };

  3. X x; auto p = x.shared_from_this();

  4. auto cfg = std::make_shared<Config>(); std::shared_ptr<int> v(cfg, &cfg->port); cfg.reset(); use(*v);

21.4.9 Megoldások

1. megoldás. A w weak hivatkozás, ezért nem növeli a strong countot. b = a után a strong count 2. a.reset() után csak b tartja életben az objektumot, így b.use_count() értéke 1, és w.expired() hamis, azaz 0. A w.lock() sikeres, új shared_ptr-t ad, amelynek hatására a strong count átmenetileg 2 lesz; a *c értéke 42. A kimenet tehát:

1 0
42

Az ellenőrzési feltétel: a c élettartama alatt az objektum nem semmisülhet meg, és a c megszűnése után a strong count visszaesik 1-re. Ha a b-t is elengeddük, a w.expired() igazzá válik.

2. megoldás. Az alábbi példában a Subject a tulajdonos, az Observer a weak_ptr-rel csak megfigyel. A ciklus azért szakad meg, mert az Observer nem tartja életben a Subject-et.

#include <iostream>
#include <memory>
#include <string>
#include <vector>

struct Subject;

struct Observer {
    std::string name;
    std::weak_ptr<Subject> subject;
    explicit Observer(std::string n) : name(std::move(n)) {}
    void on_event() const;
};

struct Subject {
    int value = 0;
    std::vector<std::shared_ptr<Observer>> observers;
    void notify() const {
        for (const auto& o : observers) {
            o->on_event();
        }
    }
};

void Observer::on_event() const {
    if (auto s = subject.lock()) {
        std::cout << name << " sees " << s->value << "\n";
    } else {
        std::cout << name << " subject gone\n";
    }
}

int main() {
    auto s = std::make_shared<Subject>();
    auto o = std::make_shared<Observer>("o1");
    o->subject = s;
    s->observers.push_back(o);
    s->value = 7;
    s->notify();
    s.reset();
    o->on_event();
}

A Subject erősen birtokolja az observereket, az Observer gyengén figyeli a Subject-et. A s.reset() után a Subject megsemmisül, mert a weak hivatkozás nem tartja életben; az o viszont a lokális shared_ptr miatt még él, és a subject.lock() üres lesz. Az ellenőrzési feltétel: a Subject destruktora lefut, az Observer nem ér el felszabadított Subject-et, és a ciklus megszűnik.

3. megoldás. (a) Hibás: p.get() átadása egy új shared_ptr-nek új control blockot hoz létre, miközben p már tulajdonol. Ez double control block és dupla felszabadítás veszélye. Javítás: auto q = p;.

  1. Hibás: a konstruktorban még nincs shared_ptr owner, ezért a shared_from_this() C++17-től std::bad_weak_ptr-t dob. Javítás: a konstruktor ne hívja; a make_shared után, külön init() lépésben hívjuk.

  2. Hibás: a stacken létrehozott x-et nem birtokolja shared_ptr, ezért a shared_from_this() szintén bad_weak_ptr-t dob. Javítás: auto p = std::make_shared<X>(); auto q = p->shared_from_this();.

  3. Helyes: az aliasing constructor a v számára a cfg control blockját használja, ezért a cfg.reset() után a Config életben marad, amíg v él. A *v a port tagra mutat, és a Config élettartama lefedi. Az ellenőrzési feltétel: v.use_count() a létrehozás után 2, majd a cfg.reset() után 1; a v megszűnésekor a Config felszabadul.

21.4.10 Összefoglaló

A shared_ptr megosztott tulajdonlást ad: az utolsó erős hivatkozás megszűnésekor determinisztikusan megsemmisíti a kezelt objektumot. A control block élettartama ettől külön válik: a weak hivatkozások életben tartják, ezért a make_shared allokációja a weak tail miatt tovább foglalhat. A nyers pointerből készített shared_ptr mindig új control blockot hoz létre, ezért ugyanarra a pointerre csak egy tulajdonosi láncot szabad építeni. Az aliasing constructor a teljes objektumot tartja életben, miközben egy subobjectre mutat. A weak_ptr::lock() a biztonságos ellenőrzés és használat eszköze, a referencia-ciklust pedig az egyik irány weak hivatkozásra cserélésével kell megszakítani. Az enable_shared_from_this csak akkor működik, ha az objektumnak már van shared_ptr ownerje; konstruktorban, stacken vagy unique_ptr mellett nem használható.