Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 6

Efficient Data Transfer: Const-References, Rvalue References, and Move Semantics

Recommended resources

Welcome to the final lesson of our "Modern C++" module. In our previous sessions, we've built a strong foundation: you've wrestled with manual memory management, mastered smart pointers with RAII, and learned how to write generic code using templates to create a reusable Tensor class. You now have the tools to define flexible data structures, but a crucial question remains: how do we work with them efficiently?

This lesson addresses that question head-on. We'll explore the mechanisms C++ provides for passing large data structures, like our Tensor, without incurring the high cost of unnecessary copies. You will learn about const references, the modern additions of rvalue references (&&), and the powerful concept of move semantics. Mastering these features is essential for writing the kind of high-performance code that underpins the inference engine we aim to build. By the end of this lesson, you'll be able to make conscious, informed decisions about how to design function interfaces for maximum efficiency.

1. The Problem: The High Cost of Copying

When you pass an object to a function by value, the compiler creates a complete copy of it. For simple types like an int or a double, this is cheap and often desirable. However, for a large object like a Tensor that might manage megabytes or even gigabytes of data on the heap, making a deep copy is incredibly expensive. It involves a new memory allocation and a byte-for-byte copy of the entire dataset.

Traditionally, C++ programmers avoid this cost for read-only access by passing a constant reference (const T&). This passes only the address of the object, not the object itself, completely avoiding a copy.

// Inefficient: creates a deep copy of the entire tensor
void process_tensor_by_value(Tensor<float> t);

// Efficient: passes a reference, no copy is made
void process_tensor_by_const_ref(const Tensor<float>& t);

This works beautifully when a function only needs to read from an object. But what if the function needs to take ownership of the object, for example, a Layer class that needs to store a weight tensor as a member variable? A const reference won't work, as we can't modify it or take ownership. Before C++11, our only option was to accept a const reference and then make a copy internally, or accept by value and pay the copy cost upfront. This dilemma—the tension between safety, ownership, and performance—led to one of modern C++'s most significant features: move semantics.


2. Move Semantics: A Smarter Way to Transfer Ownership

The core idea behind move semantics is simple: if we know an object is a temporary (an "rvalue") and is about to be destroyed anyway, why should we pay to copy its resources? Why not just "steal" them? This is precisely what move semantics allows.

To make this possible, C++11 introduced a new kind of reference: the rvalue reference, denoted by &&. This allows the compiler to differentiate between:

  • Lvalues: Expressions that have a name and a persistent location in memory (e.g., int x;, my_tensor). You can take their address.
  • Rvalues: Temporary, unnamed expressions that exist only for the duration of a single statement (e.g., the literal 42, the return value of a function create_tensor()).

By providing function overloads that accept rvalue references, we can create special, highly efficient code paths for handling temporary objects.

Klaus Iglberger provides a fantastic visual explanation of this concept using std::vector. He contrasts the expensive deep copy with the cheap "move" operation, which just involves swapping a few pointers.

Back to Basics: Move Semantics (part 1 of 2) - Klaus Iglberger - CppCon 2019

This CppCon talk, "Back to Basics: Move Semantics", clearly motivates and defines the core concepts.

Watch the segment from the introduction to see a visual comparison of copying a vector versus moving it. This perfectly illustrates the performance benefit. Continue from lvalues and rvalues to understand the formal distinction. Pay close attention to the simple rule: if it has a name, it's an lvalue. This section explains how rvalue references (&&) enable the compiler to choose a different, more efficient function for temporaries.

The image below illustrates the difference in memory operations when a container like std::vector reallocates. The copy version performs new allocations and copies each element. The move version simply moves the pointers, which is vastly more efficient.

This diagram shows the memory operations for a container resizing. "With Copy" involves costly element-wise duplication. "With Move" efficiently transfers ownership of the underlying data by re-pointing internal pointers, leaving the old objects in an "unspecified" but valid state.

3. Implementing the Move: Move Constructors and std::move

You've already encountered the implementation side of this in our lesson on the "Rule of Five". A class enables move semantics by providing a move constructor and a move assignment operator. These special functions take an rvalue reference (T&&) to the source object. Inside, they don't copy the data; they take over the source object's resources (like its heap-allocated pointer) and then set the source object's pointers to nullptr. This leaves the source object in a valid (destructible) but empty state.

Watch Amir Kirsh implement a move constructor and move assignment operator from scratch for a MyString class. This practical demonstration will connect the theory to the code you've written before.

Back to Basics: Rvalues and Move Semantics in C++ - Amir Kirsh - CppCon 2024

This segment from "Back to Basics: Rvalues and Move Semantics" provides a clear, live-coded example.

Focus on the practical implementation from this section. Notice how he implements the move constructor by performing a shallow copy of the internal pointer and size, and crucially, "nulling out" the source object's pointer to prevent a double-free. For the move assignment, he uses std::swap, a common and effective idiom.

