DongGu
移动语义真相

移动语义真相

在现代 C++ 中,std::move 是实现移动语义(move semantics)的核心工具。

虽然 move 表示移动,但是 std::move 本身并不移动任何东西,它只是做一个无条件的类型转换————将一个变量强制转换为右值引用(rvalue reference),从而向编译器表明该变量的内容可以被”偷走”。

在移动语义出现之前,如果我想将一个容器内的元素移动到另一个容器内,只能通过拷贝构造或拷贝赋值运算符。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Buffer {
public:
char* data;

Buffer(const char* str) {
data = new char[std::strlen(str) + 1];
std::strcpy(data, str);
}

// 拷贝构造函数
Buffer(const Buffer& other) {
data = new char[std::strlen(other.data) + 1];
std::strcpy(data, other.data); // 深拷贝
}

// 拷贝赋值运算符
Buffer& operator=(const Buffer& other) {
if (this != &other) {
delete[] data;
data = new char[std::strlen(other.data) + 1];
std::strcpy(data, other.data); // 深拷贝
}
return *this;
}

~Buffer() { delete[] data; }
};

Buffer src("data");
Buffer dest(src); // 触发拷贝构造
Buffer dest2("other");
dest2 = src; // 触发拷贝赋值

这种 O(N) 级别的操作开销过大,但是通过移动语义就可以将复杂度降低到 O(1) 级别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Buffer {
public:
char* data;

Buffer(const char* str) {
data = new char[std::strlen(str) + 1];
std::strcpy(data, str);
}

// 移动构造函数
Buffer(Buffer&& other) noexcept
: data(other.data) {
other.data = nullptr; // 防止二次释放
}

// 移动赋值运算符
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data; // 释放旧资源
data = other.data; // 偷取资源
other.data = nullptr; // 置空原指针
}
return *this;
}

~Buffer() { delete[] data; }
};

Buffer src("data");
Buffer dest(std::move(src)); // 触发移动构造
Buffer dest2("other");
dest2 = std::move(dest); // 触发移动赋值

我们可以发现,当 std::move 将变量类型转换为右值引用后,编译器调用的移动构造或移动赋值运算内部只是进行了指针的拷贝,并将原始指针置空。通过 std::move 与移动构造、移动赋值运算符的配合,我们可以在 O(1) 的时间复杂度内将旧对象的内容”窃取”到新对象。只是这种”窃取”并不是真的移动数据在内存中的位置,它更像是一种所有权的变更。

右值与右值引用

在上面的例子中,移动构造函数的参数类型是 Buffer&& 而非 const Buffer&。这里的 && 就是右值引用(rvalue reference),它是 C++11 引入的一种新的引用类型。

要理解右值引用,首先要理解 C++ 中表达式的两种值类别(value category):

  • 左值(lvalue):能够取地址、有名字、生命周期持续到当前作用域结束的对象。例如变量 srcdest,解引用指针得到的结果等。
  • 右值(rvalue):不能取地址、没有名字、生命周期即将结束的临时对象。例如字面量 42、函数按值返回的临时对象、显式 std::move 转换后的结果等。

在 C++11 之前,只有左值引用(T&)这一种引用类型。const T& 虽然可以同时绑定左值和右值,但被引用的对象是只读的,无法修改。

右值引用 T&& 恰好填补了这个空白:它专门绑定到右值,并且允许修改被引用的对象。这正是移动语义的基础——当一个对象是右值时,意味着没有人会在意它之后的状态,我们可以安全地”偷走”它的内部资源,而不需要做深拷贝。

编译器在重载决议时会根据实参的值类别来匹配:

  • 传入左值 → 匹配 const T& → 调用拷贝构造函数(保留原对象)
  • 传入右值 → 匹配 T&& → 调用移动构造函数(窃取原对象资源)

std::move 的作用,就是无条件地将一个左值强制转换为右值引用,从而诱使编译器选择移动重载。这也再次印证了文章开头的观点:std::move 本身并不移动任何东西,它只是做了一个”类型标记”,告诉编译器”请把这个变量当作右值来处理”。

std::move 的实现

std::move 的具体实现其实非常短:

1
2
3
4
5
6
7
8
namespace std {

template <typename T>
constexpr typename std::remove_reference<T>::type&& move(T&& t) noexcept {
return static_cast<typename std::remove_reference<T>::type&&>(t);
}

}

其中 constexpr 告诉编译器这个类型转换可以在编译期执行,零运行时开销;typename 用以消除歧义,让编译器清楚后面是一个类型而不是类成员;noexcept 承诺这个强制转换永远不会抛出异常,让编译器放心优化。

