21.5 Owner, view és erőforrásszerződés C++ API-ban

A smart pointerek ismerete önmagában még nem elég ahhoz, hogy egy C++ API ne legyen élettartam-hibás. Ebben az alfejezetben azt vizsgáljuk meg, hogyan fejezi ki a függvény szignatúrája a valós tulajdonlási szándékot: ki birtokol, ki csak kölcsönöz, meddig él a hivatkozott objektum, és mikor lesz érvénytelen egy korábban kiadott nézet. A cél, hogy a paramétertípus önmagában is dokumentálja a szerződést, ne pedig kommentárban kelljen kitalálni. A C oldalról hozott fegyelem itt közvetlen folytatás: a Memóriamásolás, összehasonlítás és byte view (7.4) szerződéslogikája ugyanaz, csak típusba öntve.

21.5.1 Tulajdonlási szándék mint API-szerződés

Három szerepet különböztetünk meg. Az owner (tulajdonos) az az objektum, amelynek élettartamához az erőforrás élettartama kötődik; ez zárja le, szabadítja fel, vagy csökkenti a hivatkozás-számot. A borrowed (kölcsönzött) hozzáférés nem tulajdonol: a hívó felelőssége, hogy az objektum a hívás idejére érvényes legyen. A view (nézet) nem tulajdonló, több elemre kiterjedő hozzáférés, típusa szerződése szerint lehet kontiguis (pl. std::span) vagy szemantikus (pl. std::string_view).

A tulajdonlási invariáns: minden nyilvános függvény minden paramétere esetén a dokumentációnak és a típusnak egyértelműen meg kell mondania, hogy a függvény 1. átveszi a tulajdonjogot (sink), 2. csak kölcsönzi a hívás idejére, vagy 3. megosztott tulajdonba kerül (hosszú távú közös élettartam).

Ha a szignatúra nem fejezi ki ezt, a hívó kénytelen tippelni, és a hiba nem fordítási, hanem futásidejű dangling formában jelenik meg. Az alábbi táblázat a tipikus hozzárendelést mutatja; a konkrét típust mindig az indok dönti el, nem a táblázat önmagában.

Szándék Ajánlott paraméter Indok
Olvasás, nagy típus const T& nincs másolás, nincs tulajdonjog
Olvasás, kicsi triv másolható T olcsóbb, mint indirekció
Sink (a függvény megtartja) T + std::move egy mozgatással tagba tehető
Sink, egyedi erőforrás std::unique_ptr<T> explicit tulajdonjog-átadás
Kölcsönzés, nem lehet null T& referenciával garantált jelenlét
Kölcsönzés, lehet null T* null jelentés dokumentálandó
Nem tulajdonló tömb std::span<const T> hossz és tulajdonlás szétválik
Megosztott tulajdon std::shared_ptr<T> csak ha a függvény is osztozik

21.5.2 Érték szerinti átvétel, const& és sink

A const T& akkor helyes, ha a függvény csak olvas, és nem tárolja el a referenciát. A leggyakoribb hiba, hogy a szerző a const std::string& paramétert átveszi, majd a törzsben tagba másolja. Ekkor a hívó oldalon létrejön egy temporary, abból a törzsben egy másolat, és a temporary megsemmisül – három objektum, két másolás, egyik feleslegesen. A sink paraméter ezt egyetlen mozgatásra csökkenti: érték szerint vesszük át, majd std::move-val adjuk tovább.

#include <string>
#include <utility>

class Label {
    std::string text_;
public:
    // sink: átveszi és megtartja
    explicit Label(std::string text) : text_(std::move(text)) {}

    // csak olvas: nem tárolja, const& a helyes
    void print(const std::string& prefix) const;
};

Fordítás (GCC vagy Clang, nem futtatás):

g++ -std=c++20 -Wall -Wextra -Wpedantic example.cpp -c

Levezetés (költségvázlat). Érték szerinti sink esetén lvalue argumentummal a hívó oldalon egy másolás történik a paraméterbe, majd a tagba egy mozgatás; rvalue argumentummal nulla másolás és egy mozgatás. const& sink esetén lvalue argumentumnál egy másolás a tagba (a paraméter nem másol), rvalue argumentumnál a temporary létrehozása és a törzsben egy újabb másolás. A by-value sink tehát rvalue esetben vagy ugyanannyi, vagy kevesebb másolást végez, és ami fontosabb: a hívó kód szintjén látható, hogy az argumentum átadódik, nem csak „átnéz” a határon.

Kivétel: ha a típus drága és a függvény mégsem tárolja el, akkor const& a helyes; a sink nem ajánlás mindenre, hanem az átvétel kifejezője. C++20-tól a fordító sok esetben elkerüli a felesleges mozgatást, de ez nem teszi helyessé a rossz szándékú szignatúrát.

21.5.3 unique_ptr és shared_ptr: mikor melyik

