Language[lang]

C++ move semantics: what std::move and && really do

Section
Language [lang]
Standard
C++20
Compilers
MSVC 19.44, Clang 19.1
Published
Length
17 min read
In this answer9 clauses
  1. 1What a move is: taking the resources of an object nobody needs
  2. 2lvalues, xvalues and prvalues, as C++17 defines them
  3. 3std::move is a cast; the move constructor does the moving
  4. 4Which overload an argument binds to
  5. 5What a moved-from object holds
  6. 6Returning locals: implicit move, and why return std::move(x) hurts
  7. 7C++23: the simpler rule, and what it breaks
  8. 8Forwarding references and std::forward
  9. 9Four move mistakes and the checks that catch them

Abstract

A move builds or assigns an object by taking over another object’s resources, such as its heap buffer, instead of copying them, and std::move does none of that work: it is a cast to an rvalue reference that lets overload resolution choose the move constructor or move assignment. Since C++17 a prvalue initializes its destination with no move at all, and return local; already moves when the copy is not elided (in more cases since C++20, by a simpler rule in C++23), so return std::move(local); only gets in the way.

Two boxes joined by a downward arrow: the upper one tied to a row of hatched cells by a grey dashed line, the lower one by a violet pointer
Figure 1 — A move hands the source’s resources to the new object and leaves the source valid but unspecified.

A move constructs or assigns an object by taking over the resources of another object that nobody needs any more, such as a heap buffer or a file handle, instead of copying them. std::move takes no part in that work. It is a cast to an rvalue reference, and all it does is let overload resolution pick the move constructor or move assignment operator, which do the moving. T&& on an ordinary parameter is an rvalue reference, which binds only to rvalues; T&& with T deduced is a forwarding reference, which binds to anything and goes with std::forward.

This guide is written for C++20 and notes what C++23 changed. Rvalue references and moves date from C++11, and three later standards changed what they do in practice. C++17 redefined prvalues, so initializing an object from a temporary or from a function’s return value involves no move at all. C++20 made more return statements move implicitly (P1825R0), and C++23 replaced the two-step rule behind implicit move with a simpler one (P2266R3). Every sample was compiled with MSVC 19.44 (/std:c++20 /EHsc /W4 /permissive-) and Clang 19.1 (-std=c++20 -Wall -Wextra, on Microsoft’s standard library) on one Windows x64 machine, without optimization unless the text says otherwise, and the C++23 samples also with -std=c++23, /std:c++23preview and /std:c++latest; the outputs and errors shown are what those builds produced there.

What changed: initializing an object from a temporary is a move the compiler may elide no move at all, because the prvalue initializes the object directly.

since C++17

What a move is: taking the resources of an object nobody needs

Here is a class that owns a heap array, with the copy and the move side by side:

[Example 1:
#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::size_t n) : data_(new int[n]{}), size_(n) {}
    ~Buffer() { delete[] data_; }

    Buffer(const Buffer& other)  // copy: a new array, every element copied
        : data_(new int[other.size_]), size_(other.size_) {
        std::copy(other.data_, other.data_ + size_, data_);
    }

    Buffer(Buffer&& other) noexcept  // move: take the array, leave none
        : data_(std::exchange(other.data_, nullptr)),
          size_(std::exchange(other.size_, 0)) {}

    Buffer& operator=(Buffer other) noexcept {  // copy or move, then swap
        std::swap(data_, other.data_);
        std::swap(size_, other.size_);
        return *this;
    }

    std::size_t size() const { return size_; }

private:
    int* data_;
    std::size_t size_;
};

int main() {
    Buffer a(1000);
    Buffer b = a;             // copy: a second array of 1,000 ints
    Buffer c = std::move(a);  // move: c takes a's array; a is left empty
    std::printf("%zu %zu %zu\n", a.size(), b.size(), c.size());
}

— end example]

Both compilers print 0 1000 1000. The copy constructor allocates and copies a thousand ints. The move constructor copies a pointer and a size and leaves a empty but valid: its destructor handles nullptr, and a.size() returns 0. That is all a move is: a constructor or an assignment that is allowed to plunder its argument because the argument is an rvalue. The standard containers work the same way, which is why move-constructing a std::vector takes constant time whatever its size. std::exchange (C++14) stores a new value and returns the old one in one step.

