22.3 Move constructor, moved-from állapot és std::move

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.

22.3.1 Értékkategóriák, 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.

22.3.1.1 Túlterhelés-feloldás lépésenként

Tekintsük a következő két konstruktort:

Buffer(const Buffer&); // másoló konstruktor
Buffer(Buffer&&);      // move konstruktor

A Buffer b = std::move(a); esetén a lépések:

  1. a típusa Buffer, értékkategóriája lvalue.
  2. std::move(a) visszatérési típusa Buffer&&, értékkategóriája xvalue.
  3. Mindkét konstruktor jelölt: a const Buffer& és a Buffer&& is köthető az xvalue-hoz.
  4. A Buffer&& pontosabb illeszkedés, mert nem kell const-képesítést hozzáadni; a move konstruktor lesz kiválasztva.
  5. A move konstruktor törzse végzi el a tényleges erőforrás-átadást.

Ezzel szemben const Buffer c; Buffer d = std::move(c); esetén:

  1. std::move(c) típusa const Buffer&&.
  2. A Buffer(Buffer&&) nem köthető const Buffer&&-hoz, mert az const eldobását jelentené.
  3. A Buffer(const Buffer&) köthető, ezért a másoló konstruktor fut.
  4. A forrás változatlan marad, erőforrás-átadás nem történik.

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.

22.3.2 Move constructor és move assignment: a tényleges átadás helye

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_demo

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

22.3.3 A moved-from állapot szerződése

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.

22.3.4 Tipikus hibák

22.3.5 Összefoglaló

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.

22.3.6 Feladatok

  1. 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?

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

  3. 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?

22.3.7 Megoldások

22.3.7.1 1. megoldás

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.

22.3.7.2 2. megoldás

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.

22.3.7.3 3. megoldás

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.