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:

  1. Copies the internal pointer, size, and capacity from a to b
  2. Sets a’s pointer to nullptr, 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::move is a cast, not an operation — it enables move overload selection
  • Mark move constructors noexcept — required for use inside std::vector resize
  • 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::move a return statement
  • Sink parameters: take by value, std::move into storage — avoids extra copy for rvalue callers

What’s next