22.2 Mély másolás, értéktípus és exception guarantee

A konfiguráció, a socketpuffer és a tanúsítványlánc közös tulajdonsága, hogy egy natív programban többször továbbadják, eltárolják és összehasonlítják őket. Ha a típus másolása csak a bájtok vagy a mutatók átmásolását jelenti, két objektum fog ugyanarra az erőforrásra mutatni, és a felszabadításért mindkettő felelősnek hiszi magát. A C ezt a szerződést névvel és dokumentációval oldja meg (buf_clone, buf_free, lásd Tulajdonlási szerződés és cleanup ágak); a C++ lehetőséget ad arra, hogy a másolás jelentése magában a típusban legyen definiálva. Ez az alfejezet azt mutatja meg, mit kell egy ilyen típusnak ígérnie, hogyan teljesíthető ez az ígéret másolás közben fellépő hibák esetén is, és mikor jobb, ha a kézi másolás helyett standard tárolókra bízzuk a munkát. A mozgatás mint önálló téma a Move constructor, moved-from állapot és std::move részre, a swap és noexcept teljes elemzése a noexcept, swap és container-reallocation részre tartozik; itt csak annyit használunk belőlük, amennyi a másolási garanciához feltétlenül kell.

22.2.1 A shallow másolás öröksége és az értéktípus definíciója

A C nyelvű értékadás tagmásolás: ha egy struktúra pointert tartalmaz, a másolat ugyanazt a címet kapja. A két objektum megfigyelhető állapota látszólag független, valójában a mögöttes buffer közös. Ebből három tipikus hiba következik: a dupla felszabadítás, a felszabadított memória olvasása, és az a rejtett csatorna, amelyen az egyik példány módosítása a másikban is látszik. A hiba nem attól függ, hogy a program jól vagy rosszul használja-e az API-t: a típus ígérete hiányos.

Definíció (értéktípus). Legyen TT egy osztály, és jelölje ≈\approx a megfigyelhető állapot egyenlőségét, azaz azt, hogy minden publikus, nem módosító művelet ugyanazt az eredményt adja a két objektumon. TT akkor értéktípus, ha

  1. másolható: létezik copy constructor és copy assignment, amelyek után az invariáns érvényes;
  2. ≈\approx ekvivalenciareláció: reflexív, szimmetrikus és tranzitív;
  3. bármely xx és y=copy(x)y = \operatorname{copy}(x) esetén x≈yx \approx y, továbbá xx és yy bármely további módosítása után sem változik meg a másik megfigyelhető állapota.

A harmadik pont a függetlenségi feltétel, és pontosan ezt sérti meg a shallow másolás. Fontos, hogy a feltétel a megfigyelhető állapotról szól, nem a bájtokról: két objektum lehet bájtra különböző (például eltérő kapacitású, de azonos tartalmú buffer) és mégis ekvivalens.

Definíció (másolási invariáns). Legyen II predikátum az TT objektumokon. II másolási invariáns, ha (a) minden konstruált objektumra igaz, (b) minden publikus művelet után igaz marad, és (c) minden másolási művelet eredményére is igaz. Az (a)–(b) a klasszikus invariáns, a (c) a másolásra vonatkozó többlet: a másolat nem kerülhet olyan állapotba, amelyet egyetlen konstruktor sem állítana elő.

A megosztott reprezentáció nem tiltott, csak korlátozott. Ha a megosztott rész immutable, akkor a megosztás megfigyelhetetlen, és a típus továbbra is értéktípus lehet; ez a shared_ptr<const Blob> és a copy-on-write mintája. Ha a megosztott rész mutálható, a függetlenség sérül, és a típus inkább referencia-szemantikájú, nem értéktípus. A döntést az alábbi táblázat foglalja össze.

