Skip to main content
C++ · lesson 14 of 24
C++ advanced Lesson 14 of 24

Move Semantics and Perfect Forwarding in C++

Move semantics run for real: value categories, what std::move does to a string, copy vs move timed, copy elision, pessimizing moves, and std::forward.

By SkillByExample · Updated

Before C++11, handing a large object to someone else meant copying it, even when you were done with the original. Move semantics lets a type transfer its resources instead — a vector’s heap buffer changes owner in O(1) — and the compiler picks copy or move from the argument’s value category.

Value Categories

An lvalue has an identity you can name or take the address of; an rvalue is a temporary about to disappear. Overloads on &, const& and && show which is which:

#include <iostream>
#include <string>

void take(std::string&)       { std::cout << "lvalue ref\n"; }
void take(const std::string&) { std::cout << "const lvalue ref\n"; }
void take(std::string&&)      { std::cout << "rvalue ref\n"; }

std::string make() { return "temp"; }

int main() {
    std::string s = "named";
    const std::string cs = "const";
    take(s);                       // named variable: lvalue
    take(cs);                      // const lvalue
    take(make());                  // function's returned temporary: rvalue
    take(s + "!");                 // result of an expression: rvalue
    take(std::move(s));            // cast to rvalue

    std::string&& rref = make();   // an rvalue reference binds to the temporary...
    take(rref);                    // ...but rref itself has a name: lvalue!
}
$ g++ -std=c++20 -Wall -Wextra -o categories categories.cpp
$ ./categories
lvalue ref
const lvalue ref
rvalue ref
rvalue ref
rvalue ref
lvalue ref

A named variable is an lvalue; a function’s returned temporary and the result of s + "!" are rvalues; std::move(s) turns a name into an rvalue. The last line is the one that trips everyone up: rref has type std::string&&, but it has a name, so using it is an lvalue expression. Type and value category are different things. An rvalue reference also refuses to bind to an lvalue directly:

int main() {
    int x = 10;
    int&& bad = x;      // an rvalue reference can't bind to an lvalue
    return bad;
}
$ g++ -std=c++20 -Wall -Wextra -o rref_fail rref_fail.cpp 2>&1 | grep error
rref_fail.cpp:3:17: error: cannot bind rvalue reference of type 'int&&' to lvalue of type 'int'

std::move Is a Cast

std::move changes nothing by itself; it lets a move constructor be chosen. You can watch whether the heap buffer changed hands by comparing data() pointers:

#include <iostream>
#include <string>
#include <utility>