This works automatically for rvalues, but what if we have an lvalue (a named object) that we're finished with and want to move from? We can't just pass it to a function expecting a T&&. To handle this, C++11 introduced std::move.

Despite its name, std::move does not move anything. It's a static_cast that unconditionally casts its argument to an rvalue reference. It's a signal to the compiler: "I know this is an lvalue, but I give you permission to treat it as an rvalue. I promise I won't use its contents again." This cast enables the compiler to select the move constructor or move assignment operator, triggering the efficient resource transfer.

Tensor<float> t1({1, 256});
// Tensor<float> t2 = t1;          // Calls copy constructor. t1 is still valid.
Tensor<float> t2 = std::move(t1); // Calls move constructor. t1 is now in a valid but empty state.

The following table neatly summarizes which reference types can bind to which value categories and the priority the compiler uses for overload resolution. This is the logic that std::move leverages.

This matrix shows binding rules for C++ references. For example, when passing an rvalue (rightmost column), the compiler prefers an rvalue reference (`&&`) with priority 1, but will fall back to a const lvalue reference (`const &`) with priority 3 if a `&&` overload isn't available.

4. A Modern Idiom: Pass-by-Value for "Sink" Functions

With move semantics, we can write overloaded functions: one that takes const T& for lvalues (copying) and another that takes T&& for rvalues (moving).

class MyClass {
public:
    // Overload for lvalues (makes a copy)
    void set_data(const std::string& data) {
        m_data = data; 
    }
    // Overload for rvalues (moves the data)
    void set_data(std::string&& data) {
        m_data = std::move(data);
    }
private:
    std::string m_data;
};

This works, but it leads to code duplication. Modern C++ offers a more elegant solution for functions that need to take ownership of an argument (so-called "sink" functions): pass by value.

This may sound counterintuitive after everything we've discussed, but combining pass-by-value with std::move internally results in clean, efficient code.

The article "Unraveling the Myth of Always Passing by Const-Reference" explains this idiom beautifully.

Move Semantics in C++: Unraveling the Myth of Always ...

This article argues that the old C++ rule of "always pass by const-reference" is outdated and presents a superior modern alternative for sink functions.

Read through the article starting from the Context and Problem sections to understand the motivation. Continue through Programming without move semantics and Programming with move semantics, which set up the problem of code duplication. Finally, read The ideal implementation. This is the key takeaway. The author shows how a single function that takes its parameter by value can handle both lvalue and rvalue arguments efficiently and elegantly.

The logic of the pass-by-value idiom is as follows for a function void set_data(std::string s):

  1. Implementation: Inside the function, you unconditionally std::move from the parameter s into the class member: m_data = std::move(s);.
  2. Calling with an lvalue: set_data(my_string);
    • A copy is made from my_string into the parameter s.
    • s is then moved into m_data.
    • Total: 1 copy, 1 move.
  3. Calling with an rvalue: set_data("temporary string");
    • The temporary is move-constructed directly into the parameter s.
    • s is then moved into m_data.
    • Total: 2 moves (and one of these is often elided by the compiler). Significantly cheaper than a copy.

This approach results in a single, clean function, delegating the choice of copy-or-move to the caller, who knows whether they still need their original object.


5. An Essential Detail: The noexcept Specification

One last critical detail: for move operations to be used automatically by Standard Library containers like std::vector, they must be marked noexcept.

Imagine std::vector needs to reallocate its buffer. It wants to move the elements from the old buffer to the new one. But what if the move constructor for one of the elements throws an exception halfway through? The vector would be left in a corrupted state—some elements moved, some not. To uphold its strong exception safety guarantee (if an operation fails, the vector remains unchanged), std::vector will check if your move constructor is noexcept. If it is not, it will fall back to the slower (but safe) copy constructor. This is a subtle but major performance trap.

// Bad: std::vector will copy this class instead of moving it during reallocation.
MyWidget(MyWidget&& other) { /* ... */ }

// Good: Guaranteed not to throw, so std::vector can safely use it.
MyWidget(MyWidget&& other) noexcept { /* ... */ }

Amir Kirsh explains this exact scenario and its performance impact.

Back to Basics: Rvalues and Move Semantics in C++ - Amir Kirsh - CppCon 2024

This final clip explains a critical performance consideration for move semantics.

Watch the section on noexcept from this timestamp. The speaker clearly explains why containers like std::vector require this guarantee to use move semantics during reallocation.


6. Project: Implementing an Efficient Sink Function

Let's apply these concepts to our Tensor class. You will add explicit move operations to the Tensor class to make the behavior visible, then implement a Layer class with a set_weights method using the modern pass-by-value idiom.

