第 20.10 節

Rvalue references and move semantics

0瀏覽次數0訪問次數--跳出率--平均停留

What problem does this section solve?

Consider such a scenario:

std::vector<int> create_large_vector()
{
    std::vector<int> v(1000000);
    // ... 填充数据 ...
    return v;  // C++98 中会拷贝整个 vector!(非常慢)
}

In C++98, returning large objects from functions caused deep copies, resulting in poor performance. C++11 introduced move semantics, which changes "copying someone else's data" to "stealing data from another object," significantly improving performance.

What is this feature?

  • lvalue: A named object that can have its address taken. For example, variables x, arr[0].
  • Right-value: A temporary, soon-to-be-destroyed object. Examples include 42, x + y, and std::move(x).
  • Rvalue Reference T&&: A reference bound to an rvalue.
  • Move semantics: Avoiding unnecessary deep copies by "stealing" a right value's resources instead of copying.
  • std::move: Converts the left value to an rvalue reference, telling the compiler, "I won't use this object anymore; you can steal its resources."

C++ standard version

C++11

Required header files

#include <utility>  // for std::move, std::forward

Basic Syntax

int&& rref = 42;           // 右值引用绑定到临时值
std::string&& sr = s1 + s2;  // 绑定到表达式结果

// std::move:把左值转为右值
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1);  // v1 的数据被"偷"到 v2,v1 变成空

Left Value vs Right Value Cheat Sheet

Expressionlvalue/rvalueExplanation
int x = 5;x is an lvalue.Has a name and a memory address
42rvalueliteral
x + yrvalueProvisional results
f() returns non-referencervaluetemporary objects
f() returns a referencelvalueReference is an alias.
std::move(x)xvalue (expiring value)cast to an rvalue reference

Example code

Example 1: Copy vs. Move — Why We Need Move Semantics

#include <iostream>
#include <vector>

int main()
{
    std::vector<int> v1 = {1, 2, 3, 4, 5};

    // 拷贝(两份独立的数据)
    std::vector<int> v2 = v1;
    std::cout << "after copy:\n";
    std::cout << "  v1 size = " << v1.size() << "\n";  // 5
    std::cout << "  v2 size = " << v2.size() << "\n";  // 5

    // 移动(v1 的数据被"偷"到 v3,v1 变成空)
    std::vector<int> v3 = std::move(v1);
    std::cout << "after move:\n";
    std::cout << "  v1 size = " << v1.size() << "\n";  // 0
    std::cout << "  v3 size = " << v3.size() << "\n";  // 5

    return 0;
}

Results

after copy:
  v1 size = 5
  v2 size = 5
after move:
  v1 size = 0
  v3 size = 5

Example 2: Customizing the Move Constructor of a Class Based on Example 1

#include <algorithm> // for std::copy
#include <iostream>
#include <string>
#include <utility>  // for std::move

class Buffer
{
    int* data;
    size_t size;

public:
    // 构造函数
    Buffer(size_t n) : data(new int[n]), size(n)
    {
        std::cout << "Constructor: allocated " << n << " ints\n";
    }

    // 拷贝构造函数(深拷贝)
    Buffer(const Buffer& other) : data(new int[other.size]), size(other.size)
    {
        std::copy(other.data, other.data + size, data);
        std::cout << "Copy constructor: deep copied " << size << " ints\n";
    }

    // 移动构造函数(偷数据)
    Buffer(Buffer&& other) noexcept
        : data(other.data), size(other.size)
    {
        other.data = nullptr;  // 让原对象安全析构
        other.size = 0;
        std::cout << "Move constructor: stole " << size << " ints\n";
    }

    ~Buffer()
    {
        delete[] data;
        std::cout << "Destructor\n";
    }

    size_t get_size() const { return size; }
};

int main()
{
    Buffer buf1(1000);

    // 拷贝:会触发深拷贝
    Buffer buf2 = buf1;
    std::cout << "buf1 size after copy: " << buf1.get_size() << "\n";

    // 移动:数据被偷走,没有深拷贝!
    Buffer buf3 = std::move(buf2);
    std::cout << "buf2 size after move: " << buf2.get_size() << "\n";
    std::cout << "buf3 size after move: " << buf3.get_size() << "\n";

    return 0;
}

Results

Constructor: allocated 1000 ints
Copy constructor: deep copied 1000 ints
buf1 size after copy: 1000
Move constructor: stole 1000 ints
buf2 size after move: 0
buf3 size after move: 1000
Destructor
Destructor
Destructor

Buffer 示例详解:std::copystd::move 与对象初始化