A std::unique_ptr<T> érték szerinti átvétele a tulajdonjog átadását jelenti: a hívó tudja, hogy a mutató üres lesz a visszatérés után, és hogy a felelősség átszállt. A const std::unique_ptr<T>& ezzel szemben csak kölcsönöz, de a szándék homályos: miért van a pointer becsomagolva, ha nem adjuk át? Ilyenkor szinte mindig T& (nem null) vagy T* (null megengedett) a helyes. A unique_ptr<T>& csak akkor indokolt, ha a függvény a pointert magát módosítja (pl. reset-tel lecseréli).

#include <memory>

class Socket { public: int fd; };

class Server {
    std::unique_ptr<Socket> sock_;
public:
    // sink: a hívó tulajdonjogot ad át
    explicit Server(std::unique_ptr<Socket> s) : sock_(std::move(s)) {}

    // kölcsönzés, garantáltan nem null, nem tartja meg
    void send(Socket& s);

    // kölcsönzés, null is lehet
    void try_send(Socket* s);
};

A std::shared_ptr<T> érték szerinti átvétele akkor helyes, ha a függvény a pointert el is tárolja vagy továbbadja – vagyis valóban osztozik a tulajdonláson. A const std::shared_ptr<T>& önmagában nem biztosít hosszabb élettartamot: ha a függvény egy aszinkron callbackbe becsomagolja a referenciát, a shared_ptr visszaszámlálója a hívás után csökkenhet, és a callback a semmire mutat. A helyes megoldás a shared_ptr másolata a closure-be, vagy weak_ptr. A részletes control block és a weak_ptr viselkedés a shared_ptr, control block és weak_ptr (21.4) tárgyát képezi.

Fontos antipattern: const std::shared_ptr<T>& fogadása csupán azért, hogy „életben tartsuk” az objektumot. Ha a függvény nem osztozik, ez nem kölcsönzés, hanem hamis tulajdonosi látszat, és elrejti a valódi élettartam-szerződést.

21.5.4 span és string_view: nem tulajdonló előkép és invalidáció

A std::span<const T> (C++20) és a std::string_view nem tulajdonol. Tároló és mérethatár együtt kerül átadásra, de az érvényesség a hívó felelőssége. Előfeltétel: az alatta lévő elemek a span élettartama alatt érvényesek maradnak, és a span maga nem mutat egy már felszabadított puff erre. A string_view esetében ez ki is egészül azzal, hogy a mutatott karakterek nem módosulhatnak olyan művelettel, ami a view által látott tartományt érvényteleníti.

Az invalidáció forrásai konkrétan:

#include <cstdint>
#include <span>
#include <vector>

std::span<const std::uint8_t> loan(const std::vector<std::uint8_t>& buf) {
    return {buf.data(), buf.size()};
}

std::uint8_t wrong() {
    std::vector<std::uint8_t> buf(16, 0xEE);
    auto view = loan(buf);
    buf.reserve(4096);           // újrapufferelés: a view dangling lesz
    return view[0];              // UB: nem definiált viselkedés
}

std::uint8_t right() {
    std::vector<std::uint8_t> buf(16, 0xEE);
    auto v = buf[0];             // érték, nem nézet
    buf.reserve(4096);
    return v;                    // érvényes
}

A wrong() függvény tanítási példa: UB-t mutat, kimenete nincs garantálva, és a fordító optimalizálás mellett egészen mást is csinálhat. A right() változat helyes, mert értéket ment, nem nézetet. Ha a nézetre valóban szükség van a reserve után is, akkor a sorrendet kell megfordítani (először reserve, aztán nézet), vagy a nézetet a tartomány élettartamához kell kötni.

21.5.5 Aszinkron callback és a tulajdonos élettartama

Aszinkron callback esetén a callback élettartama jellemzően hosszabb, mint a regisztráló scope-é. Capture by reference ([&]) szinte mindig hiba, kivéve, ha a hívó dokumentáltan blokkol a callback befejezéséig. A tulajdonos megtartásához shared_ptr másolata kell a closure-be; a puszta megfigyeléshez weak_ptr.

#include <functional>
#include <memory>

class Worker : public std::enable_shared_from_this<Worker> {
public:
    using Callback = std::function<void()>;
    void post(Callback cb);

    // helyes: a callback a tulajdonost életben tartja
    void keep_alive() {
        auto self = shared_from_this();
        post([self] { /* self->... */ });
    }

    // helyes: a callback nem hosszabbítja meg, de észleli a megszűnést
    void observe() {
        std::weak_ptr<Worker> w = shared_from_this();
        post([w] {
            if (auto p = w.lock()) { /* p->... */ }
        });
    }
private:
    void run();
};

A keep_alive esetben a worker életben marad a callback lefutásáig, akkor is, ha a scope már kilépett. Az observe esetben a callback csak jelzi, hogy az objektum még él; ha nem, csendesen kilép. Mindkettő érvényes, de más szerződés: az első erőforrást tart fenn, a második hibát detektál. A szerződést a függvény nevének és dokumentációjának is tükröznie kell, nem csak a kódnak.

Az async capture nem menti meg attól, aki this-t capture-el shared_ptr nélkül: ez a klasszikus „raw this + async” hiba, és a hiba csak akkor látszik, ha a worker a callback előtt megszűnik.