A class like Buffer needs all five special members. Most classes should need none, because they hold their resources in members such as std::vector and std::unique_ptr; the guide to the rule of zero shows when the compiler’s versions are right and when they are not. The move constructor is noexcept because std::vector moves its elements when it grows only if their move can’t throw or they can’t be copied; when to mark a move constructor noexcept has the rule.

lvalues, xvalues and prvalues, as C++17 defines them

Whether an argument can be moved from depends on its value category, which is a property of the expression, not of the object’s type. C++11 introduced the three categories and C++17 redefined the third; cppreference’s value categories page has the current definitions, from which this table is condensed.

Category What it is Examples
lvalue An expression with identity that is not an xvalue x, *p, v[0], the name of a T&& variable
xvalue An expression with identity whose object’s resources can be reused std::move(x), static_cast<T&&>(x), std::move(p).first
prvalue Since C++17, an expression that initializes an object or computes a value 42, x + 1, T{}, a call to a function returning T

An rvalue is an xvalue or a prvalue, and that is what T&& binds to. decltype on a parenthesized expression reports the category, so it can be checked at compile time:

[Example 2:
#include <string>
#include <type_traits>
#include <utility>

std::string s = "text";
std::string&& r = std::move(s);
std::pair<std::string, int> p;
std::string make();

// decltype((e)) is T& for an lvalue, T&& for an xvalue, T for a prvalue
using std::is_same_v, std::string;
static_assert(is_same_v<decltype((s)), string&>);              // lvalue
static_assert(is_same_v<decltype((r)), string&>);              // lvalue
static_assert(is_same_v<decltype((std::move(s))), string&&>);  // xvalue
static_assert(is_same_v<decltype((std::move(p).first)), string&&>);
static_assert(is_same_v<decltype((make())), string>);          // prvalue
static_assert(is_same_v<decltype((s + s)), string>);           // prvalue

int main() {}

— end example]

Note the second assertion. A variable declared as std::string&& is an lvalue when you use it by name, because it has a name and can be used again. Inside a function that takes std::string&&, you have to std::move the parameter to move from it:

[Example 3:
#include <cstdio>
#include <string>
#include <utility>

void take(std::string&& r) {
    std::string a = r;             // r has a name, so it is an lvalue: copy
    std::string b = std::move(r);  // std::move(r) is an xvalue: move
    std::printf("%s\n%s\n", a.c_str(), b.c_str());
}

int main() {
    take(std::string("long enough that the characters live on the heap"));
}

— end example]

Both lines print the whole string: a was copy-constructed from r, and b was move-constructed from it, after which r is valid but unspecified.

The C++17 change is in the third row. In C++11 and C++14 a prvalue of class type was a temporary object, and initializing a variable from it was a move that the compiler was allowed to elide. Since C++17 a prvalue is not an object until it has to be one. It initializes its result object directly, and a temporary is materialized, as an xvalue, only where an object is needed, for instance when a reference binds to it. So there is nothing to move, and a type with no copy or move constructor can still be returned by value:

[Example 4:
struct Pinned {  // can be neither copied nor moved
    int id = 0;
    Pinned() = default;
    Pinned(const Pinned&) = delete;
    Pinned(Pinned&&) = delete;
};

Pinned make() { return Pinned(); }  // C++17: nothing to copy or move

int main() {
    Pinned p = make();                  // p is make()'s result object
    Pinned q = Pinned(Pinned(make()));  // still one object, no moves
    return p.id + q.id;
}

— end example]

With C++17 or C++20 flags both compilers build it. With C++14 flags both reject it for calling the deleted Pinned(Pinned&&). Returning a prvalue and initializing from one cost no move; returning a named local can, as the section on returns below shows.

Pieces dropping into an open violet box and building a block in place, beside a grey dashed box and arrow for the temporary that is no longer made
Figure 2 — Pieces dropping into an open violet box and building a block in place, beside a grey dashed box and arrow for the temporary that is no longer made

std::move is a cast; the move constructor does the moving