Reprezentáció Másolás költsége Függetlenség Fő kockázat
shallow mutató O(1)O(1) nincs dupla felszabadítás, aliasing
mély másolat O(n)O(n) teljes allokációs hiba, memóriaköltség
megosztott immutable (COW) O(1)O(1) + referenciaszám megfigyelhetetlen atomi referenciaszám, élettartam
megosztott mutálható O(1)O(1) nincs rejtett csatorna a példányok között

22.2.2 Copy constructor és copy assignment

A két másolási művelet különbözik abban, milyen állapotból indul:

T(const T& other);            // copy constructor: új objektum
T& operator=(const T& other); // copy assignment: már létező objektum

A copy constructor mindig „tiszta lapon” dolgozik. Ha a konstruktor testében kivétel keletkezik, az objektum nem jön létre, a már létrehozott tagok destruktorai lefutnak (a részleges konstrukció szabálya, lásd Destruktor és részleges konstrukció). Ezért a copy constructor viszonylag könnyen ad erős garanciát. A copy assignment ezzel szemben egy már élő objektumot ír felül: előbb el kell dobnia a régi erőforrást, majd meg kell szerezte az újat, és a kettő között nem maradhat félkész állapot. A visszatérési érték azért referencia, hogy a hozzárendelés láncolható legyen, és hogy a beépített típusok szemantikájához illeszkedjen; a paraméter azért const T&, mert érték szerinti paraméterhez ismét másolás kellene, ami a copy constructort végtelen rekurzióba vinné.

22.2.3 Az exception guarantee előképe

A garanciák szókészlete három szintet különböztet meg; a részletes tárgyalás a No-throw, strong és basic exception guarantee részben következik.

A basic guarantee is az invariánsra épül: nem azt ígéri, hogy minden változatlan, hanem azt, hogy az objektum használható és felszabadítható marad. A naiv „előbb törlöm, aztán allokálok” assignment még ezt sem teljesíti, mert hiba esetén a mutató a felszabadított bufferre mutat, miközben a méret nem nulla — az objektum destruktora dupla felszabadítást kísérelne meg.

22.2.4 Copy-and-swap

A klasszikus minta a nehéz esetet a könnyű esetre vezeti vissza: készítsünk teljes másolatot, majd cseréljünk egy nem dobó művelettel.

ByteBuffer& operator=(const ByteBuffer& other) {
    if (this != &other) {
        ByteBuffer tmp(other); // 1. teljes másolat, dobhat
        swap(tmp);             // 2. noexcept csere
    }
    return *this;
}                              // 3. tmp destruktora felszabadítja a régit

Levezetés (vázlat). Ha az 1. lépés kivételt dob, a tmp nem jön létre, *this érintetlen marad, tehát az erős garancia teljesül. Ha az 1. lépés sikeres, a 2. lépés noexcept, tehát nem hibázhat; a 3. lépés destruktor, amely nem dobhat. Így a művelet vagy teljesen végrehajtódik, vagy egyáltalán nem változtat a célon. A minta tehát akkor ad erős garanciát, ha a másolat elkészítése közben fellépő hiba nem érinti a célt, és a swap nem dob. Utóbbi feltétel nélkül a garancia elvész; erről részletesen a noexcept, swap és container-reallocation szól.

A költség oldala: a copy-and-swap egy allokációt, egy másolást és egy felszabadítást végez, ugyanannyit, mint egy hibamentes naiv assignment, de a hibaágban is konzisztens marad. Állapotfenntartó (stateful) allokátor esetén viszont a tmp-nek a cél allokátorával kell készülnie, különben a swap után olyan buffer kerül a célba, amelyet nem a saját allokátora szabadít fel. A std::pmr tárolóknál ez kézzel kezelendő, mert a swap nem cserél allokátort; a részleteket a pmr, memory_resource és region lifetime tárgyalja.

22.2.5 Self-aliasing és invalidáció

