std::move doesn't move anything
I finally opened the file. /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__utility/move.h, the place where std::move actually lives on my Mac. I had been typing std::move for weeks by then, the HPX code I’m learning from is full of it, so my code imitated it, while quietly believing it was some kind of compiler intrinsic that picks data up and carries it somewhere. Here is the entire implementation, libc++‘s underscores and all:
template <class _Tp>
_LIBCPP_NODISCARD_EXT inline _LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR
__libcpp_remove_reference_t<_Tp>&&
move(_LIBCPP_LIFETIMEBOUND _Tp&& __t) _NOEXCEPT {
typedef _LIBCPP_NODEBUG __libcpp_remove_reference_t<_Tp> _Up;
return static_cast<_Up&&>(__t);
}
Strip the macros and it’s this:
template <class T>
constexpr remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<remove_reference_t<T>&&>(t);
}
That’s it. A cast. std::move takes a thing and casts it to &&. The cast does not move a single byte. In optimized code there is normally no machine instruction corresponding to the cast, although an unoptimized build can still contain the inline function machinery around it.
So I sat there with two questions and they wouldn’t go away. If std::move is just a cast, what actually moves things? And what on earth is the type remove_reference_t<T>&& such that casting to it makes my program faster?
Answering those two questions took me about two weeks of evenings, because the answers kept demanding answers to deeper questions, and the trail ends somewhere I did not expect: in the template machinery, and then in HPX’s build configuration, of all places.
This is the writeup, in the order I actually took the trip. Everything here runs on my machine (M4 MacBook Pro, 10 cores, 16 GB, Apple clang 15.0.0, libc++), every benchmark number is a real measurement from this machine, and the production code examples come from HPX at commit 2bb509d7e0 (stable-1910), because that’s the codebase I’m learning distributed computing from and it uses every trick in this post in anger.
the object stayed at the same address
The stripped function still assumes that T&&, remove_reference_t, and static_cast mean something. I did not need any of them yet. I needed to settle what the function receives.
Start with one ordinary declaration:
std::string text(64, 'h');
There are three different things hiding in that line.
The value is the information the program can observe, here a sequence of sixty-four h characters. The object is the std::string that holds the state needed to represent that value. The storage is the region of memory occupied by that object. The C++ working draft states the relationship precisely: an object occupies a region of storage during its lifetime.
text is the object’s name. The name is not the object, just as a filename is not the bytes in the file. A name is source code the compiler can resolve to an entity. The object exists while the program runs.
I can ask for the address of its storage:
std::string* pointer = &text;
The & in &text is the address-of operator. It produces the memory address where text begins. The * beside std::string says that pointer is a pointer, an object whose value is an address of a std::string. Later, *pointer follows that address back to the string. This operation is called dereferencing.
The symbols are unfortunate because & means something different in a declaration:
std::string& alias = text;
Here alias is a reference. A reference is another way to reach an existing object. It must be initialized, and it cannot later be changed to refer to a different object. Assignment through it assigns to the object. Those are properties of reference initialization, not conventions supplied by std::string:
alias[0] = 'H';
std::printf("%c\n", text[0]); // H
No second string appeared. alias and text reach the same object. Taking their addresses proves it:
std::printf("%s\n", &alias == &text ? "same object" : "different objects");
same object
A copy has the opposite shape:
std::string copy = text;
std::printf("same value: %s\n", copy == text ? "yes" : "no");
std::printf("same object: %s\n", © == &text ? "yes" : "no");
same value: yes
same object: no
Two objects can hold equal values while occupying different storage. Changing copy[0] does not change text[0]. This distinction matters because constructors and destructors act on objects, not on abstract values. A copy constructor creates a second object from the first object’s value. A move constructor also creates an object, but it is allowed to reuse resources owned by the source.
The word lifetime belongs to the object too. Storage can exist before an object’s lifetime begins and remain after it ends, although ordinary local variables hide that distinction. For a local object with automatic storage duration, the familiar case is simple: once initialization completes, the object’s lifetime has begun, and leaving the surrounding block ends it. If the type has a destructor, the destructor runs on the way out.
struct TracedScope {
~TracedScope() { std::puts("scope object destroyed"); }
};
{
TracedScope local;
std::puts("inside scope");
}
std::puts("after scope");
inside scope
scope object destroyed
after scope
Scope and lifetime often line up in code like this, but they are not synonyms. Scope is a rule about where a name can be used in source code. Lifetime is a runtime interval during which the object exists. An object created with new, called dynamic allocation, can outlive the scope that created it. A reference can remain in scope after the object it referred to has died, leaving a dangling reference. Those distinctions become less optional once resources start changing owners.
There is another address inside std::string. The string object has its own storage, but a long string usually owns a separate character buffer:
std::printf("string object: %p\n", static_cast<void*>(&text));
std::printf("character buffer: %p\n", static_cast<const void*>(text.data()));
%p asks C’s printf to print an address. It expects that address in the general pointer form void*, which is why the casts appear. On this libc++ build, the addresses differ for the sixty-four-character string. The object is a small handle containing bookkeeping. Its data() pointer reaches the separately allocated characters. A short string may store its characters inside the object instead, so different addresses are an observation about this input and implementation, not a language guarantee.
That separation explains why copying can be expensive. Copying the string object may also require allocating and copying the character buffer it owns. It also gives a move constructor something useful to steal: the pointer to that buffer.
None of that stealing occurs in this expression:
const char* characters_before = text.data();
std::string&& marked = std::move(text);
std::printf("same object: %s\n", &marked == &text ? "yes" : "no");
std::printf("same buffer: %s\n",
marked.data() == characters_before ? "yes" : "no");
The run printed:
same object: yes
same buffer: yes
const char* is a pointer through which the character cannot be modified. The double ampersand gives marked a different reference type whose binding rules come next. It is still a reference, so this declaration creates no destination string and calls no string constructor. The object stays at the same address. Its character buffer stays at the same address. Its value remains available because nobody has consumed the cast’s result by constructing or assigning another string.
This leaves two pieces of source code:
text
std::move(text)
They designate the same object. The difference is not in the object, its storage, its address, its lifetime, or its character buffer. The difference belongs to the expressions themselves. C++ needs a name for that difference because it controls which reference can bind and, through that binding, which constructor can run.
Expressions have a property you can’t see
The object experiment rules out a runtime mutation. A cast converts an expression to a type. static_cast<float>(3) takes an int value and computes a float value. std::move casts to T&&, but the pointer is the same, the object is the same, no constructor runs, and no memory is touched. What changed is something the language tracks about the expression, not the object.
This was the first thing I had wrong. I thought types were the whole story. They’re not. Every expression in C++ has two properties: a type, and a value category. The type says what kind of thing it is. The value category says, roughly, what you’re allowed to do with it: can you take its address, can you assign to it, and, the question this whole post hangs on, is anyone going to notice if you gut it for parts.
The C++ working draft defines three primary categories:
- An lvalue is an expression that determines the identity of an object or function and is not an xvalue. In the object cases here,
xis an lvalue andarr[1]is an lvalue. They designate existing objects, and you can take their addresses. - A prvalue (“pure rvalue”) is an expression whose evaluation initializes an object or computes an operand’s value.
x + 1is a prvalue.std::string("hello")is a prvalue. In modern C++, the context supplies its result object, which avoids requiring a separate temporary in many cases. - An xvalue (“expiring value”) is the weird one, and it’s the one
std::moveproduces. An xvalue determines the identity of an object, like an lvalue, while saying that its resources may be reused, usually because it is near the end of its lifetime.
There are also two umbrella categories: glvalue (lvalue or xvalue: determines identity) and rvalue (prvalue or xvalue: can bind where an rvalue is required). The xvalue sits in the overlap. It has identity, and its resources may be reused.
The names are historical and no longer describe assignment position reliably. Arrays are lvalues and cannot be assigned as whole arrays. A function call returning a reference can also be an lvalue. The standard’s own note makes the more useful warning: these terms apply to expressions, not to values.
Here’s the part I didn’t expect: you can ask the compiler directly which category an expression has. decltype with double parentheses, decltype((expr)), yields T& for lvalues, T&& for xvalues, and plain T for prvalues. So you can build a classifier out of three partial specializations:
template <class T> struct category { static constexpr const char* name = "prvalue"; };
template <class T> struct category<T&> { static constexpr const char* name = "lvalue"; };
template <class T> struct category<T&&> { static constexpr const char* name = "xvalue"; };
#define SHOW(expr) std::printf("%-28s %s\n", #expr, category<decltype((expr))>::name)
(I wrote that classifier by pattern-matching off other people’s code before I understood how it worked. It’s a template trick called partial specialization, the same machinery std::move itself is built from, which I only realized near the end of this whole dig.)
I threw twelve expressions at it. Before you read the output, try to classify these yourself, because I got several wrong on the first try:
std::string make() { return "made"; }
std::string&& steal(std::string& s) { return std::move(s); }
int x = 0;
int arr[3] = {1, 2, 3};
std::string s = "hello";
SHOW(x);
SHOW(x + 1);
SHOW(++x);
SHOW(x++);
SHOW("hello");
SHOW(std::string("hello"));
SHOW(std::move(s));
SHOW(s.size());
SHOW(arr[1]);
SHOW(make());
SHOW(steal(s));
SHOW((x > 0 ? x : arr[0]));
Real output, Apple clang 15, -std=c++17:
x lvalue
x + 1 prvalue
++x lvalue
x++ prvalue
"hello" lvalue
std::string("hello") prvalue
std::move(s) xvalue
s.size() prvalue
arr[1] lvalue
make() prvalue
steal(s) xvalue
(x > 0 ? x : arr[0]) lvalue
Three of these surprised me.
++x versus x++. Pre-increment is an lvalue, post-increment is a prvalue. And it makes sense once you think about what each returns: ++x gives you back the variable itself, incremented, so it has identity. x++ has to give you the old value, which doesn’t live anywhere anymore; it’s a freshly minted temporary. The value category is telling you about the machine semantics: one returns a reference into existing storage, the other returns a copy.
"hello" is an lvalue. A string literal! It feels like the most temporary thing imaginable, but it’s an array of six const char with static storage duration. It has an address and exists for the whole program. Meanwhile std::string("hello"), which looks more like a named, established thing, is a prvalue that initializes the result object supplied by its context. The syntax is a poor guide to the categories.
And std::move(s) versus std::string("hello"): both are “rvalues,” both can select an rvalue overload, but they are different categories and the difference is real. std::move(s) is an xvalue: the object s already exists, has an address, and will still be there after a later move; this expression says its resources may be reused. std::string("hello") is a prvalue whose result object is supplied by the surrounding context. If that context initializes std::string t, construction can happen directly in t.
So this is what std::move does. The whole thing. It takes an expression that was an lvalue, an expression the compiler was treating with care because someone might look at the object later, and produces an xvalue expression designating the same object. No data moves. No flag is stored in the object. The compiler classifies the new expression differently.
The consumer of that classification is references. (Quick vocabulary, since everything below leans on it: C++ lets several functions share one name as long as their parameter types differ, overloads, and the compiler picks one per call site; that picking is overload resolution.) The value category of an expression decides which references can bind to it, reference binding decides which overload wins, and that, finally, is where behavior actually changes.
Binding rules, or: who is allowed to grab what
C++ has two kinds of references, and all of move semantics is built on which one binds to which value category. I wrote out the cases and let clang adjudicate each one, because I kept finding my intuitions were folklore. Each compiled in isolation:
case 1: OK | int x = 1; int& r = x;
case 2: REJECT | int& r = 42;
error: non-const lvalue reference to type 'int' cannot bind
to a temporary of type 'int'
case 3: OK | const int& r = 42;
case 4: REJECT | int x = 1; int&& r = x;
error: rvalue reference to type 'int' cannot bind to lvalue of type 'int'
case 5: OK | int&& r = 42;
case 6: OK | int x = 1; int&& r = std::move(x);
case 7: REJECT | const int x = 1; int& r = x;
error: binding reference of type 'int' to value of type 'const int'
drops 'const' qualifier
case 8: REJECT | int x = 1; int&& a = std::move(x); int&& b = a;
error: rvalue reference to type 'int' cannot bind to lvalue of type 'int'
case 9: OK | long g(); const int& r = g();
The grid that falls out of this:
lvalue xvalue prvalue
T& (non-const) yes no no
const T& yes yes yes
T&& no yes yes
Three rows, three different philosophies.
T& is the strictest: it binds only to non-const lvalues. Case 2 is the famous one, int& r = 42; doesn’t compile, and the reason is a design decision from the 1980s that I think history has vindicated. The 42 there is a temporary, an object the compiler materializes mid-statement and destroys at the end of it. If you could bind a mutable reference to a temporary, you could “modify” it, and your modification would vanish into a value that nobody can ever observe. The classic disaster: void increment(long& x) called with an int. The int would convert to a temporary long, the temporary would get incremented, and the caller’s int would sit there unchanged. Silent, total failure. So non-const lvalue references refuse temporaries, full stop.
const T& is the universal receiver. It binds to everything: lvalues, xvalues, prvalues, anything. This is case 3 and case 9, and case 9 is wilder than it looks: g() returns a long, and we’re binding a const int& to it. The compiler converts the long to a temporary int and binds the reference to that temporary, and then, this is the genuinely strange part, extends the temporary’s lifetime to match the reference. A temporary that should evaporate at the semicolon lives as long as r does. This rule exists so that const std::string& s = f(); works without dangling.
Before C++11, const T& was the only tool anyone had for “accept anything without copying it,” and the entire pre-move standard library is shaped by this. push_back(const T&), operator=(const T&), every function that wanted efficiency took const T&. And it has exactly one flaw, but it’s fatal for performance: const. The function receives a temporary that’s about to die, knows nothing about it, and is bound by const to leave it intact. So it copies. Every v.push_back(make_string()) in pre-2011 C++ copied a string that was already scheduled for demolition. const T& can see everything but touch nothing.
T&& is the new row, the C++11 row, and it’s the complement of the first: it binds only to rvalues. Case 4 is the rule that makes move semantics safe: an rvalue reference cannot bind to a plain lvalue. You cannot accidentally hand your living, breathing local variable to a function that’s planning to gut it. You have to say std::move(x), which is to say: you have to cast, which is to say, you have to write down, in greppable text, “I consent to this object being hollowed out.”
That’s the whole design, and it clicked embarrassingly late for me: std::move exists to satisfy case 4. The binding rules were deliberately written so that destructive functions can’t capture lvalues, and std::move is the explicit, visible escape hatch that converts your lvalue into something they can capture. The cast is the consent form.
Case 8 is the one that broke my brain, and it deserves its own section.
The named rvalue reference is an lvalue
Look at case 8 again:
int x = 1;
int&& a = std::move(x); // fine
int&& b = a; // ERROR: rvalue reference cannot bind to lvalue
a is an rvalue reference. It says so right there in the declaration. And yet a, the expression, on the second line, is an lvalue, and another rvalue reference refuses to bind to it.
The rule: the value category of an expression depends on the expression, not on the declared type of the variable in it. a has a name. You can refer to it again on the next line. It has identity, someone (you, one line later) might be looking at it. By every definition we set up earlier, the expression a is an lvalue. The fact that its type is int&& is a separate axis entirely. Type and value category are independent coordinates, and this is the single most common place they get confused.
Where this actually bites people is inside functions taking rvalue references:
void probe(const std::string&) { std::puts("probe(const string&)"); }
void probe(std::string&&) { std::puts("probe(string&&)"); }
void sink(std::string&& v) {
probe(v); // which one?
probe(std::move(v)); // which one?
}
sink(std::string("temp"));
Output:
inside sink, probe(v) -> probe(const string&)
inside sink, probe(std::move(v)) -> probe(string&&)
The caller passed a temporary. v is declared std::string&&. And probe(v) still selects the lvalue overload, because the expression v is a named thing with identity inside the function body. The rvalue-ness of the argument is not a property that flows through the parameter; it dies at the function boundary. If you want to pass the expiring-ness along, you have to re-assert it with another std::move.
This is why you see std::move on rvalue reference parameters all over real codebases, which looks redundant (“it’s already an rvalue reference!”) and is in fact load-bearing every single time. Without it, the parameter quietly copies.
While I had the overload probe set up, I ran the rest of the interesting calls through it:
std::string s = "hello";
const std::string cs = "const hello";
probe(s); // -> probe(string&) as expected
probe(cs); // -> probe(const string&) as expected
probe(std::move(s)); // -> probe(string&&) as expected
probe(std::move(cs)); // -> probe(const string&) !!
probe(make()); // -> probe(string&&)
probe(s + cs); // -> probe(string&&) operator+ returns prvalue
Look at probe(std::move(cs)). Moving a const object. It compiles. It runs. And it silently selects the copy path. std::move(cs) produces an expression of type const std::string&&, and a const rvalue can’t bind to std::string&& (the move ctor needs to mutate the source, and const forbids it), but it binds to const std::string& just fine, because that row of the table accepts everything. The compiler does exactly what the binding rules say, no warning, no error, and your “optimization” is a copy with extra typing. clang-tidy has a check dedicated to exactly this (performance-move-const-arg), which tells you how often it happens in the wild: std::move on a const member, on a const local, on a const parameter, all theater, all copies.
std::move doesn’t move anything. Even when it’s supposed to enable a move, const quietly vetoes it, and nothing tells you.
Non-const references hide mutation, and HPX does it everywhere
One more thing about that binding table before descending a layer. Look at row one, T&. It’s the only row that grants mutation rights, and here’s the thing: at the call site, you cannot see it.
process(config);
Does that mutate config? In Rust, you’d know: the call site would read process(&mut config) or it wouldn’t mutate, the language forces the consent to be visible on both ends. In C++, process(T&), process(const T&), and process(T) all look identical at the call site. The only way to know whether your variable just got rewritten is to go read the signature.
I went looking through HPX for places where this actually matters, because HPX leans on this idiom hard.
The one that permeates the entire codebase is error_code& ec. From libs/full/performance_counters/src/manage_counter.cpp:
counter_status manage_counter::install(
hpx::id_type const& id, counter_info const& info, error_code& ec)
{
// ...
return detail::add_counter(id, info_, ec); // line 61
}
Read the call detail::add_counter(id, info_, ec) cold. Three arguments going in. What it actually does: ec is an out-parameter; add_counter writes its error state into it. HPX even has a default, error_code& ec = throws, a sentinel meaning “if you don’t pass one, I’ll throw instead.” So counter.start(launch::sync_policy) and counter.start(launch::sync_policy, ec) have completely different failure behavior, distinguished by an argument whose mutation is invisible.
The one that actually spooked me is serialization archives. From libs/core/serialization/include/hpx/serialization/vector.hpp:49:
void serialize(input_archive& ar, std::vector<T, Allocator>& v, unsigned)
{
v.clear(); // <- your vector just got emptied
// ...
}
Every call site reads serialize(ar, t, 0). Symmetric, innocent. For an input_archive, t is being overwritten wholesale, starting with a clear(). For an output archive, t is only read. Same call syntax, opposite data flow, and the direction is encoded in the archive’s type, not in anything you can see at the call. The first time I stepped through a deserialization and watched a vector I thought of as “being saved” get emptied on line one, I went back and reread every serialize call I had written.
There are more of these, hpx::wait_any takes your vector of futures by mutable reference, gid_type’s load streams two 64-bit words straight into the object’s guts, but the two above carry the point. None of this is a dunk on HPX; the error_code& idiom predates std::expected by twenty years, and out-parameters were the only way to return error-plus-value without exceptions. The point is the reading discipline it forces: in C++, a call site tells you nothing about mutation. When I read HPX now, I read signatures first, calls second.
Okay. So: value categories classify expressions, binding rules turn categories into overload selection, T&& overloads are where the stealing happens, and std::move is the cast that grants consent. There’s a question I’ve been stepping around, though. Why is stealing safe at all? If a move constructor rips the heap buffer out of a string, the gutted local object is still alive, and its destructor is still going to run. Why doesn’t everything explode?
The answer is the actual foundation of the whole edifice, and it’s older than move semantics by twenty years.
The layer below: destructors are the load-bearing wall
C++ has no garbage collector, and the standard answer to “then how does anything get cleaned up” is three words: scopes run destructors. When an object’s lifetime ends (the closing brace, the delete, the stack unwind), its destructor, the ~ClassName() function, the constructor’s undo, runs, deterministically, at a knowable point in the program text. The idiom built on this is called RAII, “Resource Acquisition Is Initialization.” Terrible name. The destructor is the point; acquisition in the constructor is just bookkeeping so that the destructor knows what to release.
Here’s the canonical shape, a file handle (the small integer the OS hands you to name an open file), and every line of it answers a question this post needs answered:
class FileHandle {
int fd_ = -1;
public:
explicit FileHandle(const char* path, int flags, mode_t mode = 0644)
: fd_(::open(path, flags, mode)) {
if (fd_ < 0) throw std::runtime_error("open failed");
}
~FileHandle() {
if (fd_ >= 0) ::close(fd_);
}
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
FileHandle(FileHandle&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
FileHandle& operator=(FileHandle&& o) noexcept {
if (this != &o) { if (fd_ >= 0) ::close(fd_); fd_ = std::exchange(o.fd_, -1); }
return *this;
}
int get() const { return fd_; }
};
The constructor opens. The destructor closes. Copying is deleted, and this is a design statement, not a limitation: a file descriptor is one int naming one kernel resource; if two FileHandle objects held fd 3, both destructors would close fd 3, and the second close would either fail or, much worse, close a file that some other part of the program had just opened under the recycled number 3. There is no sane copy semantics for exclusive ownership. So we forbid it.
But moving is fine, because moving is ownership transfer: std::exchange(o.fd_, -1) grabs the source’s descriptor and leaves -1, the “I own nothing” sentinel, in its place. After the move, the source is a hollow object whose destructor will look at -1, shrug, and do nothing. (The noexcept on both move operations is a promise that they cannot throw an exception. File it under “looks like politeness” for now; it isn’t.)
And there it is. That’s the answer to the question that ended the last section. Stealing is safe because the thief leaves a note. The move constructor must put the source into a state where the inevitable destructor call is harmless. The destructor is going to run, that’s non-negotiable, deterministic, brace-scheduled; move semantics work with that guarantee, not around it. The whole protocol is: take the resource, null the source, let the destructor find the null.
Now the proof that the destructor actually delivers on its half of the deal. The claim “the destructor always runs” is strongest where humans are weakest: error paths. I wrote a function that opens a file, writes to it, and then throws an exception before any visible cleanup, and I counted the process’s open file descriptors before and after (by probing fcntl(fd, F_GETFD) across the fd table):
void work_that_throws() {
FileHandle f("/tmp/raii_test.txt", O_CREAT | O_WRONLY);
::write(f.get(), "hello\n", 6);
throw std::runtime_error("disk fell off mid-function");
// no close() anywhere in sight
}
int before = count_open_fds();
try { work_that_throws(); } catch (const std::exception& e) { /* ... */ }
int after = count_open_fds();
Output, real run:
opened fd=3
closed fd=3
caught: disk fell off mid-function
open fds before=3 after=3 (leak if after > before)
--- move test ---
opened fd=3
a.get()=-1 b.get()=3
closed fd=3
The closed fd=3 line prints before the caught line. Stack unwinding ran the destructor on its way out of the function, mid-exception, before the catch block ever saw the error. And in the move test: one open, one close, even though two FileHandle objects existed. a died holding -1 and stayed silent.
To feel why this matters, write the version without it. Three resources, manual cleanup, exceptions possible:
void process_manually(const char* in_path) {
int fd = ::open(in_path, O_RDONLY); // resource 1
if (fd < 0) return;
char* buf = static_cast<char*>(malloc(1 << 20)); // resource 2
if (!buf) { ::close(fd); return; } // cleanup site A
FILE* log = fopen("/tmp/log.txt", "a"); // resource 3
if (!log) { free(buf); ::close(fd); return; } // cleanup site B
parse(fd, buf); // throws? leaks all three. leak point 1
transform(buf); // throws? leaks all three. leak point 2
flush_to(log, buf); // throws? leaks all three. leak point 3
fclose(log); free(buf); ::close(fd); // cleanup site C
}
Three early-return cleanup sites, each of which must release exactly the prefix of resources acquired so far, in the right order, and each of which has to be updated whenever a resource is added or reordered. Plus three calls that, if any of them throws, skip all cleanup entirely. This shape of code is where 1990s C++ got its reputation. The RAII version is three declarations:
void process_with_raii(const char* in_path) {
FileHandle fd(in_path, O_RDONLY);
Buffer buf(1 << 20);
LogFile log("/tmp/log.txt");
parse(fd.get(), buf.data());
transform(buf.data());
flush_to(log.get(), buf.data());
}
Zero cleanup sites. Any exception, any early return, any future edit that adds a fourth resource: the compiler schedules the cleanup, in the right order, every time. RAII deleted the error-handling code.
“In the right order” is doing quiet work in that sentence, and it’s specified, not incidental: destructors run in exact reverse order of construction. I verified the obvious case (a, b constructed, then b, a destroyed) and then went looking for a bug that only exists because of ordering, because a rule this strict must be load-bearing somewhere. Here’s the minimal one:
struct Worker {
~Worker() { if (g_logger) g_logger->log("worker shutting down"); }
};
{ // correct: logger first, worker second
Logger logger; g_logger = &logger;
Worker w;
} // ~Worker logs fine, then ~Logger
{ // bugged: worker first, logger second
Worker w;
Logger logger; g_logger = &logger;
} // ~Logger runs FIRST, then ~Worker logs into a corpse
Real output (my Logger tattles instead of crashing, to keep the demo deterministic):
--- correct order: Logger first, Worker second ---
logger up
log: worker shutting down
logger down
--- bugged order: Worker first, Logger second ---
logger up
logger down
[USE-AFTER-DESTROY] log called on dead logger: worker shutting down
Two lines swapped, and the worker’s destructor now dereferences a destroyed object. In real code with a real logger this is a use-after-free that fires only during shutdown, which is the worst possible time to debug anything. The rule to internalize: if X’s destructor uses Y, then Y must be constructed before X. Declaration order is dependency order. (This same rule, applied across separately-compiled source files instead of within a scope, is the “static initialization order fiasco,” where the order is unspecified and the bug becomes a lottery.)
Locks are resources too
The cleanest demonstration that RAII is about invariants, not memory, is std::mutex, the lock threads take so that only one of them touches a piece of shared data at a time. The resource being managed is “this mutex is held,” and leaking it doesn’t waste bytes, it deadlocks your program. The standard library ships three RAII wrappers, in increasing order of capability:
std::lock_guard is the FileHandle of locks: lock in constructor, unlock in destructor, no other operations, not even an early unlock. std::unique_lock adds an ownership state: you can unlock() early, lock() again, construct it deferred, and, fitting this post’s theme, it’s movable, so lock ownership can be transferred out of a function (this is what std::condition_variable::wait needs). std::scoped_lock takes multiple mutexes at once and acquires them with a deadlock-avoidance algorithm.
That last one I wanted to see fail-safe with my own eyes, so I wrote the textbook deadlock: two threads taking the same two mutexes in opposite orders, 100,000 iterations each.
void transfer_ab() {
for (int i = 0; i < 100000; ++i) {
std::scoped_lock lk(m1, m2); // locks both, deadlock-free
++counter;
}
}
void transfer_ba() {
for (int i = 0; i < 100000; ++i) {
std::scoped_lock lk(m2, m1); // OPPOSITE order, still fine
++counter;
}
}
With raw m1.lock(); m2.lock(); this hangs in milliseconds (thread 1 holds m1 wanting m2, thread 2 holds m2 wanting m1, forever). With scoped_lock, both orderings are safe; it uses the same try-and-back-off algorithm as std::lock. The run completes:
counter = 200003 (expected 200002), no deadlock
I recompiled twice before rereading my own code, because I wrote the expected 200002 label myself and the program kept disagreeing with me. Then I recounted: my warm-up code before the threads does ++counter once under a lock_guard and twice under the unique_lock (once before its early unlock(), once after re-locking). 1 + 2 + 200,000 is 200,003. The program was right; my label was wrong.
The wrappers waiting for me
The reason I’m grinding through RAII at this depth is that the systems/GPU world I’m heading into is wall-to-wall unmanaged resources, every one of them an RAII wrapper waiting to be written. Most of them are the FileHandle skeleton with a different pair of C functions in the constructor and destructor, CUDA device memory, CUDA streams and events, sockets. But two on my list have different shapes, and they’re the ones I keep thinking about. Pinned host memory (cudaMallocHost/cudaFreeHost): the destructor must not run while a GPU transfer is still reading the buffer, so the wrapper’s invariant couples to stream synchronization, the destructor has a precondition that lives outside the object. And mmap/munmap: the destructor needs the mapping’s length, not just its pointer, so the wrapper has to carry metadata purely for the sake of cleanup. (Threads are their own comedy: std::thread’s destructor calls std::terminate if you forgot to join, RAII as a tripwire; C++20’s std::jthread makes the destructor join instead, RAII as a guarantee.)
Every one of these wrappers needs the same four lines: delete copy, write move, null the source, destructor checks the null. The move constructor isn’t an optimization here. For move-only resources it’s the ownership-transfer protocol, and without it RAII types would be prisoners of the scope they were born in: unable to be returned, stored in vectors, or handed to threads.
Which means I can finally come back up a layer. We have objects that own things, destructors that always run, and a consent mechanism (std::move, the cast) for marking objects stealable. Time to actually steal something and measure what it’s worth.
What stealing is worth: real numbers
Time to put numbers on this. The setup: a std::vector of one million std::strings, each 64 characters long. The length matters and I’ll come back to why. Methodology: 10 runs per case, -O2, a do_not_optimize asm barrier on the results so clang can’t delete the work, timed with steady_clock, destruction kept outside the timed window (my first version timed the destination’s destructor along with the operation and I spent a while confused about why “moving” a vector apparently took 15 milliseconds, it was the million strings being freed). Hardware is the M4 MacBook Pro from the intro. These are my real measurements; on your machine expect the ratios to hold and the absolute numbers to wobble.
Four cases. Copy the whole vector; move the whole vector; copy element-by-element into a reserved destination; move element-by-element into a reserved destination.
vector copy (1M x 64B strings) mean 19.634 ms min 17.198 max 35.347 sd 5.550 (n=10)
vector move mean 0.000 ms min 0.000 max 0.000 sd 0.000 (n=10)
element-wise copy (reserved) mean 18.271 ms min 17.978 max 18.805 sd 0.253 (n=10)
element-wise move (reserved) mean 3.068 ms min 2.839 max 3.427 sd 0.170 (n=10)
(One of the ten copy runs hit 35 ms. I didn’t chase it, something else wanted the machine for a moment. That’s what max columns are for.)
The whole-vector move reads 0.000 ms because it’s below the resolution of a millisecond counter, so I re-ran it with a nanosecond clock:
run 0: move took 0 ns
run 1: move took 0 ns
run 2: move took 0 ns
run 3: move took 42 ns
run 4: move took 0 ns
Zero or 42, never anything in between, because 42 ns is one tick. Apple silicon’s steady_clock runs off a 24 MHz timebase, so time on this machine is quantized to 41.7 ns steps, and the honest reading of that table is “at most one tick.” Moving a vector of a million strings costs the same as moving a vector of ten strings or ten billion: it’s three pointer assignments. std::vector is a 24-byte handle (begin, end, capacity-end) pointing at a heap block, heap meaning the manually-managed memory pool that new allocates from, as opposed to the stack, the scratch space that lives and dies with each function call. The move constructor copies the three pointers and nulls the source’s. The million strings never hear about it. O(1), and not a fast O(n), a real O(1).
Against that, the copy: 19.6 milliseconds. That’s one million operator new calls (each 64-char string is over libc++‘s inline capacity, so each one heap-allocates), one million 64-byte memcpys, plus the vector’s own allocation. Half a million times slower than the move, give or take, the exact ratio is silly to compute when the denominator is one clock tick. The ratio isn’t the point anyway, the shape is: copy scales with the data, move doesn’t.
The element-wise rows are the subtler lesson. Element-wise move is 3.1 ms, not 42 ns, because moving a million strings individually still does a million header copies (each string move copies ~24 bytes of pointer/size/capacity and nulls the source), touching ~48 MB of memory. Six times faster than element-wise copy, because zero allocations, but emphatically not free. Move is “don’t copy the heap stuff”; it was never “don’t do anything.” When the container can transfer one block instead of N elements, it’s nanoseconds; when something forces per-element work, you’re back in milliseconds. (This same distinction is why std::vector growth wants noexcept move constructors, and why a reallocation is element-wise even though a move is O(1). Different operations, different costs, same vocabulary.)
And the length caveat I promised: at 64 characters, every string owns a heap block, so copying is expensive and moving is cheap. If I’d used short strings, libc++‘s small-string optimization would store them inline, no heap block exists, and “move” would degrade into exactly a copy.
Writing the thief: Buffer, and the bug ASan caught
The standard containers hide the move machinery, so to own it I wrote the smallest class that needs it, and I wrote it with the classic bug first, on purpose, because I wanted to watch the failure happen rather than take the rule on faith:
struct Buffer {
size_t n_;
float* data_;
explicit Buffer(size_t n) : n_(n), data_(new float[n]) {}
~Buffer() { delete[] data_; }
Buffer(const Buffer&) = delete;
Buffer& operator=(const Buffer&) = delete;
// BUG: steals the pointer but leaves the source pointing at it too
Buffer(Buffer&& o) noexcept : n_(o.n_), data_(o.data_) {
// o.data_ = nullptr; <- the missing line
}
};
int main() {
Buffer a(1024);
Buffer b = std::move(a);
} // ~b frees data_, then ~a frees the SAME data_
The thief forgot to leave the note. Both a and b now hold the same pointer, both destructors will run (reverse order, as we established), and the same heap block gets freed twice. Here’s what AddressSanitizer says, real output, trimmed to the interesting part:
==62622==ERROR: AddressSanitizer: attempting double-free on 0x000105f02900 in thread T0:
#1 0x102993d18 in Buffer::~Buffer() c2_buffer_doublefree.cpp:6
#3 0x10299393c in main c2_buffer_doublefree.cpp:17
freed by thread T0 here:
#1 0x102993d18 in Buffer::~Buffer() c2_buffer_doublefree.cpp:6
#3 0x102993920 in main c2_buffer_doublefree.cpp:17
previously allocated by thread T0 here:
#1 0x102993b18 in Buffer::Buffer(unsigned long) c2_buffer_doublefree.cpp:5
SUMMARY: AddressSanitizer: double-free
Same destructor, same line, two frames in main: the report is practically a diagram of the bug.
Except that’s the second thing that happened, and the first thing is worth recording. I initially compiled this demo with -O1 -fsanitize=address, ran it, and got… nothing. Clean exit. No report. I genuinely thought I’d built the wrong file. What actually happened: nothing ever reads data_, so at -O1 clang noticed the new float[1024] was unobservable and deleted the allocation entirely (C++ explicitly permits eliding allocations), and with no allocation there’s no double-free, and ASan truthfully reported a program with no memory errors, because the program it instrumented didn’t have any. The bug is in my source code but not in my binary. At -O0 the allocation survives and ASan fires.
So a green ASan run at -O1 proved nothing about this source-level suspicion. Sanitizers instrument the program that survives compilation, not my mental model of the operations I expected to survive. I used -O0 to make this small demonstration observable, but that is not a general ASan prescription. Clang’s own ASan documentation recommends -O1 or higher for ordinary use. The durable fix is to make the operation observable, run the sanitizer in the optimization modes that matter to the product, and treat a clean run as evidence about those binaries rather than proof that every build from the source is safe.
The fixed class, which is the template I now write by muscle memory:
Buffer(Buffer&& o) noexcept
: n_(std::exchange(o.n_, 0)), data_(std::exchange(o.data_, nullptr)) {}
Buffer& operator=(Buffer&& o) noexcept {
if (this != &o) {
delete[] data_; // free what I held
n_ = std::exchange(o.n_, 0);
data_ = std::exchange(o.data_, nullptr); // take, and null the source
}
return *this;
}
Move assignment has two extra duties the constructor doesn’t: free your own resource before taking theirs (the target was a live object, the constructor’s target was raw storage), and survive self-assignment, hence if (this != &o). Without the guard, c = std::move(c) frees c’s buffer and then “steals” the freed pointer from itself. Does self-move happen in real code? Not literally, but swap(v[i], v[j]) with i == j, or algorithms shuffling ranges, can produce it through aliases. The guard costs one compare. ASan confirms the fixed version clean, including a deliberate self-move.
Both special members are marked noexcept, and here is where the politeness theory dies. During reallocation, std::vector has to relocate every element while preserving its exception guarantees. When a type is copyable but its move constructor might throw, standard-library implementations commonly choose the copy path; std::move_if_noexcept expresses that exact decision. A throwing move could leave the old range partly modified with no reliable way back. Forget noexcept and a move-enabled type may silently copy during vector growth. The element-wise numbers above show the size of the possible difference on this input, although a vector reallocation has its own allocation and relocation costs.
Proving the title: three moves that don’t move
I keep saying std::move is a cast. Here’s the demonstration that finally made it concrete for me, a Traced type that announces its constructors, and four scenarios:
struct Traced {
Traced() { std::puts(" default ctor"); }
Traced(const Traced&) { std::puts(" COPY ctor"); }
Traced(Traced&&) noexcept { std::puts(" MOVE ctor"); }
};
// case 1 // case 2 // case 3
Traced t; const Traced t; Traced t;
std::move(t); Traced u = std::move(t); Traced&& r = std::move(t);
case 1: std::move with no receiver
default ctor
(nothing happened)
case 2: std::move on a const object
default ctor
COPY ctor
case 3: std::move into a reference
default ctor
(nothing happened)
case 4: for contrast, an actual move
default ctor
MOVE ctor
Case 1: std::move(t); as a bare statement does nothing at all. No constructor runs. It’s a cast whose result nobody used, exactly as inert as writing (int)x; on its own line. Clang even warns about it (ignoring return value of function declared with 'nodiscard'), which I love: the standard library annotated std::move with [[nodiscard]] precisely because the function alone has no effect, a formal admission, in attribute form, that std::move doesn’t move anything.
Case 2 is our const trap again, now visible in the constructor log: you wrote std::move, you got COPY ctor.
Case 3 might be the purest one: binding Traced&& r = std::move(t) constructs nothing. A reference is an alias; r is just another name for t. The xvalue got consumed by reference binding and no object was harmed.
Only case 4, where an actual constructor receives the xvalue, does a move happen. So the full sentence, the precise version of this post’s title: std::move re-tags an expression so that overload resolution, at some other site (a constructor, an assignment operator, a probe(string&&)), is permitted to select the destructive overload. The move, when it happens at all, happens there. The cast is an enabling condition, never the act.
What’s left behind: the moved-from state
If moves leave the source destructible, what exactly is the source afterwards? The standard’s phrase for moved-from standard-library objects is “valid but unspecified state”: the object’s invariants hold, you can call any function with no preconditions (destructor, assignment, clear(), empty()), but the value is anyone’s guess.
Folklore says that for std::string specifically, a short string might survive a move with its contents intact, because the small-string optimization means there’s no heap buffer to steal, so the move is a copy and implementations might not bother clearing the source. I tested it, libc++, Apple clang 15:
std::string short_s = "hi"; // SSO, lives inline
std::string long_s(64, 'y'); // heap-allocated
const void* long_buf_before = long_s.data();
std::string short_dst = std::move(short_s);
std::string long_dst = std::move(long_s);
moved-from short: size=0 data=""
moved-from long : size=0 data=""
long buffer transferred: yes (same heap pointer)
reused : "reused after move"
Folklore busted, at least here: libc++ empties both. The long string’s heap buffer really did transfer (same pointer observed in the destination, that’s the actual theft), and the source was reset to empty either way. But, and this matters, “libc++ does it” is not “the standard guarantees it.” libstdc++ or MSVC are entitled to leave "hi" in the short one. Code that reads a moved-from string and expects "" is gambling on an implementation detail. The only portable moves (sorry) are: destroy it, or give it a new value first, which the last line demonstrates, assignment reanimates the husk and it’s a perfectly good string again.
For your own types, though, “unspecified” is a choice, not an obligation. You define the moved-from state, and sometimes a meaningful one is the design. My favorite shape, a pooled connection where moved-from means “returned to pool, but still identifiable”:
struct PooledConnection {
int id_;
bool owns_;
PooledConnection(PooledConnection&& o) noexcept : id_(o.id_), owns_(o.owns_) {
o.owns_ = false; // moved-from keeps its id, loses ownership
}
~PooledConnection() {
if (owns_) return_to_pool(id_);
// else: husk, but id_ is still readable for logging
}
};
conn 42 checked out
a.id_ = 42 (moved-from, still meaningful)
conn 42 returned to pool
conn 42: dead husk destroyed (id still readable: 42)
The moved-from object keeps its identity for diagnostics while surrendering the resource. The general principle: a move constructor is two assignments and a promise, and you author the promise. The minimum promise is “destructible and assignable.” Anything beyond that, like id_ surviving, is API surface you’re committing to.
Where HPX moves things, and why
Theory secured, I went looking at how the codebase I actually work in uses this. Three load-bearing examples from HPX 2bb509d7e0. One note for reading them: HPX writes HPX_MOVE instead of std::move. I assumed namespacing hygiene and read on.
First, libs/core/futures/include/hpx/futures/promise.hpp:75, the move constructor of promise_base:
promise_base(promise_base&& other) noexcept
: shared_state_(HPX_MOVE(other.shared_state_))
, future_retrieved_(other.future_retrieved_)
, shared_future_retrieved_(other.shared_future_retrieved_)
{
other.future_retrieved_ = false;
other.shared_future_retrieved_ = false;
}
shared_state_ is an intrusive_ptr (a shared pointer whose reference count lives inside the object it manages) pointing at the heart of the future/promise pair, the shared state where the eventual value lands. Moving it transfers the reference without touching the reference count: a copy would increment, then the source’s destruction would decrement, two atomic operations on a hot path for nothing. And look at the constructor body: it manually resets the source’s flags. The “leave a note” protocol from our FileHandle, in production, written by people who do this for a living.
Second, libs/core/futures/include/hpx/futures/future.hpp:378, inside then(), the continuation mechanism:
static auto then(Derived&& fut, F&& f, error_code& ec = throws)
-> decltype(...)
{
// ...
return future_then_dispatch<std::decay_t<F>>::call_alloc(
allocator_type{}, HPX_MOVE(fut), HPX_FORWARD(F, f));
}
When you chain fut.then(callback), the future is moved into the continuation machinery. This isn’t about speed, it’s about semantics: a non-shared future represents a unique claim on a result, and then() consumes that claim and issues a new future for the continuation’s result. The move encodes “this handle is spent” in the type system. (Notice the parameter is Derived&& fut and the body still writes HPX_MOVE(fut). Named rvalue reference parameter, lvalue inside the body. Section three of this post, in the wild, the re-assertion is mandatory.)
Third, libs/full/parcelset/include/hpx/parcelset/parcelport_impl.hpp:269, the network send path (a parcel is HPX’s unit of network communication, action + arguments + destination):
detail::parcel_await_apply(HPX_MOVE(p), HPX_MOVE(f), archive_flags_,
[this, dest](parcel&& p, write_handler_type&& f) {
if (/* can send immediately */)
send_immediate_impl(dest, &f, &p, 1);
else
enqueue_parcel(dest, HPX_MOVE(p), HPX_MOVE(f));
});
Follow p through that excerpt: moved into parcel_await_apply, received by the lambda as parcel&& p, moved again into enqueue_parcel. A parcel can carry megabytes of serialized argument data, and it changes hands here twice without a single byte of payload copied, a relay race where only the baton-pointer moves. Notice that the three examples move for three different reasons, the promise to dodge refcount traffic, then() to encode in the type system that the old handle is spent, the parcelport because copying megabytes per hop would be absurd. Same cast, different job each time.
So: moves are real, cheap, and everywhere. Now the twist, which took me longer to accept than anything above: the best move is the one that doesn’t happen, and sometimes writing std::move actively prevents that.
Copy elision: the compiler was ahead of us the whole time
Here’s a question that bugged me early and I postponed: if pre-C++11 code copied everything, how was any of it usable? Functions returned vectors by value in 1998. Was everyone just eating a full deep copy on every return?
No, and the answer reorders the whole story. Compilers have been eliding copies on return paths since the 1990s, long before move semantics existed. The trick is brutally simple: when a function returns by value, the caller already knows where the result needs to end up (the variable being initialized), so the compiler passes that address into the function, and the function constructs its result directly there. No return-copy ever exists to optimize away. This is RVO, return value optimization, and its cousin NRVO (named RVO) does the same when you return a named local.
I instrumented it with a counting version of Traced and four return patterns:
Traced rvo() { return Traced{}; } // return a temporary
Traced nrvo() { Traced t; t.payload = 1; return t; } // return a named local
Traced nrvo_move() { Traced t; t.payload = 1; return std::move(t); }
Traced two_paths(bool b) {
Traced a; Traced b2;
if (b) return a; // two different
return b2; // named returns
}
First, the world as it would be with no elision and no moves. -std=c++14 -fno-elide-constructors shows the naive cost of the calling convention (each return materializes a value in a return slot, then the caller initializes its variable from the slot):
rvo: defaults=1 copies=0 moves=2
nrvo: defaults=1 copies=0 moves=2
nrvo_move: defaults=1 copies=0 moves=2
two_paths: defaults=2 copies=0 moves=2
Two extra constructor calls per return. (Moves, not copies, only because Traced has a move constructor; delete it and these all become copies, the actual 1998 experience.) Now the same code, default -std=c++17:
rvo: defaults=1 copies=0 moves=0
nrvo: defaults=1 copies=0 moves=0
nrvo_move: defaults=1 copies=0 moves=1
two_paths: defaults=2 copies=0 moves=1
Read rvo and nrvo: one default construction, zero anything else. The object the function built and the object the caller named are the same object. Construction happened once, directly in the caller’s storage, two stack frames coordinating on one address.
Now read nrvo_move: adding std::move to the return statement made it worse. One extra move constructor versus plain return t;. The mechanism: NRVO requires return t; to be returning the name of a local, so the compiler can prove “this local is the return object” and fuse their storage. return std::move(t); returns an xvalue expression, not a name; the pattern match fails, NRVO is off, and the compiler dutifully move-constructs the return value from your xvalue. Your explicit consent form, the cast we spent half this post learning to write, blocked the better optimization that needed no consent at all.
So the rule, which clang will even enforce for you (-Wpessimizing-move warns on exactly this): never std::move a local into a return. Plain return t; is never worse and usually strictly better. There’s even a safety net behind it: since C++11, if NRVO doesn’t apply, return t; implicitly treats t as an rvalue anyway (you can’t observe t after the return, so the standard lets the compiler steal without the cast). You literally cannot win by writing std::move there.
two_paths shows the boundary of NRVO honestly: two different locals can satisfy the return, the compiler can’t construct both into the return slot, so one path eats a move. NRVO is an optimization with structural preconditions, not a law. Single named return object: free. Branchy returns: cheap, one move, courtesy of the implicit-rvalue rule.
C++17 makes the temporary disappear by definition
Everything above, NRVO included, is still officially optional: the standard permits the fusion but doesn’t require it. C++17 went further for one specific case, and the way it did it resolves the xvalue/prvalue distinction from the top of this post.
C++17 changed the prvalue model. A class prvalue is no longer required to materialize a separate temporary before initializing its destination. It has a result object, and the surrounding context supplies that object. In Pinned p = make_pinned();, the prvalue returned by make_pinned() initializes p directly. “Guaranteed copy elision” is the familiar name, but in this case there is no temporary copy for an optimizer to remove. The language describes one result object from the start. The temporary materialization rules say when a separate temporary actually has to appear.
The proof is a type that cannot be copied or moved at all:
struct Pinned {
Pinned() { std::puts(" Pinned ctor"); }
Pinned(const Pinned&) = delete;
Pinned(Pinned&&) = delete;
};
Pinned make_pinned() { return Pinned{}; } // returning a recipe
int main() {
Pinned p = make_pinned(); // recipe executed here, in p's storage
}
Under C++14 this is a hard error, and clang’s message shows it trying to find the very constructors we deleted:
error: call to deleted constructor of 'Pinned'
Pinned make_pinned() { return Pinned{}; }
note: 'Pinned' has been explicitly marked deleted here
Pinned(Pinned&&) = delete;
Under C++17, same source, same compiler: compiles, runs, prints Pinned ctor exactly once. Not because the compiler optimized the move away, but because the language no longer asks for a move to exist. Factory functions for immovable types (mutexes, atomics, lock guards, anything address-pinned) are legal now, by definition rather than by optimizer mercy.
This recontextualizes the whole post. For prvalues, the modern C++ answer to “how do I avoid the copy?” is construction of the result object at its destination, with no intermediate object. Move semantics is the tool for xvalues, for expressions designating objects that already exist and whose resources may be reused. The two halves of “rvalue” from section one use different mechanisms: prvalues initialize their result objects, while xvalues let constructors and assignments take resources from existing objects. std::move only produces the latter kind of expression. In the guaranteed-elision case, nothing moves because there is no separate source object to move from.
My updated mental checklist for “returning things,” which is shorter than I expected when I started: return prvalues or single named locals by value, never decorate a return with std::move, and reserve std::move for the cases where an object with a name needs to surrender its resources mid-life: into a member, into a container, into a sink parameter, across a then().
unique_ptr: ownership becomes a type
Everything so far manages resources by convention: Buffer nulls its source because I remembered to write it, the destructor pairs with the constructor because I wrote both. The next layer down is making the convention into a type so the compiler enforces it, and the purest specimen is unique_ptr. It’s also, historically, the vindication of this whole subject: C++98 tried to build it as auto_ptr and failed, because without rvalue references, “transfer on copy” was the only option, and a copy constructor that mutates its source broke every algorithm that assumed copies were copies. auto_ptr was deprecated, then deleted from the standard. unique_ptr is auto_ptr with move semantics, which is to say: move semantics is the missing language feature that made single ownership expressible.
I wrote a minimal one from scratch. Sixty lines, the whole thing:
template <class T>
class UniquePtr {
T* p_ = nullptr;
public:
UniquePtr() = default;
explicit UniquePtr(T* p) noexcept : p_(p) {}
~UniquePtr() { delete p_; }
UniquePtr(const UniquePtr&) = delete;
UniquePtr& operator=(const UniquePtr&) = delete;
UniquePtr(UniquePtr&& o) noexcept : p_(std::exchange(o.p_, nullptr)) {}
UniquePtr& operator=(UniquePtr&& o) noexcept {
if (this != &o) { delete p_; p_ = std::exchange(o.p_, nullptr); }
return *this;
}
T* get() const noexcept { return p_; }
T& operator*() const noexcept { return *p_; }
T* operator->() const noexcept { return p_; }
explicit operator bool() const noexcept { return p_ != nullptr; }
T* release() noexcept { return std::exchange(p_, nullptr); }
void reset(T* p = nullptr) noexcept { delete std::exchange(p_, p); }
};
Every line is something from earlier in this post. Deleted copies: exclusive ownership has no copy semantics (the FileHandle argument). std::exchange in the move: take and null, the thief’s note. delete p_ before taking in move-assignment: free your own before stealing theirs. The self-assignment guard. noexcept so vectors will actually move it. delete nullptr is defined as a no-op, which is why the destructor needs no if. The only new entries are release(), which is ownership transfer out to a raw pointer (the note without the theft), and reset(), which is “new tenant, evict the old one.” I ran ten test cases over it covering default/null state, transfer, source-nulling, assignment freeing the old target, self-move, release, reset, both dereference operators, and a round-trip through a function boundary, with a live-object counter asserting zero leaks after each, under ASan:
all 10 tests passed, no leaks (Probe::live == 0)
ASan exit=0
Where smart pointers earn their keep, though, isn’t the happy path, it’s the path we proved out with FileHandle: exceptions. The experiment, with a live-counter instead of a leak checker:
void raw_version(bool fail) {
Res* a = new Res;
Res* b = new Res;
might_throw(fail); // <- everything below never runs
delete b;
delete a;
}
void smart_version(bool fail) {
auto a = std::make_unique<Res>();
auto b = std::make_unique<Res>();
might_throw(fail); // unwinding runs ~unique_ptr
}
raw version after exception: live=2 LEAKED
unique_ptr version after throw: live=0 clean
Before C++17, make_unique over unique_ptr<T>(new T) also closed a real evaluation-order hole. In f(unique_ptr<A>(new A), g()), an implementation could evaluate new A, then call g(), then construct the unique_ptr; if g() threw in that window, the A was an orphan no destructor could find. C++17 prohibited that interleaving, although the order between complete argument evaluations remains unspecified. make_unique is still the clearer form: allocation and ownership begin inside one call, and the type name is written once.
shared_ptr: what shared ownership actually costs
unique_ptr answers “exactly one owner.” shared_ptr answers “last one out turns off the lights,” and the price of that answer is a second heap object that most people never picture. Some honest sizes first, from my machine:
sizeof(Big*) = 8
sizeof(std::unique_ptr<Big>) = 8 <- zero overhead, it IS the pointer
sizeof(std::shared_ptr<Big>) = 16
sizeof(std::weak_ptr<Big>) = 16
unique_ptr compiles down to a raw pointer; the ownership rules exist purely at compile time and cost nothing at runtime. That’s worth pausing on: the entire single-ownership protocol, the deleted copies, the transfer-and-null, all of it evaporates into the type system. shared_ptr is twice the size because it carries two pointers: one to your object, one to the control block, the hidden heap allocation where the bookkeeping lives:
shared_ptr<T> control block your object
+----------------+ +--------------------+ +-----------+
| T* ptr |---------->| | +-->| T |
| ctrl* ctl |-----+ | strong count: 3 | | +-----------+
+----------------+ +---->| weak count: 1 | |
| deleter | |
shared_ptr<T> (copy) | T* (or T inline) |--+
+----------------+ +--------------------+
| T* ptr |------------------------------------^
| ctrl* ctl |----------->(same control block)
+----------------+
Strong count: how many shared_ptrs exist; hits zero, the T is destroyed. Weak count: how many weak_ptrs are watching; the control block itself stays alive until both counts are zero, which is how a weak_ptr can safely answer “is it still there?” about an object that’s already gone. (This also explains the one sharp edge of make_shared: it puts T inside the control block, one allocation instead of two, but then the T’s memory can’t be freed until the last weak_ptr lets go of the block.)
The counts through a lifecycle, printed live:
after make_shared: use_count=1
after copy b = a: use_count=2
after move c = move(b): use_count=2 (move doesn't touch the count)
after weak_ptr w = a: use_count=2 (weak doesn't count)
inside w.lock(): use_count=3
after scope exit: use_count=1
The third line is this post’s thesis surfacing again: copying a shared_ptr increments the count, moving one doesn’t. A move transfers the existing claim, two pointer copies and a null-out, no atomics. This is exactly why HPX’s promise_base moved its intrusive_ptr instead of copying it. When you see std::move on a shared_ptr in a hot path, the author is dodging the refcount.
Why dodge it? Because the count is an atomic, an operation the hardware guarantees happens indivisibly even with many cores hammering the same memory, and it has to be, since shared_ptr’s whole job is cross-thread ownership. Atomics on contended memory are where multicore performance goes to die. So: 16 threads, 10 million copies, measure it. Two scenarios that differ in one line: all threads copying the same shared_ptr (one control block, all cores fighting over it) versus each thread copying its own (16 control blocks, no sharing). Three runs:
16 threads x 625000 copies each (10M total)
run 0: shared control block 717.0 ms | private control blocks 13.8 ms | ratio 52.0x
run 1: shared control block 769.2 ms | private control blocks 24.5 ms | ratio 31.4x
run 2: shared control block 795.0 ms | private control blocks 15.4 ms | ratio 51.6x
Call it fifty times slower. (Run 1’s ratio dipped to 31x not because contention got kinder but because the private baseline caught scheduler noise and nearly doubled; the shared side never gets lucky like that.) Same instruction count, same data, same machine. The entire difference is cores playing hot potato with one cache line, cores don’t share memory byte-by-byte, they pass around fixed-size chunks called cache lines, and a core must own a line exclusively to write it. So the line holding the reference count ricochets between cores at a few hundred cycles per bounce, ten million times. The private-block version does the same atomic ops uncontended at roughly 25 ns per copy-destroy pair; contended, over a microsecond. Nothing about the language changed, only the sharing topology.
The practical decoding: shared_ptr copies are not “a little slower” than moves, they’re a scalability liability when one object is hot. Pass const shared_ptr& (or better, T&/T*) down call chains that don’t take ownership; copy at the boundary where ownership actually forks; move whenever the claim is being handed off rather than duplicated.
Cycles, and ownership as a graph you have to draw
The strong count has a blind spot you can construct in four lines:
struct NodeS {
std::shared_ptr<NodeS> next;
std::shared_ptr<NodeS> prev; // strong back-edge: the bug
};
auto a = std::make_shared<NodeS>();
auto b = std::make_shared<NodeS>();
a->next = b; b->prev = a;
strong cycle:
a.use_count=2 b.use_count=2
(scope exited: no destructor ran, both leaked)
weak back-edge:
a.use_count=1 b.use_count=2
NodeW destroyed
NodeW destroyed
When the scope ends, the stack’s a drops node-a’s count from 2 to 1, the stack’s b likewise, and there they sit, each kept alive by the other’s interior pointer, unreachable from anywhere, counts pinned at 1, destructors never printed. Reference counting cannot collect cycles; that’s not an implementation gap, it’s the algorithm. (It’s also why “shared_ptr is C++‘s garbage collection” is wrong in the one case where you’d lean on a GC hardest.)
The fix is weak_ptr on the back-edge, and the run above shows both destructors firing. But the fix only tells you the mechanism; the design question is which edge gets demoted, and the answer has to come from the domain: ownership must form a DAG, parents own children, children point back weakly. For the small system I’m designing this into (a task scheduler), the exercise that actually helped was drawing it:
Scheduler ──unique──> WorkQueue ──unique──> [Task, Task, ...]
│ │
└─shared─> ThreadPool <───── weak ─────────┘
(tasks may outlive a pool ref, never own it)
Every edge in the graph gets a label from exactly four options: unique (one owner, the default, costs nothing), shared (forked lifetime, costs atomics, must justify itself), weak (observation without ownership), or raw pointer/reference (non-owning, lifetime guaranteed by scope elsewhere). My rule after doing this exercise: if I can’t label an edge, I don’t understand the design yet, and shared_ptr is how I’d be hiding that from myself. unique_ptr is the default; shared is the exception that needs a sentence of justification.
The same idea, distributed: hpx::id_type
Here’s where this stops being a single-process story. HPX is a distributed runtime: objects live on one machine (a locality) and are referenced from many others. A reference count in one process’s memory can’t see remote references. So how do you do shared_ptr across a cluster?
HPX’s global pointer is hpx::id_type (libs/full/naming_base/include/hpx/naming_base/id_type.hpp:48), and its declaration already tells you lifetime is the whole game:
struct id_type
{
enum class management_type
{
unknown_deleter = -1,
unmanaged = 0, ///< unmanaged GID
managed = 1, ///< managed GID
managed_move_credit = 2 ///< managed GID that will give up all
///< credits when sent
};
// ...
hpx::intrusive_ptr<naming::detail::id_type_impl> gid_;
};
unmanaged is the raw pointer of the distributed world: a name with no lifetime tracking. managed participates in distributed reference counting. And the mechanism behind managed is the cleverest thing in this post that I didn’t write a single line of code for: credits, baked into spare bits of the 128-bit global identifier itself. From gid_type.hpp:55:
static constexpr std::uint64_t credit_base_mask = 0x1full;
static constexpr std::uint16_t credit_shift = 24;
static constexpr std::uint64_t was_split_mask = 0x80000000ull;
static constexpr std::uint64_t has_credits_mask = 0x40000000ull;
The naive distributed refcount would message the object’s home locality on every reference copy and drop: an atomic increment that costs a network round-trip. Credits amortize that. The scheme, as I read it from the source: a GID carries a credit value, stored as log2 in five bits of the id’s most-significant word (the code comment says it plainly: “We store the log2(credit) in the gid_type”). When an id is sent to another locality, the sender doesn’t phone home; it splits its credits, keeping half and sending half, marking both with was_split_mask. Each copy can keep splitting, powers of two all the way down, no communication. Only at the edges does the network get involved: when a managed id is destroyed, its credits are returned to the home locality, and the object dies when all outstanding credit comes home. And managed_move_credit, per its comment, is the move semantics of the distributed world: send the id with all its credits, surrendering your claim, ownership transfer without touching the global count, which is precisely what std::move does to a shared_ptr, lifted to cluster scale.
I want to be straight about the limits of my understanding here: I’ve read the bit-mask layer and the comments, and the split-in-half, log2-encoded scheme is clear at that level. What I haven’t traced is the exhaustion path, what happens when an id holding credit 1 needs to be copied again (presumably a synchronous top-up from the home locality, which is the round-trip the scheme exists to avoid), and how AGAS (HPX’s global address service, the directory that knows where every distributed object lives) reconciles returned credit against splits in flight. That’s a future post; the parcelset code I quoted earlier is where those credits physically travel.
The thing I take from id_type isn’t HPX trivia. It’s that the concepts in this post are scale-free. Local: unique_ptr (sole owner), shared_ptr (counted owners), raw pointer (unmanaged), move (transfer the claim). Distributed: managed_move_credit, managed with credits, unmanaged, sending-with-all-credits. Same four ownership shapes, same trade-offs, the network just makes the costs visible enough that nobody could pretend they’re free.
Opening the watch: how std::move is actually built
Back to the line that started all this, because I owed myself an implementation from memory:
template <class T>
constexpr remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<remove_reference_t<T>&&>(t);
}
Every piece of this puzzled me at some point. Why is the parameter T&& when we just spent a whole section establishing that rvalue references don’t bind to lvalues, yet std::move(x) works on lvalues? Why remove_reference_t before adding && back? What even is remove_reference_t? It turns out you can’t build this one-liner without three template-machinery features, and pulling that thread unravels into the whole template system. Bottom of the rabbit hole. Let’s go.
First: what is a template? Not a function. A template is a recipe for stamping out functions (we’ve seen this recipe-not-object pattern once already, with prvalues; the language likes this trick). Until you call it, no code exists. I verified this in the most literal way available: nm lists every function that actually made it into a compiled object file, so I compiled a template and looked.
template <class T> T biggest(T a, T b) { return a < b ? b : a; }
biggest(1, 2); // forces int version into existence
biggest(1.0, 2.0); // forces double version
$ nm g1.o | c++filt | grep -E "biggest|Box"
00000000000000dc T double biggest<double>(double, double)
0000000000000094 T int biggest<int>(int, int)
000000000000013c T Box<char>::get() const
0000000000000124 T Box<int>::get() const
Two distinct functions, at two distinct addresses, stamped into the binary, one per instantiation. (Compiler Explorer shows the same thing with nicer colors; nm works offline.) Call it with a third type, get a third function. This is why template-heavy code bloats binaries and why your build takes forever: the compiler is running a code generator, and the generator’s input language is types.
Second piece: remove_reference. The standard one is spelled exactly like my hand-rolled version:
template <class T> struct my_remove_ref { using type = T; }; // primary
template <class T> struct my_remove_ref<T&> { using type = T; }; // partial spec
template <class T> struct my_remove_ref<T&&> { using type = T; }; // partial spec
This is partial specialization, and the way to read it is as pattern matching on type structure, the same mental model as matching on a list’s shape in a functional language. The primary template is the catch-all. The <T&> specialization says “if the argument is anything-followed-by-&, bind T to the anything.” Given int&, the second pattern matches with T = int, so ::type is int, the reference is peeled off. There’s no subtraction operator on types; “removing” the reference works by capturing the part you want from inside the pattern. The classifier from the top of this post, the one I promised to explain, is the identical trick with three static const char*s instead of three types, matching the T/T&/T&& shape that decltype((e)) encodes the value category into. Loop closed.
(For completeness, since I needed both in this project: that’s partial specialization, a narrower pattern still containing wildcards. Full specialization, template <> struct Thing<int>, names one exact type, no wildcards, and is the right tool when one specific type needs different behavior. Partial is for whole families, all pointers, all references, all pair<A,B>. Function templates can’t be partially specialized at all, only overloaded, which is why all this machinery lives in structs.)
Third piece, the deep one: how does std::move(x) accept an lvalue through a T&& parameter? Because T&& in a deduction context is not an rvalue reference. Scott Meyers calls it a universal (forwarding) reference, and the standard makes it work via two special rules. Rule one, special deduction: passing an lvalue std::string deduces T = std::string& (with the reference!), passing an rvalue deduces T = std::string. Rule two, reference collapsing: when substitution would create a reference-to-reference, which you can’t write by hand, the compiler collapses it, and the rule is that lvalue wins:
template <class T> using lref = T&;
template <class T> using rref = T&&;
static_assert(std::is_same_v<lref<int&>, int&>); // & & -> &
static_assert(std::is_same_v<lref<int&&>, int&>); // && & -> &
static_assert(std::is_same_v<rref<int&>, int&>); // & && -> &
static_assert(std::is_same_v<rref<int&&>, int&&>); // && && -> &&
(These compile; that’s the proof. & is dominant, && only survives when both sides are &&.) Now trace my_move(x) with lvalue x: T deduces to string&, the parameter T&& is string& &&, collapses to string&, binds to the lvalue, fine. Return type: remove_reference_t<string&> is string, add &&, get string&&, cast, xvalue out. Trace it with an rvalue: T = string, parameter string&&, return string&&. Both inputs, one output category. std::move is a funnel: whatever reference-ness comes in, the pattern-match strips it and stamps && on. That’s the entire function, and now every character of it is accounted for: T&& to accept anything, remove_reference_t to erase what it was, && to declare what it now is, static_cast because a value category change is, formally, a conversion.
forward: the conditional cast
The same machinery, configured differently, gives the other famous one-liner. The problem first, because I hit it before I understood the solution. A wrapper that passes its argument along:
template <class T>
void broken_wrapper(T&& arg) {
target(arg); // BUG
}
broken_wrapper(std::string{"tmp"}); // rvalue in...
-> target(string&) ...lvalue out
An rvalue went in; the lvalue overload got called. Of course: arg has a name, named things are lvalues (the sink lesson, third time now, it keeps being the answer). But I can’t fix it with std::move(arg) either; that would force the rvalue path even when the caller passed an lvalue it intends to keep using. I need a cast that restores whatever the caller had, and the information about what the caller had is sitting in exactly one place: the deduced T. Lvalue caller: T = string&. Rvalue caller: T = string. So cast to T&& and let reference collapsing replay the original category:
template <class T>
constexpr T&& my_forward(my_remove_ref_t<T>& t) noexcept {
return static_cast<T&&>(t); // T=string&: collapses to string&, lvalue stays lvalue
} // T=string : string&&, rvalue restored
std::move unconditionally stamps &&; std::forward<T> replays the caller’s category through collapsing. One is a sledgehammer, one is a mirror. (The real std::forward has a second overload taking my_remove_ref_t<T>&& with a static_assert blocking rvalue-to-lvalue forwarding, an edge case you hit roughly never, but libc++ carries it, and now I know why.) The fixed wrapper, traced with clang’s __PRETTY_FUNCTION__ showing the deduction live:
wrapper(s): deduced: void wrapper(T &&) [T = std::string &]
-> target(string&)
wrapper(cs): deduced: void wrapper(T &&) [T = const std::string &]
-> target(const string&)
wrapper(std::move(s)): deduced: void wrapper(T &&) [T = std::string]
-> target(string&&)
Three different callers, three different deductions, three correct landings, one line of body. This is perfect forwarding, and combined with variadics it’s the factory pattern every make_* and emplace_* in the standard library is built on:
template <class T, class... Args>
T make(Args&&... args) {
return T(std::forward<Args>(args)...);
}
Verified against a Widget with copy, move, and (int, double) constructors: a named string in selects Widget(const string&), a moved or temporary string selects Widget(string&&), (3, 2.5) selects the two-arg constructor. The wrapper is transparent: zero copies introduced, the exact overload you’d have gotten calling the constructor directly. Note the return, too: T(...) is a prvalue, the recipe travels to the caller’s storage, mandatory elision, no move on the way out. Every layer of this post is in those three lines.
The pack, and the language above the language
Args&&... drags in the last two template features I needed. A pack is a template parameter that stands for any number of arguments, that’s what the ... declares. Fold expressions (C++17) are how you consume a pack without writing recursion, and the detail that impressed me is that the standard defined the empty pack correctly:
template <class... Ts> auto sum(Ts... xs) { return (xs + ... + 0); }
template <class... Ts> bool all_of(Ts... xs) { return (xs && ...); }
sum(1,2,3,4) = 10
sum() = 0 (empty pack, identity element)
all_of() = 1 (empty pack, vacuous truth)
&& over nothing is true, + needs an explicit 0 seed (the unseeded form is a compile error on empty packs): somebody on the committee remembered their algebra, each operator folding to its identity.
And once you’ve seen partial specialization as pattern matching and packs as lists, it’s hard to unsee what the template system actually is: a purely functional language that executes at compile time, where types are values and specializations are the match arms. The standard proof-of-Turing-completeness exhibits:
template <unsigned N> struct Factorial { static constexpr unsigned long value = N * Factorial<N-1>::value; };
template <> struct Factorial<0> { static constexpr unsigned long value = 1; };
template <class A, class B> struct IsSame { static constexpr bool value = false; };
template <class A> struct IsSame<A, A> { static constexpr bool value = true; };
static_assert(Factorial<10>::value == 3628800);
static_assert( IsSame<int, int>::value);
static_assert(!IsSame<int, const int>::value); // const is part of the type
Recursion with a full specialization as the base case; equality as a two-wildcard pattern that only matches when both wildcards bind the same type. constexpr functions have made this style mostly obsolete for arithmetic, and thank goodness. But is_same and remove_reference are not museum pieces: they’re the exact two type-level programs that std::move and the value-category classifier are made of. The metaprogramming layer isn’t a parlor trick bolted onto C++; it’s the layer the “normal” library is implemented in.
So the watch is fully open. The cast that doesn’t move anything is: a function template (stamped per type), whose parameter exploits special deduction rules, whose return type is computed by pattern-matching on type structure, glued by reference collapsing. One line of source, four language features deep.
There’s one problem left, and it’s the problem that dominates real generic codebases like HPX: these stamped-out templates accept everything. make<Widget> will happily try to instantiate with arguments no Widget constructor accepts and detonate with an error novel three layers deep. The last leg of this trip is how C++ learned to say no.
Teaching templates to say no: SFINAE
The original mechanism is an acronym that sounds like a disease: SFINAE, “Substitution Failure Is Not An Error.” The rule itself is small. When the compiler substitutes deduced types into a template’s signature and the result is malformed, that’s not a compile error; the candidate is just silently dropped from the overload set. The hack built on the rule: deliberately construct signatures that become malformed for the types you want to reject.
The standard tool is enable_if, which is two lines of the pattern-matching we just learned (primary template with no ::type; specialization for true that has one), and using it looks like this:
template <class T>
std::enable_if_t<std::is_integral_v<T>, void> describe(T) {
std::puts(" integral path");
}
template <class T>
std::enable_if_t<!std::is_integral_v<T>, void> describe(T) {
std::puts(" non-integral path");
}
When T is int, the first overload’s return type substitutes to void and the second’s substitution fails, no ::type exists, candidate dropped, no error. Call with a string and the roles flip. It works. It’s also a hack: the function’s constraint is encoded in its return type, the logic is duplicated and negated across the overloads (forget the ! and you get ambiguity errors), and the intent is invisible unless you already know the idiom.
C++17 gave the same dispatch a civilized form for many cases, one function, compile-time branch, dead branch never instantiated:
template <class T>
void describe(T) {
if constexpr (std::is_integral_v<T>) std::puts(" integral path");
else std::puts(" non-integral path");
}
Both versions produce identical output; the if constexpr one reads like code instead of like an incantation. But if constexpr only helps inside a function that has already been selected. It can’t remove a function from the overload set, so it can’t express “this constructor doesn’t exist for that type.” For that, real codebases kept paying the SFINAE tax, and I went and measured the tax in HPX: 738 occurrences of enable_if under libs/ (git grep -o enable_if | wc -l, at the pinned commit). Here’s a typical one, the constructor of hpx::function, libs/core/functional/include/hpx/functional/function.hpp:62, with its comment preserved because the comment is the confession:
// the split SFINAE prevents MSVC from eagerly instantiating things
template <typename F, typename FD = std::decay_t<F>,
typename Enable1 = std::enable_if_t<!std::is_same_v<FD, function>>,
typename Enable2 =
std::enable_if_t<is_invocable_r_v<R, FD&, Ts...>>>
function(F&& f)
{
assign(HPX_FORWARD(F, f));
}
Decode it: Enable1 says “don’t let this constructor hijack copy construction” (without it, function(other_function) would deduce F = function& and this greedy forwarding constructor would outcompete the real copy constructor, the classic perfect-forwarding footgun, our T&& funnel from two sections ago biting back). Enable2 says “only exist if F is actually callable with the right signature.” Two real constraints, expressed as two dummy template parameters with meaningless names, split apart not for clarity but because MSVC instantiates them too eagerly otherwise. Constraint logic, naming convention, and compiler workaround all tangled into one signature. This is the state of the art circa 2017, and HPX has 731 of these.
The tag_invoke detour
While auditing how HPX uses this machinery I kept running into a pattern that deserves its own subsection, because it’s where forwarding, SFINAE, and library design converge: customization point objects. The problem they solve: HPX wants hpx::async(thing, args...) to work on executors, schedulers, and user types it has never heard of, letting each type supply its own implementation, without the dispatch ambushes that plague raw ADL. The mechanism (libs/core/tag_invoke/include/hpx/functional/tag_invoke.hpp:233):
// helper base class implementing the tag_invoke logic for CPOs
template <typename Tag, typename Enable>
struct tag
{
template <typename... Args, /* enable_if machinery */>
constexpr auto operator()(Args&&... args) const
noexcept(is_nothrow_tag_invocable_v<Tag, Args&&...>)
-> tag_invoke_result_t<Tag, Args...>
{
return tag_invoke(
static_cast<Tag const&>(*this), HPX_FORWARD(Args, args)...);
}
};
The public API is an object, not a function; calling it forwards (there’s our HPX_FORWARD, perfect forwarding carrying the user’s value categories through the dispatch layer) to an unqualified tag_invoke(TheTag, args...), which ADL resolves in the argument’s namespace. (ADL, argument-dependent lookup: when the compiler sees an unqualified call f(x), it also searches the namespace that x’s type lives in. It’s why std::cout << x finds the right operator<< without you qualifying it.) One funnel, one extension point name. I built the minimal reproduction to convince myself there’s no additional magic:
namespace mini {
template <class Tag>
struct tag {
template <class... Args>
constexpr auto operator()(Args&&... args) const
-> decltype(tag_invoke(std::declval<Tag>(), std::forward<Args>(args)...)) {
return tag_invoke(static_cast<const Tag&>(*this), std::forward<Args>(args)...);
}
};
inline constexpr struct serialize_t : tag<serialize_t> {} serialize{};
}
namespace user {
struct Matrix { int rows, cols; };
void tag_invoke(mini::serialize_t, const Matrix& m) { /* ... */ }
}
serializing Matrix 3x4 (found via ADL)
serializing Tensor dims=5 (different namespace, still found)
Thirty lines, works, and note the decltype(...) trailing return type on operator(): that’s SFINAE again, making the CPO callable only for types that provided a tag_invoke overload. (HPX also has tag_fallback for “use the user’s version if it exists, else mine,” which is the same trick with an is_tag_invocable_v test split across two overloads.) A related pattern I keep bumping into, argument_type<Tag>, lives in libs/full/collectives/include/hpx/collectives/argument_types.hpp:20: a single struct argument_type parameterized by empty tag structs (num_sites_tag, this_site_tag, generation_tag…) so that collectives take num_sites_arg(4), this_site_arg(2) instead of a row of bare size_ts that the caller can silently transpose. Tags as compile-time units of measure. Cheap, and it turns argument-order bugs into type errors.
Concepts: constraints become a language feature
C++20 finally promoted constraints from idiom to feature. A concept is a named, reusable compile-time predicate:
template <class T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template <class T>
concept Printable = requires(std::ostream& os, T v) {
{ os << v } -> std::same_as<std::ostream&>;
};
template <class T>
concept PrintableNumber = Numeric<T> && Printable<T>;
template <PrintableNumber T>
void show(T v) { std::cout << " value: " << v << "\n"; }
The requires expression is the genuinely new power: Printable doesn’t ask “is T on my list,” it asks “does the expression os << v compile and return ostream&,” duck typing, checked statically, written as the actual code you intend to run. Concepts compose with ordinary &&/||. They evaluate as plain bools (static_assert(Numeric<int>) just works, no ::value ceremony). And constraining a function reads like a type annotation instead of a trap for the uninitiated.
The famous selling point is error messages, so I tested the claim head-to-head, same mistake (show(std::string{"no"})), SFINAE version versus concept version. The honest result surprised me: for this toy case, clang’s SFINAE error was 7 lines and the concepts error was 25. The claim as popularly stated is just false at this scale, clang’s SFINAE diagnostics have gotten good. But the content differs in kind:
SFINAE: note: candidate template ignored: requirement
'std::is_integral_v<std::string> || ...' was not satisfied
concepts: note: because 'std::string' does not satisfy 'Numeric'
note: because 'is_integral_v<std::string>' evaluated to false
note: and 'std::string' does not satisfy 'floating_point'
note: because 'is_floating_point_v<std::string>' evaluated to false
The SFINAE note dumps the raw boolean soup at you. The concepts version walks the constraint tree, naming each level: which concept failed, which branch of it, which leaf predicate, with your domain vocabulary (Numeric) in the trace. At toy scale that’s longer; at HPX scale, where the SFINAE version is Enable1/Enable2 nested four templates deep and the “boolean soup” is sixty lines of substitution stack, the named tree is the difference between reading the error and re-deriving it. Line count was the wrong metric; structure is the win. With that corrected expectation, the hpx::function constructor rewrite:
template <typename F>
requires(!std::same_as<std::decay_t<F>, function> &&
std::invocable<std::decay_t<F>&, Ts...>) // sketch; r-checking elided
function(F&& f)
{
assign(HPX_FORWARD(F, f));
}
No dummy parameters, no name-only Enable1, constraints stated where a reader looks for them, and the MSVC eager-instantiation workaround simply has no equivalent because there’s nothing to eagerly instantiate. And this isn’t hypothetical for HPX: the codebase at this commit already contains 32 named concept definitions and 1,667 requires occurrences alongside the 738 enable_ifs (same git grep counting), a migration captured mid-stride. You can even watch both eras share one signature, e.g. hpx::post’s implementation (libs/core/execution_base/include/hpx/execution_base/execution.hpp:225):
inline constexpr struct post_t final
: hpx::functional::detail::tag_fallback<post_t>
{
private:
template <executor_any Executor, typename F, typename... Ts>
requires(std::invocable<F&&, Ts&&...>)
friend HPX_FORCEINLINE decltype(auto) tag_fallback_invoke(
post_t, Executor&& exec, F&& f, Ts&&... ts)
{
return detail::post_fn_helper<std::decay_t<Executor>>::call(
HPX_FORWARD(Executor, exec), HPX_FORWARD(F, f),
HPX_FORWARD(Ts, ts)...);
}
} post{};
Read the constraints like documentation, because that’s what they are now: executor_any Executor (a concept used as a type qualifier: this argument must satisfy executor-ness), requires std::invocable<F&&, Ts&&...> (the callable must actually be callable with these arguments, checked before instantiation instead of as a fireball during it). Meanwhile the dispatch is the tag_fallback CPO from the last section, and the argument handling is HPX_FORWARD everywhere, value categories preserved from the user’s call all the way into the executor. Every layer of this post is stacked in those fifteen lines, in code whose job is launching distributed work, not demonstrating C++.
I read a handful more of HPX’s constraints while I was in there, hpx::dataflow guarding an overload with !is_allocator_v so that two overloads don’t fight over the same calls, move_only_function’s constructor still wearing the old split-enable_if shape, waiting its turn in the migration. The pattern across all of them: constraints in generic code are mostly not about rejecting users, they’re about overloads negotiating with each other over who takes which call. SFINAE made that negotiation look like line noise; concepts make it look like policy.
The macro at the bottom
One loose end left: why does HPX write HPX_MOVE instead of std::move? I’d been assuming namespacing hygiene, or some pre-C++11 compatibility fossil, the whole time. Near the end of this dig I finally opened libs/core/config/include/hpx/config/move.hpp:
#if defined(HPX_HAVE_BUILTIN_FORWARD_MOVE)
#include <utility>
#define HPX_MOVE(...) std::move(__VA_ARGS__)
#else
#include <type_traits>
#define HPX_MOVE(...) \
static_cast<std::remove_reference_t<decltype(__VA_ARGS__)>&&>(__VA_ARGS__)
#endif
When HPX’s build feature test defines HPX_HAVE_BUILTIN_FORWARD_MOVE, the macro uses std::move. Otherwise it writes the cast directly. The pinned header does not explain the reason for that choice, so I cannot turn the feature-test name into a stronger historical claim. The fallback still proves the narrower point I was chasing: HPX can replace the library helper with static_cast<std::remove_reference_t<decltype(...)>&&>(...), the exact operation from __utility/move.h, with decltype standing in for deduction. The runtime effect expected from the move expression is the cast, not hidden byte movement inside the helper.
Two weeks ago I would have read that macro as noise. Now its two branches are legible. One spells the operation std::move; the other spells out the cast. The feature test changes how HPX expresses the operation, not what happens to the object named by the expression.
What I haven’t done is close the loop on the distributed side: I can read managed_move_credit in the enum, and I know what it claims to do, but I haven’t traced a parcel carrying an id through a credit split on the wire, watched the log2 field actually change, or found the exhaustion path. The parcelport code from the move-semantics section is sitting right there, and put_parcel takes its parcel by value (void put_parcel(parcel p, write_handler_type f);, libs/full/parcelset/include/hpx/parcelset/parcelhandler.hpp:165). By value. After this post, that signature is a design document: a sink parameter, expecting callers to move in, recipe or theft, caller’s choice. That’s the next trace.