21.5.6 Összefoglaló

A paramétertípus nem dekoráció, hanem a tulajdonlási szerződés része. Érték szerint venni csak akkor, ha a függvény megtartja (sink) vagy kicsi triv másolható; const& olvasásra, T&/T* kölcsönzésre; unique_ptr csak tulajdonjog-átadásra; shared_ptr csak tényleges megosztásra; span/string_view nem tulajdonló átadásra, a dokumentált érvényességi ablakkal. A view érvénytelenné válik, ha a mögötte álló tároló újrapufferelődik, törlődik, reset-elődik, vagy rehash-elődik. Az async callback élettartam-szerződése vagy shared_ptr megtartással, vagy weak_ptr megfigyeléssel oldható meg – a [&] capture csak indokolt kivétel lehet.

21.5.7 Feladatok

1. (magyarázó) Szignatúra-választás. Az alábbi függvényekhez válassz helyes paramétertípust, és egy mondatban indokold: (a) Logger::write(std::string msg) – a logger megtartja a szöveget, (b) Hash::compute(const std::vector<uint8_t>& data) – csak olvas, (c) Socket::shutdown(std::unique_ptr<Connection> c) – a socket lezárja és eldobja, (d) Timer::schedule(std::function<void()> cb) – a callback a timer megszűnése után is lefuthat.

2. (kódolási) Sink átírás. Az alábbi kód felesleges másolást végez. Írd át sink paraméterre, és indokold a mozgatások számát:

class Config {
    std::string name_;
public:
    explicit Config(const std::string& n) : name_(n) {}
};

3. (diagnosztikai) Dangling view. Az alábbi kódrészletben keresd meg a hibát, és add meg a javított változatot:

std::vector<int> buf{1,2,3};
std::span<int> s = buf;
buf.push_back(4);
int x = s[3];

4. (tervezés) Async observer. Adott egy Session osztály, amelyet egy hálózati réteg shared_ptr-rel tart életben. Írj egy subscribe(callback) metódust, ami a session bezárása után nem hívja meg a callbacket, de a session élettartama alatt igen.

21.5.8 Megoldások

1. megoldás. (a) std::string by-value + std::move a törzsben: a logger megtartja, ez sink. (b) const std::vector<uint8_t>&: csak olvas, a vektor élettartama a hívás idejére szükséges, és nem nullázandó; mérethez kötött hozzáféréshez std::span<const uint8_t> is jó, ha a hívó esetleg nyers puffert ad át. (c) std::unique_ptr<Connection> by value: a socket átveszi és eldobja, a tulajdonjog egyértelmű. (d) std::function<void()> by value: a timer megtartja; a callback élettartamát a std::function másolása biztosítja, de a capture-nek weak_ptr-t kell használnia, ha a callback a sessionre hivatkozik.

2. megoldás.

class Config {
    std::string name_;
public:
    explicit Config(std::string n) : name_(std::move(n)) {}
};

lvalue argumentummal: egy másolás a paraméterbe, egy mozgatás a tagba (a korábbi egy másolással szemben ez ugyanannyi, csak a temporary megszűnése tisztább). rvalue argumentummal: nulla másolás, egy mozgatás. Az eredeti const& változat rvalue esetén is másolt a törzsben, ezért a sink verzió jobb. Ellenőrzés: static_assert(std::is_nothrow_move_constructible_v<std::string>) és a fordító -Wall -Wextra mellett ne figyelmeztessen felesleges másolásra.

3. megoldás. A push_back(4) újrapufferelheti a vektort, mert a méret elérte a kapacitást; ilyenkor a s.data() dangling lesz, az s[3] UB. Javítás: a push_back előtt reserve, és csak utána kössük a nézetet, vagy elemenként olvassunk a vektorból:

std::vector<int> buf{1,2,3};
buf.reserve(8);
std::span<int> s = buf;
buf.push_back(4);
int x = s[3]; // érvényes, mert nem pufferelődött újra

Ellenőrzés: buf.capacity() >= 4 a push_back előtt, és a s.data() == buf.data() a push_back után (ugyanaz a puffer).

4. megoldás. A session shared_ptr-rel tartott; a callbacket weak_ptr-rel kell megfigyelővé tenni, hogy a session megszűnése után a callback ne hívódjon meg:

class Session : public std::enable_shared_from_this<Session> {
public:
    void subscribe(std::function<void()> cb) {
        std::weak_ptr<Session> w = shared_from_this();
        callbacks_.push_back([w, cb = std::move(cb)] {
            if (auto p = w.lock()) { cb(); }
        });
    }
private:
    std::vector<std::function<void()>> callbacks_;
};

Az owner a Session (a shared_ptr tartja), a view a callback weak_ptr-je. Invalidáló esemény: a session reset-je vagy a shared_ptr grófszám nullára csökkenése; ekkor a w.lock() üres shared_ptr-t ad, a callback nem fut le. Ellenőrzés: a session megszűnése után a callback ne legyen meghívva, és a callback ne tartsa életben a sessiont (weak_ptr::use_count a regisztráció után maradjon a korábbi értéken).