A C-ben megszokott struct-értékadás tagról tagra másol,
ezért egy nyers pointert tartalmazó struktúra másolása csak a pointer
értékét másolja: két objektum ugyanarra az allokációra fog mutatni, és a
kétszeres free elkerülhetetlen. A C++ értékszemantikája ezt
a problémát nem tünteti el, hanem lehetővé teszi, hogy a másolás és a
mozgatás szerződését az osztály explicit módon szabályozza. Az előző részben rögzítettük a mély másolás és az
exception guarantee alapjait; itt azt vizsgáljuk, mikor történik valóban
erőforrás-átadás, és mit jelent a moved-from állapot. A
std::move neve félrevezető: önmagában nem mozgat, hanem
értékkategóriát változtat, hogy a túlterhelés-feloldás a move műveletet
válassza.
T&& és a std::move mint
kategóriakonverzióA C++ kifejezések értékkategóriája dönti el, melyik konstruktor vagy
függvény lesz kiválasztva. Az lvalue olyan kifejezés,
amely egy létező objektumot azonosít, és általában címet lehet venni
vele. A prvalue ideiglenes értéket jelöl, amely még nem
feltétlenül rendelkezik külön objektummal. Az xvalue
olyan glvalue, amely egy objektumot jelöl, de jelzi, hogy annak
erőforrásai újrahasznosíthatók; tipikusan ilyen a
std::move(e) eredménye. A glvalue az lvalue és az xvalue
közös halmaza, az rvalue pedig az xvalue és a prvalue közös halmaza.
Ezek a kategóriák nem díszítések: a konstruktorválasztás és a
függvénytúlterhelés ezeken alapul.
A nem template kontextusban használt T&&
rvalue referencia. A Buffer&&
típusú paraméter csak rvalue-hoz köthető, lvalue-hoz nem. Fontos
azonban, hogy egy névvel rendelkező Buffer&&
változó maga lvalue kifejezés: a neve miatt
azonosítható, címet lehet venni vele, és a move konstruktor hívásához
újra std::move-ra van szükség. A referencia típusa és a
kifejezés értékkategóriája tehát nem ugyanaz.
A standard library std::move lényegében a következőhöz
hasonló kategóriakonverzió:
template<class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<std::remove_reference_t<T>&&>(t);
}A T&& itt forwarding reference, amelynek
részletes deduction szabályait a forwardingról szóló
rész tárgyalja; most csak annyi kell, hogy a visszatérési típus
mindig egy nem-referencia típusra vonatkozó &&,
azaz rvalue referencia. A std::move(a) nem hoz létre új
objektumot, nem szabadít fel memóriát és nem másol adatot. Egy
static_cast-hez hasonló jelölés, amelynek hatására a
kifejezés xvalue lesz. A tényleges erőforrás-átadás attól függ, hogy
erre az xvalue-ra melyik konstruktor vagy assignment illeszkedik, és
hogy annak a törzse mit tesz.
Tekintsük a következő két konstruktort:
Buffer(const Buffer&); // másoló konstruktor
Buffer(Buffer&&); // move konstruktorA Buffer b = std::move(a); esetén a lépések:
a típusa Buffer, értékkategóriája
lvalue.std::move(a) visszatérési típusa
Buffer&&, értékkategóriája xvalue.const Buffer& és a
Buffer&& is köthető az xvalue-hoz.Buffer&& pontosabb illeszkedés, mert nem kell
const-képesítést hozzáadni; a move konstruktor lesz kiválasztva.Ezzel szemben const Buffer c; Buffer d = std::move(c);
esetén:
std::move(c) típusa
const Buffer&&.Buffer(Buffer&&) nem köthető
const Buffer&&-hoz, mert az const eldobását
jelentené.Buffer(const Buffer&) köthető, ezért a másoló
konstruktor fut.Ez a gyakori hiba: a const objektumon használt
std::move nem move-ot, hanem másolást eredményez. A
std::move önmagában nem „mozgat”; csak akkor lesz move, ha
a cél típusnak van megfelelő move művelete, és a túlterhelés-feloldás
azt választja.
A move konstruktor T(T&&) alakú
special member function, amely egy új objektumot inicializál a forrás
erőforrásainak átvételével. A move assignment
T& operator=(T&&) alakú, amely egy már létező
objektumba veszi át az erőforrásokat. Mindkettőnek biztosítania kell,
hogy a forrás utána érvényes, destruálható és assignolható állapotban
maradjon. A saját típus moved-from invariánsát a típus szerződésében
kell dokumentálni; a standard library számos típusa csak annyit
garantál, hogy az állapot érvényes, de nem specifikált.
Az alábbi példa egy Buffer osztályt mutat, amelynek
invariánsa: size_ == 0 esetén
data_ == nullptr, különben data_ pontosan
size_ darab std::byte-ra mutat. A moved-from
állapotot szándékosan üres buffernek definiáljuk.
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <utility>
class Buffer {
public:
Buffer() noexcept = default;
explicit Buffer(std::size_t n)
: data_{n ? new std::byte[n] : nullptr}, size_{n} {}
Buffer(const Buffer& other)
: data_{other.size_ ? new std::byte[other.size_] : nullptr},
size_{other.size_} {
if (size_ != 0) {
std::copy_n(other.data_, size_, data_);
}
}
Buffer(Buffer&& other) noexcept
: data_{other.data_}, size_{other.size_} {
other.data_ = nullptr;
other.size_ = 0;
}
Buffer& operator=(const Buffer& other) {
if (this != &other) {
Buffer tmp{other};
swap(tmp);
}
return *this;
}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
~Buffer() { delete[] data_; }
void swap(Buffer& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
std::size_t size() const noexcept { return size_; }
std::byte* data() noexcept { return data_; }
const std::byte* data() const noexcept { return data_; }
// Moved-from invariáns: data_ == nullptr && size_ == 0.
bool empty() const noexcept { return size_ == 0; }
private:
std::byte* data_ = nullptr;
std::size_t size_ = 0;
};
void consume(const Buffer&) { std::cout << "copy overload\n"; }
void consume(Buffer&&) { std::cout << "move overload\n"; }
Buffer make_buffer(std::size_t n) { return Buffer{n}; }
void sink(Buffer&& r) {
Buffer copy = r; // r neve miatt lvalue: másolás
Buffer moved = std::move(r); // xvalue: move
(void)copy;
(void)moved;
}
int main() {
Buffer a{4};
consume(a); // lvalue: const Buffer&
consume(std::move(a)); // xvalue: Buffer&&
Buffer b = std::move(a); // move konstruktor
std::cout << "a.size=" << a.size() << " b.size=" << b.size() << '\n';
const Buffer c{4};
Buffer d = std::move(c); // const forrás: másolás
std::cout << "c.size=" << c.size() << " d.size=" << d.size() << '\n';
Buffer e{4};
e = std::move(e); // önmozgatás: a guard miatt változatlan
std::cout << "e.size=" << e.size() << '\n';
Buffer f = make_buffer(3); // C++17 óta garantált elision; lásd 22.4
std::cout << "f.size=" << f.size() << '\n';
sink(Buffer{2});
}A példa önálló fordítási egység. A laborban a rögzített GCC vagy Clang verzióval például így építhető:
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 buffer_demo.cpp -o buffer_demoA program logikája alapján az alábbi kimenet várható; ez illusztráció, nem mért eredmény:
copy overload
move overload
a.size=0 b.size=4
c.size=4 d.size=4
e.size=4
f.size=3
A Buffer b = std::move(a); esetében a move konstruktor
törzse a pointert és a méretet átveszi, majd a forrást nullára állítja.
A tényleges erőforrás-átadás tehát nem a std::move
hívásban, hanem a kiválasztott move konstruktor törzsében történik. A
sink függvényben jól látszik, hogy a
Buffer&& r paraméter neve lvalue:
Buffer copy = r; másol,
Buffer moved = std::move(r); mozgat. A move assignmentben a
this != &other ellenőrzés az önmozgatás ellen véd;
enélkül a delete[] data_ után other.data_ már
a felszabadított saját bufferre mutatna, és a másolás nem definiált
viselkedéshez vezetne.
A move konstruktort és a move assignmentet noexcept-ra
érdemes tervezni, ha nem allokálnak. A
std::vector<Buffer> újraallokációja ugyanis
move_if_noexcept logikát használhat: ha a move konstruktor
nem noexcept, és a másoló konstruktor elérhető, a tároló
inkább másolhat. Ennek részletei a noexcept és
container-reallocation részben következnek.
A moved-from állapot nem azt jelenti, hogy az
objektum megszűnt. Az objektum élettartama a move után is tart, a
destruktora lefut, és assignmenttel újra használható. Azt jelenti, hogy
az erőforrásai átkerültek, ezért az értéke csak annyiban használható,
amennyit a típus szerződése garantál. A standard library általános
megfogalmazása szerint a moved-from objektum érvényes, de nem
specifikált állapotú: az osztály invariánsai teljesülnek, de az
értékre nem lehet következtetni. Ez alól vannak kivételek; a
std::unique_ptr például move után garantáltan null pointert
tárol.
| Típus / művelet | Moved-from garancia | Következmény |
|---|---|---|
saját Buffer |
data() == nullptr, size() == 0 |
bármely tagfüggvény biztonságosan hívható; a korábbi
data() pointer csak az új tulajdonos élettartamáig
érvényes |
std::unique_ptr<T> |
get() == nullptr |
garantáltan üres; újra assignolható |
std::string |
érvényes, de nem specifikált | size() hívható, de az értéke nem hordoz szerződést; ne
feltételezd, hogy üres |
std::optional<T> |
engaged marad, a benne lévő T moved-from |
has_value() igaz lehet; a benne lévő értékre ugyanaz a
moved-from szabály vonatkozik |
A saját típusnál érdemes pontos szerződést adni, mert a hívó csak
abból tud biztonságosan dolgozni. A Buffer esetében a move
után az empty() igaz, a data() null, a
size() nulla. Ez erősebb a „valid but unspecified”
állapotnál, és lehetővé teszi a forrás újrahasználatát például így:
Buffer b{8};
Buffer c = std::move(b);
if (b.empty()) {
b = Buffer{16}; // új erőforrás; a régi pointer nem használható b tartalmaként
}Az observer invalidáció külön figyelmet kér. Ha a move előtt pointert, referenciát vagy nézetet vettünk a forrás erőforrására, az nem feltétlenül invalidálódik azonnal, de a tulajdonos megváltozik:
Buffer b{8};
std::byte* p = b.data();
Buffer c = std::move(b);
// p továbbra is az allokációra mutat, de az most c tulajdona.
// b.data() == nullptr; ha c megszűnik, p dangling lesz.A pointer tehát nem attól lesz érvénytelen, hogy a b
moved-from lett, hanem attól, hogy az allokáció élettartama már a
c-hez kötődik. Ugyanez a helyzet a
std::string_view és a std::span esetében: a
nézet nem tulajdonol, ezért a mögöttes buffer tulajdonosának
élettartamához kell kötni. Mozgatás után a forrás nézetére támaszkodni
különösen veszélyes, mert a forrás moved-from állapota nem garantálja a
régi buffer megtartását.
Az önmozgatás (a = std::move(a);) a saját típusnál
kezelhető guarddal, ahogy a példában. A standard library tárolóinál az
önmozgatás megengedett, de az eredmény jellemzően nem az eredeti érték;
erre nem szabad építeni. A saját típus szerződésében azt is rögzíteni
kell, hogy az önmozgatás után az objektum változatlan marad-e, vagy
valamilyen dokumentált állapotba kerül. A legbiztonságosabb, ha a move
assignment önmozgatás esetén érintetlenül hagyja az objektumot.
std::move önmagában mozgatna. Nem:
csak xvalue-vé alakít. Ha nincs megfelelő move művelet, vagy a forrás
const, másolás történik.T&& paramétert
move-ként használunk. A paraméter neve lvalue;
std::move(r) kell a move-hoz.const objektumot mozgatunk. A
const Buffer&& nem köthető
Buffer&&-hoz; a másoló konstruktor fut.this == &other.noexcept move konstruktor. A
std::vector újraallokációja másolhat, ami
teljesítményvesztés és erősebb követelmény a másolható típusra.A std::move egy kategóriakonverzió: lvalue-ból vagy más
kifejezésből xvalue-t csinál, hogy a túlterhelés-feloldás a move
konstruktort vagy move assignmentet válassza. A tényleges
erőforrás-átadás a kiválasztott move művelet törzsében történik; a
std::move önmagában semmit nem mozgat. A move művelet után
a forrás moved-from állapotba kerül: érvényes, destruálható és
assignolható, de az értéke csak a típus szerződése szerint használható.
A saját típusnál ezt az invariánst dokumentálni kell, a
const forrás pedig gyakran csendben másoláshoz vezet. Az
observerek, pointerek és nézetek élettartamát a move után az új
tulajdonoshoz kell kötni.
Magyarázó feladat. Adott
const std::string s = "abcdefgh"; std::string t = std::move(s);.
Írd le lépésenként, melyik konstruktor fut, és miért nem történik
erőforrás-átadás. Mit garantál a standard s
értékére?
Kódolási feladat. Egészítsd ki a
Buffer osztályt egy önmozgatás-biztos move assignmenttel,
és dokumentáld a moved-from invariánst. Írj rövid ellenőrző kódot, amely
move után ellenőrzi a forrás és a cél méretét.
Diagnosztikai feladat. Adott:
std::string s = "abcdefgh";
std::string_view sv = s;
std::string t = std::move(s);
std::cout << sv.size() << '\n';Mi a probléma a sv használatával? Milyen esetekben lehet
mégis biztonságos, és hogyan kellene átírni a kódot?
A s típusa const std::string,
értékkategóriája lvalue. A std::move(s) visszatérési típusa
const std::string&&, értékkategóriája xvalue. A
std::string move konstruktorának paramétere
std::string&&, amely nem köthető
const std::string&&-hoz, mert az const eldobását
jelentené. A másoló konstruktor const std::string&
paramétere viszont köthető, ezért a másoló konstruktor fut. A
t független másolat lesz, a s változatlan
marad; erőforrás-átadás nem történik. A standard a s
értékére nézve azt garantálja, hogy érvényes marad, de a
std::move itt nem módosítja, mert nem move történt.
Ellenőrzési feltétel: t == s, és a s hossza
változatlan.
Az önmozgatás-biztos move assignment például:
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
}
return *this;
}A std::exchange a régi értéket visszaadja, és az új
értéket beállítja; így a forrás pointere és mérete egy lépésben
nullázódik. Az invariáns dokumentációja: „moved-from állapotban
data_ == nullptr és size_ == 0; minden
tagfüggvény hívható, a data() pointer csak az új tulajdonos
élettartamáig érvényes.” Ellenőrző kód:
Buffer a{8};
Buffer b{4};
b = std::move(a);
assert(a.empty());
assert(a.size() == 0);
assert(b.size() == 8);
b = std::move(b); // önmozgatás
assert(b.size() == 8);Az utolsó ellenőrzés a guard működését mutatja. A tényleges futtatás
és az assert eredménye a tanuló laborjának része; itt a kód
szerkezeti ellenőrzési feltételt ad.
A std::string_view sv nem tulajdonolja a karaktereket,
csak a s által kezelt bufferre mutat. A
std::string t = std::move(s); után a s
moved-from állapotú: érvényes, de nem specifikált. Kis string esetén a
karakterek akár az s belső, SSO-pufferében maradhatnak,
nagy string esetén a buffer átkerülhet a t-be. A standard
nem garantálja, hogy a sv által mutatott buffer tovább él,
és azt sem, hogy a t ugyanarra a bufferre mutat. Ezért a
sv használata a move után dangling vagy nem specifikált
tartalmú lehet.
Biztonságosabb a nézetet a move előtt használni, vagy a move után új
nézetet készíteni a t-ből:
std::string s = "abcdefgh";
std::string t = std::move(s);
std::string_view sv = t; // a tulajdonos t, amíg t él
std::cout << sv.size() << '\n';Ha a nézetre a move után is szükség van, a mögöttes tárolót hosszabb
élettartamú objektumban kell tartani, vagy a nézetet a move előtt el
kell dobni. Ellenőrzési feltétel: a sv élettartama soha nem
haladja meg a mögöttes buffer tulajdonosának élettartamát.