C++'s std::move from const: The Move That Silently Copies

2026-05-24

This program loads an expensive template and then hands it off to several worker documents. The author was careful to use std::move everywhere to avoid copies. The instrumentation in the constructors should confirm the moves are happening.

#include <iostream>
#include <vector>
#include <string>

class Document {
    std::vector<std::string> lines;
public:
    Document(std::vector<std::string> l) : lines(std::move(l)) {}

    Document(const Document& o) : lines(o.lines) {
        std::cout << "[copy: " << lines.size() << " lines]\n";
    }
    Document(Document&& o) noexcept : lines(std::move(o.lines)) {
        std::cout << "[move: " << lines.size() << " lines]\n";
    }
};

const Document load_template() {
    return Document({"header", "body", "footer"});
}

int main() {
    const Document tmpl = load_template();
    Document working = std::move(tmpl);   // expected: move
    Document another = std::move(tmpl);   // expected: move (or UB?)
    Document third   = std::move(tmpl);
}

Output:

[copy: 3 lines]
[copy: 3 lines]
[copy: 3 lines]

Three copies. And weirdly, tmpl is still intact after all those "moves" — its lines vector still contains the original three strings. What happened?

The Bug

std::move on a const object produces a const T&&, which the move constructor cannot accept — so overload resolution silently falls back to the copy constructor.

The thing to remember is that std::move doesn't move anything. It's just a cast — it converts its argument to an rvalue reference. The actual movement happens inside whichever constructor or assignment operator is selected by overload resolution.

Here, tmpl has type const Document. So std::move(tmpl) is a const Document&&. Now overload resolution looks at the two candidates:

So the copy constructor wins. No warning, no error — just a silent fallback. The "optimization" did nothing, and on a hot path with large objects you've turned three pointer swaps into three deep copies.

What makes this nasty:

The Fix

Don't declare objects const if you intend to move from them later. Either drop the const on the source, or accept that you're handing out copies:

// Option 1: drop const if you're going to move
Document tmpl = load_template();
Document working = std::move(tmpl);  // [move: 3 lines]

// Option 2: only move from the last user
const Document tmpl = load_template();
Document working = tmpl;             // honest copy
Document another = tmpl;             // honest copy
Document last    = std::move(...);   // only if you can drop const

If you want a compile-time guardrail, you can declare a deleted overload:

Document(const Document&&) = delete;

Now std::move(tmpl) on a const object becomes a hard error instead of a silent copy — useful when the performance of moving really matters.

Key Takeaway: std::move is just a cast to an rvalue reference; moving from a const object produces a const T&& that binds to the copy constructor, so your "move" silently becomes a copy with no diagnostic.

All newsletters