cppreference’s std::move page gives its entire effect as static_cast<typename std::remove_reference<T>::type&&>(t). Written out, with the constexpr it has had since C++14:

[Example 5:
#include <cstdio>
#include <type_traits>
#include <utility>

struct Tracer {  // says which constructor ran
    Tracer() = default;
    Tracer(const Tracer&) { std::puts("copy"); }
    Tracer(Tracer&&) noexcept { std::puts("move"); }
};

namespace my {
template <class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(t);
}
}  // namespace my

int main() {
    Tracer a;
    const Tracer b{};
    Tracer c = my::move(a);   // move
    Tracer d = std::move(a);  // move: the same cast
    Tracer e = std::move(b);  // copy: a const Tracer&& can't bind to
                              // Tracer&&, but it can bind to const Tracer&
    std::move(a);             // nothing happens: a cast, no constructor
}

— end example]

Both compilers print move, move, copy. my::move and std::move produce the same cast, and the cast alone does nothing: the last line runs no constructor. Microsoft’s STL declares std::move [[nodiscard]], so both compilers here warn that its result is discarded. The third line copies because std::move(b) on a const Tracer is a const Tracer&&, which can’t bind to the move constructor’s Tracer&& but can bind to the copy constructor’s const Tracer&. A type with no move constructor copies the same way, and for int and the other trivially copyable types a move and a copy are the same operation.

Which overload an argument binds to

Overload resolution, not std::move, decides between copying and moving. With the usual pair of overloads:

[Example 6:
#include <cstdio>
#include <string>
#include <utility>

void f(const std::string&) { std::puts("f(const std::string&)"); }
void f(std::string&&) { std::puts("f(std::string&&)"); }

int main() {
    std::string s = "x";
    const std::string cs = "y";
    f(s);                 // lvalue
    f(cs);                // const lvalue
    f(std::move(s));      // xvalue
    f(std::string("z"));  // prvalue
    f("literal");         // converted to a prvalue std::string
    f(std::move(cs));     // const xvalue
}

— end example]

Both compilers print the same six lines:

Argument Category Binds to
s lvalue f(const std::string&)
cs const lvalue f(const std::string&)
std::move(s) xvalue f(std::string&&)
std::string("z") prvalue f(std::string&&)
"literal" converted to a prvalue std::string f(std::string&&)
std::move(cs) const xvalue f(const std::string&)

With only f(const std::string&), every call compiles and binds to it, which is why code written before C++11 still compiles. With only f(std::string&&), the calls with s, cs and std::move(cs) don’t compile. A by-value parameter, f(std::string), accepts all six and copies or moves into the parameter accordingly. Which of these to give your own functions is the subject of pass by value or const reference.

What a moved-from object holds

After a move, the source still exists and its destructor still runs. What it holds depends on the type. For the standard library, [lib.types.movedfrom] says that, unless otherwise specified, moved-from objects are placed “in a valid but unspecified state”: the class invariants hold, so any operation without a precondition is safe, and one with a precondition needs a check or a new value first.

[Example 7:
#include <cassert>
#include <memory>
#include <string>
#include <utility>
#include <vector>

int main() {
    std::vector<std::string> v;
    std::string s = "long enough that the characters live on the heap";
    v.push_back(std::move(s));  // s: valid but unspecified

    s.clear();         // fine: clear() has no precondition
    s = "reused";      // fine: assignment puts a known value back
    assert(s.back() == 'd');  // fine now: s is known to be non-empty

    auto p = std::make_unique<int>(42);
    auto q = std::move(p);
    assert(p == nullptr);  // guaranteed: a moved-from unique_ptr is null
    assert(*q == 42);
}

— end example]

Some types do specify the state. A moved-from std::unique_ptr is null, by its move constructor’s postcondition u.get() == nullptr, and a moved-from std::shared_ptr is empty. Nothing promises that a moved-from std::string or std::vector is empty, so assign to it or clear() it before you read it. Even x = std::move(x) is allowed for library types and leaves x valid but unspecified. For your own types, the state is whatever the move operations leave behind; the Core Guidelines’ C.64 asks that it be valid, and Buffer above leaves an empty buffer.

