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.
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 egy osztály, és jelölje 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. akkor értéktípus, ha
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 predikátum az objektumokon. 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ó | nincs | dupla felszabadítás, aliasing | |
| mély másolat | teljes | allokációs hiba, memóriaköltség | |
| megosztott immutable (COW) | + referenciaszám | megfigyelhetetlen | atomi referenciaszám, élettartam |
| megosztott mutálható | nincs | rejtett csatorna a példányok között |
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ő objektumA 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é.
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.
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égitLevezeté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.
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.
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.
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_bufferA 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.
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.
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.
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.
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?
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.
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
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.
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.