int main() {
    std::string a(100, 'x');                        // too long for the small-string buffer
    const char* buffer = a.data();
    std::string b = std::move(a);                   // move constructor
    std::cout << std::boolalpha << "b got a's buffer: " << (b.data() == buffer)
              << ", a.size() = " << a.size() << "\n";

    std::string small = "hi";                       // fits in the object itself (SSO)
    const char* sbuf = small.data();
    std::string t = std::move(small);
    std::cout << "short string buffer transferred: " << (t.data() == sbuf) << "\n";

    const std::string c(100, 'y');
    const char* cbuf = c.data();
    std::string d = std::move(c);                   // const: can't be moved from
    std::cout << "const source moved: " << (d.data() == cbuf)
              << ", c.size() = " << c.size() << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o std_move std_move.cpp
$ ./std_move
b got a's buffer: true, a.size() = 0
short string buffer transferred: false
const source moved: false, c.size() = 100

For the 100-character string, b took over a’s exact buffer and a was left empty. The short string’s buffer did not transfer: libstdc++ stores strings of up to 15 characters inside the object itself (the small-string optimization), so “moving” it copies two bytes — still cheap, just not a pointer handoff. The third case is a real trap: std::move on a const object produces a const std::string&&, which can’t bind to the move constructor’s std::string&&, so the copy constructor runs silently. c keeps its 100 characters and d got a fresh buffer.

What a move saves is easiest to see with a big container, built with -O2:

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

int main() {
    using clock = std::chrono::steady_clock;
    std::vector<int> original(10'000'000, 7);

    auto t0 = clock::now();
    std::vector<int> copied = original;             // copies 40 MB
    auto t1 = clock::now();
    std::vector<int> moved = std::move(original);   // takes over the buffer
    auto t2 = clock::now();

    auto us = [](auto d) { return std::chrono::duration<double, std::micro>(d).count(); };
    std::cout << "copy: " << us(t1 - t0) << " us\n";
    std::cout << "move: " << us(t2 - t1) << " us\n";
    std::cout << "sizes: copied " << copied.size() << ", moved " << moved.size()
              << ", original " << original.size() << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o move_cost move_cost.cpp
$ ./move_cost
copy: 24034.7 us
move: 0.054 us
sizes: copied 10000000, moved 10000000, original 0

Copying ten million ints allocated 40 MB and wrote every element — tens of milliseconds on this run. Moving swapped three pointers and took well under a microsecond. Exact times vary between runs; the thousands-fold gap doesn’t. The moved-from original reports size 0.

Writing Move Operations

A class that owns a raw resource steals it in its move constructor and leaves the source empty but valid. std::exchange does the take-and-reset in one expression.

#include <cstddef>
#include <iostream>
#include <utility>

class Buffer {
    char* data_;
    std::size_t size_;
public:
    explicit Buffer(std::size_t n) : data_(new char[n]), size_(n) {}
    ~Buffer() { delete[] data_; }
    Buffer(const Buffer&) = delete;                 // move-only, to keep this short
    Buffer& operator=(const Buffer&) = delete;
    Buffer(Buffer&& o) noexcept
        : data_(std::exchange(o.data_, nullptr)),   // take the pointer, leave nullptr
          size_(std::exchange(o.size_, 0)) {}
    Buffer& operator=(Buffer&& o) noexcept {
        if (this != &o) {
            delete[] data_;                         // release what we had
            data_ = std::exchange(o.data_, nullptr);
            size_ = std::exchange(o.size_, 0);
        }
        return *this;
    }
    std::size_t size() const { return size_; }
};

int main() {
    Buffer a(64), b(8);
    Buffer c = std::move(a);                        // move construct
    std::cout << "after construct: a=" << a.size() << " c=" << c.size() << "\n";
    b = std::move(c);                               // move assign: b's 8 bytes freed
    std::cout << "after assign:    b=" << b.size() << " c=" << c.size() << "\n";
    a = Buffer(16);                                 // a moved-from object is reusable
    std::cout << "reused a:        a=" << a.size() << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o buffer buffer.cpp
$ ./buffer
after construct: a=0 c=64
after assign:    b=64 c=0
reused a:        a=16

After the move construction, a owns nothing (size 0) and c holds the 64 bytes. Move assignment first frees what b had, then takes c’s buffer. The moved-from a is still a valid object, so assigning a new Buffer(16) to it works — that’s the contract: destructible and assignable, contents unspecified. Both move operations are noexcept so std::vector<Buffer> will move rather than copy on reallocation (the classes lesson shows the difference when it’s missing).

Copy Elision, RVO and Pessimizing Moves

Returning an object often involves neither a copy nor a move: the compiler constructs it directly in the caller’s variable.

#include <iostream>
#include <utility>

struct Tracer {
    Tracer() { std::cout << "  construct\n"; }
    Tracer(const Tracer&) { std::cout << "  copy\n"; }
    Tracer(Tracer&&) noexcept { std::cout << "  move\n"; }
};

Tracer make_prvalue() { return Tracer{}; }              // guaranteed elision (C++17)
Tracer make_named()   { Tracer t; return t; }           // NRVO: allowed, usually done
Tracer make_moved()   { Tracer t; return std::move(t); } // blocks NRVO
Tracer pass_through(Tracer t) { return t; }             // parameter: implicitly moved

int main() {
    std::cout << "prvalue:\n";      [[maybe_unused]] Tracer a = make_prvalue();
    std::cout << "named:\n";        [[maybe_unused]] Tracer b = make_named();
    std::cout << "std::move:\n";    [[maybe_unused]] Tracer c = make_moved();
    std::cout << "parameter:\n";    [[maybe_unused]] Tracer d = pass_through(Tracer{});
}
$ g++ -std=c++20 -Wall -Wextra -o rvo rvo.cpp 2>&1 | grep warning
rvo.cpp:12:51: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
$ ./rvo
prvalue:
  construct
named:
  construct
std::move:
  construct
  move
parameter:
  construct
  move

Returning a temporary (return Tracer{};) is guaranteed elision since C++17: one construction, nothing else. Returning a named local is NRVO — not guaranteed, but GCC did it here, again with no move. Writing return std::move(t) changes the returned expression from “the local t” to “an rvalue reference”, which disqualifies elision, so an extra move appears — and -Wall flags it as -Wpessimizing-move. Parameters can’t be elided (the caller constructed them), but return t; on a parameter moves implicitly, so no std::move is needed there either.

Forwarding References and std::forward

In a template, T&& with T deduced is a forwarding reference: it binds to lvalues and rvalues alike. Inside the function, though, the parameter has a name, so it’s an lvalue:

#include <iostream>
#include <string>
#include <utility>

void sink(const std::string&) { std::cout << "  sink(const&): copies\n"; }
void sink(std::string&&)      { std::cout << "  sink(&&): can move\n"; }

template <typename T>
void wrapper_bad(T&& arg) { sink(arg); }                    // arg has a name: lvalue

template <typename T>
void wrapper(T&& arg) { sink(std::forward<T>(arg)); }       // restores the category

int main() {
    std::string s = "hello";
    std::cout << "wrapper_bad(std::move(s)):\n";  wrapper_bad(std::move(s));
    std::cout << "wrapper(s):\n";                 wrapper(s);
    std::cout << "wrapper(std::move(s)):\n";      wrapper(std::move(s));
    std::cout << "wrapper(std::string(\"x\")):\n"; wrapper(std::string("x"));
}
$ g++ -std=c++20 -Wall -Wextra -o forward forward.cpp
$ ./forward
wrapper_bad(std::move(s)):
  sink(const&): copies
wrapper(s):
  sink(const&): copies
wrapper(std::move(s)):
  sink(&&): can move
wrapper(std::string("x")):
  sink(&&): can move

wrapper_bad received an rvalue but passed arg on as an lvalue, so the copying overload ran — the rvalue-ness was lost at the first hop. std::forward<T>(arg) restores it: when the caller passed an lvalue, T is std::string& and forward returns an lvalue; when the caller passed an rvalue, T is std::string and forward returns an rvalue. This is how emplace_back and std::make_unique pass their arguments to constructors untouched.

Only T&& with T itself deduced is a forwarding reference. A concrete type or a template wrapped around T is a plain rvalue reference:

#include <string>
#include <vector>

template <typename T> void f(T&&) {}                    // forwarding reference
void g(std::string&&) {}                                // rvalue reference
template <typename T> void h(std::vector<T>&&) {}       // rvalue reference, not forwarding

int main() {
    std::string s;
    std::vector<int> v;
    f(s);               // fine: T = std::string&
    g(s);               // rejected: s is an lvalue
    h(v);               // rejected: v is an lvalue
}
$ g++ -std=c++20 -Wall -Wextra -o not_forwarding not_forwarding.cpp 2>&1 | grep error
not_forwarding.cpp:12:7: error: cannot bind rvalue reference of type 'std::string&&' {aka 'std::__cxx11::basic_string<char>&&'} to lvalue of type 'std::string' {aka 'std::__cxx11::basic_string<char>'}
not_forwarding.cpp:13:7: error: cannot bind rvalue reference of type 'std::vector<int>&&' to lvalue of type 'std::vector<int>'

f(s) compiled; g and h rejected lvalues, because std::vector<T>&& can only ever be an rvalue reference to some vector.

Move-Only Types

std::unique_ptr, std::thread and file streams can be moved but not copied, so ownership can’t be accidentally duplicated.

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

std::vector<std::unique_ptr<int>> pool;

void add(std::unique_ptr<int> p) {          // by value: the caller must hand it over
    pool.push_back(std::move(p));           // and so must we
}

int main() {
    auto p = std::make_unique<int>(42);
    add(std::move(p));
    add(std::make_unique<int>(7));          // a temporary moves implicitly
    std::cout << std::boolalpha << "p is null: " << (p == nullptr)
              << ", pool size " << pool.size() << ", first " << *pool[0] << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o move_only move_only.cpp
$ ./move_only
p is null: true, pool size 2, first 42

Every hand-off of the named p needs an explicit std::move, and afterwards p is null — the ownership visibly left. A temporary like std::make_unique<int>(7) moves on its own. Forget the std::move and the compiler refuses:

#include <memory>

void add(std::unique_ptr<int>) {}

int main() {
    auto p = std::make_unique<int>(42);
    add(p);                                 // would need a copy
}
$ g++ -std=c++20 -Wall -Wextra -o copy_unique_fail copy_unique_fail.cpp 2>&1 | grep error
copy_unique_fail.cpp:7:8: error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = int; _Dp = std::default_delete<int>]'

Practice

  1. Add void take(const std::string&&) to categories.cpp and call take(std::move(cs)). Which overload runs, and what does it tell you about moving from const objects?

    Solution

    The new const&& overload runs. Without it, the call would pick take(const std::string&) — the same thing that happened to d = std::move(c) in std_move.cpp. A const&& can’t be moved from in any useful way, so in practice std::move on a const object means “copy”.

  2. Remove noexcept from Buffer’s move constructor, make it copyable, and push three Buffers into a std::vector without reserve. How do you tell whether moves or copies happened?

    Solution

    Add a logging copy constructor. Without noexcept, the vector’s reallocations log copies of the existing elements; with it, they’re moved. std::vector uses std::move_if_noexcept so that a throwing move can’t leave it half-relocated.

  3. Write template <typename T, typename... Args> std::unique_ptr<T> make(Args&&... args) that forwards to T’s constructor, and use it to build a std::string(3, 'z').

    Solution
    template <typename T, typename... Args>
    std::unique_ptr<T> make(Args&&... args) {
        return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
    }
    
    auto p = make<std::string>(3, 'z');   // "zzz"

    This is essentially std::make_unique: each argument keeps its value category on the way to the constructor.

  4. Make pass_through in rvo.cpp take const Tracer& and return it. What changes in the output, and why?

    Solution

    The move line becomes copy. return t; only moves implicitly for local objects and by-value parameters; a const& refers to someone else’s object, so returning it must copy.

  5. In move_cost.cpp, time std::vector<int> moved2 = std::move(copied); after it has already been moved to. What does copied.size() report afterwards?

    Solution

    The move is again sub-microsecond, and copied.size() is 0. Moving from any vector — even an empty one — just swaps its three internal pointers.

Frequently Asked Questions

Does std::move actually move anything?
No. std::move is only a cast to an rvalue reference. It makes the move constructor or move assignment eligible; those functions do the actual transfer. If the object is const, or its type has no move operations, the copy constructor runs instead.
When should I use std::forward?
In a template taking a forwarding reference (T&& with T deduced), when you pass the argument on to another function. std::forward<T>(arg) keeps an rvalue an rvalue and an lvalue an lvalue; plain arg would always be an lvalue because it has a name.
Should I write return std::move(local)?
No. Returning a local by name already lets the compiler elide the copy (NRVO) or, failing that, move it implicitly. Wrapping it in std::move forces a move and blocks elision; GCC warns about it with -Wpessimizing-move, part of -Wall.
What state is an object in after being moved from?
A valid but unspecified state: its destructor must work and you may assign it a new value, but you shouldn't rely on its contents. Standard containers and strings are empty in practice, and your own types should leave something equally safe, such as a null pointer and size 0.
Why must move constructors be noexcept?
std::vector relocates elements with std::move_if_noexcept when it grows. If the move constructor might throw, it copies instead to keep its strong exception guarantee, so a missing noexcept silently turns cheap moves into full copies.