Két különböző problémát szokás összekeverni. Az önhozzárendelés (x = x) esetén a naiv implementáció előbb felszabadítja a buffert, majd a forrásból — amely ugyanaz az objektum — másolni próbál, így felszabadított memóriát olvas. A copy-and-swap ezt önmagában is kezeli: a tmp a swap előtt elkészül, a régi buffer csak a tmp destruktorában szabadul fel, amikor már senki nem hivatkozik rá. Az if (this != &other) csak optimalizálás, nem a helyesség feltétele.

Az aliasing ennél általánosabb: a hívó olyan referenciát vagy mutatót ad át, amely a célobjektum belsejére mutat. Az assignment után a cél korábbi tartalmára szerzett pointerek, iterátorok és nézetek érvénytelenné válnak, mert a régi buffer felszabadult. Ezt az API-szerződésben rögzíteni kell, ahogy azt az Owner, view és erőforrásszerződés C++ API-ban rész is hangsúlyozza. Fontos különbség a mozgatáshoz képest: a másolás a forrást nem változtatja meg, ezért a forrásra adott nézetek érvényesek maradnak; csak a cél korábbi nézetei halnak meg.

22.2.6 Rule-of-zero alternatíva standard tárolóval

Ha az osztály tagjai maguk is értéktípusok, a fordító által generált másolási műveletek tagonként mély másolatot készítenek, és a hibakezelést a tagok végzik. Ekkor a saját osztálynak nem kell destruktor, copy constructor és copy assignment — ez a rule of zero, amelyet a Rule of zero, three és five rész vezetett be.

struct Config {
    std::string name;
    std::vector<std::byte> payload;

    friend bool operator==(const Config&, const Config&) = default; // C++20
};

A std::vector másoló konstruktora a forrás elemeit másolja; ha egy elem másolása dob, a konstruktor nem fejeződik be, és a már létrehozott elemek destruktorai lefutnak, tehát nem marad félig kitöltött objektum. Az assignment garanciája viszont nem feltétel nélküli: függ az elem típusának másolási műveleteitől és az allokátor tulajdonságaitól, ezért a rule of zero nem azt jelenti, hogy minden művelet erős garanciát ad, hanem azt, hogy a garanciáért a tagok felelnek. A std::string belső reprezentációjára épülő nézetek (például std::string_view) élettartamára a std::string, std::string_view és szövegownership rész tér ki.

22.2.7 Kidolgozott példa és build

A példa két változatot mutat: a kézi ByteBuffer-t copy-and-swap assignmenttel, és a rule-of-zero Config-ot. A ByteBuffer invariánsa: size_ == 0 esetén data_ == nullptr, egyébként data_ egy size_ bájtos, saját tulajdonú tömb.

// byte_buffer.cpp — C++20, GCC/Clang, -std=c++20
#include <cstddef>
#include <cstring>
#include <string>
#include <utility>
#include <vector>
#include <iostream>

class ByteBuffer {
public:
    ByteBuffer() = default;
    explicit ByteBuffer(std::size_t n)
        : size_(n), data_(n ? new std::byte[n]() : nullptr) {}

    ByteBuffer(const ByteBuffer& other)
        : size_(other.size_),
          data_(other.size_ ? new std::byte[other.size_] : nullptr) {
        if (size_ != 0) std::memcpy(data_, other.data_, size_);
    }

    ByteBuffer& operator=(const ByteBuffer& other) {
        if (this != &other) {
            ByteBuffer tmp(other); // dobhat: strong guarantee
            swap(tmp);             // noexcept
        }
        return *this;
    }

    ~ByteBuffer() { delete[] data_; }

    void swap(ByteBuffer& other) noexcept {
        std::swap(size_, other.size_);
        std::swap(data_, other.data_);
    }

    std::size_t size() const noexcept { return size_; }
    const std::byte* data() const noexcept { return data_; }
    std::byte* data() noexcept { return data_; }

    friend bool operator==(const ByteBuffer& a, const ByteBuffer& b) noexcept {
        if (a.size_ != b.size_) return false;
        if (a.size_ == 0) return true;
        return std::memcmp(a.data_, b.data_, a.size_) == 0;
    }

private:
    std::size_t size_ = 0;
    std::byte* data_ = nullptr;
};