Returning locals: implicit move, and why return std::move(x) hurts

return x;, where x is a local variable or a parameter, needs no std::move. The compiler may construct a local directly in the caller’s object (the named return value optimization, NRVO), which the standard allows but never requires; where it doesn’t, it treats x as an rvalue and moves it. Four returns, counted:

[Example 8:
#include <cstdio>
#include <utility>

struct Tracer {  // says which constructor ran
    Tracer() = default;
    Tracer(const Tracer&) { std::puts("  copy"); }
    Tracer(Tracer&&) noexcept { std::puts("  move"); }
};

Tracer named() {
    Tracer t;
    return t;  // NRVO allowed, not required; without it, a move
}

Tracer pessimized() {
    Tracer t;
    return std::move(t);  // not a plain name: no NRVO, always a move
}

Tracer from_param(Tracer t) {
    return t;  // a parameter is never elided: a move
}

Tracer from_rvalue_ref(Tracer&& t) {
    return t;  // a move since C++20; a copy by the C++11-17 rules
}

int main() {
    std::puts("named:");
    named();
    std::puts("pessimized:");
    pessimized();
    std::puts("from_param:");
    from_param(Tracer());
    std::puts("from_rvalue_ref:");
    from_rvalue_ref(Tracer());
}

— end example]

What each build printed in each return (“none” means no constructor ran):

Function Clang 19.1 MSVC 19.44, NRVO on MSVC 19.44, NRVO off
named() none none move
pessimized() move move move
from_param() move move move
from_rvalue_ref() move move move

“None” for named() means t was built in the caller’s object. Clang elided it with every flag set used here, C++14 to C++23. MSVC elided it with the flags above (/permissive-), with /O2, with /std:c++20 or with /Zc:nrvo, and moved it under cl /EHsc /W4 /std:c++17 alone. That is what Microsoft documents for /Zc:nrvo from Visual Studio 2022 17.4: optional NRVO is off by default and turned on by /O2, /permissive-, or /std:c++20 and later. It is also what “allowed but not required” looks like, and why no program should depend on the count.

pessimized() always moves: std::move(t) is not the name of a local, so NRVO no longer applies. Clang warns about it under -Wall (-Wpessimizing-move: “moving a local object in a return statement prevents copy elision”), and the Core Guidelines say the same in F.48: “Don’t return std::move(local)”. from_param() moves too: a parameter is never elided, but it is moved implicitly.

Which returns move implicitly has grown since C++11; cppreference’s return statement page gives the rules by standard:

  • C++11: a local variable or by-value parameter named in return is first treated as an rvalue. If that finds no constructor whose parameter is an rvalue reference to the variable’s own type, overload resolution runs again with the name as an lvalue, which copies.
  • C++20 (P1825R0): rvalue reference variables count too, so from_rvalue_ref() moves. The own-type condition is gone, so a converting constructor that takes its argument by value, or a base class’s move constructor, is chosen as well. throw moves a parameter the same way.
  • C++23 (P2266R3): a move-eligible name in return is an xvalue, with one overload resolution instead of two (next section).

The two C++20 cases that involve conversions:

[Example 9:
#include <cstdio>
#include <utility>

struct Tracer {  // says which constructor ran
    Tracer() = default;
    Tracer(const Tracer&) { std::puts("  copy"); }
    Tracer(Tracer&&) noexcept { std::puts("  move"); }
};

struct Holder {
    Holder(Tracer t) : t_(std::move(t)) {}  // takes a Tracer by value
    Tracer t_;
};

struct Base {
    Tracer t;
};
struct Derived : Base {};

Holder wrap() {
    Tracer t;
    return t;  // C++20: t is moved into the parameter; C++17: copied
}

Base slice() {
    Derived d;
    return d;  // C++20: Base(Base&&) is used; C++17: Base(const Base&)
}

int main() {
    std::puts("wrap:");
    wrap();
    std::puts("slice:");
    slice();
}

— end example]