Your Task:

  1. Instrument Tensor.h:
    • Open the Tensor.h you created in the last lesson.
    • Add an explicit move constructor and move assignment operator. Since std::unique_ptr is already movable, you can just default them, but for this exercise, we'll implement them to print a message.
    
    
    
    #include <iostream> // For std::cout
    #include <utility>  // For std::move
    
    // Inside the Tensor class template
    public:
        // ... (existing constructor)
    
        // Move constructor
        Tensor(Tensor&& other) noexcept 
            : m_shape(std::move(other.m_shape)), m_data(std::move(other.m_data)) {
            std::cout << "Tensor MOVE CONSTRUCTOR called." << std::endl;
        }
    
        // Move assignment operator
        Tensor& operator=(Tensor&& other) noexcept {
            if (this != &other) {
                m_shape = std::move(other.m_shape);
                m_data = std::move(other.m_data);
                std::cout << "Tensor MOVE ASSIGNMENT operator called." << std::endl;
            }
            return *this;
        }
    
    Note: We also need a copy constructor and copy assignment for the lvalue case to work. Add simple ones if you don't have them.
    // Copy constructor (for demonstration)
    Tensor(const Tensor& other) : m_shape(other.m_shape) {
        size_t total_elements = 1;
        for (size_t dim : m_shape) total_elements *= dim;
        m_data = std::make_unique<T[]>(total_elements);
        std::copy(other.m_data.get(), other.m_data.get() + total_elements, m_data.get());
        std::cout << "Tensor COPY CONSTRUCTOR called." << std::endl;
    }
    
    // Copy assignment (for demonstration)
    Tensor& operator=(const Tensor& other) {
        if (this != &other) {
            m_shape = other.m_shape;
            size_t total_elements = 1;
            for (size_t dim : m_shape) total_elements *= dim;
            m_data = std::make_unique<T[]>(total_elements);
            std::copy(other.m_data.get(), other.m_data.get() + total_elements, m_data.get());
            std::cout << "Tensor COPY ASSIGNMENT operator called." << std::endl;
        }
        return *this;
    }
    
  2. Create Layer.h:
    • Define a simple Layer class template that holds a Tensor for its weights.
    • Implement set_weights using the pass-by-value idiom.
    
    
    
    #include "Tensor.h"
    
    template <typename T>
    class Layer {
    public:
        // Use the pass-by-value "sink" idiom
        void set_weights(Tensor<T> new_weights) {
            m_weights = std::move(new_weights); // Unconditionally move from the parameter
        }
    private:
        Tensor<T> m_weights;
    };
    
  3. Test in main.cpp:
    • Instantiate a Layer.
    • Lvalue case: Create a named Tensor, then pass it to set_weights. Observe the output.
    • Rvalue case: Pass a temporary Tensor directly to set_weights. Observe the output.
    
    
    
    #include "Layer.h"
    
    int main() {
        Layer<float> my_layer;
    
        std::cout << "--- Setting weights from an lvalue ---" << std::endl;
        Tensor<float> weights_lvalue({10, 20});
        my_layer.set_weights(weights_lvalue); // Pass the named object
        // What do you expect to see here? A copy, then a move.
        // After this, weights_lvalue is still valid because a copy was made.
    
        std::cout << "\n--- Setting weights from an rvalue ---" << std::endl;
        my_layer.set_weights(Tensor<float>({30, 40})); // Pass a temporary
        // What do you expect to see here? A move (or two). No copy!
    
        return 0;
    }
    
  4. Analyze the Output: Compile and run your code. Does the output match your expectations? You should see the COPY CONSTRUCTOR called for the lvalue case, followed by a MOVE ASSIGNMENT. For the rvalue case, you should see the MOVE CONSTRUCTOR followed by MOVE ASSIGNMENT, with no copy.

Conclusion

This lesson completes our foundational survey of modern C++. You've now seen how C++ enables not just generic programming through templates, but also highly efficient programming through fine-grained control over object copying and moving.

Key Takeaways:

  • Values Have Categories: Lvalues have names and persist; rvalues are unnamed temporaries. Rvalue references (&&) let us write code that specifically targets these temporaries.
  • Move, Don't Copy: Move semantics allow for "stealing" resources from temporary objects instead of performing expensive deep copies. This is implemented via move constructors and move assignment operators.
  • std::move is a Cast: It signals your intent to treat an lvalue as an rvalue, enabling a move. It's a promise that you're done with the object.
  • Pass-by-Value for Sinks: For functions that take ownership of an argument, the modern idiom is to pass by value and use std::move internally. This creates a single, simple function that is efficient for both lvalue and rvalue arguments.
  • noexcept is for Performance: Always mark your move operations noexcept to ensure STL containers can use them for maximum performance.

With this, Module 1 is complete. You have established a robust understanding of the C++ features most critical for building high-performance systems. You are now ready to leave the realm of pure CPU programming and step into the world of parallel hardware. In the next module, we will begin our journey into GPU programming, starting with an introduction to CUDA and Metal.