示例 2 中最容易混淆的地方主要有三个:

  1. std::copy 到底复制了什么;
  1. std::move 到底做了什么;
  1. 为什么 Buffer b = a;Buffer b(a); 都会调用拷贝构造函数。

下面结合 Buffer 类逐一说明。

std::copy:真正复制一段元素

std::copy 定义在 <algorithm> 中,常见形式可以简化写成:

template<class InputIt, class OutputIt>
OutputIt std::copy(InputIt first, InputIt last, OutputIt destination);

三个参数的含义如下:

parameterMeaning
first源数据的起始位置

|last|源数据的结束位置,但不包含该位置|

|destination|目标区域的起始位置|

示例中的代码是:

std::copy(other.data, other.data + size, data);

可以理解为:

other.data 开始,复制到 other.data + size 之前为止,并把结果写入 data 指向的内存。

假设 size == 3,它大致等价于:

data[0] = other.data[0];
data[1] = other.data[1];
data[2] = other.data[2];

也可以手写成循环:

for (size_t i = 0; i < size; ++i)
{
    data[i] = other.data[i];
}

std::copy 的返回值是目标区域复制结束后的下一个位置。这里复制了 size 个元素,因此:

int* end = std::copy(other.data, other.data + size, data);

// 此时 end 等于 data + size

本例不需要继续使用这个位置,所以直接忽略了返回值。

需要注意,std::copy 不会自动为目标区域申请内存。调用它之前,data 必须已经指向足够大的有效内存。本例在成员初始化列表中已经完成了申请:

data(new int[other.size])
std::move:本身并不移动数据

std::move 定义在 <utility> 中。它的作用不是复制数据,也不是搬运数据,而是把一个表达式转换成可以绑定到右值引用的形式。

它的函数模板可以简化理解为:

template<class T>
std::remove_reference_t<T>&& std::move(T&& value) noexcept;

对于下面这句代码:

std::move(buf2)

参数是 buf2,返回结果可以简单理解为一个 Buffer&&。它相当于告诉编译器:

buf2 原来的资源可以被转移,不必继续保留原来的内容。

std::move(buf2) 这一表达式本身不会修改 buf2。真正转移资源的是随后被调用的移动构造函数或移动赋值运算符。

For example:

Buffer buf3 = std::move(buf2);

std::move(buf2) 使右侧表达式可以匹配:

Buffer(Buffer&& other) noexcept;

于是编译器调用移动构造函数,由移动构造函数真正接管指针:

Buffer(Buffer&& other) noexcept
    : data(other.data), size(other.size)
{
    other.data = nullptr;
    other.size = 0;
}

因此,可以把它们的职责记成:

std::copy:真正复制元素
std::move:把对象转换成“允许被移动”的表达式
移动构造函数:真正负责转移资源

单独写下面这句通常没有实际效果:

std::move(buf2);

因为返回的右值引用没有被用于构造、赋值或函数传参,移动构造函数也就不会被调用。

拷贝构造为什么是“深拷贝”

下面这句会调用拷贝构造函数:

Buffer buf2 = buf1;

对应的构造函数是:

Buffer(const Buffer& other)
    : data(new int[other.size]), size(other.size)
{
    std::copy(other.data, other.data + size, data);
}

它做了两件事:

  1. buf2 重新申请一块独立的数组内存;
  1. buf1 数组中的每个元素复制到新内存中。

拷贝完成后,两者保存的内容相同,但管理的是不同的内存:

buf1.data ──> [buf1 自己的 1000 个 int]

buf2.data ──> [buf2 自己的 1000 个 int]

所以销毁或修改其中一个对象,不会直接影响另一个对象,这就是深拷贝。

移动构造为什么要清空原对象

移动构造函数没有重新申请数组,也没有复制 1000 个元素,而是直接接管指针:

data(other.data), size(other.size)

接管后必须执行:

other.data = nullptr;
other.size = 0;

否则新对象和原对象会同时保存同一个指针:

buf2.data ─┐
           ├──> [同一块数组内存]
buf3.data ─┘

两个对象析构时都会执行:

delete[] data;

这样就可能对同一块内存释放两次,产生未定义行为。

把原对象的指针设为 nullptr 后,所有权关系变成:

buf2.data = nullptr

buf3.data ──> [原来由 buf2 管理的数组内存]

而下面的操作是安全的:

delete[] nullptr;

所以移动后的 buf2 仍然可以正常析构。在这个自定义实现中,我们还明确把它的 size 设成了 0

为什么 Buffer b = a; 等同于 Buffer b(a);

下面两种写法都在创建一个新的对象 b

Buffer b = a;  // 拷贝初始化
Buffer b(a);   // 直接初始化

虽然第一种写法中出现了等号,但这里的 = 不是赋值运算符,因为 b 在这条语句执行前还不存在,它正在被创建。