struct Config {
    std::string name;
    std::vector<std::byte> payload;

    friend bool operator==(const Config&, const Config&) = default;
};

int main() {
    ByteBuffer a(4);
    ByteBuffer b = a;  // copy constructor
    ByteBuffer c;
    c = a;             // copy assignment
    b = b;             // self-assignment
    std::cout << (a == b) << (a == c) << '\n';

    Config cfg1{"node-1", {std::byte{1}, std::byte{2}}};
    Config cfg2 = cfg1; // tagonkénti mély másolat
    cfg2.name = "node-2";
    std::cout << (cfg1 == cfg2) << '\n';
}
g++ -std=c++20 -Wall -Wextra -Wpedantic -g -O2 \
    -fsanitize=address,undefined byte_buffer.cpp -o byte_buffer
./byte_buffer

A fordításhoz rögzített GCC vagy Clang kiadás szükséges; a -fsanitize=address,undefined a memóriahibák és az aláírt túlcsordulás detektálására szolgál. A példát a fejezet írásakor nem futtattam, a fenti parancs a tanuló laborjához tartozik; a kimenet illusztratív, nem mért eredmény. A hibás implementációt szándékosan külön, jelölt részletként mutatjuk be, mert nem része a buildnek:

// TANÍTÁSI PÉLDA — szándékosan hibás, ne használd
ByteBuffer& operator=(const ByteBuffer& other) {
    delete[] data_;                     // 1. régi erőforrás elhagyása
    size_ = other.size_;
    data_ = other.size_ ? new std::byte[other.size_] : nullptr; // 2. dobhat!
    if (data_) std::memcpy(data_, other.data_, other.size_);
    return *this;
}

Az 1. lépés után a data_ a felszabadított bufferre mutat. Ha a 2. lépés std::bad_alloc-ot dob, az objektum size_ értéke már az új méret, data_ viszont a régi cím — az invariáns sérül, a destruktor dupla felszabadítást kísérel meg. Ha this == &other, a memcpy felszabadított memóriából olvas. Az implementáció tehát nemhogy erős, de még alapvető garanciát sem ad.

22.2.8 Gyakori hibák

A leggyakoribb tévedés a shallow tag (const char*, nyers T*) használata olyan osztályban, amelyet értéktípusként szeretnénk másolni. A második az önhozzárendelés kihagyása a naiv assignmentből, a harmadik a swap noexcept nélkül hagyása, ami a copy-and-swap erős garanciáját csendben megszünteti. Gyakori az is, hogy az operator== a reprezentációra (például a buffer kapacitására) hasonlít, nem a megfigyelhető állapotra, és így nem lesz ekvivalenciareláció. Végül az assignment void visszatérési értéke megakadályozza a láncolást, és eltér a beépített típusok szemantikájától.

22.2.9 Feladatok

  1. Magyarázó feladat. Adott egy C API: struct buf { char *p; size_t n; }; buf_clone(const buf*), buf_free(buf*). Mutasd meg, miért nem értéktípus a struct buf, és mit kell megváltoztatni a C++ portban.

  2. Kódolási feladat. Egészítsd ki a ByteBuffer osztályt egy assign_from(const std::byte* p, std::size_t n) tagfüggvénnyel, amely erős garanciát ad, és önmtartalmazó híváskor (b.assign_from(b.data(), b.size())) sem olvas felszabadított memóriát. Indokold a lépéseket.

  3. Diagnosztikai feladat. A fenti naiv operator=-ra vezesd le, milyen garanciát ad, és sorold fel a konkrét hibákat. Milyen sanitizer-jelzésre számítasz, és mit kellene a kódban megváltoztatni?

  4. Tervezési feladat. Egy konfigurációt gyakran másolnak, de ritkán módosítanak. Válassz a mély másolat, a shared_ptr<const T> és a copy-on-write között, és indokold a döntést költség, élettartam és egyenlőség szempontjából.

