Move Semantics and std::move
Motivation
In C++, move semantics is a powerful technique that enables the transfer of ownership of resources, avoiding costly and unnecessary copies. Without it, operations on large objects — such as vectors, strings, or complex data structures — can silently degrade performance. Yet despite its importance, move semantics is often overlooked or misunderstood.
In this post, we’ll break down the mechanics behind move semantics, explore the key C++ features that make it possible, and walk through a concrete example showing how it can improve code performance.
std::move
Our entry point to understanding move semantics is the C++ API call std::move.
#include <utility> // Required header to use std::move
template< class T >
constexpr std::remove_reference_t<T>&& move( T&& t ) noexcept;
Despite its name, std::move does not move anything by itself. Instead, it performs a static_cast to an rvalue reference (static_cast<T&&>(t)), marking an object as eligible to be moved from.
std::string s = "test string";
// std::move(s) does not move anything by itself.
// It casts s to an rvalue reference (std::string&&),
// allowing it to bind to a move constructor or move assignment.
std::string t(std::move(s));
// After the move, s is left in a valid but unspecified state.
// It can still be used (assigned to, destroyed, etc.),
// but its contents should not be relied upon.
// Guaranteed to print "test string"
std::cout << t << "\\n";
// The contents of s are unspecified after the move.
// For std::string, most implementations leave it empty,
// but the standard does not require that.
if (s.empty())
{
std::cout << "s has been moved from, and is now empty\\n";
}
Having std::move return an r-value reference is what allows move constructors to be invoked which execute the actual move semantics.
A couple of foundational details to note as follows. After s is moved from, t now owns the string buffer that s once had, and s is left in a valid but unspecified state (you can query it, assign to it, or destroy it), but its contents are unspecified — most standard library implementations make it empty, but this is not something to rely on.
In addition, not that In contrast to a copy construction for “t”, the contents of "test string" would have been duplicated, leaving both s and t with their own copies of the data (at higher performance cost).
Move Semantics
You may have noticed in the section above that when t was constructed, it was done so from an r-value reference. Depending on your familiarity with C++, this may not have been an obvious thing, I know for me it certainly wasn’t at some point. Can you guess what the signature of this constructor must have been for the move construction above to have happened?
Think about it for a second and then check your answer below:
std::string(std::string&& other) noexcept
{
// Transfer ownership of other's buffer into this object
this->ptr = other.ptr;
this->len = other.len;
// Leave other in a valid but unspecified state
other.ptr = nullptr;
other.len = 0;
}
This is known as a move constructor. It accepts an r-value reference—typically produced by std::move—and transfers ownership of resources from the source object to the newly constructed one.
The good thing for us is that most standard library C++ containers have a move constructor (queues, vector, strings, etc.), which means that you can easily implement the move semantics for custom classes that are composed of container types. All is needed is a std::move(t) where t is a standard library container object. For non standard library objects other than primitive types which might be a simple assignment overwrite, it is up to the developer to implement the exact details of how non-primitive types beign updated as a result of a move constuctor being invoked.
Move Constructor and Move Assignment Overload.
The following is a canonical move constructor and move assignment overload.
struct Foo
{
Foo() = default;
// Move constructor
Foo(Foo&& other) noexcept : v(std::move(other.v)) {}
// Move assignment operator
Foo& operator=(Foo&& other) noexcept
{
if (this != &other) // protect against self-assignment
{
v = std::move(other.v);
}
return *this;
}
std::vector<int> v;
};
Now we understand the core concept behind what a move constructor and move assignment operator are meant to do
Common Pitfall – Copy Invoked instead of move
Suppose we have the following code
#include <iostream>
#include <string>
class ConsumedString {
public:
std::string value;
// Construct from const l-value
ConsumedString(const std::string& str) : value(str) {
std::cout << "Constructed from const l-value std::string\\n";
}
// Construct from r-value
ConsumedString(std::string&& str) : value(std::move(str)) {
std::cout << "Constructed from r-value std::string\\n";
}
// Copy constructor
ConsumedString(const ConsumedString& other) : value(other.value) {
std::cout << "Copy constructor called\\n";
}
// Move constructor
ConsumedString(ConsumedString&& other) noexcept : value(std::move(other.value)) {
std::cout << "Move constructor called\\n";
}
};
void sink(ConsumedString s) {
std::cout << "In sink: " << s.value << "\\n";
}
int main() {
const std::string test_str = "Test string";
std::cout << "Consuming test string: " << test_str << "\\n";
// Attempt to move the string into sink
sink(std::move(test_str));
return 0;
}
Sink is called, which takes ConsumedString type as a parameter. It can be constructed from either an r-value std::string reference (std::string&&) —resulting in a move, no actual new string creation — or const l-value std::string (const std::string&) — invoking a deep copy of the string.
At first glance, it seems like std::move(test_str) should invoke the r-value constructor of ConsumedString, resulting in a move. However, this is not what happens.
The key detail is that test_str is declared as const std::string. When passed to std::move, it becomes a const std::string&& — a const r-value reference. Since the move constructor of std::string requires a non-const r-value, the compiler cannot use it. Instead, it falls back to the copy constructor.
Rule of thumb: Move constructors and move assignment operators cannot be invoked on const objects, because moving typically involves modifying the source — transferring ownership, nulling out internal buffers, etc. const prevents such mutation.
This subtle behavior can lead to performance issues if you’re unintentionally copying large objects when you meant to move them. Always ensure the source object is non-const if you intend to move it.
Example
We are now ready to tackle a more real-world scenario where move semantics and std::move usage would result in preventing performance issues.
This example builds a bounded, blocking TelemetryQueue that stores timestamped, sequenced simulated telemetry messages carrying a large std::vector<std::byte> payload, then contrasts two producer paths—one that copies telemetry into the queue and one that moves it—while a consumer drains items using a std::condition_variable/std::mutex pair to preserve FIFO order without busy-waiting. By constructing telemetry from caller-supplied batches (zero-copy when ownership is transferred) and pushing with emplace_back(std::move(t)), the benchmark isolates the cost of duplicating the big payload vs. just transferring its pointers/size; on repeated runs, the move-based push averaged about 1.5–2× faster than the copy path, reflecting the deep-copy avoided on the hot path
TelemetryQueue.hpp
#ifndef TELEMETRY_QUEUE_HPP
#define TELEMETRY_QUEUE_HPP
#include <iostream>
#include <deque>
#include <mutex>
#include <condition_variable>
#include <vector>
#include <cassert>
#include <optional>
struct Telemetry {
// hot metadata (small)
uint64_t timestamp_ns;
uint32_t seq;
std::string topic; // e.g., "imu", "gps", "power"
// cold, potentially huge payload
std::vector<std::byte> payload; // compressed protobuf / flatbuffer blob
// Moving is cheap (pointer/size steals); copying is expensive (deep copy).
};
class TelemetryQueue
{
public:
explicit TelemetryQueue(size_t len) : max_len(len) { assert(max_len > 0); }
// Will show with metrics that the copy version is less performant
void push_data_copy(Telemetry data)
{
std::unique_lock<std::mutex> lock(m);
not_full_cv.wait(lock, [&]{ return q.size() < max_len;});
q.push_back(std::move(data));
lock.unlock();
not_empty_cv.notify_one();
}
void push_data(Telemetry&& data)
{
std::unique_lock<std::mutex> lock(m);
not_full_cv.wait(lock, [&]{ return q.size() < max_len;});
q.push_back(std::move(data));
lock.unlock();
not_empty_cv.notify_one();
}
Telemetry pop_data(void)
{
std::unique_lock<std::mutex> lock(m);
not_empty_cv.wait(lock, [&] { return !q.empty(); });
// get oldest data
Telemetry data = std::move(q.front());
q.pop_front();
lock.unlock();
not_full_cv.notify_one();
return data;
}
size_t getSize(void)
{
std::unique_lock<std::mutex> lock(m);
return q.size();
}
private:
std::deque<Telemetry> q;
std::mutex m;
std::condition_variable not_empty_cv;
std::condition_variable not_full_cv;
size_t max_len;
};
#endif
main.cpp
#include <iostream>
#include <thread>
#include <chrono>
#include <atomic>
#include <csignal>
#include "TelemetryQueue.hpp"
uint32_t last_sequence_num; // Telemetry data sequence number
using namespace std::chrono;
const int PAYLOAD_SIZE = 1024; // Simulated telemetry payload size received over the network
std::atomic<bool> running;
std::mutex m;
void writingDataToFile(Telemetry &&data)
{
std::cout << "Simulating writing data to file ts: " << data.timestamp_ns << " seq: " << data.seq << " topic: " << data.topic << "\\n";
// Simulate writing to file taking longer
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
void getTelemetryFromNetwork(Telemetry &data)
{
uint64_t ns = duration_cast<nanoseconds>(steady_clock::now().time_since_epoch()).count();
data.timestamp_ns = ns;
last_sequence_num = (last_sequence_num + 1) % UINT32_MAX;
data.seq = last_sequence_num;
data.topic = "imuFusedData";
for (uint32_t i = 0; i < PAYLOAD_SIZE; ++i)
{
data.payload.emplace_back(std::byte(i % UINT8_MAX));
}
}
void producer_via_move(TelemetryQueue &q)
{
int i = 0;
const int ITER = 100;
Telemetry data;
// Produces 100 elements so we can time and compare move and copy for pushing data into queue
auto start = steady_clock::now();
while (i < 100)
{
std::unique_lock<std::mutex> lock(m);
if (!running)
{
std::cout << "Producing thread is exiting" << "\\n";
return;
}
lock.unlock();
// Get simulated telemetry data over the network;
getTelemetryFromNetwork(data);
// Push simulated data to queue, queue is implemented in a thread-safe maner
q.push_data(std::move(data));
++i;
}
auto stop = steady_clock::now();
auto ms = duration_cast<milliseconds>(stop - start).count();
std::cout << "producing and pushing 100 elements in queue via move took " << ms << " ms\\n";
}
void producer_via_copy(TelemetryQueue &q)
{
int i = 0;
const int ITER = 100;
Telemetry data;
// Produces 100 elements so we can time and compare move and copy for pushing data into queue
auto start = steady_clock::now();
while (i < 100)
{
std::unique_lock<std::mutex> lock(m);
if (!running)
{
std::cout << "Producing thread is exiting" << "\\n";
return;
}
lock.unlock();
// Get simulated telemetry data over the network;
getTelemetryFromNetwork(data);
// Push simulated data to queue, queue is implemented in a thread-safe maner
q.push_data_copy(data);
++i;
}
auto stop = steady_clock::now();
auto ms = duration_cast<milliseconds>(stop - start).count();
std::cout << "producing and pushing 100 elements in queue via move took " << ms << " ms\\n";
}
void consumer(TelemetryQueue &q)
{
while (true)
{
std::unique_lock<std::mutex> lock(m);
if (!running)
{
std::cout << "Consuming thread is exiting" << "\\n";
return;
}
lock.unlock();
// Get telemetry data from queue
writingDataToFile(std::move(q.pop_data()));
}
}
void handleSigint(int signum)
{
if (signum == SIGINT)
{
std::cout << "SIGINT Caught. Program clean-up\\n";
running = false;
}
}
int main(void)
{
TelemetryQueue telemetryQ(100);
running = true;
std::signal(SIGINT, handleSigint);
// Uncomment only one of the producers to show example
std::thread producer_thread(producer_via_move, std::ref(telemetryQ));
//std::thread producer_thread(producer_via_copy, std::ref(telemetryQ));
std::thread consumer_thread(consumer, std::ref(telemetryQ));
producer_thread.join();
consumer_thread.join();
return 0;
}
Key Findings

Best Practices
Wrapping resources in RAII handles (std::unique_ptr, file handles, sockets) that should have exactly one owner.
Don’t call std::move on const objects: moving requires modifying the source to leave it valid but unspecified.
Avoid return std::move(obj) unless you know why: it disables NRVO, which is usually faster than moving.
After a move, don’t assume anything about the object beyond validity — reassign or destroy it before further use.
Prefer passing by reference when possible; when ownership transfer is needed (e.g., producer → consumer), prefer moves over copies for performance.
Use move when:
Returning large objects from functions when NRVO doesn’t apply.
Inserting or emplacing elements into STL containers (e.g., std::vector::push_back(std::move(x))).
Passing objects across threads or tasks where the original scope won’t use them anymore.