两种写法都会寻找一个能够使用 a 来构造 b 的构造函数。由于 a 的类型是 Buffer,所以都会匹配:

Buffer(const Buffer& other);

也就是拷贝构造函数。

可以把它们理解成:

Buffer b = a;  // 创建 b,并用 a 初始化
Buffer b(a);   // 创建 b,并用 a 初始化

对于本例,这两种写法的效果相同。

构造和赋值的判断方法

判断调用的是构造函数还是赋值运算符,关键不在于有没有等号,而在于左侧对象是否正在被创建。

Buffer a(1000);

Buffer b = a;  // b 正在创建:调用拷贝构造函数
Buffer c(a);   // c 正在创建:调用拷贝构造函数

Buffer d = std::move(a);  // d 正在创建:调用移动构造函数
Buffer e(std::move(b));   // e 正在创建:调用移动构造函数

如果对象已经存在,再使用等号,才是赋值:

Buffer x(100);
Buffer y(200);

x = y;             // x 已存在:调用拷贝赋值运算符
x = std::move(y);  // x 已存在:调用移动赋值运算符

可以用一句话记忆:

对象正在“出生”时调用构造函数;对象已经存在,只是更换内容时调用赋值运算符。

The corresponding relationship is as follows:

|writing method|对象状态|调用的函数|

|:---|:---|:---| |Buffer b = a;|b 正在创建|拷贝构造函数|

|Buffer b(a);|b 正在创建|拷贝构造函数|

|Buffer b = std::move(a);|b 正在创建|移动构造函数|

|Buffer b(std::move(a));|b 正在创建|移动构造函数|

|b = a;|b 已经存在|拷贝赋值运算符|

|b = std::move(a);|b 已经存在|移动赋值运算符|

拷贝初始化和直接初始化并非永远相同

从语法分类上看:

Buffer b = a;  // copy-initialization,拷贝初始化
Buffer b(a);   // direct-initialization,直接初始化

在当前 Buffer 示例中,它们都会调用普通的拷贝构造函数,因此效果相同。但面对带有 explicit 的转换构造函数时,两者会出现区别。

For example:

class Number
{
public:
    explicit Number(int value)
    {
    }
};

直接初始化可以显式调用这个构造函数:

Number n1(10);  // 正确

拷贝初始化则不允许隐式使用 explicit 构造函数:

Number n2 = 10;  // 错误

因此,更准确的说法是:

对于本例中的同类型对象拷贝,Buffer b = a;Buffer b(a); 都调用拷贝构造函数;但“拷贝初始化”和“直接初始化”在所有场景下并不完全等价。

补充: 当前 Buffer 类手动管理动态内存,但只实现了析构函数、拷贝构造函数和移动构造函数。若还要安全支持 b = ab = std::move(a),还应实现拷贝赋值运算符与移动赋值运算符,这通常称为 Rule of Five(五法则)

Example 3: Building on Example 2, move semantics make function returns of large objects efficient.

#include <iostream>
#include <vector>
#include <string>

// 返回大 vector(C++11 起自动启用移动语义,不需要手动 std::move)
std::vector<int> make_data(int n)
{
    std::vector<int> v(n);
    for (int i = 0; i < n; ++i)
    {
        v[i] = i * 10;
    }
    return v;  // ✅ 编译器自动移动(或 RVO 优化),不拷贝
}

int main()
{
    auto data = make_data(5);
    std::cout << "data: ";
    for (int n : data)
    {
        std::cout << n << " ";
    }
    std::cout << "\n";
    std::cout << "size = " << data.size() << "\n";

    // ⚠️ 错误做法:不要对返回值用 std::move!
    // auto data2 = std::move(make_data(5));  // ❌ 不要这样写!破坏 RVO 优化

    // ✅ 正确做法:赋值给已有变量时用 = std::move(source)
    std::vector<int> old = {100, 200};
    std::vector<int> fresh = std::move(old);  // 把 old 的内容移给 fresh
    std::cout << "old size = " << old.size() << "\n";      // 0
    std::cout << "fresh size = " << fresh.size() << "\n";  // 2

    return 0;
}

Results

data: 0 10 20 30 40 
size = 5
old size = 0
fresh size = 2

runtime results

See the "running results" for each example above.

Key syntax explanation in the example

|Here is the translation of the provided Simplified Chinese Markdown fragment into natural American English, following all specified rules.