By the C++17 rules, wrap() copies t into Holder’s parameter and slice() copies the Base part of d. Both compilers here moved in every case, from_rvalue_ref() included, and did so with -std=c++14, -std=c++17, /std:c++14 and /std:c++17 as well as with C++20 flags. Clang’s status page lists P1825R0 as a defect report, which it applies to older modes; MSVC behaved the same way here. A compiler that follows the C++17 text instead still builds this code, and copies.

C++23: the simpler rule, and what it breaks

C++23 drops the two-step lookup: a move-eligible name in a return statement is an xvalue (P2266R3). That makes a function that returns an rvalue reference work as written:

[Example 10:
#include <string>
#include <utility>

// C++23 (P2266R3): a move-eligible name in a return statement is an
// xvalue, so it binds to an rvalue reference return type.
std::string&& pass_through(std::string&& s) {
    return s;  // C++20: error, s is an lvalue; C++23: OK
}

int main() {
    std::string a = "text";
    std::string b = pass_through(std::move(a));
    return b.size() == 4 ? 0 : 1;
}

— end example]

This sample compiles only on Clang here: Clang 19.1 builds and runs it with -std=c++23 and rejects it with -std=c++20 (“rvalue reference to type … cannot bind to lvalue”). MSVC 19.44 rejects it in every mode, /std:c++23preview and /std:c++latest included. Microsoft lists simpler implicit move from Visual Studio 2026 18.0 (MSVC Build Tools 14.50), against GCC 13 and Clang 13, as the C++23 table of Since When shows; rvalue references themselves date from GCC 4.3, Clang 2.9 and Visual Studio 2015 in its C++11 table.

The same rule turns a dangling reference from a warning into an error:

[Example 11:
int& dangling() {
    int i = 0;
    return i;  // C++20: a warning; C++23: an error, i is an xvalue
}

int main() {}

— end example]

With C++20 flags both compilers only warn (Clang: “reference to stack memory associated with local variable ‘i’ returned”; MSVC: C4172). With -std=c++23 Clang refuses it, because i is now an xvalue and a non-const int& can’t bind to it; MSVC 19.44, without P2266R3, still warns and builds it. The paper lists the other code it breaks, mostly code that relied on a returned name being an lvalue, for example in a function returning decltype(auto).

Forwarding references and std::forward

In a template, T&& with T deduced from the argument is a forwarding reference, not an rvalue reference. Given an lvalue std::string, T is deduced as std::string& and the parameter collapses to std::string&; given an rvalue, T is std::string and the parameter is std::string&&. std::forward<T>(x) casts back to an rvalue only in the second case, so the argument reaches the next function as what it was:

[Example 12:
#include <cstdio>
#include <string>
#include <utility>
#include <vector>

void sink(const std::string&) { std::puts("sink(const std::string&)"); }
void sink(std::string&&) { std::puts("sink(std::string&&)"); }

template <class T>
void relay(T&& x) {            // forwarding reference: T is deduced
    sink(std::forward<T>(x));  // an rvalue only if the argument was one
}

template <class T>
void not_forwarding(std::vector<T>&& v);  // rvalue reference
template <class T>
void not_forwarding_either(const T&& x);  // rvalue reference

template <class T>
struct Box {
    void put(T&& x);  // rvalue reference: T is fixed by Box<T>
};

void also_forwarding(auto&& x);  // forwarding reference (C++20 syntax)

int main() {
    std::string s = "x";
    relay(s);               // T = std::string&: sink(const std::string&)
    relay(std::move(s));    // T = std::string:  sink(std::string&&)
    relay(std::string());   // T = std::string:  sink(std::string&&)
}

— end example]

All three calls print what the comments say, on both compilers. Only that exact form is a forwarding reference: std::vector<T>&& and const T&& are rvalue references, and so is T&& in a member of a class template, where T was fixed when the class was instantiated. auto&& is the other spelling, in a variable, a range-for loop, a generic lambda’s parameter (C++14) or, since C++20, any function parameter. The Core Guidelines’ F.19 sums up the use: pass by TP&& and “only std::forward the parameter”.

Four move mistakes and the checks that catch them

Each of these compiles without a warning under /W4 and -Wall -Wextra:

