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.
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 |
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 -cLevezeté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.
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.
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:
std::vector::push_back, insert,
emplace_back, reserve, resize, ha
újrapufferelés történik. A data() pointer és minden
korábban kiadott span érvénytelenné válik.erase, clear,
pop_back a törölt elemre (és utána a vector méretére)
mutató nézetet érvényteleníti; a vector elemei törlés után
áthelyeződhetnek.unique_ptr::reset,
shared_ptr::reset, owner = nullptr
felszabadítja az erőforrást; minden korábban kiadott T*,
T& és span dangling lesz.std::map és
std::set referencia- és iteratorstabilitása insert/erase
alatt megmarad (a törölt elem kivételével);
std::unordered_map rehash esetén minden korábbi referencia
és iterator érvénytelenné válik.#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.
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.
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.
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.
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 újraEllenő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).