std::move from const: The Move That Silently Copies2026-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?
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:
Document(Document&&) — wants a non-const rvalue reference. A const Document&& cannot bind to it (you can't drop const).Document(const Document&) — const lvalue references bind happily to const rvalues. ✓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:
std::move(x) looks like a move.const to a local variable for safety, and performance silently regresses.store(T t) { container.push_back(std::move(t)); } looks fine until T is deduced as const Something.-Wpessimizing-move and friends, but they don't catch this case.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.
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.