22.2.10 Megoldások

1. megoldás. A struct buf nem értéktípus, mert a C értékadás és a paraméterátadás tagmásolást végez, így két buf ugyanarra a p-re mutat, és a függetlenségi feltétel sérül: az egyik buf_free-ja után a másik dangling pointert tart. A buf_clone és buf_free használata a hívó felelőssége, nem a típusé. A C++ portban a copy constructor és a copy assignment a p tartalmát új bufferbe másolja, a destruktor felszabadítja a saját buffert, és az operator== a (n, tartalom) párost hasonlítja. Ellenőrzési feltétel: két másolat módosítása után a másik tartalma nem változik, és a destruktorok sorrendje nem okoz dupla felszabadítást ASan alatt.

2. megoldás. Az assign_from először új buffert allokál és másol a bemenetről, majd swap-pel átveszi azt; a régi buffer csak a lokális másolat destruktorában szabadul fel. Ha a bemeneti pointer a saját bufferre mutat, a másolás még azelőtt megtörténik, hogy a régi buffer felszabadulna, tehát nincs use-after-free. Ha az allokáció dob, a cél érintetlen marad. Ellenőrzési feltétel: b.assign_from(b.data(), b.size()) után b tartalma változatlan, és ASan/UBSan nem jelez.

3. megoldás. A naiv változat nem ad sem erős, sem alapvető garanciát. Hibák: (a) a delete[] után a data_ dangling, miközben size_ még a régi értéket tükrözheti; (b) ha a new dob, az invariáns sérül, a destruktor dupla felszabadítást kísérel meg; (c) önhozzárendelésnél a memcpy felszabadított memóriából olvas. ASan a (c) esetben heap-use-after-free-t, a (b) esetben double-free-t jelezhet. Javítás: copy-and-swap, azaz teljes másolat készítése a régi buffer érintése nélkül, majd noexcept swap.

4. megoldás. Ha a reprezentáció immutable, a shared_ptr<const ConfigData> olcsó másolatot ad O(1)O(1) költséggel, és a megosztás megfigyelhetetlen, tehát az értékszemantika megmarad. A mutáció ekkor új reprezentációt hoz létre, ami a ritka módosítás miatt kedvező. A mély másolat egyszerűbb és kiszámíthatóbb, de minden másolás allokál. A copy-on-write a kettő kombinációja, viszont a referenciaszámot (jellemzően atomikusan) karban kell tartani, és a shared_ptr élettartamára, ciklusaira és a weak_ptr használatára is figyelni kell, ahogy a shared_ptr, control block és weak_ptr rész kifejti. Ellenőrzési feltétel: a választott modell mellett a másolat függetlensége megfigyelhető marad, és az operator== a tartalmat hasonlítja.

22.2.11 Összefoglaló

Az értéktípus ígérete nem a bájtok másolása, hanem a megfigyelhető függetlenség: a másolat egyenlő, de a további módosítások nem szivárognak át. A shallow másolás ezt sérti; a mély másolat teljesíti, cserébe allokációt és hibalehetőséget hoz. A copy constructor tiszta lapon dolgozik, ezért könnyebben ad erős garanciát; a copy assignmentnek a régi erőforrást is kezelnie kell. A copy-and-swap ezt a különbséget használja ki: a dobható részt a másolat készítésére korlátozza, majd egy noexcept cserével commitál. A naiv „törlés, majd allokálás” még alapvető garanciát sem ad, mert hiba esetén az invariáns sérül. A legtöbb esetben a rule-of-zero és a standard tárolók jelentik a legkevesebb kockázatú megoldást, de a garancia ilyenkor a tagoktól függ, nem automatikus. Az erős garancia pontos feltételei és a swap noexcept követelménye a No-throw, strong és basic exception guarantee, illetve a noexcept, swap és container-reallocation részben folytatódnak.