根据 template 类型推导规则与引用折叠(reference collapsing)规则:

  • std::move 传入的变量是一个左值时,T 的推导结果是一个左值引用 T&
  • std::move 传入的变量是一个右值时,T 的推导结果是一个普通的非引用类型 T

我们希望 std::move 的返回值类型是右值引用,于是我们使用了 std::remove_reference<T>::type 进行类型萃取(type trait),它会剥离 T 的引用,替换为非引用的原始类型,之后再拼接上 && 就得到了右值引用类型 T&&

通过 std::move 的实现代码,我们可以意识到:std::move 并没有移动任何数据,甚至没有生成任何运行时代码,它只是在编译期将变量类型无条件地转换为右值引用类型,后续由编译器匹配构造函数,决议生成调用拷贝或移动相关函数的汇编代码。

std::move 失效的情况

std::move 将变量类型转换为右值引用类型后,并不意味着后续一定会执行移动构造或移动赋值运算,而且有时即使执行了移动相关函数,速度也没有比拷贝相关函数提升多少。

下面分别介绍会引发 std::move 失效的情况:

语法/重载层面

1. 对象被 const 修饰

std::move 会将 const T 转换为 const T&&(带 const 的右值引用)。然而,标准的移动构造/移动赋值函数签名要求实参必须是可修改的右值引用(T&&)。由于无法将 const T&& 绑定到 T&&,编译器只能退而求其次,去匹配接受 const T& 的拷贝构造/拷贝赋值函数。比如:

1
2
3
4
5
6
7
8
9
10
class string {
public:
...
string(const string& rhs); // 拷贝构造函数
string(string&& rhs); // 移动构造函数
...
};

const std::string s = "Hello World";
std::string s2 = std::move(s); // 实际调用的是拷贝构造函数!

此时 std::move(s) 返回类型为 const std::string&&,编译器无法匹配参数类型为 string&& 的移动构造函数,只能调用拷贝构造函数。

2. 类没有声明或显式禁用了移动语义

根据 C++ 规则,当类中自定义了析构函数、拷贝构造函数或拷贝赋值运算符,编译器不会自动生成默认的移动构造函数和移动赋值运算符。

在这种情况下,或者是在类中禁用/删除了移动构造(= delete)的情况下对该类的对象使用 std::move,由于找不到移动重载,编译器同样会降级调用拷贝构造函数。

3. 成员变量或基类无法移动

如果一个类自动生成了移动构造函数,但它的某个成员变量(或基类)禁用/删除了移动构造(= delete)或者只有拷贝函数,那么在移动该外层对象时,这个特定的成员变量依然会执行拷贝。

性能层面

有时候,虽然 std::move 触发了移动语义,但是性能相对于拷贝并没有提升。

1. 内置基础类型

intchardouble、原生指针(char*)等基本类型,它们本身在栈或寄存器中,内部没有指向堆内存的指针。对基础类型使用 std::move,移动的本质依然是按字节复制,不会带来性能上的提升。

2. 带有 SSO 的短 std::string

现代 C++ 的 std::string 实现普遍采用了 SSO(Small String Optimization,小字符串优化)技术:当字符串长度较短(通常在 15 或 22 字节以内)时,字符串数据不会分配在堆上,而是直接保存在 std::string 对象内部的栈缓冲区中。这种技术避免了频繁的堆区内存操作。

对于这类短字符串,移动操作无法通过”交换堆指针”来实现,只能将栈上的字节逐个复制过去。因此,移动短字符串的开销与拷贝短字符串几乎完全一致。

3. std::array 或固定大小的栈上数组

std::array<T, N> 的内存全在栈上,它不像 std::vector 那样只持有指向堆内存的指针。当你 std::move 一个 std::array 时,它必须逐个移动内部的 N 个元素。如果元素类型的移动开销较大,或者数组很大,整体复杂度依然是 O(N),达不到 std::vector 移动时 O(1) 的效果。

安全层面

1. 移动函数缺少 noexcept 声明

std::vector 在动态扩容(reallocate)或 resize 时,为了保证强异常安全保证(strong exception guarantee,即如果扩容失败,原 vector 必须保持原状不被破坏),会严格检查元素的移动构造函数是否标记了 noexcept

如果你的类的移动构造函数没有加上 noexceptstd::vector 在扩容时会放弃使用移动构造,转而强制逐个拷贝所有元素。


这次的插图来自画师 Aio

图片地址:https://www.pixiv.net/artworks/126297401

文章内容参考:Effective Modern C++ 42 Specific Ways to Improve Your Use of C++11 and C++14 (Scott Meyers) Item 23、Item 29

本文作者:DongGu
本文链接:https://donggu.xyz/2026/08/06/现代C++学习/移动语义真相/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可