移动语义(move semantics)允许对象把资源交给另一个对象,省去本来需要的资源复制;真正执行转移的是构造函数或赋值运算符,不是 std::move。
std::move 只是一次类型转换。移动后对象仍然存活,但它的值通常不应被假定为空,也不应按移动前的值继续使用。
优先遵循零法则;确实要自定义资源所有权时,维持源对象不变量,并只在操作保证不抛异常时标记 noexcept。
是什么,为什么存在
移动语义(move semantics) 让一个对象用另一个即将被放弃的对象来初始化或赋值。复制通常要为目标创建一份独立资源,移动则可以接管源对象已经拥有的资源。例如,动态数组可以转移缓冲区指针,而不必逐个复制元素。
这项机制解决的是所有权转移,不是普通整数赋值。它常出现在按值返回对象、向容器插入临时值、转移 std::unique_ptr,以及让对象接管函数参数时。对只含少量标量的类型,移动可能与复制成本相同;语言从未承诺移动一定更快。
C++ 通过重载选择表达意图。复制构造函数通常接收 const T&,移动构造函数通常接收 T&&。当源表达式可以绑定到后者时,重载解析才有机会选择移动操作;最终做多少工作,仍由被调用的构造函数或赋值运算符决定。
资源所有者尤其适合移动语义。文件句柄、套接字和独占指针不能安全地复制所有权,却可以把唯一所有者从一个对象换成另一个对象。移动后的源对象还会析构,所以它必须保持可安全析构的状态。
工作原理
C++ 的表达式有值类别(value category)。左值(lvalue)通常表示有身份、可再次引用的对象;纯右值(prvalue)用于计算一个值或初始化结果对象;将亡值(xvalue)表示资源可以被复用的有身份对象。xvalue 和 prvalue 合称 rvalue。
右值引用(rvalue reference) 写作 T&&。它能绑定到 rvalue,并让重载集区分可转移的来源与普通左值。这里判断的是表达式,不是变量声明中的类型:一个有名字的变量表达式总是左值,即使该变量的类型是 T&&。
std::move(value) 不会读取缓冲区、清空容器或调用构造函数。它大致把表达式转换为对应类型的 xvalue。随后发生的初始化、赋值或函数调用再做重载解析;如果没有可用的移动重载,代码仍可能复制。
移动构造函数从源对象取得资源并建立一个新对象。移动赋值运算符还要先处理目标对象当前拥有的资源,再接管源资源。两者都要考虑自移动、基类、每个成员以及源对象不变量,漏掉其中一项就可能造成泄漏、重复释放或逻辑错误。
标准库类型在未另行说明时,会把移动后的源对象置于 有效但未指定状态(valid but unspecified state) 。有效表示对象不变量仍成立,并且可以析构或赋新值;未指定表示不能猜测它保留了什么值。自定义类型的移动后约定由该类型自己的接口和实现决定。
下面这张表只描述常见表达式形态。函数返回类型、强制转换和 decltype 等规则会产生更多组合,但移动代码首先要分清这四种情况。
| 表达式 | 值类别 | 对典型重载的影响 |
|---|---|---|
有名字的对象 value | lvalue | 优先绑定到 T& 或 const T& |
| 字面量或同类型临时结果 | prvalue | 可以初始化结果对象或绑定到 T&& |
std::move(value) | xvalue | 可以绑定到 T&& |
有名字的 T&& 参数 value | lvalue | 不会仅凭声明类型自动再次移动 |
值类别不说明对象是否真的临近生命周期终点。std::move 可以把仍会存活很久的对象表达成 xvalue,因此正确性依赖程序员兑现不再依赖旧值的承诺。编译器主要检查类型是否可绑定,通常无法判断这项所有权意图是否合理。
示例
观察值类别和重载
第一个例子只观察重载选择,两个 inspect 重载都不修改参数。因此,输出还能证明 std::move 本身没有移动字符串内容。
#include <iostream>
#include <string>
#include <utility>
void inspect(const std::string& value) {
std::cout << "lvalue overload: " << value << '\n';
}
void inspect(std::string&& value) {
std::cout << "rvalue overload: " << value << '\n';
}
int main() {
std::string order = "tea";
inspect(order);
inspect(std::move(order));
std::cout << "after std::move: " << order << '\n';
// 有名字的右值引用表达式仍然是左值。
std::string&& alias = std::move(order);
inspect(alias);
inspect(std::move(alias));
}lvalue overload: tea
rvalue overload: tea
after std::move: tea
lvalue overload: tea
rvalue overload: teainspect(order) 看到左值,因此选择 const std::string&。inspect(std::move(order)) 看到 xvalue,因此选择 std::string&&。右值引用变量 alias 一旦有了名字,表达式 alias 也是左值;要再次把它交给右值重载,需要显式转换。
不要从这个例子推导出移动后字符串总会保留内容。这里的右值重载没有从参数构造或赋值另一个字符串,所以没有执行资源转移。只要把函数体改成真正接管 value,源对象的值就要按相应类型的移动后约定解释。
这种区分在移动构造函数内部很常见。参数 other 的声明类型虽然是 Buffer&&,表达式 other 仍是左值。要移动它的成员,需要对成员使用 std::move(other.member),或者使用能表达同一转移的成员操作。
实现所有权转移
下面的 ByteBuffer 拥有一块动态数组。复制被删除,移动操作接管 std::unique_ptr,同时用 std::exchange 把源对象的长度重置为 0。重置长度不是语言要求,而是这个类为了维持 size() == 0 与空指针一致而选择的不变量。
#include <cstddef>
#include <iostream>
#include <memory>
#include <utility>
class ByteBuffer {
public:
explicit ByteBuffer(std::size_t size)
: data_(size ? std::make_unique<unsigned char[]>(size) : nullptr),
size_(size) {}
ByteBuffer(const ByteBuffer&) = delete;
ByteBuffer& operator=(const ByteBuffer&) = delete;
ByteBuffer(ByteBuffer&& other) noexcept
: data_(std::move(other.data_)),
size_(std::exchange(other.size_, 0)) {
std::cout << "move-constructed\n";
}
ByteBuffer& operator=(ByteBuffer&& other) noexcept {
if (this != &other) {
data_ = std::move(other.data_);
size_ = std::exchange(other.size_, 0);
}
std::cout << "move-assigned\n";
return *this;
}
[[nodiscard]] std::size_t size() const noexcept { return size_; }
private:
std::unique_ptr<unsigned char[]> data_;
std::size_t size_ = 0;
};
int main() {
ByteBuffer incoming(4);
ByteBuffer stored(std::move(incoming));
std::cout << "sizes: " << incoming.size() << ", " << stored.size() << '\n';
ByteBuffer replacement(2);
stored = std::move(replacement);
std::cout << "sizes: " << replacement.size() << ", " << stored.size() << '\n';
}move-constructed
sizes: 0, 4
move-assigned
sizes: 0, 2移动构造直接初始化一个还没有资源的目标。移动赋值面对的目标已经拥有四字节缓冲区;给 data_ 做移动赋值时,std::unique_ptr 会先释放旧数组,再接管两字节数组。自移动检查则避免同一个对象先释放自己的资源再试图接管它。
这个实现特意展示了资源对和源对象不变量,但生产代码通常不需要亲自写它。若类只由 std::vector、std::string、智能指针等 RAII 成员组成,并且它们的默认移动后状态满足类不变量,编译器生成的成员操作往往更准确。
noexcept 是接口承诺,不是性能装饰。这里的两项成员操作都不抛异常,所以移动操作可以诚实地标记为 noexcept。如果函数体调用了可能抛异常的代码,硬加这个标记会让异常逃出时调用 std::terminate。
转移独占所有权
std::unique_ptr 把所有权约束编码进类型:复制操作被删除,移动操作把所管理的指针交给目标,并让源指针变空。按值接收 std::unique_ptr<T> 的函数明确表示它会成为所有者;调用者必须写出 std::move 才能交出一个有名字的指针。
#include <iostream>
#include <memory>
#include <string>
#include <utility>
struct Report {
explicit Report(std::string report_name)
: name(std::move(report_name)) {}
~Report() {
std::cout << "destroyed: " << name << '\n';
}
std::string name;
};
void publish(std::unique_ptr<Report> report) {
std::cout << "publishing: " << report->name << '\n';
}
int main() {
auto draft = std::make_unique<Report>("weekly");
std::cout << "before: " << (draft ? "present" : "empty") << '\n';
publish(std::move(draft));
std::cout << "after: " << (draft ? "present" : "empty") << '\n';
}before: present
publishing: weekly
destroyed: weekly
after: emptypublish 的参数在函数结束时析构,所以报告也在打印 after 之前销毁。这里可以依赖 draft 变空,因为 std::unique_ptr 自己的接口给出了这个后置状态;这比把所有标准库类型的移动后状态都假定为空更具体。
如果函数只使用报告而不取得生命周期所有权,应接收 Report&、const Report& 或合适的观察指针。把 std::unique_ptr 按值传入只为读取内容,会迫使调用者放弃所有权,并让接口表达错误意图。
临时指针可以直接传入,例如 publish(std::make_unique<Report>("daily")),因为临时表达式已经可以绑定到移动路径。不要给每个临时值再套一层 std::move;它既不增加所有权信息,也可能掩盖对值类别规则的误解。
noexcept 如何影响转移策略
需要在保留原值与转移原值之间选择的泛型代码,可以使用 std::move_if_noexcept。当 T 的移动构造保证不抛异常,或者 T 根本不能复制时,它返回可供移动的右值引用;否则它返回供复制使用的 const T&。
#include <iostream>
#include <utility>
template<bool Nothrow>
struct Record {
static inline int copies = 0;
static inline int moves = 0;
int id;
explicit Record(int value) : id(value) {}
Record(const Record& other) : id(other.id) {
++copies;
}
Record(Record&& other) noexcept(Nothrow) : id(other.id) {
++moves;
}
};
template<class T>
void relocate(const char* label) {
T::copies = 0;
T::moves = 0;
T source(7);
T destination(std::move_if_noexcept(source));
std::cout << label << ": copies=" << T::copies
<< ", moves=" << T::moves
<< ", id=" << destination.id << '\n';
}
int main() {
relocate<Record<false>>("throwing move");
relocate<Record<true>>("noexcept move");
}throwing move: copies=1, moves=0, id=7
noexcept move: copies=0, moves=1, id=7如果可能抛异常的移动在中途修改了源对象,调用方可能无法恢复原值。复制则能先建立独立的新对象,失败时仍保留来源。std::vector 等标准库组件会利用类型特征决定重定位策略,但具体保证还受元素是否可复制、分配器和所调用操作约束。
不要把这段输出当成移动速度的基准。它只记录选择了哪个构造函数,没有测量时间。一次移动可能交换一个指针,也可能逐个处理元素;一次复制也可能因为小对象表示而很便宜。需要性能结论时,应对实际类型、数据规模、标准库实现和构建配置做基准测试。
陷阱
把已移动对象当成空对象
标准库对象通常仍可以析构、重新赋值,也可以调用不依赖特定当前值且满足前置条件的操作。修复方法是把移动视为该值最后一次有意义的读取,并在确实要复用变量时先赋新值。若 API 承诺了更具体的移动后状态,应引用该类型自己的文档,不要从一次运行结果猜测。
对 const 对象调用 std::move
修复方法是不要给准备转移所有权的局部对象加 const,并检查接收端究竟提供了哪些重载。也不要为了让代码看起来在移动而去掉本来必要的常量性;有时复制就是正确结果。
在返回局部对象时强制移动
修复方法是写 return result;。当 NRVO 可用时,编译器可以直接构造返回对象;不能消除时,返回语句对符合条件的自动存储期局部对象有隐式移动规则。显式 std::move 只应出现在确实需要改变值类别、且没有破坏消除机会的地方。
遗漏或误写 noexcept
修复方法是让异常规格来自真实成员操作。默认化的移动操作通常能推导正确的条件;手写时,可以用 std::is_nothrow_move_constructible_v<T> 等类型特征做静态检查,并为会抛异常的路径保留诚实的接口。
手写不完整的特殊成员函数
修复方法是优先把资源放进 RAII 成员并采用零法则。确实需要自定义时,把析构、复制构造、复制赋值、移动构造和移动赋值作为一组审查,并分别测试构造、赋值、自赋值、异常路径和析构。
在模板包装器里无条件移动
修复方法是先判断参数代表所有权接收还是透明转发。所有权接收可以明确使用按值参数或具体类型的右值引用;透明包装器使用 std::forward<T>(value) 保留调用者的值类别。完美转发的完整推导规则属于模板主题。
特殊成员函数与零法则
编译器隐式声明移动构造函数需要满足一组条件。其中一个常被忽略的条件是类没有用户声明的析构函数;同样,用户声明的复制构造或复制赋值也会影响隐式移动声明。结果可能是代码看似能接受 rvalue,实际却由 const T& 复制构造完成。
常见声明对隐式操作的影响可以先按下表排查,最终结果还要结合每个基类和成员是否支持相应操作。
| 类中的声明 | 需要检查的结果 |
|---|---|
| 没有用户声明的特殊成员 | 复制和移动可能由编译器隐式声明 |
| 用户声明析构函数 | 不会隐式声明移动构造和移动赋值 |
| 用户声明复制构造或复制赋值 | 不会隐式声明移动操作 |
| 用户声明移动构造或移动赋值 | 隐式复制操作会被定义为删除 |
显式 = default 或 = delete | 意图可见,但可用性仍取决于成员和基类 |
如果类需要虚析构函数等自定义声明,又确实希望保留移动能力,应明确审查并按需要写出 = default 或 = delete。默认化并不等于保证可用:某个基类或成员不能执行相应操作时,默认化的特殊成员函数仍可能被定义为删除。用类型特征和实际调用点编译测试,比只看声明可靠。
默认移动按基类和非静态数据成员逐个执行移动初始化或移动赋值。对 std::unique_ptr 这样的成员,它会转移所有权;对整数这样的成员,所谓移动仍只是复制数值。因此,一个默认移动后的对象可能出现空指针与未变长度的组合,是否有效取决于类不变量。
这就是零法则的实际价值。类若把资源及其元数据封装在一个已经正确实现所有权的成员中,通常无需再同步多组状态。std::vector<std::byte> 比裸指针加长度更容易得到正确的复制、移动和析构行为;代码也少了重复实现特殊成员函数的机会。
五法则不是要求每个类都手写五个函数。它提醒你,自定义其中一项时,其他默认操作可能不再存在,或者语义不再匹配。先争取零法则;无法做到时,再明确决定每一项是默认、删除还是自定义。
移动赋值与移动构造还有一个不同点:赋值的目标已经是完整对象。实现必须妥善结束目标的旧所有权,并在自移动时保持可析构、可赋值的有效状态。交换式实现或 RAII 成员赋值常能减少分支,但仍要检查业务不变量,而不只是指针是否为空。
拷贝消除、隐式移动与代价
拷贝消除(copy elision) 让实现省略本来可能出现的复制或移动。在 C++17 以后,用同类型 prvalue 初始化结果对象的若干场景采用直接构造语义,并不先创建一个临时对象再移动。观察不到移动构造日志,不代表移动语义失效;可能根本没有需要移动的中间对象。
返回同类型的命名局部对象时,NRVO 允许把局部对象直接构造在调用方的结果位置。若没有采用 NRVO,符合条件的局部对象还能由返回语句的隐式移动规则处理。写 return std::move(local); 会改变表达式形态,使 NRVO 条件不再满足,所以默认写法应当是按名称返回。
noexcept 影响的是故障恢复策略。若移动保证不抛异常,先移动若干元素再遇到问题不会由移动本身打断;若移动可能抛出且复制可用,先复制到新存储通常更容易维持强异常保证。若类型不可复制,容器可能只能尝试可能抛异常的移动,此时具体异常保证要查看相应操作的规范。
移动的复杂度取决于表示形式和上下文。普通 std::vector 常能转移内部存储,但受分配器传播和相等性等规则影响的操作可能需要逐元素移动。std::array 的移动则会对每个元素执行移动。仅凭代码中出现 std::move,无法推出常数时间或某个加速倍数。
API 设计应先表达所有权,再考虑省掉哪次复制。只观察对象时使用引用;无条件取得现有对象所有权时可以接收右值引用;既要接受左值又要在内部保存独立值时,按值接收再移动到成员有时更简单。每种选择都有调用方成本,不能用一条移动规则替代接口分析。
最后,std::move 也不负责保证被调函数真的消费参数。一个 T&& 参数可以只读取对象,也可以把它移动到成员,还可以错误地保存一个悬空引用。审查移动代码时要沿着调用链找到真正改变所有权的位置,而不是停在类型转换处。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题