A C-ben egy struct értékadása és paraméterátadása
byte-onkénti másolás. Ha a struktúra mutatót tartalmaz egy dinamikusan
allokált erőforrásra, a másolat ugyanarra a címre mutat: két
„tulajdonos” jön létre ugyanarra az erőforrásra. A következmény vagy
double free, vagy use-after-free. Ez a shallow copy (sekély másolás)
probléma, amelyet a C típusrendszere nem tud kifejezni.
/* shallow_copy.c - szemléltető példa; futtatása nem definiált viselkedéshez vezet */
#include <stdlib.h>
struct buffer {
char *data;
size_t len;
};
int main(void) {
struct buffer a;
a.len = 4;
a.data = malloc(a.len);
if (a.data == NULL) return 1;
struct buffer b = a; /* sekély másolat: b.data == a.data */
free(b.data);
free(a.data); /* double free: nem definiált viselkedés */
return 0;
}gcc -std=c17 -Wall -Wextra -Wpedantic shallow_copy.c -o shallow_copy
# A példa szándékosan hibás; futtatni nem javasolt.A C++ értékszemantikája nem tünteti el a problémát, hanem
kifejezhetővé teszi: a special member functions (különleges
tagfüggvények) adják meg, hogy egy típus hogyan másolható, mozgatható és
semmisíthető meg. Ez az alfejezet a generálási szabályokat, a rule of
three/five/zero döntési keretét és a copy/move policy megválasztását
tárgyalja. A std::move mint kategóriakonverzió és a
moved-from állapot részletes tárgyalása a Move
constructor, moved-from állapot és std::move része; itt csak a
special memberek szerepéig jutunk el. Az owner/view szerződésre építünk
az Owner, view és erőforrásszerződés C++
API-ban, az invariáns fogalmára az Osztály,
konstruktor és invariáns, a C értékmásolásra a Struktúrák, tagok és értékmásolás résznél. A
szabályok forrása a forrásjegyzék [C++20]
tétele: [class.default.ctor], [class.copy.ctor], [class.copy.assign],
[class.dtor] és [dcl.fct.def.delete]; a táblázatot alább egy
próbaprogrammal ellenőrizzük.
C++20-ban hat special member function van: default constructor, destructor, copy constructor, copy assignment operator, move constructor, move assignment operator. Ezeket a compiler akkor deklarálja implicit módon, ha a program nem deklarálja őket. A deklaráció azonban nem garantál definíciót: a compiler bizonyos esetekben „defined as deleted” (töröltként definiált) állapotba teszi őket, más esetekben egyáltalán nem deklarálja (suppressed, elnyomott).
Pontos definíció: egy special member function akkor „implicit módon deklarált”, ha a felhasználó nem deklarálta; akkor „defined as deleted”, ha a generált definíció nem lenne érvényes (például egy tag nem másolható); és akkor „nem deklarált”, ha egy másik user-declared special member ezt kizárja. A három állapot megkülönböztetése kulcs: a törölt függvényre történő hívás fordítási hibát ad, míg a nem deklarált függvény helyett a túlterhelés-feloldás másikat, jellemzően a copy-t választja.
A generálási szabályokat a következő táblázat foglalja össze. Jelölések: I = implicit módon deklarált és definiált, ha a tagok engedik; U = user-declared; D = defined as deleted; – = nem deklarált.
| User-declared | default ctor | destructor | copy ctor | copy assign | move ctor | move assign |
|---|---|---|---|---|---|---|
| semmi | I | I | I | I | I | I |
| default ctor | U | I | I | I | I | I |
| destructor | I | U | I* | I* | – | – |
| copy ctor | – | I | U | I* | – | – |
| copy assign | I | I | I* | U | – | – |
| move ctor | – | I | D | D | U | – |
| move assign | I | I | D | D | – | U |
A csillaggal jelölt esetben az implicit copy művelet generálása deprecated: az implicit copy constructoré, ha az osztálynak user-declared copy assignmentje vagy destruktora van; az implicit copy assignmenté, ha user-declared copy constructora vagy destruktora van. Ez nem tiltja meg a generálást, de a szabvány a jövőbeni eltávolítás lehetőségét fenntartja; új kódban ne támaszkodjunk rá.
A táblázat három mintázata. Először: a user-declared destructor, copy ctor vagy copy assign elnyomja a move operations deklarációját, ilyenkor a move helyett a copy hívódik, ha elérhető. Másodszor: a user-declared move ctor vagy move assign törli a copy operations-t, ez teszi lehetővé a move-only típust. Harmadszor: a default ctor csak akkor generált, ha nincs semmilyen user-declared konstruktor, ezért a copy ctor és a move ctor sorában is „–” áll; a copy assignment vagy a move assignment viszont nem konstruktor, mellettük a default ctor megmarad. A többi special member generálását a default ctor nem befolyásolja.
A táblázatot minimáltípusokkal ellenőrizhetjük. Minden típus pontosan
egy special membert deklarál, egy naplózó Probe tagon
keresztül pedig az is látszik, hogy rvalue argumentumnál move vagy copy
fut, illetve törölt-e a művelet (D):
// special_members.cpp — C++20: a generálási táblázat ellenőrzése
#include <cstdio>
#include <type_traits>
#include <utility>
struct Probe { // naplózza, melyik műveletét hívták
static inline const char* last = "";
Probe() = default;
Probe(const Probe&) { last = "copy"; }
Probe(Probe&&) noexcept { last = "move"; }
Probe& operator=(const Probe&) { last = "copy"; return *this; }
Probe& operator=(Probe&&) noexcept { last = "move"; return *this; }
};
// Soronként egy user-declared special member; az int-es konstruktor csak
// a példányosításhoz kell, és a default ctoron kívül semmit nem befolyásol.
struct None { Probe p; None() = default; explicit None(int) {} };
struct DefCtor { Probe p; DefCtor() {} explicit DefCtor(int) {} };
struct Dtor { Probe p; ~Dtor() {} };
struct CopyCtor { Probe p; explicit CopyCtor(int) {}
CopyCtor(const CopyCtor& o) : p(o.p) {} };
struct CopyAssign { Probe p;
CopyAssign& operator=(const CopyAssign& o) { p = o.p; return *this; } };
struct MoveCtor { Probe p; explicit MoveCtor(int) {}
MoveCtor(MoveCtor&& o) noexcept : p(std::move(o.p)) {} };
struct MoveAssign { Probe p;
MoveAssign& operator=(MoveAssign&& o) noexcept { p = std::move(o.p); return *this; } };
template <class T>
const char* via_move_ctor() { // melyik konstruktor fut rvalue-ból?
if constexpr (!std::is_move_constructible_v<T>) return "D";
else {
T a{1};
Probe::last = "";
T b(std::move(a));
return Probe::last;
}
}
template <class T>
const char* via_move_assign() {
if constexpr (!std::is_move_assignable_v<T>) return "D";
else {
T a{1}, b{1};
Probe::last = "";
b = std::move(a);
return Probe::last;
}
}
template <class T>
void row(const char* name) {
std::printf("%-11s default=%d copy_ctor=%d copy_assign=%d "
"rvalue_ctor=%s rvalue_assign=%s\n", name,
std::is_default_constructible_v<T>,
std::is_copy_constructible_v<T>, std::is_copy_assignable_v<T>,
via_move_ctor<T>(), via_move_assign<T>());
}
// Az int-es konstruktor nélküli típusokhoz aggregát/default példányosítás.
template <> const char* via_move_ctor<Dtor>() { Dtor a; Probe::last = ""; Dtor b(std::move(a)); return Probe::last; }
template <> const char* via_move_assign<Dtor>() { Dtor a, b; Probe::last = ""; b = std::move(a); return Probe::last; }
template <> const char* via_move_ctor<CopyAssign>() { CopyAssign a; Probe::last = ""; CopyAssign b(std::move(a)); return Probe::last; }
template <> const char* via_move_assign<CopyAssign>() { CopyAssign a, b; Probe::last = ""; b = std::move(a); return Probe::last; }
template <> const char* via_move_assign<MoveAssign>() { MoveAssign a, b; Probe::last = ""; b = std::move(a); return Probe::last; }
int main() {
row<None>("semmi");
row<DefCtor>("default");
row<Dtor>("destructor");
row<CopyCtor>("copy ctor");
row<CopyAssign>("copy assign");
row<MoveCtor>("move ctor");
row<MoveAssign>("move assign");
}g++ -std=c++20 -Wall -Wextra -Wpedantic -Wdeprecated-copy-dtor \
special_members.cpp -o special_members
./special_membersBuildjegyzőkönyv (lab-gcc profil). A
kimenet soronként (default, copy_ctor,
copy_assign, rvalue-konstrukció, rvalue-értékadás):
semmi 1 1 1 move move; default 1 1 1 move
move; destructor 1 1 1 copy copy; copy ctor 0
1 1 copy copy; copy assign 1 1 1 copy copy;
move ctor 0 0 0 move D; move assign 1 0 0 D
move. Ahol az rvalue-művelet copy, ott a move nincs
deklarálva (–), és a túlterhelés-feloldás a copy-t választja; ahol
D, ott a move nincs deklarálva, a copy pedig törölt. A
fordító négy elavultsági figyelmeztetést ad, pontosan a csillaggal
jelölt cellákra.
A = default és = delete szintaxis:
class X {
public:
X() = default; // kérjük a generált default ctort
X(const X&) = delete; // copy tiltása
X& operator=(const X&) = delete;
~X() = default;
};A = default akkor hasznos, ha a special member-t
deklarálni akarjuk, például virtual destructor miatt, de a definíciót a
compilerre bízzuk. A = delete bármely függvényre
alkalmazható, de nem veszi ki a jelöltet a
túlterhelés-feloldásból: a törölt függvény ugyanúgy jelölt, ugyanúgy
rangsorolódik, és ha ő nyer, a program hibás ([dcl.fct.def.delete]).
Ezért alkalmas tiltott konverziók kifejezésére: a törölt overload
„elkapja” azt a hívást, amely különben egy nem kívánt konverzióval egy
másik overloadhoz jutna. Egyetlen szabványos kivétel van: a defaulted,
de töröltként definiált move constructort és move assignmentet a
túlterhelés-feloldás figyelmen kívül hagyja ([class.copy.ctor],
[class.copy.assign]), így ilyenkor a copy-változat futhat. Ettől
különbözik a constraint vagy SFINAE miatt ki sem választott jelölt,
amely a túlterhelés-feloldásban részt sem vesz, így a hívás egy másik
jelöltre eshet:
// deleted_overload.cpp — C++20: a deleted jelölt részt vesz a kiválasztásban
#include <concepts>
#include <cstdio>
void f(long) { std::puts("f(long)"); }
void f(int) = delete; // jelölt marad; ha nyer, a hívás hibás
void g(double) { std::puts("g(double)"); }
template <std::integral T>
void g(T) { std::puts("g(integral)"); } // nem integrálisra ki sem jelölt
int main() {
f(1L); // f(long): exact match, a deleted f(int) veszít
// f(1); // HIBA: az f(int) exact match nyer, de deleted
g(1); // g(integral)
g(1.5); // g(double): a sablon a constraint miatt nem életképes
}Buildjegyzőkönyv (lab-gcc profil). A
program f(long), g(integral),
g(double) kimenettel fut; a kikommentezett
f(1) visszakapcsolva
use of deleted function 'void f(int)' fordítási hibát
ad.
A rule of three a C++11 előtti ökölszabály: ha egy osztálynak user-declared destructora, copy constructorja vagy copy assignment operatora van, akkor nagy valószínűséggel mindháromra szükség van. Az ok a fenti táblázatban látható: ha csak a destructort írjuk meg, a copy operations generáltak maradnak, és sekélyen másolnak. Ez pont a C-beli shallow copy hiba C++ változata.
A rule of five a C++11 utáni kiegészítés: ugyanez érvényes a move constructorra és a move assignment operatorra is. Ha az osztály erőforrást birtokol, és a move operations-t is definiáljuk, akkor öt függvényt kell összehangolni: destructor, copy ctor, copy assign, move ctor, move assign. A „rule of five” nem azt jelenti, hogy mindig mind az ötöt kézzel kell megírni; azt jelenti, hogy ha bármelyiket kézzel írjuk, végig kell gondolni a többit.
Nézzük a kézzel kezelt buffer rule-of-five változatát. A példa teljes, fordítható; a buildparancsot a blokk után adjuk.
// buffer_r5.cpp - rule of five, kézi erőforrás-kezelés
#include <cstddef>
#include <cstring>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t n = 0)
: data_{n ? new char[n] : nullptr}, size_{n} {}
~Buffer() { delete[] data_; }
Buffer(const Buffer& other)
: data_{other.size_ ? new char[other.size_] : nullptr},
size_{other.size_} {
if (data_ != nullptr) {
std::memcpy(data_, other.data_, size_);
}
}
Buffer& operator=(const Buffer& other) {
if (this != &other) {
Buffer tmp{other}; // erős garancia: előbb elkészül az új érték
swap(tmp);
}
return *this;
}
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 {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
std::size_t size() const noexcept { return size_; }
const char* data() const noexcept { return data_; }
private:
char* data_;
std::size_t size_;
};g++ -std=c++20 -Wall -Wextra -Wpedantic -fsanitize=address,undefined buffer_r5.cpp -o buffer_r5A copy assignment copy-and-swapot használ: az other-ből
előbb elkészül egy teljes másolat (tmp), és csak akkor
cseréljük ki a régi állapotot, ha ez sikerült. Így ha a másolás közben
kivétel dobódik, például a new sikertelen, a
*this változatlan marad: ez az erős kivételgarancia (strong
exception guarantee). A self-assignment (b = b) esetét a
this != &other feltétel kezeli; copy-and-swap esetén ez
elhagyható lenne, de a move assignmentnél kötelező, különben a saját
erőforrásunkat szabadítanánk fel, majd vennénk át.
A move constructor és move assignment itt a tulajdonjog átadását
végzi: a forrás pointerét átveszi, majd a forrást nullázza. A
std::move önmagában csak kategóriakonverzió; a tényleges
erőforrás-átadás a move operationsban történik. Ennek részletes
tárgyalása a Move constructor, moved-from állapot és
std::move részé.
Az erőforrás-tulajdonos osztály szerződése két alapvető irányt vehet. A másolható owner copy operationsa mély másolatot készít, move operationsa átadja a tulajdonjogot. Ezt akkor érdemes választani, ha az erőforrás másolása értelmes és megengedett, például egy buffer tartalma. A move-only owner copy operationsa törölt, move operationsa átadja a tulajdonjogot. Ezt akkor kell választani, ha az erőforrás egyedi, például file descriptor, socket, lock vagy egyedi allokáció, és két tulajdonos nem lehet.
A move-only szerződést a copy operations = delete
jelölésével fejezzük ki. Az alábbi példa egy részlet: a
UniqueFd osztály definíciója, amely POSIX fd-et
birtokol.
#include <unistd.h>
class UniqueFd {
public:
explicit UniqueFd(int fd = -1) noexcept : fd_{fd} {}
~UniqueFd() { if (fd_ >= 0) ::close(fd_); }
UniqueFd(const UniqueFd&) = delete;
UniqueFd& operator=(const UniqueFd&) = delete;
UniqueFd(UniqueFd&& other) noexcept : fd_{other.fd_} {
other.fd_ = -1;
}
UniqueFd& operator=(UniqueFd&& other) noexcept {
if (this != &other) {
if (fd_ >= 0) ::close(fd_);
fd_ = other.fd_;
other.fd_ = -1;
}
return *this;
}
int get() const noexcept { return fd_; }
private:
int fd_;
};Itt a copy törlése nem stílus kérdése: ha engednénk, két
UniqueFd ugyanazt az fd-t zárná le, ami double close-t és
más fd-ek véletlen lezárását okozhatja. A move-only szerződés a
tulajdonjog egyediségét fejezi ki a típusban.
A self-assignment és a részleges hiba kezelése a move assignmentnél
is kritikus. Ha this == &other, a régi fd lezárása után
ugyanazt az fd-t vennénk át, ami már érvénytelen. Ezért kell a
this != &other ellenőrzés. A copy assignmentnél a
copy-and-swap adja az erős garanciát; a move assignmentnél a „delete
then take” sorrend általában részleges hibát okozhat, ha a felszabadítás
után az átvétel kivételt dobna. Mivel itt csak fd-t zárunk és int-et
másolunk, ez nem fordulhat elő, de általános erőforrásnál a move
assignmentet is érdemes úgy megírni, hogy előbb átvegyük az új
erőforrást, és csak utána szabadítsuk fel a régit.
A rule of zero a modern C++ irányvonal: ha lehetséges, egyik special
member function-t se definiáljuk. Ezt akkor tehetjük meg, ha az osztály
minden tagja maga kezeli az erőforrását, és a compiler generált
copy/move operations helyesen működnek. A tagok lehetnek standard
tárolók (std::vector, std::string), okos
pointerek (std::unique_ptr, std::shared_ptr)
vagy más RAII típusok.
A rule-of-zero Buffer:
// buffer_r0.cpp - rule of zero
#include <cstddef>
#include <vector>
class Buffer {
public:
explicit Buffer(std::size_t n = 0) : data_(n) {}
std::size_t size() const noexcept { return data_.size(); }
char* data() noexcept { return data_.data(); }
const char* data() const noexcept { return data_.data(); }
private:
std::vector<char> data_;
};g++ -std=c++20 -Wall -Wextra -Wpedantic buffer_r0.cpp -o buffer_r0Itt nincs destructor, copy ctor, copy assignment, move ctor, move
assignment. A std::vector destructora felszabadítja a
tárolást, copy ctorja mély másolatot készít, move ctorja átadja a
tárolást. A compiler generált special memberjei ezeket hívják, és
helyesen működnek. A move-only változat ugyanígy működik
std::unique_ptr<char[]> taggal: a
unique_ptr nem másolható, ezért a Buffer copy
operations implicit módon töröltek lesznek, a move operations pedig
generáltak.
A rule of zero előnyei: kevesebb kézi kód és kevesebb hiba; a
compiler generált műveletei optimalizálhatók, például trivial copy
esetén; a tagok cseréje, például vector helyett
unique_ptr, nem igényel kézi special member átírást; az
invariáns a tagok invariánsaira épül, nem kell külön kézi
erőforrás-kezelést összehangolni.
A rule of zero nem alkalmazható, ha az osztály invariánsa olyan, hogy a tagok másolása vagy mozgatása nem felel meg neki, például két tag konzisztenciáját kézzel kell fenntartani, vagy ha a tagok nem RAII-k. Ilyenkor a rule of three/five szerint kell eljárni.
A döntési sorrend: először vizsgáljuk meg, hogy minden tag tud-e
önállóan viselkedni; ha igen, rule of zero. Ha nem, kell-e mély másolás;
ha igen, rule of five, vagy rule of three, ha a move nem indokolt. Ha az
erőforrás egyedi, move-only szerződés: = delete copy, move
operations. Minden esetben ellenőrizni kell a self-assignmentet és a
kivételgaranciát.
= default-tal vissza
kell hozni őket, ha a tagok engedik. Fordítva: ha copy-t írunk, a
move-ok elnyomottak lesznek, és a move helyett copy hívódik.a = a
esetén a move assignment felszabadíthatja a saját erőforrását, majd
érvénytelen pointert vesz át. Javítás: this != &other
vagy copy-and-swap.= default nem másolható taggal. Ha a
tag nem másolható, a = default copy ctor törölt lesz, nem
generált; a hibaüzenet gyakran a hívás helyén jelenik meg. Javítás: a
tag cseréje vagy a copy tiltása.Magyarázó. Adott:
struct S {
~S();
int x;
};Mely special member functions generálódnak, törlődnek, illetve nem
deklarálódnak? Másolható-e S, és mi történik a move
kísérletnél?
Kódolási. Alakítsd át a
buffer_r5.cpp Buffer osztályát rule-of-zero
változattá std::vector<char> használatával. Indokold,
miért marad helyes a copy és a move.
Diagnosztikai. Az alábbi osztály double free-t okoz. Miért, és hogyan javítod minimálisan?
class Buf {
public:
explicit Buf(std::size_t n) : p_{new char[n]}, n_{n} {}
~Buf() { delete[] p_; }
private:
char* p_;
std::size_t n_;
};Tervezési. Írj move-only RAII wrappert egy POSIX file descriptor köré. A copy operations legyenek töröltek, a move operations adják át a tulajdonjogot, a destructor zárja le az fd-t. Miért nem szabad a copy-t engedélyezni?
1. megoldás. A user-declared destructor miatt a move
constructor és a move assignment nem deklarált, azaz elnyomott. A copy
constructor és a copy assignment implicit módon generált; a szabvány ezt
deprecatednek jelöli, de működik. A default constructor generált, mert
nincs más user-declared constructor. Az S tehát másolható;
a std::move(s) egy S&&-t ad, de mivel
nincs move ctor, a copy ctor hívódik. Ellenőrzési feltétel:
std::is_copy_constructible_v<S> igaz, és a move
kísérlet is lefordul, de a copy ctor fut.
2. megoldás. A Buffer tagja
std::vector<char> lesz; a constructor
data_(n), a size() és data() a
vector megfelelő függvényeit hívja. Nem definiálunk destructort,
copy/move operations-t. A vector copy ctorja mély másolatot készít, move
ctorja átadja a tárolást; a compiler generált Buffer
copy/move ezeket hívja. Ellenőrzési feltétel: a copy után a két buffer
tartalma független; a move után a forrás size() értéke
általában 0, de a vector moved-from állapota nem specifikált, csak
érvényes.
3. megoldás. A Buf-nak csak destructora
van, a copy ctor és copy assignment implicit generált, sekélyen másolja
a p_ pointert. Buf b2 = b1; után
b1.p_ == b2.p_, és mindkét destructor
delete[]-eli ugyanazt a tömböt: double free, azaz nem
definiált viselkedés. Minimális javítások: a copy operations törlése
= delete jelöléssel, ha a Buf move-only owner;
rule of five szerinti mély copy ctor és copy assignment, valamint move
ctor és move assignment; vagy rule of zero
std::vector<char> vagy
std::unique_ptr<char[]> taggal. Ellenőrzési feltétel:
a javított változat ASan-nel futtatva nem jelez double free-t; a copy
után a két objektum külön tárolóra mutat.
4. megoldás. A UniqueFd osztály a fenti
példa szerint definiálható. A copy-t azért nem szabad engedélyezni, mert
az fd egyedi OS-erőforrás: két UniqueFd ugyanazt az fd-t
zárná le, ami double close-t, más fd-ek véletlen lezárását és nehezen
diagnosztizálható hibákat okoz. A move-only szerződés a tulajdonjog
egyediségét fejezi ki. Ellenőrzési feltétel: a copy hívása fordítási
hibát ad; a move után a forrás get() értéke -1, a cél a
régi fd-t birtokolja. A megoldás részlet, a teljes programhoz
main és a megfelelő include-ok szükségesek.
A C shallow copy problémáját a C++ special member functions teszik
kifejezhetővé. A compiler hat special member functiont deklarálhat:
default ctor, destructor, copy ctor, copy assignment, move ctor, move
assignment. Ezek lehetnek implicit módon definiáltak, töröltek vagy
elnyomottak; a user-declared destructor vagy copy elnyomja a move-okat,
a user-declared move törli a copy-okat. A rule of three a
destructor–copy ctor–copy assignment hármast, a rule of five a move
operations-t is összehangolja. A rule of zero a modern alapértelmezés:
RAII tagokkal a compiler generált műveletei helyesek. Az owner
szerződése lehet másolható, mély copy-jal, vagy move-only,
= delete copy-jal. Minden kézi assignmentnél kezelni kell a
self-assignmentet és a részleges hibát: a copy-and-swap erős garanciát
ad, a move assignmentnél a this != &other ellenőrzés
kötelező. A részletes move-szemantika és a moved-from állapot a Move constructor, moved-from állapot és std::move,
a copy elision a Copy elision, RVO/NRVO és
értékvisszaadás, a noexcept és a container-reallocation
a noexcept, swap és container-reallocation
témája.