A tárolók és algoritmusok viselkedése nem csak a típus
műveleteitől függ, hanem azok kivételszerződésétől is.
Ez az a pont, ahol a C++ értékszemantika és a standard library garantái
összekapcsolódnak: egy helyesen megírt move constructor nem csak
gyorsabb, hanem egyben kivételbiztonsági állítás is, amelyet a
std::vector növekedése közvetlenül kihasznál — vagy éppen
figyelmen kívül hagy. Ebben a részben azt vesszük végig, hogyan lesz a
noexcept vállalásból tárolóviselkedés, és miért nem
mindegy, hogy ezt a vállalást valóban be tudjuk-e tartani.
Az előző részek eredményeire építünk: a mély másolás és az erős kivételgarancia szerződését a Mély másolás, értéktípus és exception guarantee rögzítette, a visszatérési érték optimalizálását pedig a Copy elision, RVO/NRVO és értékvisszaadás tárgyalta. Itt már feltesszük, hogy a move constructor és a move assignment cserélhető állapotot hagy maga után, nem magyarázzuk újra, mit jelent a mozgatás.
Két különböző dolgot nevezünk noexcept-nak, és a kétféle
jelentést érdemes élesen szétválasztani.
A noexcept specifier
(kivételspecifikáció) a függvénydeklaráció része, és szerződést rögzít a
függvény viselkedéséről: ha a függvényből kivétel szabadul ki, a program
std::terminate-mel leáll. Fontos pontosítás: a specifier
nem tiltja meg, hogy a függvény belsejében kivétel keletkezzen
és elkapásra kerüljön; csak arról szól, mi történik, ha a kivétel a
függvény határán kilép. A szabvány nem garantálja, hogy a
terminate előtt a stack kigöngyölődik; az, hogy történik-e
unwinding, implementációfüggő, tehát erre építeni nem lehet. C++17 óta a
kivételspecifikáció a függvénytípus része: noexcept
függvényre mutató pointer implicit módon átalakítható potenciálisan dobó
függvényre mutató pointerré, visszafelé nem. Overload resolutionben
viszont nem lehet noexcept szerint túlterhelni.
A noexcept operator ezzel szemben egy
kifejezés: noexcept(E) egy fordítási idejű
bool, amely akkor true, ha az E
kiértékelése nem tartalmaz potenciálisan dobó műveletet. Az operandus
nem értékelődik ki (unevaluated operand), tehát E-nek nem
kell futásidőben végrehajthatónak lennie. Függvényhívás esetén az
eredmény a hívott függvény deklarált kivételspecifikációjából
következik: ha f noexcept(false), akkor
noexcept(f()) hamis, akkor is, ha f a
gyakorlatban soha nem dob.
| Kérdés | noexcept specifier |
noexcept operator |
|---|---|---|
| Hol jelenik meg | függvénydeklaráció része | kifejezés |
Fordítási idejű bool? |
nem, deklaráció | igen |
| Kiértékelődik az operandus? | nem értelmezhető így | nem |
| Megszegés következménye | std::terminate |
nincs (nincs vállalás) |
| Alapértelmezés | noexcept(false), potenciálisan dobó |
– |
A legfontosabb alapértelmezés: a felhasználó által megírt
függvény kivételspecifikáció nélkül potenciálisan dobó. Ez alól kivétel
a destruktor, amely implicit módon noexcept(true), kivéve,
ha valamely tag vagy bázis destruktora potenciálisan dob. A
defaulted speciális tagfüggvények kivételspecifikációja viszont
a tagokéból számítódik ki: egy defaulted move constructor
noexcept, ha minden tag és bázis move-ja nem dobó.
Ebből következik a gyakorlati csapda, amelyre később visszatérünk: ha
kézzel megírsz egy move constructort, és elfelejted a
noexcept-ot, akkor az potenciálisan dobóvá válik —
akkor is, ha a törzse valójában semmit nem dob. A vállalás hiánya
önmagában is viselkedést vált ki a tárolókban.
Egy sablon nem tudja előre, hogy a paramétertípusa dob-e. Ilyenkor a specifier argumentuma egy konstans kifejezés lehet, és a szerződés a típusfüggvényre hárul:
// box.cpp — ISO C++20
#include <type_traits>
#include <utility>
template <class T>
class Box {
public:
Box() = default;
explicit Box(const T& v) : value_(v) {}
explicit Box(T&& v) : value_(std::move(v)) {}
Box(const Box&) = default;
// feltételes szerződés: pontosan akkor noexcept, ha T move-ja az
Box(Box&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
: value_(std::move(other.value_)) {}
private:
T value_{};
};
static_assert(std::is_nothrow_move_constructible_v<Box<int>>);
static_assert(!std::is_nothrow_move_constructible_v<Box<Box<int>>> == true);Itt a std::is_nothrow_move_constructible_v trait csak
előkép: annyit jelent, hogy a T(T&&)
konstruktor deklaráltan nem dobó-e. A lényeg a mintázat: a wrapper ne
vállaljon többet, mint amit a tagja tud. Ha a wrapper feltétel nélkül
noexcept-ot írna, akkor egy dobó T esetén a
saját szerződését szegné meg, és a hiba nem itt, hanem jóval később,
std::terminate formájában jelentkezne.
Trait nélküli esetben ugyanez a
noexcept(noexcept(kifejezés)) idiómával írható fel: a belső
operátor megmondja, hogy a tényleges művelet dob-e, a külső pedig ezt
teszi szerződéssé.
A std::vector növekedése a következőt jelenti: új
tárterületet kell allokálni, az elemeket át kell vinni, majd a régit
felszabadítani. Az erős garancia azt kívánja, hogy ha bármelyik lépés
kivétellel megszakad, a vektor maradjon pontosan abban az állapotban,
amelyben a művelet előtt volt.
A probléma a mozgatással van. A move művelet alapvetően roncsoló: a forrásobjektumot moved-from állapotba teszi. Ha az átvitel közben, a harmadik elem mozgatásakor kivétel keletkezik, akkor az első kettő forrása már moved-from, azaz az eredeti értékük elveszett. Ezért mozgatással az erős garancia általában nem teljesíthető.
A std::move_if_noexcept(x)
(<utility>) pontosan ezt a dilemmát oldja fel: rvalue
referenciát ad vissza, ha a típus move konstruktora
noexcept, vagy ha a típus egyáltalán nem másolható
(ilyenkor nincs választás); egyébként lvalue referenciát ad, azaz a
másolást választja. A szabvány magát a garanciát írja elő, nem
a mechanizmust, de a megfigyelhető viselkedés a legtöbb implementációban
(libstdc++, libc++) ezen az eszközön keresztül valósul meg: a
vector::reserve, push_back és
emplace_back újraallokációnál a nem dobó move-ot részesíti
előnyben, egyébként pedig másol.
A vonatkozó szerződés tömören: ha az újraallokáció során kivétel
keletkezik, akkor erős garancia érvényes, amennyiben T
másolható, vagy
std::is_nothrow_move_constructible_v<T> igaz; ha
T nem másolható és a move-ja dob, akkor a hatás nem
specifikált. Ezért a nem dobó move constructor nem esztétikai kérdés,
hanem az erős garancia előfeltétele.
Nézzünk egy teljes, fordítható példát, amely méri a másolások és mozgatások számát:
// reallocation_demo.cpp — ISO C++20
#include <cstddef>
#include <cstdio>
#include <type_traits>
#include <utility>
#include <vector>
class Buffer {
public:
Buffer() = default;
explicit Buffer(std::size_t n)
: data_(n ? new int[n]{} : nullptr), size_(n) {}
Buffer(const Buffer& other) // mély másolás
: data_(other.size_ ? new int[other.size_] : nullptr),
size_(other.size_) {
for (std::size_t i = 0; i < size_; ++i) data_[i] = other.data_[i];
++copies_;
}
Buffer(Buffer&& other) noexcept // nem dobó move
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
++moves_;
}
// copy-and-swap: a paraméter másolása a hívó kontextusában történik
Buffer& operator=(Buffer other) {
swap(*this, other);
return *this;
}
~Buffer() { delete[] data_; }
friend void swap(Buffer& a, Buffer& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}
static int copies() { return copies_; }
static int moves() { return moves_; }
private:
int* data_ = nullptr;
std::size_t size_ = 0;
static inline int copies_ = 0;
static inline int moves_ = 0;
};
static_assert(std::is_nothrow_move_constructible_v<Buffer>);
static_assert(std::is_nothrow_swappable_v<Buffer>);
int main() {
std::vector<Buffer> v;
v.reserve(1);
v.emplace_back(0); // helyben konstruál, nincs másolás/mozgatás
const int c0 = Buffer::copies();
const int m0 = Buffer::moves();
v.reserve(2); // egyetlen újraallokáció, egy elem átvitelével
std::printf("copies=%d moves=%d\n",
Buffer::copies() - c0, Buffer::moves() - m0);
}Fordítás a laborprofilban rögzített fordítóval:
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o reallocation_demo reallocation_demo.cpp
# vagy
clang++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o reallocation_demo reallocation_demo.cppA várt megfigyelés (illusztráció, nem mért eredmény) a
noexcept move mellett copies=0 moves=1. Ha a
move constructorból töröljük a noexcept-ot — ekkor a
static_assert már fordításkor elbukik, ezért azt is törölni
kell —, a várt kimenet copies=1 moves=0: a vektor a
másolást választja. Két korlátot érdemes rögzíteni: a
reserve(1) utáni kapacitás pontos értéke nem garantált, és
a növekedési stratégia is implementációfüggő, ezért a kimenet csak a
tényleges laborprofilban tekinthető ellenőrzöttnek.
A std::swap C++11 óta move-alapú: egy temporárist készít
move konstrukcióval, majd két move assignementet hajt végre.
Következésképpen pontosan akkor noexcept, ha a
T move konstruktora és move assignmentje nem dobó:
noexcept(std::is_nothrow_move_constructible_v<T> && std::is_nothrow_move_assignable_v<T>).
A szabvány std::swap használatához a
MoveConstructible és MoveAssignable
követelményt írja elő.
Egyedi tulajdonlást hordozó típusnál ennél jobb megoldás a handle-ök
cseréje. A fenti Buffer::swap nem allokál, nem másol, csak
két pointert és két méretet cserél, ezért feltétel nélkül
noexcept lehet — ez a „nonthrowing swap” lényege. A helyes
hívási minta két lépésből áll:
using std::swap; // alapértelmezett jelölt
swap(a, b); // ADL: megtalálja a felhasználói swap-et isA std::swap itt csak tartalék; ha a típus névterében
(vagy friend függvényként) van saját swap, az
argumentumfüggő névkeresés (ADL) azt választja. Ez az ADL-előkép: a
teljes névfeloldási szabályrendszert később tárgyaljuk, itt elég annyit
rögzíteni, hogy a „using std::swap; swap(a,b);” idióma
nélkül a felhasználói swap nem feltétlenül találódik meg. A
std::sort, a tárolók swap tagja és számos
algoritmus ezen a szerződésen keresztül dolgozik; ha a swap dob, az
elemek sorrendje nem specifikált, de erőforrás nem szabadulhat el — ez a
basic guarantee.
A destruktor implicit noexcept(true). Ha a destruktorból
kivétel szabadul ki, std::terminate fut. Ez nem véletlen
szigorítás: a destruktorok jellemzően stack unwinding közben futnak, és
egy második, kifelé távozó kivétel mellett a programnak nincs értelmes
folytatása. A tárolók szempontjából még élesebb a helyzet: a
vector újraallokációja a régi elemek destruktorait hívja
meg, és ha ez dob, a tároló belső invariánsa (méret, kapacitás,
lefoglalt tárterület) már nem állítható helyre.
A gyakorlati minta ezért az, hogy a destruktor soha nem dob, a
hibalehetőséget pedig vagy külön, hívható műveletbe tesszük (például
flush() vagy close(), amely
error_code-ot ad vissza), vagy a destruktoron belül
elnyeljük:
~Session() {
try {
flush_best_effort(); // nem dob tovább
} catch (...) {
// naplózás vagy csendes elnyelés: a destruktorból nem szabadulhat ki kivétel
}
}A destruktor noexcept-ságát érdemes ki is írni, ha
kézzel definiálod: így a szándék látható, és a fordító nem a tagokból
következtet.
A noexcept költsége aszimmetrikus. A helyes vállalás
előnye nem elsősorban a kódgenerálásban jelenik meg, hanem abban, hogy a
standard library algoritmusai választhatnak: a
vector növekedése mozgat, a std::swap nem dob,
az erős garancia tartható. A hiányzó vállalás csendes
teljesítményveszteség: a tároló másol, és erről semmi nem
figyelmeztet.
A valótlan vállalás viszont nem teljesítmény-, hanem helyességkérdés:
std::terminate. A tipikus hibaforrások: allokáció egy
noexcept függvényben (new →
std::bad_alloc), potenciálisan dobó hívás egy
noexcept wrapperben, illetve feltétel nélküli
noexcept sablonban, ahol a tagtípus dobhat. Ugyanebbe a
körbe tartozik a std::vector elemeként tárolt típus
destruktorának dobása is.
| Helyzet | Következmény | Teendő |
|---|---|---|
Move constructor noexcept nélkül |
a vektor növekedéskor másol | írd ki a noexcept-ot, ha a törzs valóban nem dob |
Move constructor hamis noexcept-tal |
std::terminate dobáskor |
csak akkor vállald, ha minden tag move-ja nem dobó |
| Destruktor dob | std::terminate |
nyeld el, vagy külön close()/flush() |
noexcept wrapper dobó hívással |
std::terminate |
feltételes noexcept vagy vedd le a vállalást |
| Swap allokál/másol | lassú, esetleg dobó | handle-csere, member swap + friend
swap |
Összefoglalva: a noexcept nem dekoráció, hanem a típus
kivételszerződése, amelyre a tárolók és algoritmusok építenek. A helyes
sorrend: először biztosítani, hogy a művelet valóban nem dob, aztán
vállalni — feltételesen, ha a típusfüggő; és mindig gondoskodni arról,
hogy a destruktor ne legyen a terminate forrása.
1. feladat (diagnosztika). Adott egy
Payload osztály, amelynek van felhasználó által írt copy és
move konstruktora, utóbbi noexcept nélkül. Magyarázd el,
miért másol a std::vector<Payload>
reserve hívásakor, és hogyan mutatnád ezt meg anélkül, hogy
a programot lefuttatnád.
2. feladat (kódolás). Egészítsd ki a
Buffer osztályt úgy, hogy megfeleljen a következő
szerződéseknek: nem dobó move constructor, nem dobó move assignment,
tagfüggvény swap és névtérszintű swap. Igazold
static_assert-tel, hogy
std::is_nothrow_move_constructible_v<Buffer> és
std::is_nothrow_swappable_v<Buffer> igaz.
3. feladat (hibajavítás). Egy Session
destruktora noexcept-ként deklarált, és meghívja a
flush() tagfüggvényt, amely std::bad_alloc-ot
dobhat. Mi történik hiba esetén, és hogyan alakítanád át a kódot, hogy a
hiba ne vezessen std::terminate-hez, de a hiba ténye se
vesszen el?
4. feladat (tervezés). Írj egy
Wrapper<T> osztályt, amelynek move constructorának
kivételspecifikációja feltételesen noexcept, és indokold,
miért nem szabad feltétel nélkül noexcept-ot írni.
1. megoldás. A felhasználó által megírt move
constructor noexcept nélkül potenciálisan dobó. A
reserve erős garanciát ad, amelyet mozgatással nem tudna
tartani: egy félbeszakadt átvitel után a forráselemek moved-from
állapotban maradnának, és nem állítható helyre az eredeti tartalom.
Ezért a std::move_if_noexcept logikája szerint, mivel a
típus másolható és a move-ja potenciálisan dobó, a másolást választja.
Ellenőrzés fordításidejű eszközökkel:
static_assert(!std::is_nothrow_move_constructible_v<Payload>);
és
static_assert(std::is_copy_constructible_v<Payload>);
— a két feltétel együtt pontosan azt a kombinációt adja, amelyben a
másolás várható. A viselkedés megfigyeléséhez másolás- és
mozgatásszámláló tagokat kell a típusba tenni; futás nélkül csak a fenti
trait-állítások és a szabvány garanciaszabálya áll rendelkezésre.
2. megoldás. A kulcs, hogy a move műveletek csak handle-t cserélnek, ne allokáljanak:
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
void swap(Buffer& other) noexcept {
using std::swap;
swap(data_, other.data_);
swap(size_, other.size_);
}
friend void swap(Buffer& a, Buffer& b) noexcept { a.swap(b); }Az önellenőrzés feltétele:
static_assert(std::is_nothrow_move_constructible_v<Buffer>);
és
static_assert(std::is_nothrow_swappable_v<Buffer>);
forduljon le, valamint a ~Buffer() továbbra is
noexcept maradjon. A move assignmentben az
önértékadás-ellenőrzés kötelező, különben a delete[] data_
után a saját pointerünket olvasnánk vissza.
3. megoldás. A destruktorból kiszabaduló kivétel
std::terminate-hez vezet; a noexcept
deklaráció ezt csak hangsúlyozza, a destruktor implicit módon amúgy is
nem dobó. A javítás: a flush() maradjon meg hívható, hibát
jelző műveletként (error_code-kal vagy kivétellel), a
destruktor pedig csak best-effort tisztítást végezzen, kivételt
elnyelve:
~Session() noexcept {
try { flush(); }
catch (...) { /* itt nem szabadulhat ki kivétel */ }
}A hiba ténye ne vesszen el: a flush() explicit meghívása
a normál vezérlési folyamatban történjen, ahol a hívó még tud reagálni.
Ellenőrzési feltétel: a destruktor deklarációja noexcept,
és a törzsében minden potenciálisan dobó hívás try blokkban
van.
4. megoldás. A wrapper a tagja szerződését örökli:
template <class T>
class Wrapper {
T value_{};
public:
Wrapper(Wrapper&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
: value_(std::move(other.value_)) {}
};Feltétel nélküli noexcept esetén egy dobó T
mellett a wrapper a saját szerződését szegné meg, és a hiba
std::terminate formájában, a keletkezés helyétől távol
jelentkezne. A feltételes forma pontosan akkor vállal, amikor a tag
tudja tartani a vállalást; a
std::is_nothrow_move_constructible_v itt csak előkép, a
teljes trait-rendszert később tárgyaljuk.