ExampleDiscusses whatNewly emerged syntaxWhy write it this wayPrecautions
Example 1Copy vs Move Comparisonstd::move(), post-movement size=0After a move, the source object is "hollowed out," becoming valid but in an unspecified state.After a move operation, do not use the source object again unless it is reassigned.
Example 2custom move constructorBuffer(Buffer&&)noexceptother.data=nullptrMove constructors directly transfer the pointer without allocating new memory.After moving, you must set other.data to null, otherwise duplicate deletions will occur.
Example 3return value and assignment optimizationRVO, return v without adding moveThe compiler will optimize return values through move/RVO, and adding a move actually prevents RVO.Avoid using std::move when returning local variables

Why should the move constructor be declared with noexcept?

Primarily to prevent the standard library containers (like std::vector) from silently falling back to copying when moving objects during operations such as resizing, which would otherwise lead to unexpected performance degradation and potential issues with non-copyable types.

When expanding capacity, std::vector must move old elements to new memory. If the move constructor for an element type might throw an exception, vector, for exception safety, the container may prefer to use the copy constructor instead. In other words: even if you've written a move constructor, if you haven't written noexcept, the container might not use it during expansion.

class Buffer
{
public:
    Buffer(Buffer&& other) noexcept
    {
        // 偷资源,不抛异常
    }
};

This difference isn't noticeable when moving a single object; only when vector<Buffer> triggers a large push_back expansion does noexcept potentially impact whether the container chooses to move or copy.

When to use std::move, and when not to

SceneYes, use std::move.Reason
Transfer resources from an existing object to another object.Clearly transfer ownership or content.
Pass unique_ptr to the function that receives ownership.unique_ptr cannot be copied, only moved.
return local variables让编译器执行返回值优化(RVO)或具名返回值优化(NRVO)。
For the const objectConst objects cannot be truly moved and typically become copies.
After move, still want to continue reading the original object's contentA moved-from object is only guaranteed to be destructible and reassignable.

Common Errors

Error 1: After move, continue to use the source object

std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a);
std::cout << a[0];  // ❌ a 已经被"掏空",行为未定义

Correct practice: after move, do not use the source object again, or check whether it is empty first.

Error 2: Using move on a const object

const std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a);  // ❌ 实际执行的是拷贝!const 对象不能移动

Correct practice: move should not be used for const objects.

Error 3: Adding std::move to Return Values

Adding std::move to a return statement is a common mistake. It prevents Return Value Optimization (RVO), which is a compiler optimization that can eliminate the copy/move entirely. In C++11 and later, you should just return the object directly; the compiler will handle efficient transfer automatically.

std::vector<int> func()
{
    std::vector<int> v(1000);
    return std::move(v);  // ❌ 阻止了 RVO 优化!
}

Correct approach: directly return v;, the compiler will automatically optimize.

Error 4: Move constructor not marked noexcept

MyClass(MyClass&& other) { ... }  // 缺少 noexcept

The correct approach: MyClass(MyClass&& other) noexcept { ... } — not marking it noexcept causes containers like vector to degrade to copying when they expand.

使用建议

  • 明确目标:在开始前确定您的具体需求,以便选择最合适的工具或教程。
  • 充分利用资源:参考官方文档、教程和博客,这些资料能帮助您快速上手并解决问题。
  • 实践应用:通过动手操作项目或编写代码来巩固学习成果,提升实际操作能力。
  • 问题解决:遇到困难时,查阅参考资料或寻求社区支持,逐步培养独立解决问题的能力。
  • 分享经验:完成项目后,可以撰写文章或博客分享心得,帮助其他学习者。

如果需要针对特定领域(如单片机、机器人或环境搭建)的进一步建议,请提供更多信息,我将为您细化内容。

  1. Do not use std::move when returning: Allow the compiler to optimize automatically (RVO/NRVO).
  2. When assigning to an existing object, use std::move(source): Use target = std::move(source) to avoid copying.
  3. Do not use the source object after moving (unless reassigning or resetting it).
  4. The move constructor must be marked as noexcept: Otherwise, STL containers won't call it during reallocation.
  5. std::move does not move anything: it is just a type conversion, converting an lvalue into an rvalue reference. The actual move happens in the move constructor or move assignment operator.

Summary

  • Lvalues have names and are addressable; rvalues are temporary (literals, temporary objects).
  • An rvalue reference T&& binds to an rvalue.
  • Move semantics "steal" resources from right-value references to avoid deep copying.
  • std::move(x) converts an lvalue to an rvalue, telling the compiler "x can be stolen."
  • When returning local variables, avoid using std::move (let the compiler optimize it itself).
  • The move constructor must be marked noexcept.

Engineering expansion

In ROS2, when publishing messages or transferring large sensor data such as point clouds or images, move semantics can help avoid copying large amounts of data. In Boost.Asio, move semantics are extensively used in the callback parameters of asynchronous operations to efficiently pass data buffers.

音乐页