Move semantics and rvalue references

C++ · Advanced · 18 min

Move semantics is why returning big objects by value is cheap in modern C++, and you need it to write efficient resource-owning types.

Copying a std::vector duplicates all its elements. But if the source is about to die anyway, such as a temporary or a local being returned, we could just steal its heap buffer. Move semantics does exactly that.

Expressions have value categories. An lvalue has a name and an identity (x, v[0]); an rvalue is a temporary (f(), a + b, Buffer(8)). An rvalue reference, T&&, binds only to rvalues, so overloads can tell "copy from something that stays alive" from "take from something that's going away":

  • T(const T&) is the copy constructor; T(T&&) is the move constructor, which takes the resource and leaves the source empty but valid.
  • The same pair exists for assignment operators.

std::move(x) doesn't move anything. It's a cast to T&& that says "I'm done with x, you may steal from it". After a move, the object is in a valid but unspecified state: you may destroy it or assign a new value, but don't rely on its contents. (Standard containers do document that a moved-from vector is empty.)

Rules that matter:

  • Mark move operations noexcept, or std::vector will copy instead of move when it grows.
  • Return locals by plain return x;. The compiler elides the copy (NRVO) or moves implicitly. Writing return std::move(x); can block the elision.
  • Since C++17, returning or initializing from a prvalue (return Buffer(1024);) is guaranteed to construct in place, with no copy or move at all.
  • Moving a const object silently copies, because const T&& can't bind to the move constructor.

For functions that store their argument, a common idiom is to take it by value and move it in: void set(std::string s) { name_ = std::move(s); }.

Example

#include <iostream>
#include <utility>
#include <vector>

class Buffer {
public:
    explicit Buffer(std::size_t n) : data_(n) {}
    Buffer(const Buffer& o) : data_(o.data_) { std::cout << "copy\n"; }
    Buffer(Buffer&& o) noexcept : data_(std::move(o.data_)) { std::cout << "move\n"; }
    Buffer& operator=(const Buffer&) = default;
    Buffer& operator=(Buffer&&) noexcept = default;
    std::size_t size() const { return data_.size(); }
private:
    std::vector<char> data_;
};

Buffer make() { Buffer b(1024); return b; }   // NRVO or implicit move, never a copy

int main() {
    Buffer a(100);
    Buffer b = a;                // copy: a is an lvalue
    Buffer c = std::move(a);     // move: std::move casts a to Buffer&&
    Buffer d = make();           // typically prints nothing: elided
    std::vector<Buffer> v;
    v.push_back(Buffer(8));      // move: the temporary is an rvalue
    std::cout << b.size() << ' ' << c.size() << ' ' << d.size() << '\n';  // 100 100 1024
}

Remember

  • std::move is only a cast; the move constructor does the stealing
  • Moved-from objects are valid but unspecified: assign or destroy them only
  • Return locals by name; don't return std::move(local);
  • Move operations should be noexcept

Watch out for

  • Calling std::move on a const object and getting a silent copy.

Check yourself

  1. What does `std::move(x)` do by itself?
  2. Why is `return std::move(local);` usually worse than `return local;`?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: en.cppreference.com/w/cpp/language/move_constructor