The copy problem
Before C++11, returning an object from a function meant copying it:
1std::vector<float> getReadings() {
2 std::vector<float> v(1000);
3 fill(v);
4 return v; // copy — 1000 floats allocated and memcpy'd
5}
6
7auto readings = getReadings(); // another copy
Compilers applied Return Value Optimisation (RVO) in simple cases, but complex code, containers of containers, or non-trivial return paths all fell back to copies. Large data structures were passed by pointer or reference to avoid the cost.
Move semantics give C++ a type-system mechanism for transferring ownership of resources without copying them.
What a move is
A move is a cheap ownership transfer: the source gives its internal buffer to the destination, then sets itself to a valid-but-empty state.
1std::vector<float> a(1000); // allocates 1000 floats
2std::vector<float> b = std::move(a);
3// b now owns the buffer — no allocation, no copy
4// a is valid but empty: a.size() == 0
Under the hood, std::vector’s move constructor:
- Copies the internal pointer, size, and capacity from
atob - Sets
a’s pointer tonullptr, size and capacity to 0
Three pointer-sized copies. No malloc, no memcpy.
Rvalue references
Move semantics are enabled by rvalue references (T&&). An rvalue is a value
without a persistent name — a temporary, a function return value, or explicitly
cast with std::move.
1void sink(std::vector<float>&& v); // takes an rvalue reference — can steal from v
2
3std::vector<float> data(100);
4
5sink(std::move(data)); // explicit move — data is now empty
6sink(getReadings()); // implicit move — temporary, no std::move needed
std::move is just a cast — it doesn’t move anything. It casts data to an
rvalue reference, which allows the overload resolution to select the move
constructor or move assignment operator.
Writing move constructors
For a class that owns a resource, implement the move constructor:
1class Buffer {
2public:
3 explicit Buffer(size_t n)
4 : data_(new float[n]), size_(n) {}
5
6 // Copy constructor — deep copy
7 Buffer(const Buffer& other)
8 : data_(new float[other.size_]), size_(other.size_) {
9 std::copy(other.data_, other.data_ + size_, data_);
10 }
11
12 // Move constructor — steal the pointer
13 Buffer(Buffer&& other) noexcept
14 : data_(other.data_), size_(other.size_) {
15 other.data_ = nullptr;
16 other.size_ = 0;
17 }
18
19 // Move assignment
20 Buffer& operator=(Buffer&& other) noexcept {
21 if (this != &other) {
22 delete[] data_;
23 data_ = other.data_;
24 size_ = other.size_;
25 other.data_ = nullptr;
26 other.size_ = 0;
27 }
28 return *this;
29 }
30
31 ~Buffer() { delete[] data_; }
32
33private:
34 float* data_;
35 size_t size_;
36};
noexcept on move constructors is critical. Standard containers (e.g.
std::vector during reallocation) only use the move constructor if it’s marked
noexcept — otherwise they fall back to copy. Always mark moves noexcept if
they can’t throw.
The rule of five (and zero)
If you define any of: destructor, copy constructor, copy assignment, move
constructor, move assignment — you should define all five (or use = default
/ = delete explicitly).
1class Buffer {
2public:
3 Buffer(const Buffer&) = default; // or implement
4 Buffer(Buffer&&) = default;
5 Buffer& operator=(const Buffer&) = default;
6 Buffer& operator=(Buffer&&) = default;
7 ~Buffer() = default;
8};
For classes that only hold other move-enabled types (no raw pointers, no handles), the compiler-generated defaults are correct — this is the rule of zero: define none of the five, and the compiler generates correct versions from the members.
1class SensorData {
2 std::vector<float> samples_; // move-enabled
3 std::string name_; // move-enabled
4 // Rule of zero — compiler generates correct copy and move
5};
Returning objects — NRVO and move
Modern C++ compilers apply Named Return Value Optimisation (NRVO): when a function returns a named local variable, the compiler constructs it directly in the caller’s storage — no copy, no move.
1std::vector<float> getReadings() {
2 std::vector<float> v(1000); // constructed in place at call site
3 fill(v);
4 return v; // NRVO — no copy or move if optimisation applies
5}
If NRVO can’t apply (multiple return paths returning different objects), the compiler falls back to implicit move from the return expression.
Don’t std::move a return value — it inhibits NRVO:
1// WRONG — std::move prevents NRVO
2std::vector<float> getReadings() {
3 std::vector<float> v(1000);
4 return std::move(v); // forces a move; NRVO would have been better
5}
Return by value, let the compiler optimise.
Zero-copy patterns
Moving into a container
1std::vector<Buffer> buffers;
2Buffer b(1024);
3buffers.push_back(std::move(b)); // moves b — no copy of 1024 floats
4// b is now empty — don't use it after the move
Forwarding in a factory
1template <typename T, typename... Args>
2std::unique_ptr<T> make(Args&&... args) {
3 return std::make_unique<T>(std::forward<Args>(args)...);
4}
std::forward is perfect forwarding — it preserves lvalue/rvalue category
of the arguments. If called with an rvalue, it forwards an rvalue; with an lvalue,
an lvalue. This is the standard pattern for generic factory functions and wrappers.
Sink parameters
When a function stores its argument, take it by value and move it in:
1class Logger {
2 std::string prefix_;
3public:
4 // By value — caller can pass an rvalue (moved in) or lvalue (copied in once)
5 explicit Logger(std::string prefix)
6 : prefix_(std::move(prefix)) {}
7};
8
9Logger l1("SENSOR"); // one construction, one move — optimal
10Logger l2(std::string("DISPLAY")); // one construction, one move
11std::string s = "APP";
12Logger l3(s); // one copy (s is an lvalue)
13Logger l4(std::move(s)); // one move — s is now empty
This is more efficient than taking const std::string& (which would require an
explicit copy inside the constructor) when the caller has a temporary to give away.
Move and embedded firmware
On embedded targets, std::vector and dynamic allocation are often forbidden.
Move semantics still matter for:
Moving unique_ptr: passing ownership of a heap-allocated driver or resource
without copying the managed object.
1auto sensor = std::make_unique<Bmp280>(hi2c1);
2pipeline.setSensor(std::move(sensor)); // no copy of the driver object
Moving fixed-size buffers:
1struct Frame {
2 std::array<uint8_t, 64> data;
3 size_t len;
4};
5
6// Moving a Frame — compiler generates a move that memcpy's 64 bytes
7// Same as copy for trivially copyable types, but the intent is explicit
For trivially copyable types (std::array<uint8_t, N>, plain structs with POD
members), move is identical to copy — the compiler copies the bytes regardless.
Move semantics matter for types with non-trivial resources (heap, file handles,
HAL handles with cleanup logic).
Summary
- Move semantics transfer resource ownership without copying — O(1) vs O(n)
std::moveis a cast, not an operation — it enables move overload selection- Mark move constructors
noexcept— required for use insidestd::vectorresize - Rule of zero: let the compiler generate copy/move if your class holds only move-enabled members
- NRVO eliminates copies on return — don’t
std::movea return statement - Sink parameters: take by value,
std::moveinto storage — avoids extra copy for rvalue callers
What’s next
- Smart pointers and ownership —
unique_ptris the primary application of move semantics - Lock-free queues — moving data between threads without allocation
- std::span and string_view — avoiding copies by not owning data at all