22.5 noexcept, swap és container-reallocation

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.

22.5.1 A noexcept specifier és a noexcept operator

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.

22.5.2 Feltételes noexcept: a szerződés típusfüggő esete

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é.

22.5.3 move_if_noexcept és a vector újraallokációs garanciája

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.cpp

A 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.

22.5.4 Nem dobó swap és az ADL előkép

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 is

A 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.

22.5.5 A destruktorból kiszabaduló kivétel

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.

22.5.6 Költség, garancia és a valótlan noexcept ára

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.

22.5.7 Feladatok

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.

22.5.8 Megoldások

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.