移动语义真相
在现代 C++ 中,std::move 是实现移动语义(move
semantics)的核心工具。
虽然 move 表示移动,但是 std::move
本身并不移动任何东西,它只是做一个无条件的类型转换————将一个变量强制转换为右值引用(rvalue
reference),从而向编译器表明该变量的内容可以被”偷走”。
在移动语义出现之前,如果我想将一个容器内的元素移动到另一个容器内,只能通过拷贝构造或拷贝赋值运算符。例如:
1 | |
这种 O(N) 级别的操作开销过大,但是通过移动语义就可以将复杂度降低到 O(1) 级别:
1 | |
我们可以发现,当 std::move
将变量类型转换为右值引用后,编译器调用的移动构造或移动赋值运算内部只是进行了指针的拷贝,并将原始指针置空。通过
std::move 与移动构造、移动赋值运算符的配合,我们可以在 O(1)
的时间复杂度内将旧对象的内容”窃取”到新对象。只是这种”窃取”并不是真的移动数据在内存中的位置,它更像是一种所有权的变更。
右值与右值引用
在上面的例子中,移动构造函数的参数类型是
Buffer&& 而非
const Buffer&。这里的 &&
就是右值引用(rvalue reference),它是 C++11
引入的一种新的引用类型。
要理解右值引用,首先要理解 C++ 中表达式的两种值类别(value category):
- 左值(lvalue):能够取地址、有名字、生命周期持续到当前作用域结束的对象。例如变量
src、dest,解引用指针得到的结果等。 - 右值(rvalue):不能取地址、没有名字、生命周期即将结束的临时对象。例如字面量
42、函数按值返回的临时对象、显式std::move转换后的结果等。
在 C++11
之前,只有左值引用(T&)这一种引用类型。const T&
虽然可以同时绑定左值和右值,但被引用的对象是只读的,无法修改。
右值引用 T&&
恰好填补了这个空白:它专门绑定到右值,并且允许修改被引用的对象。这正是移动语义的基础——当一个对象是右值时,意味着没有人会在意它之后的状态,我们可以安全地”偷走”它的内部资源,而不需要做深拷贝。
编译器在重载决议时会根据实参的值类别来匹配:
- 传入左值 → 匹配
const T&→ 调用拷贝构造函数(保留原对象) - 传入右值 → 匹配
T&&→ 调用移动构造函数(窃取原对象资源)
而 std::move
的作用,就是无条件地将一个左值强制转换为右值引用,从而诱使编译器选择移动重载。这也再次印证了文章开头的观点:std::move
本身并不移动任何东西,它只是做了一个”类型标记”,告诉编译器”请把这个变量当作右值来处理”。
std::move 的实现
std::move 的具体实现其实非常短:
1 | |
其中 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 | |
此时 std::move(s) 返回类型为
const std::string&&,编译器无法匹配参数类型为
string&& 的移动构造函数,只能调用拷贝构造函数。
2. 类没有声明或显式禁用了移动语义
根据 C++ 规则,当类中自定义了析构函数、拷贝构造函数或拷贝赋值运算符,编译器不会自动生成默认的移动构造函数和移动赋值运算符。
在这种情况下,或者是在类中禁用/删除了移动构造(= delete)的情况下对该类的对象使用
std::move,由于找不到移动重载,编译器同样会降级调用拷贝构造函数。
3. 成员变量或基类无法移动
如果一个类自动生成了移动构造函数,但它的某个成员变量(或基类)禁用/删除了移动构造(= delete)或者只有拷贝函数,那么在移动该外层对象时,这个特定的成员变量依然会执行拷贝。
性能层面
有时候,虽然 std::move
触发了移动语义,但是性能相对于拷贝并没有提升。
1. 内置基础类型
像
int、char、double、原生指针(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。
如果你的类的移动构造函数没有加上
noexcept,std::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