[Example 13:
#include <string>
#include <utility>
#include <vector>

void store(std::string s);

void one(const std::string& name) {
    store(std::move(name));  // 1. moving a const: this copies
}

void two(std::vector<std::string>& out) {
    std::string line = "first";
    out.push_back(std::move(line));
    store(line);  // 2. use after move: line's value is unspecified
}

template <class T>
void three(T&& x) {
    store(std::move(x));  // 3. moves from the caller's lvalue as well
}

template <class T>
void four(T&& x) {
    store(x);  // 4. never forwarded: an rvalue argument is copied
}

int main() {
    std::vector<std::string> out;
    std::string s = "kept";
    one(s);
    two(out);
    three(s);  // s has been moved from, and the caller can't tell
    four(std::string("temporary"));
}

void store(std::string) {}

— end example]

clang-tidy 19.1, as shipped with Visual Studio 2022 17.14, reports all four with these checks in a .clang-tidy file:

[Example 14:
Checks: >
  -*,
  performance-move-const-arg,
  bugprone-use-after-move,
  bugprone-move-forwarding-reference,
  cppcoreguidelines-missing-std-forward

— end example]

  1. Moving a const object copies without a word. performance-move-const-arg: “std::move of the const variable ‘name’ has no effect; remove std::move() or make the variable non-const”.
  2. Using an object after moving from it reads an unspecified value. bugprone-use-after-move: “‘line’ used after it was moved”. Assigning to the object, or calling clear() on a standard container, makes it usable again, and the check knows that.
  3. Moving a forwarding reference moves from the caller’s lvalue, as three(s) does to s. bugprone-move-forwarding-reference: “forwarding reference passed to std::move(), which may unexpectedly cause lvalues to be moved; use std::forward() instead”.
  4. Not forwarding a forwarding reference copies an rvalue the caller was done with. cppcoreguidelines-missing-std-forward: “forwarding reference parameter ‘x’ is never forwarded inside the function body”. It flags three as well.

Keep -Wall on with Clang for the fifth, return std::move(local). For the whole subject at book length, Nicolai Josuttis’s C++ Move Semantics: The Complete Guide covers moves through C++20; it is listed with clang-tidy on Toolchain. The rest of the Language section covers how values are passed, returned and destroyed in the standard you compile with.

Questions

What does std::move actually do?

It casts its argument to an rvalue reference, exactly static_cast<std::remove_reference_t<T>&&>(t), and does nothing else. The cast makes the expression an xvalue, so overload resolution prefers a function taking T&&, such as the move constructor, and that function does the moving. With no move constructor, or with a const argument, the copy constructor runs instead.

Can I use an object after moving from it?

Yes, for operations without preconditions. A moved-from standard library object is in a valid but unspecified state, so you can assign to it, destroy it or call clear(), empty() or size(), but not back() or front() before checking. Some types promise more: a moved-from std::unique_ptr is null. Your own types leave whatever their move operations leave.

Should I write std::move when returning a local variable?

No. return local; already moves whenever the compiler does not elide the copy, while return std::move(local); is no longer a plain name, so it rules out the elision and forces a move (Core Guidelines F.48; Clang warns with -Wpessimizing-move). Write std::move in a return only for something return does not move by itself, such as a data member you are handing over or an object you hold through an lvalue reference.

What is the difference between std::move and std::forward?

std::move casts to an rvalue unconditionally. std::forward<T>(x) casts to an rvalue only when T was deduced as a non-reference type, which happens when the caller passed an rvalue, so it passes the argument on as what it was. Use std::move on objects you own and are done with, and std::forward on forwarding references (T&& with T deduced).

Why does std::move on a const object copy it?

std::move of a const T gives a const T&&, which can’t bind to the move constructor’s T&& parameter, since moving would modify the source. It binds to the copy constructor’s const T& instead, so the code compiles and copies without a word. clang-tidy’s performance-move-const-arg flags it.

Read next

  1. Rule of zero: the special members C++ writes for youLanguage · Miriam Castellanos · written for C++20
  2. Can a C++ destructor throw? What happens since C++11Language · Miriam Castellanos · written for C++20
  3. C++ virtual functions: override, pure virtual and the trapsLanguage · Miriam Castellanos · written for C++11