Az előző részben a C cleanup-szerződéseit néztük meg: minden
megszerzett erőforráshoz pontosan egy felszabadító út tartozik, és ezt
az utat goto cleanup címkék, korai return-ök
és hibakódok között kézzel kell összehangolni. A C++ válasza erre nem
egy újabb könyvtár, hanem a nyelv élettartam-szabálya: ha az erőforrást
egy objektum birtokolja, akkor a felszabadítás az objektum
destruktorában fut le, méghozzá determinisztikusan, akkor is, ha a
vezérlés kivétel miatt hagyja el a hatókört. Ezt a mintát nevezzük
RAII-nak (Resource Acquisition Is Initialization; erőforrás-életciklus
kötése objektum-élettartamhoz). Ebben a részben ezt a szerződést
konkretizáljuk két tipikus DevOps-erőforrásra: a file descriptorra és a
lockra. Mindkettőnél külön nevezzük meg a tulajdonost (owner) és a
megfigyelőt (observer), mert a legtöbb termelési hiba nem a
destruktorban keletkezik, hanem ott, hogy egy nem tulajdonló API-t
tulajdonosként kezelünk.
Egy RAII wrapper definíciója pontosítva: olyan osztály, amelynek
minden létező példánya legfeljebb egy erőforrást birtokol, a birtoklást
a konstruktor szerzi meg, a destruktor adja fel, és a példány nem
másolható, csak mozgatható. A „legfeljebb egy” megfogalmazás azért
fontos, mert megengedünk egy explicit üres állapotot is, amit egy
sentinel érték jelöl. File descriptor esetén ez a -1, mert
a 0 érvényes descriptor (a standard bemenet). Ha valaki
0-t használ sentinelnek, a wrapper a destruktorában lezárja
a szabványos bemenetet — ez a klasszikus hiba, és a tünete az, hogy a
program egy későbbi read-nél EBADF-et kap.
Az osztály invariánsa tehát: minden konstruált példányra igaz,
hogy fd_ == -1, vagy fd_ >= 0 és a
descriptor kizárólag ezen a példányon keresztül birtokolt. Az
invariáns a konstruktor után áll fenn, és minden publikus művelet után
ismét fenn kell állnia; ez a feltétel a release,
reset és mozgató műveletek szerződését is meghatározza.
Az owner/observer megkülönböztetés nyelvi szinten nem kényszeríthető
ki, ezért dokumentálni kell. A
void write_all(int fd, std::string_view data) alakú
paraméter observer: a függvény nem zárja be a descriptort, és nem is
tárolhatja el a híváson túl. A unique_fd alakú paraméter
owner-átadás; ha egy függvény unique_fd&&-t kap,
abból a hívó tudja, hogy a hívás után az ő változója üres. Keverni a
kettőt (egy int fd tagba elmenteni egy owner descriptor
értékét) az élettartam-szerződés megsértése, és pontosan ez az a hiba,
amit a smart pointerek sem fognak helyettünk megoldani.
Két konstruktor-szerep létezik, és érdemes szétválasztani őket. Az
átvevő konstruktor egy már megszerzett, nyers handlet kap, és
feltétel nélkül átveszi a tulajdonjogot; ez nem hibázhat, ezért
noexcept. A megszerző konstruktor maga hívja a
rendszerhívást, és ez hibázhat — márpedig a konstruktornak nincs
visszatérési értéke. Három szokásos megoldás van: kivételt dobni,
factory függvényt írni
std::optional/std::expected visszatéréssel,
vagy kétfázisú konstrukciót használni (ez utóbbi épp az, amit el akarunk
kerülni).
A lenti példában a unique_fd(int) explicit átvevő
konstruktor, az open_readonly pedig factory, amely
std::optional<unique_fd>-dal jelzi a sikert. A C++20
alapban a std::expected még nem elérhető (az egy későbbi
verzió eleme, ezért a laborkörnyezetben ellenőrizni kell), így az
optional a hordozható választás; a hibaokot
(errno) a hívó a factory visszatérése után olvassa ki.
Fontos, hogy a factory noexcept: a ::open és
az optional konstrukció nem dob, így a „sikertelenség” nem
kivételként, hanem értékként jelenik meg.
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <optional>
#include <utility>
#include <fcntl.h>
#include <unistd.h>
class unique_fd {
public:
unique_fd() noexcept = default; // üres, nem birtokol
explicit unique_fd(int fd) noexcept : fd_(fd) {} // átveszi a tulajdonjogot
unique_fd(const unique_fd&) = delete;
unique_fd& operator=(const unique_fd&) = delete;
unique_fd(unique_fd&& other) noexcept
: fd_(std::exchange(other.fd_, -1)) {}
unique_fd& operator=(unique_fd&& other) noexcept {
if (this != &other) {
reset(other.release());
}
return *this;
}
~unique_fd() { reset(); }
[[nodiscard]] int get() const noexcept { return fd_; }
[[nodiscard]] bool valid() const noexcept { return fd_ >= 0; }
explicit operator bool() const noexcept { return valid(); }
int release() noexcept { return std::exchange(fd_, -1); }
void reset(int fd = -1) noexcept {
if (fd_ >= 0) {
(void)::close(fd_); // hiba nem továbbítható, ezért eldobjuk
}
fd_ = fd;
}
void swap(unique_fd& other) noexcept { std::swap(fd_, other.fd_); }
private:
int fd_ = -1;
};
inline void swap(unique_fd& a, unique_fd& b) noexcept { a.swap(b); }
std::optional<unique_fd> open_readonly(const char* path) noexcept {
const int fd = ::open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) {
return std::nullopt;
}
return unique_fd{fd};
}A destruktor alapértelmezésben noexcept, és ez nem
stílus kérdése: ha egy destruktorból kivétel távozik a stack unwinding
közben, a program std::terminate-mel leáll. Ezért a
destruktorban hívott close() hibáját nem lehet és nem is
szabad kivételként továbbadni. A gyakorlati policy három dologból áll:
(1) a hibát eldobjuk vagy naplózzuk, (2) a close()-t nem
ismételjük meg, (3) a hibát ott kell kezelni, ahol még van értelme —
vagyis a felszabadítás előtti utolsó írás/flush hívásnál.
A close() ismétlése azért veszélyes, mert a
close() után a descriptor szám azonnal újra kiosztható; ha
az EINTR hatására megismételt close(fd) egy
közben megnyitott másik descriptorra fut, idegen erőforrást zárunk be. A
helyes politika: egy kísérlet, az eredmény eldobása, esetleg
stderr-re írt diagnosztika. Hasonló a helyzet
munmap-nál és fclose-nál: ez utóbbi ráadásul a
puffert is flush-olja, tehát a hiba adatvesztést is jelezhet — ezt a
hívónak std::fflush + ferror kombinációval
kell ellenőriznie, mielőtt a wrapper megsemmisül.
A négy művelet pontosan egymásra épül, és érdemes kimondani a feltételeket.
A release() visszaadja a nyers handlet, és a wrappert
üres állapotba teszi. Szerződés: a hívó a visszatérés után tulajdonos,
neki kell felszabadítania, és a wrapper további használata (a
get() kivételével) értelmetlen. A release()
nem zárja be az erőforrást — ezt sokan összekeverik a
reset()-tel.
A reset(h) lezárja az aktuálisan birtokolt erőforrást,
majd átveszi h-t. Feltétel: h vagy érvénytelen
sentinel, vagy a hívó által átadott, másutt nem birtokolt handle;
reset(fd_) önmagára nem megengedett, mert ilyenkor
lezárnánk a descriptor, majd visszatennénk a lezárt értéket az invariáns
alá.
A swap tagfüggvény és a mellette álló, azonos névtérben
deklarált szabad swap együtt biztosítja, hogy az
std::swap helyett ADL-lal a mi hatékony változatunk
hívódjon (mindkettő noexcept).
A mozgató konstruktor és mozgató értékadás a birtoklás átadása: a
forrás üres lesz. Az értékadásnál az ön-értékadást
(a = std::move(a)) a this != &other
ellenőrzés védi; enélkül a reset(a.release()) lezárná a
saját descriptorunkat, majd -1-t tenne vissza. Másolás
nincs, mert két tulajdonos ugyanarra a descriptorra dupla
close-hoz vezetne.
A lock-wrapper ugyanazt a vázat követi, de két ponton eltér. Egyrészt
a megszerzés (std::mutex::lock()) dobhat
std::system_error-t, ezért a konstruktor
nem noexcept — ha mégis az lenne, a hiba
std::terminate-mé silányulna. Másrészt a lock „handlja” nem
egy érték, hanem egy mutexre mutató pointer, ahol a nullptr
jelöli, hogy nem birtokolunk zárat. A release() itt azt
jelenti: „nem oldok fel, de nem is tartom tovább számon a zár
felszabadítását” — ez a std::unique_lock::release()
mintájára pontosan az a művelet, amivel egy külső mechanizmusnak adunk
át felelősséget.
class mutex_guard {
public:
// Nem noexcept: a std::mutex::lock() dobhat std::system_error-t.
explicit mutex_guard(std::mutex& m) : m_(&m) { m_->lock(); }
~mutex_guard() { unlock(); }
mutex_guard(const mutex_guard&) = delete;
mutex_guard& operator=(const mutex_guard&) = delete;
mutex_guard(mutex_guard&& other) noexcept
: m_(std::exchange(other.m_, nullptr)) {}
mutex_guard& operator=(mutex_guard&& other) noexcept {
if (this != &other) {
unlock();
m_ = std::exchange(other.m_, nullptr);
}
return *this;
}
std::mutex* release() noexcept { return std::exchange(m_, nullptr); }
void unlock() noexcept {
if (m_ != nullptr) {
m_->unlock(); // a szabvány szerint nem dob
m_ = nullptr;
}
}
private:
std::mutex* m_ = nullptr;
};
int main(int argc, char** argv) {
if (argc < 2) {
std::fprintf(stderr, "usage: %s FILE\n", argv[0]);
return 2;
}
auto fd = open_readonly(argv[1]);
if (!fd) {
const int e = errno; // az errno-t még a fprintf előtt mentsük el
std::fprintf(stderr, "open(%s): %s\n", argv[1], std::strerror(e));
return 1;
}
std::mutex m;
{
mutex_guard guard{m};
char buf[64];
const ssize_t n = ::read(fd->get(), buf, sizeof buf);
if (n < 0) {
const int e = errno;
std::fprintf(stderr, "read: %s\n", std::strerror(e));
return 1; // a guard és a fd is felszabadul
}
std::printf("read %zd bytes\n", n);
} // itt a zár felszabadul
return 0;
}Fordítás a laborprofilban rögzített GCC vagy Clang kiadással, C++20 módban:
g++ -std=c++20 -Wall -Wextra -pedantic -pthread -O2 -o demo demo.cpp
./demo ./demo.cppA példa POSIX/Linux környezetre készült (LP64, ssize_t =
long, ezért a %zd), és az
O_CLOEXEC elérhetősége a fordítónaplóban rögzített
makrókörnyezettől függ (a g++ Linuxon jellemzően definiálja
a _GNU_SOURCE-t). A kódot ez a dokumentum nem futtatta; a
leírt viselkedés a POSIX és a C++20 szabályain alapuló elvárás, nem mért
eredmény.
A mechanizmus, ami miatt ez működik, a hatókör elhagyása. A
destruktor lefut, ha a vezérlés normál úton kilép a blokkból, ha
return-nel kilép, ha
break/continue/goto visz ki a
blokkból, és ha kivétel miatt megindul a stack unwinding. Ez utóbbi az,
amiért a RAII kivételbiztos: a return 1; a fenti
mutex_guard hatókörén belül pontosan ugyanúgy felszabadít,
mintha egy goto cleanup címkére ugranánk — csak nem kell
kézzel megírni és karbantartani a címkét.
Két korlátot érdemes rögzíteni. Egyrészt a std::exit()
és a std::abort() nem futtatja le az automatikus tárolási
idővel rendelkező objektumok destruktorait (a std::exit a
statikusokat futtatja), tehát egy wrapper sem véd meg ott, ahol a
folyamatot kilépésre kényszerítjük. Másrészt a C-ből örökölt
longjmp, ha nem-triviális destruktorral rendelkező C++
objektumot kerül meg, nem definiált viselkedés — signal handlerekből
ezért csak async-signal-safe műveleteket szabad végezni, RAII wrapper-t
nem.
A C++20 alap nem tartalmaz szabványos scope_exit-et; a
Library Fundamentals TS kínál ilyet, ezért ha általános „hatókör végén
fusson ez a művelet” eszközre van szükség, saját, néhány soros wrappert
írunk (lásd a feladatok között).
A közös váz mindenhol ugyanaz: megszerzés a konstruktorban, felszabadítás a destruktorban, move-only, explicit üres állapot. Az eltérések a sentinelben, a felszabadítás hibajelzésében és abban vannak, hogy a handle „elengedhető-e” felszabadítás nélkül.
| Erőforrás | Megszerzés | Felszabadítás | Érvénytelen sentinel | Felszabadítás jelezhet hibát | Megjegyzés |
|---|---|---|---|---|---|
| file descriptor | open, socket, accept |
close |
-1 |
igen (EINTR, EIO), de nem ismételhető |
a 0 érvényes |
| memóriatérkép | mmap |
munmap |
MAP_FAILED |
igen | cím és hossz együtt birtokolt |
FILE* |
fopen, fdopen |
fclose |
nullptr |
igen; puffert is flush-ol | glibc-ben belső lock |
std::mutex zár |
lock, try_lock |
unlock |
nincs érték; „nem birtokol” állapot | a unlock() nem dob |
ugyanazt a mutexet kell feloldani |
A lock annyiban speciális, hogy a „handle” önmagában nem azonosítja a
felszabadítandó erőforrást: a mutexre van szükség hozzá. Ezért a
release() itt nem erőforrás-átadás a szó FD-s értelmében,
hanem felelősség-átadás; a wrapperből kikerült pointerrel a hívónak kell
unlock-olnia. A FILE* esetében pedig a
destruktor már „késő”: a flush hibáját a megsemmisítés előtt kell
ellenőrizni, különben csendesen adatot veszítünk.
A leggyakoribb hiba a 0 sentinel használata
-1 helyett; javítás: fd_ = -1 alapérték, és a
valid() a >= 0 feltételt vizsgálja, nem a
!= 0-t. A második a másolás véletlen engedélyezése (pl. egy
= default másoló konstruktor), ami dupla
close-hoz vezet; javítás: a másoló műveletek
= delete, és a mozgató műveletek noexcept-ok.
A harmadik a close() újrapróbálása EINTR-re;
javítás: egy kísérlet, az eredmény eldobása. A negyedik a
noexcept rátétele egy lock-megszerző konstruktorra;
javítás: a konstruktor hagyja, hogy a std::system_error
kifelé menjen, és a hívó döntsön. Az ötödik a release()
utáni használat: a get() -1-et ad, és a
read(-1, ...) EBADF-et ad — ez nem hiba a
wrapperben, hanem a szerződés megsértése a hívó oldalán. Végül a
hatodik: egy observer int fd paramétert egy owner osztály
tagjába menteni; ez a C projekt ownership-térképének elrontása, és a
tünet csak jóval később, egy lezárt descriptor újrahasznosításakor
jelentkezik.
A RAII wrapper lényege nem a destruktor, hanem a szerződés: pontosan
egy birtokolt erőforrás, explicit üres állapot sentinel értékkel,
move-only viselkedés, és a megszerzés hibájának értékként (vagy
kivételként) való megjelenítése a konstruktoron kívül. A felszabadítás
hibáját a destruktor nem tudja továbbadni, ezért azt a megsemmisítés
előtti utolsó ellenőrizhető ponton kell kezelni. A
release/reset/swap/mozgatás négy
külön szerződés, amelyeket külön kell dokumentálni és tesztelni. Az FD,
a mmap, a FILE* és a lock ugyanerre a vázra
illeszkedik, de a sentinel, a hibajelzés és a „felszabadítás nélküli
elengedés” lehetősége eltér.
1. feladat (alap). Egészítse ki a
unique_fd osztályt egy make_pipe() factory
függvénnyel, amely
std::optional<std::pair<unique_fd, unique_fd>>-t
ad vissza, és hiba esetén üres opcionálist. Fogalmazza meg a két
descriptor tulajdonlási viszonyát és a hibaútját.
2. feladat (közepes). Írjon egy általános
scope_exit wrappert, amely egy callable-t futtat a hatókör
elhagyásakor, és támogatja a dismiss()-t. Cserélje ki vele
a mutex_guard-ot egy olyan függvényben, ahol a zár
felszabadítása előtt még hibát kell jelenteni. Hasonlítsa össze a két
megoldás kivételbiztosságát.
3. feladat (diagnosztikai). Készítsen
unique_file wrappert std::FILE*-hoz, és
mutassa meg, miért nem elegendő a fclose() hibáját a
destruktorban eldobni. Adjon egy olyan hívási mintát, ahol a flush
hibája még a wrapper élettartama alatt kiderül.
std::optional<std::pair<unique_fd, unique_fd>> make_pipe() noexcept {
int fds[2];
if (::pipe(fds) != 0) {
return std::nullopt;
}
return std::pair<unique_fd, unique_fd>{unique_fd{fds[0]}, unique_fd{fds[1]}};
}Indoklás: a ::pipe két descriptort ír ki; a sikeres
hívás után a fds tömb mindkét eleme a hívó tulajdonába
kerül, és a pair létrehozásával azonnal két külön
unique_fd birtokolja őket. Ha a pipe hibázik,
a tömb tartalma nem használható, ezért nem szabad „óvatosan” mégis
wrapperezni. A noexcept azért tartható, mert a
pipe, az optional és a pair
konstrukció nem dob. Ellenőrzési feltétel: hibaágon a visszatérés
std::nullopt, sikerágon
a.get() >= 0 && b.get() >= 0, és a két
descriptor különbözik. Linux-specifikus változat:
pipe2(fds, O_CLOEXEC), ezt azonban a laborprofilban jelölni
kell.
#include <type_traits>
#include <utility>
template <class F>
class scope_exit {
public:
explicit scope_exit(F f) noexcept(std::is_nothrow_move_constructible_v<F>)
: f_(std::move(f)) {}
scope_exit(const scope_exit&) = delete;
scope_exit& operator=(const scope_exit&) = delete;
~scope_exit() {
if (active_) {
f_();
}
}
void dismiss() noexcept { active_ = false; }
private:
F f_;
bool active_ = true;
};Használat zárral:
std::mutex m;
{
m.lock();
scope_exit unlocker{[&m] { m.unlock(); }};
// ... kritikus szakasz ...
if (hiba) {
return; // az unlocker lefut
}
unlocker.dismiss(); // ha mégis mi oldunk fel
m.unlock();
}Indoklás: a mutex_guard típusa kifejezi a birtoklást, a
scope_exit viszont csak egy műveletet köt a hatókörhöz; ez
utóbbi akkor jó, ha a „felszabadítás” nem egy handle visszaadása, hanem
egy oldalhatás. A kivételbiztosság szempontjából a
scope_exit gyengébb: nem tiltja meg, hogy valaki kézzel is
felszabadítson, ezért a dismiss() fegyelmezett használata a
hívó felelőssége. Ellenőrzési feltétel: a dismiss() után a
callable nem fut le, hibaágon viszont pontosan egyszer.
#include <cstdio>
#include <utility>
class unique_file {
public:
unique_file() noexcept = default;
explicit unique_file(std::FILE* f) noexcept : f_(f) {}
unique_file(const unique_file&) = delete;
unique_file& operator=(const unique_file&) = delete;
unique_file(unique_file&& o) noexcept : f_(std::exchange(o.f_, nullptr)) {}
unique_file& operator=(unique_file&& o) noexcept {
if (this != &o) {
reset(o.release());
}
return *this;
}
~unique_file() { reset(); }
[[nodiscard]] std::FILE* get() const noexcept { return f_; }
std::FILE* release() noexcept { return std::exchange(f_, nullptr); }
void reset(std::FILE* f = nullptr) noexcept {
if (f_ != nullptr) {
(void)std::fclose(f_); // a flush hibája itt elveszne
}
f_ = f;
}
private:
std::FILE* f_ = nullptr;
};Használat, ahol a hiba még időben kiderül:
auto f = std::fopen("out.bin", "wb");
if (f == nullptr) { /* hiba */ }
unique_file uf{f};
if (std::fwrite(buf, 1, n, uf.get()) != n) { /* írási hiba */ }
if (std::fflush(uf.get()) != 0) { /* itt még látszik a hiba */ }
// uf destruktora innen már csak egy újabb fclose-t végezIndoklás: a fclose() hívása flush-ol, és ennek hibája
adatvesztést jelenthet; mivel a destruktor nem jelezhet, a helyes minta
a tényleges utolsó írás után explicit fflush-t hívni, és
ott kezelni a hibát. A reset() „eldobja” a visszatérési
értéket, ami tudatos döntés, és a szerződésben dokumentálni kell.
Ellenőrzési feltétel: a fflush hibáját a program a wrapper
megsemmisülése előtt észleli, a release() után pedig a
std::FILE* tulajdonosa a hívó, aki fclose-t
hív.