Az előző részben (RAII wrapper file descriptorhoz
és lockhoz (21.2)) egy kézzel írt RAII wrapperrel kötöttük össze a
fájlleíró élettartamát egy objektum élettartamával. Ez a minta helyes
volt, de minden erőforrástípushoz külön osztályt igényel: destruktort,
move konstruktort, move értékadást, törölt másolást. A standard library
erre a leggyakoribb esetre — egyetlen, dinamikusan létrehozott objektum
vagy egy külső, pointerrel azonosított erőforrás — kész, jól bejárt
megoldást ad: a std::unique_ptr-t. Ez az alfejezet azt
mutatja meg, milyen szerződést kell ehhez a típushoz illeszteni, és hol
bukik el a látszólag ártalmatlan
get()/release() használat.
A C memóriamodelljében (lásd Tulajdonlási
szerződés és cleanup ágak (8.4)) a tulajdonlás puszta konvenció: a
malloc-ot és a free-t ugyanannak a kódágnak
kell összehangolnia, és a fordító ezt nem ellenőrzi. A C++ ezt a
konvenciót típussá teszi.
Definíció. A
std::unique_ptr<T, D> egy move-only, azaz mozgatással
átadható, de nem másolható osztálysablon, amely egy T*
értéket és egy D típusú deletert tárol. A tárolt pointer az
objektum tulajdonában van: a unique_ptr destruktora a
deletert a pointerrel meghívja, és a unique_ptr élettartama
alatt a szabvány szerződése szerint egyetlen más unique_ptr
sem birtokolhatja ugyanazt az objektumot.
Előfeltételek és feltételek. - A
unique_ptr másoló konstruktora és másoló értékadása törölt;
tulajdonlást csak mozgatással lehet átadni. - A mozgatott-forrás
(moved-from) állapota definiált: üres lesz,
get() == nullptr. Ez nem “véletlenszerű szemét”, hanem
szerződéses posztkondíció. - Az üres unique_ptr legális és
normális; a deletert ilyenkor nem hívja meg (a reset() null
pointerre hatástalan). - A unique_ptr nem garantálja, hogy
a mutatott objektum érvényes: a mutatott objektumot bárki törölheti, ha
nyers pointerhez jut. A típus a tulajdonlás tényét kódolja, nem
a teljes élettartam-biztonságot.
Ez utóbbi pont a fejezet egyik fő határa: a smart pointer nem old meg
minden lifetime-hibát. Ha egy observer (T*,
T&, iterátor) túlél egy reset()-et,
move-ot vagy a owner megszűnését, a hiba ugyanúgy dangling hivatkozás
lesz, mintha C-ben dolgoznánk.
A két specializáció nem ugyanaz a típus, és nem konvertálható egymásba.
| Tulajdonság | unique_ptr<T> |
unique_ptr<T[]> |
|---|---|---|
| Alapértelmezett deleter | std::default_delete<T> →
delete p |
std::default_delete<T[]> →
delete[] p |
operator*, operator-> |
van | nincs |
operator[] |
nincs | van (C++20-tól indexelés) |
| Tipikus forrás | new T(...), make_unique<T>(...) |
new T[n], make_unique<T[]>(n) |
| Méretezés szemantikája | egy objektum | tömbtömb, a hossz nem tárolt |
A unique_ptr<T[]> nem tudja a tömb hosszát: a
delete[] a fordító által eltárolt könyvelésből dolgozik, a
size() fogalma nem létezik. Ezért a hosszt külön kell vinni
(például std::span-ban, lásd span, byte
és bounds-szerződések (31.3)) — a unique_ptr<T[]>
csak a felszabadításért felel.
Egy fontos részlet: std::make_unique<T[]>(n) a
new T[n]-t value-initializálja, azaz skalár
típusra nullázza, osztályra a default konstruktort hívja. A nyers
new T[n] ezzel szemben default-initializál, ami skalár
típusnál határozatlan értéket hagy. A make_unique tehát itt
nem csak kényelem, hanem inicializálási garancia is.
A unique_ptr legfontosabb hozadéka, hogy a tulajdonlás
már az objektum létrejötte pillanatában kialakul. Nyers
new-val ez nem így van:
// ROSSZ tanítási példa: nem ez a helyes változat
use_widget(new Widget(cfg), compute_limit()); // compute_limit() dobhatA sorrend itt nem garantált, hogy a Widget átadása előbb
megtörténik-e: ha a new Widget lefut, majd a
compute_limit() kivételt dob, a lefoglalt
Widget* semmihez sem tartozik, és elszivárog. A hívási
kifejezés nem fejeződik be, így a paraméterként átvett, deklarált
paraméter destruktora sem fut le. C++17 óta az argumentumkiértékelés
sorrendje indeterminately sequenced, de ez nem zárja ki a szivárgást,
mert a kivétel a hívás előtt történik, amikor a pointer még
senki tulajdona.
A helyes változat:
auto w = std::make_unique<Widget>(cfg); // innentől owner
use_widget(w.get(), compute_limit()); // ha dob, w destruktora lefutA make_unique a new-ot és a
unique_ptr konstrukcióját egyetlen kifejezésbe zárja, így a
new sikere és a tulajdonba vétel között nincs kivételes
pont. Ugyanezért ne írjunk
std::unique_ptr<T>(new T(...)) alakot: működik, de
elveszíti a kifejezés-szintű védelmet, és a make_unique
rövidebb is.
A C++20-as alapból visszafelé is van fallback: a
make_unique C++14-es, tehát C++20-cal mindenképp elérhető.
Ami viszont C++20-hoz kötött: a capture nélküli lambda típusa
default-konstruálható, így lambda használható deleterként anélkül, hogy
külön struktúrát írnánk — C++17-es vagy korábbi profilban ehhez
nevesített functor kell.
| Művelet | Tulajdonlás | Pointer | Mikor használd |
|---|---|---|---|
get() |
marad | változatlan | átadás nem tulajdonló API-nak |
operator*, operator-> |
marad | változatlan | közvetlen használat |
reset(p) |
marad, új pointerre | régi felszabadul, új bekerül | újratöltés, „reseat” |
reset() |
megszűnik | üres lesz | korai felszabadítás |
release() |
átadódik a hívónak | a unique_ptr üres lesz |
csak indokolt FFI/ABI határon |
| move konstruktor/értékadás | átadódik | forrás üres lesz | owner átadása |
A release() a legveszélyesebb: visszaadja a nyers
pointert, és a unique_ptr üres lesz — a felelősség a hívóé.
Ha a hívó nem tudja biztosan, hogy a címzett felszabadítja,
memóriaszivárgás lesz belőle, tipikusan úgy, hogy
ptr.release() eredményét eldobjuk. A release()
helyes használata az az eset, amikor egy C API vesz át tulajdonlást, és
a dokumentációja ezt kimondja. A reset() ezzel szemben
biztonságos: előbb felszabadít, aztán átveszi az újat.
A std::default_delete<T> egyszerűen
delete-et hív, ami nem minden erőforrásra jó. A
FILE*, a DIR*, a sqlite3*, egy
socket, egy mmap-elt régió vagy egy dlopen
handle nem delete-tel szabadul fel. A deleter ezt a rést
tölti be.
// file_owner.cpp — C++20, teljes program
#include <cstdio>
#include <memory>
#include <string>
#include <utility>
struct FileCloser {
void operator()(std::FILE* f) const noexcept {
if (f != nullptr) { std::fclose(f); }
}
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
enum class ReadResult { line, eof, error };
class LineSource {
public:
explicit LineSource(FilePtr in) : in_(std::move(in)) {} // owner átvétele
// Nem tulajdonló kimenet: a hívó birtokolja a buffert.
// line: out egy teljes (vagy az utolsó, LF nélküli) sor;
// eof: nincs több adat, out üres;
// error: olvasási hiba; out tartalma (részleges sor) nem érvényes rekord.
ReadResult next(std::string& out) {
out.clear();
for (int c = std::fgetc(in_.get()); c != EOF; c = std::fgetc(in_.get())) {
if (c == '\n') { return ReadResult::line; }
out.push_back(static_cast<char>(c));
}
if (std::ferror(in_.get())) { return ReadResult::error; } // EOF ≠ fájlvég
return out.empty() ? ReadResult::eof : ReadResult::line;
}
private:
FilePtr in_; // owner: a fájl élettartama ehhez az objektumhoz kötött
};
FilePtr open_file(const char* path) {
return FilePtr(std::fopen(path, "rb")); // nullptr → üres FilePtr, legális
}
int main(int argc, char** argv) {
if (argc != 2) {
std::fprintf(stderr, "usage: file_owner PATH\n");
return 2;
}
FilePtr f = open_file(argv[1]);
if (!f) { // üres owner: a megnyitás sikertelen
std::perror(argv[1]);
return 1;
}
LineSource source(std::move(f)); // az owner átkerül a LineSource-ba
std::string line; // a hívó birtokolja a puffert
std::size_t count = 0;
ReadResult r;
while ((r = source.next(line)) == ReadResult::line) {
++count;
}
if (r == ReadResult::error) {
std::fprintf(stderr, "%s: read error after %zu lines\n", argv[1], count);
return 3;
}
std::printf("lines=%zu\n", count);
}A példa szerződései: a LineSource az egyetlen owner; a
next() std::string& paramétere
borrowed, a hívó felel érte; a open_file
sikertelenséget üres FilePtr-rel jelez, nem kivétellel, így
a hívónak if (!f) { ... } ellenőrzést kell tennie. A
next() háromféle eredményt ad, mert az fgetc
EOF visszatérése kétféle eseményt takar: fájlvéget
vagy olvasási hibát. A kettőt csak az ferror
választja szét. Fájlvégnél az utolsó, LF nélküli sor még szabályos
line, utána eof jön; hibánál
error, és az out-ban maradt részleges sor nem
válik csendben sikeres rekorddá. A FileCloser::operator()
noexcept: a destruktorból hívott deleter nem dobhat,
különben std::terminate lenne a következmény. Ez a
lezárásra vonatkozó szerződés, és nem tiltja, hogy a next()
hibát jelezzen — eredménykóddal, ahogy itt, vagy akár kivétellel.
A main driver a teljes programhoz tartozik: argumentumot
ellenőriz, az üres ownert hibaként kezeli, megszámolja a sorokat, és
olvasási hibánál 3-mal lép ki. Build és diagnosztikai parancs:
g++ -std=c++20 -Wall -Wextra -Wpedantic -g -O1 \
-fsanitize=address,undefined file_owner.cpp -o file_owner
printf 'a\nb\nc' > three.txt # utolsó sor LF nélkül
printf 'a\nb\nc\n' > three_lf.txt
: > empty.txt
./file_owner three.txt; ./file_owner three_lf.txt; ./file_owner empty.txt
mkdir -p adir; ./file_owner adir # Linux: fopen sikerül, az olvasás EISDIRBuildjegyzőkönyv (lab-gcc profil). A
program figyelmeztetés nélkül fordult; a three.txt és a
three_lf.txt fájlra lines=3, az üres fájlra
lines=0 kimenettel, 0 kóddal futott, ASan/UBSan hibajelzés
nélkül. A könyvtárra adir: read error after 0 lines
üzenettel 3-mal lépett ki; ez glibc/Linux-specifikus reprodukció (a
fopen könyvtárra is sikerül, az első olvasás
EISDIR hibát ad), nem hordozható teszt. Nem létező fájlra a
perror üzenetét írja ki, és 1-gyel lép ki; argumentum
nélkül a használati sort írja ki, és 2-vel lép ki.
A hibaágat a sor elején és egy részleges sor után is célzottan
érdemes kipróbálni. Ehhez a glibc fopencookie függvényével
olyan FILE*-ot építünk, amely előbb adatot ad, majd
EIO hibát; a line_source.hpp a fenti program
main nélküli része:
// read_error_probe.cpp — C++20 + glibc fopencookie: injektált olvasási hiba
#include "line_source.hpp"
#include <cassert>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
struct Script { const char* data; bool sent; };
static ssize_t fail_read(void* cookie, char* buf, size_t size) {
auto* s = static_cast<Script*>(cookie);
if (!s->sent && s->data[0] != '\0') { // első hívás: adat
std::size_t n = std::strlen(s->data);
if (n > size) n = size;
std::memcpy(buf, s->data, n);
s->sent = true;
return static_cast<ssize_t>(n);
}
errno = EIO; // utána: I/O hiba
return -1;
}
static ReadResult first_after(const char* data, int skip, std::string& out) {
static Script s; s = Script{data, false};
cookie_io_functions_t io{fail_read, nullptr, nullptr, nullptr};
LineSource src(FilePtr(fopencookie(&s, "r", io)));
ReadResult r = ReadResult::line;
for (int i = 0; i <= skip; ++i) r = src.next(out);
return r;
}
int main() {
std::string out;
assert(first_after("", 0, out) == ReadResult::error); // hiba a sor elején
assert(first_after("ab\npartial", 0, out) == ReadResult::line && out == "ab");
assert(first_after("ab\npartial", 1, out) == ReadResult::error); // részleges sor után
std::puts("read_error_probe: OK");
}Buildjegyzőkönyv (lab-gcc profil).
g++ -std=c++20 -Wall -Wextra -Wpedantic -g -O1 -fsanitize=address,undefined read_error_probe.cpp
figyelmeztetés nélkül fordult, a futás kimenete
read_error_probe: OK: üres adat után azonnali hibára
error, az ab\npartial adat után az első hívás
line (ab), a második — a partial
részleges sor után érkező hibára — error. A
fopencookie GNU-kiterjesztés; más platformon ugyanez a
próba egy saját olvasási interfész mögé tett hibainjektáló forrással
végezhető el.
A unique_ptr mérete nem szabványosított. A gyakorlatban
az állapot nélküli (empty) deleterekre a fordítók üres
bázis-optimalizálást (EBO) alkalmaznak, így a
sizeof(std::unique_ptr<T>) jellemzően egy pointer
méretű; függvénypointer-deleterrel
(decltype(&std::fclose)) jellemzően két pointer;
állapotot hordozó deleterrel ennél is több. Ezek implementációs
megfigyelések, nem hordozható garanciák: ABI-stabil bináris
interfészben ne építsünk rájuk, és ne tegyük fel, hogy
sizeof(unique_ptr<T>) == sizeof(T*). Ha egy konkrét
laborprofilban mégis számít, mérjük és rögzítsük
static_assert-tel, a target ABI és a standard library
kiadásának megnevezésével.
Ugyanide tartozik, hogy a unique_ptr<T, D> csak
akkor default-konstruálható, ha a D is az. Egy capture-t
tartalmazó lambda nem default-konstruálható, így az ilyen deleterrel a
unique_ptr is csak deleter megadásával hozható létre — ez
gyakran meglepi a kezdőt.
A unique_ptr<T> deklarációjához nem kell, hogy a
T teljes típus legyen; a destruktorához, a
reset()-hez és a deleter hívásához viszont igen, mert
ezeknél sizeof(T)-re és a T destruktorára van
szükség. Ez a pimpl (pointer to implementation) minta alapja:
// widget.hpp: a Widget::Impl itt még incomplete
#include <memory>
class Widget {
public:
Widget();
~Widget(); // deklarálva, nem = default itt!
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
// widget.cpp: itt válik teljessé az Impl
#include "widget.hpp"
#include <vector>
struct Widget::Impl { std::vector<int> data; };
Widget::Widget() : impl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // a definíció helyén teljes az Impl
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;Gyakori hiba: a destruktort a headerben
= default-tal definiálni. Ekkor a destruktor a headerben,
az Impl teljessé válása előtt példányosul, és a fordítás
„invalid application of sizeof to incomplete type” hibával áll meg.
Ugyanez a helyzet a move műveletekkel: ha a headerrben
= default-ot írunk, az implicit definíció ott példányosul,
ahol az Impl még hiányos.
Az egyedi tulajdonlás akkor működik, ha az interfész egyértelműen elárulja, ki a felelős. Négy jellegzetes szignatúra:
| Szignatúra | Jelentés |
|---|---|
void sink(std::unique_ptr<T> p) |
átveszi a tulajdonlást; a hívó után üres pointert kap vissza |
void sink(std::unique_ptr<T>&& p) |
ugyanaz, &&- jelöléssel; nem fogad el
lvalue-t |
void borrow(const T& x) /
void borrow(T* x) |
nem tulajdonol; csak az owner élettartama alatt érvényes |
std::unique_ptr<T> make() |
új ownert ad vissza (move-pal) |
A T* akkor jó, ha a hiányzó érték is megengedett
(nullptr); ha nem, a T& kifejezőbb. Egyik
sem él túl egy reset()-et, move-ot vagy a owner megszűnését
— ezt a hívási szerződésben kell rögzíteni, mert a fordító nem
fogja.
Kivétel, amit érdemes fejben tartani: a
unique_ptr<Derived> nem konvertálható automatikusan
unique_ptr<Base>-re, ha a
default_delete<Base> nem tudja fogadni a
Derived*-ot; a Base-nek tehát virtuális
destruktor kell (lásd Virtuális destruktor, slicing
és ownership (24.3)). A
unique_ptr<Base>(new Derived) alak ráadásul a
make_unique védelmét elveszti, ezért ilyenkor
unique_ptr<Base>-t
make_unique<Derived>()-ből, explicit módon kell
építeni.
get() eredmény.
const T* p = owner.get(); majd
owner.reset(...) — a p dangling. Javítás: az
observer élettartamát az owner élettartamához kötni, vagy
weak_ptr-t használni (lásd shared_ptr,
control block és weak_ptr (21.4)) ott, ahol a hosszabb életű
megfigyelés valóban igény.release() a reset()
helyett. p.release(); p.reset(new T); — az első
sor elszivárogtatja a régit. Javítás: p.reset(...).new + unique_ptr konstrukció
szétbontva. Kivétel esetén szivárgás. Javítás:
make_unique..cpp-ben.unique_ptr<T[]>. A
tömb mérete nem kérdezhető le; a hosszt külön kel vinni. Javítás:
unique_ptr<T[]> + méret, vagy
std::vector<T>.1. feladat (értelmezés). Az alábbi szignatúrák közül
melyik ad át tulajdonlást, melyik csak kölcsönöz? Indokolj. (a)
void f(std::unique_ptr<Conn>), (b)
void g(const Conn&), (c)
std::unique_ptr<Conn> h(), (d)
void i(std::unique_ptr<Conn>&), (e)
void j(Conn*).
2. feladat (kódolás). Készíts FdPtr
típust POSIX fájlleíróhoz struct FdCloser deleterrel,
::close-t hívva. Írj
FdPtr open_fd(const char*) függvényt, és egy
Duplex osztályt, amely két FdPtr-t birtokol,
mozgatható, és a read/write irányt külön
tárolja. Add meg a buildparancsot.
3. feladat (diagnosztika). Az alábbi kód lefordul, de futásidőben hibás. Nevezd meg a hibát, a dangling observer élettartam-határát és a javítást.
class Session {
public:
explicit Session(std::unique_ptr<Conn> c) : conn_(std::move(c)) {}
const Conn* conn() const { return conn_.get(); }
void reconnect(std::unique_ptr<Conn> c) { conn_ = std::move(c); }
private:
std::unique_ptr<Conn> conn_;
};
auto s = std::make_unique<Session>(std::make_unique<Conn>());
const Conn* c = s->conn(); // borrowed observer
s->reconnect(std::make_unique<Conn>()); // invalidálja c-t
inspect(*c); // ?1. megoldás. (a) A paraméter érték szerinti
unique_ptr, tehát a hívó move-pal adja át, és a hívás után
a saját változója üres lesz — tulajdonlásátadás, „sink”. (b)
const Conn&: nem tulajdonol, csak a hívás idejére
érvényes, a hívó felel az élettartamért. (c) Visszatérési értékként
unique_ptr: új ownert ad, a hívó veszi át (a move-t a
visszatérési érték biztosítja). (d)
unique_ptr<Conn>&: nem ad át tulajdonlást, hanem
újraültetheti a hívó objektumát — out-paraméter, amely a hívó
ownerét módosítja; ez nem sink, mert a hívó unique_ptr-je
tovább él. (e) Conn*: nem tulajdonol; nullable observer,
élettartama a hívó felelőssége. Ellenőrzési feltétel: minden válasznál
nevezd meg, ki a owner a hívás után.
2. megoldás.
// fd_owner.cpp — C++20, Linux/POSIX
#include <unistd.h>
#include <cerrno>
#include <memory>
#include <utility>
struct FdCloser {
void operator()(int* p) const noexcept {
if (p != nullptr) { ::close(*p); delete p; }
}
};
using FdPtr = std::unique_ptr<int, FdCloser>;
FdPtr adopt_fd(int fd) {
if (fd < 0) { return FdPtr(nullptr); }
return FdPtr(new int(fd)); // a leírt értéket a heapen tároljuk
}
class Duplex {
public:
Duplex(FdPtr in, FdPtr out) : in_(std::move(in)), out_(std::move(out)) {}
Duplex(Duplex&&) noexcept = default;
Duplex& operator=(Duplex&&) noexcept = default;
Duplex(const Duplex&) = delete;
Duplex& operator=(const Duplex&) = delete;
int in_fd() const { return in_ ? *in_ : -1; } // borrowed: nem ad át ownert
int out_fd() const { return out_ ? *out_ : -1; }
bool valid() const { return static_cast<bool>(in_) && static_cast<bool>(out_); }
private:
FdPtr in_, out_;
};Indoklás: a unique_ptr deletere pointert kap, ezért a
int leírót heapen kell tárolni; a FdCloser
állapot nélküli, így a unique_ptr mérete jellemzően egy
pointer marad (ezt konkrét laborprofilban ellenőrizd, ne feltételezd). A
másolás törlése és a move noexcept-ká tétele azért kell,
hogy a Duplex konténerbe tehető legyen (lásd noexcept, swap és container-reallocation (22.5)). A
close() hibáját a deleter nem tudja jelenteni — ez tudatos
tervezési döntés; ha számít, a lezárást explicit
close_checked() metódusban kell kezelni a
reset() előtt. Build:
g++ -std=c++20 -Wall -Wextra -Wpedantic -g -fsanitize=address,undefined fd_owner.cpp -o fd_owner.
Ellenőrzési feltétel: Duplex move-ja után a forrás
valid() értéke false, és a mozgatott
Duplex destruktora pontosan két close-t vált
ki.
3. megoldás. A hiba nem a
unique_ptr-ben van, hanem a conn() által
visszaadott borrowed observerben: a const Conn* c
élettartam-határa a reconnect() hívás (pontosabban az a
pont, ahol a conn_ régi értéke reset-elődik),
nem a Session élettartama. A reconnect() után
a c dangling pointer, az inspect(*c) nem
definiált viselkedés — a tünet lehet látszólag helyes működés, ASan-hiba
vagy összeomlás, garantált kimenet nincs. Javítási lehetőségek: (i) az
observer használatát a reconnect() elé rendezni, és az
élettartamot kommentben/documentációban rögzíteni; (ii) ha hosszabb
életű megfigyelés kell, std::weak_ptr-t használni a
Conn-ra (ez shared_ptr tulajdonlást igényel,
lásd shared_ptr, control block és weak_ptr
(21.4)); (iii) az inspect-et a Session egy
tagfüggvényévé tenni, amely mindig a conn_ aktuális értékét
használja, és nem enged kívülre pointert. Ellenőrzési feltétel: a
javított változatban az observer soha nem él túl egy
reset/move/reconnect műveletet, és ezt a fejléc kommentje
kimondja.
A std::unique_ptr a C-s tulajdonlási konvenciót típusba
öntött, move-only szerződése: pontosan egy owner, determinisztikus
felszabadítás, kivételbiztos konstrukció make_unique-jal.
Az objektumtömb és az egyedi objektum két külön specializáció, eltérő
deleterrel és operátorokkal. A get() kölcsönöz, a
reset() újraültet, a release() átadja a
felelősséget — ez utóbbi csak indokolt FFI-határon használható. A custom
deleter nem memóriajellegű erőforrásokat (FILE*, fd,
handle) tesz szabályossá, de a unique_ptr mérete
implementációfüggő, ezért ABI-határon nem építhetünk rá. Incomplete type
esetén a destruktort és a move műveleteket a headerben csak deklarálni
szabad, definiálni a teljes típus mellett kell. Végül: a
unique_ptr a tulajdonlást rendezi, az observer élettartamát
nem — a get() eredménye ugyanolyan borrowed, mint bármely
nyers pointer.