Chapter 3: Move Semantics and Rvalue References
移动语义解决的主问题是:当一个对象持有堆内存、文件句柄、套接字、互斥量这类资源时,C++ 如何在保持对象生命周期规则成立的前提下,把资源所有权从一个对象转移到另一个对象,并让 STL 容器在扩容、插入和返回临时对象时减少不必要的深拷贝。
本章把移动语义放到 STL 工程路径中理解。读完后应能定位一个表达式的 value category,判断它会绑定到左值引用还是右值引用,解释 std::move 和 std::forward 对重载选择的影响,追踪 move constructor、move assignment、noexcept 与 std::move_if_noexcept 如何共同影响 std::vector 扩容。
贯穿材料使用一个持有堆数组的 Buffer。这个类型足够小,可以看到资源所有权、拷贝、移动、析构和 moved-from 状态;它又足够接近 STL 容器元素,可以解释 std::vector<Buffer> 扩容时为什么需要在 move 和 copy 之间做异常安全选择。
下面代码是教学简化代码。它表达常见资源管理形状,不代表任何标准库实现源码。标准语义以 C++ 标准为准;value category 可对照 C++ draft 的 basic.lval,标准库对象 moved-from 状态可对照 lib.types.movedfrom。
#include <algorithm>
#include <cstddef>
#include <utility>
class Buffer {
public:
Buffer() = default;
explicit Buffer(std::size_t n)
: data_(n == 0 ? nullptr : new int[n]), size_(n) {}
~Buffer() {
delete[] data_;
}
Buffer(const Buffer& other)
: Buffer(other.size_) {
std::copy_n(other.data_, size_, data_);
}
Buffer& operator=(const Buffer& other) {
if (this == &other) {
return *this;
}
Buffer tmp(other);
swap(tmp);
return *this;
}
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)),
size_(std::exchange(other.size_, 0)) {}
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) {
return *this;
}
delete[] data_;
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
return *this;
}
void swap(Buffer& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
std::size_t size() const noexcept {
return size_;
}
private:
int* data_ = nullptr;
std::size_t size_ = 0;
};
这个 Buffer 的核心约束是每一块动态数组只能由一个对象负责释放。拷贝构造需要分配新数组并复制元素;移动构造只转移指针和大小,再把源对象调整成可析构、可赋值、可调用无前置条件成员函数的状态。移动语义的工程价值就落在这里:它把“复制资源内容”转换为“转移资源责任”。
3.1 value category:lvalue、rvalue 与表达式身份
value category 是表达式属性。每个 C++ 表达式都有类型,也有 value category;这两个属性共同决定引用绑定、重载选择、临时对象物化和资源可复用性。C++11 之后,本章需要重点区分三类基础表达式:lvalue、xvalue、prvalue。rvalue 是 xvalue 和 prvalue 的合称。
lvalue 的工程含义是表达式求值能定位到一个有身份的对象或函数。身份指可以再次访问同一实体,例如局部变量名 buf、解引用表达式 *p、下标表达式 v[i]、返回左值引用的函数调用。lvalue 可以出现在赋值左侧只是历史命名来源,现代 C++ 判断时应看“是否能定位对象身份”。
prvalue 的工程含义是表达式提供一个值,用于初始化对象或作为运算输入。Buffer(1024)、make_buffer() 返回的非引用结果、整数字面量 42 都是常见 prvalue。C++17 以后 prvalue 的临时物化规则发生调整,返回值场景中会出现强制拷贝省略;这不改变本章主线,因为重载选择仍然根据表达式类别判断。
xvalue 的工程含义是表达式能定位到对象身份,同时表达式声明该对象资源可被复用。std::move(buf)、返回 Buffer&& 的函数调用、对对象类型做 static_cast<Buffer&&>(buf) 都是典型 xvalue。移动构造和移动赋值通常从 xvalue 入口获得资源转移机会。
用 Buffer 看这三类表达式,可以得到一条直接判断路径。
Buffer make_buffer() {
return Buffer(1024);
}
void use(const Buffer&);
void use(Buffer&&);
int main() {
Buffer buf(1024);
use(buf); // buf 是 lvalue,优先绑定到 const Buffer&
use(Buffer(2048)); // Buffer(2048) 是 prvalue,绑定到 Buffer&&
use(make_buffer()); // make_buffer() 是 prvalue,绑定到 Buffer&&
use(std::move(buf)); // std::move(buf) 是 xvalue,绑定到 Buffer&&
}
这段代码展示了 move 语义的第一个检查点:对象本身的存储位置和表达式类别是两件事。buf 是一个栈上对象,表达式 buf 是 lvalue;std::move(buf) 仍然指向同一个栈上对象,但表达式类别变成 xvalue。编译器根据表达式类别选择 use 的重载,移动构造也在同一套规则下被选择。
命名右值引用变量尤其容易误判。引用类型是声明类型,value category 是表达式属性;一个变量名表达式通常是 lvalue,即使这个变量的类型是 Buffer&&。
void forward_later(Buffer&& tmp) {
use(tmp); // tmp 这个表达式是 lvalue
use(std::move(tmp)); // std::move(tmp) 这个表达式是 xvalue
}
tmp 的声明类型是右值引用,它绑定到可移动实参;进入函数体后,tmp 有名字。表达式 tmp 表示一个可重复访问的对象身份,因此按 lvalue 参与重载选择。需要再次把它作为可移动对象传出时,必须显式写 std::move(tmp) 或在模板转发场景使用 std::forward<T>(tmp)。
这一节的结论是:阅读 STL 源码时先判断表达式类别,再判断引用绑定,再判断构造或赋值函数。看到 T&& 只能说明某个声明类型;看到 std::move(x)、返回右值引用的调用或临时对象表达式,才能判断调用点进入 rvalue 路径。
3.2 左值引用、右值引用与引用绑定
引用绑定规则决定一个表达式能进入哪个接口。左值引用 T& 绑定可修改 lvalue,const T& 可以绑定 lvalue 和 rvalue,右值引用 T&& 绑定 rvalue。标准库利用这组规则把“借用读取”“复制”“移动”分成不同入口。
最小重载组可以写成这样:
void inspect(const Buffer& b); // 只读取,不取得资源
void take(Buffer&& b); // 可以取得资源
Buffer a(1024);
inspect(a); // 读取已有对象
take(Buffer(2048)); // 取得临时对象资源
take(std::move(a)); // 取得 a 当前持有的资源
inspect(const Buffer&) 表达只读借用。它可以接收已有对象,也可以接收临时对象,因为 const 左值引用可以延长临时对象生命周期。这个接口通常不会修改资源所有权。take(Buffer&&) 表达可消耗入口。调用者传入临时对象或显式 std::move(a) 时,向被调函数声明:这个对象的资源可以被接管。
右值引用绑定到非临时对象时,资源所有权仍然来自那个对象。std::move(a) 不创建新 Buffer,也不分配内存;它只是让表达式进入 Buffer&& 重载。真正改变 a 状态的是后续 move constructor 或 move assignment。
Buffer src(4096);
Buffer dst(std::move(src));
// 这里 src 仍然是一个存在的对象。
// 它的类不变量成立,析构函数会安全运行。
这一点对源码阅读有直接影响。看到 allocator_traits::construct(alloc, p, std::move(old)) 时,资源转移发生在构造目标对象的过程中;std::move(old) 本身只影响表达式类别。看到函数参数 T&& x 时,也要继续看函数体是否把 x 再次移动、转发或只读取。
引用绑定还影响生命周期。const Buffer& r = Buffer(1024); 与 Buffer&& r = Buffer(1024); 都能延长这个临时对象的生命周期到引用变量作用域结束;二者的区别在于后者允许通过非 const 接口修改被绑定对象。生命周期延长只解决“临时对象还活着”的问题,不自动表达资源应被转移。
这一节可以形成一个绑定判断表:
| 形参类型 | 可绑定表达式 | 工程语义 |
|---|---|---|
T& | 可修改 lvalue | 被调函数可修改调用者对象,通常不接管资源所有权 |
const T& | lvalue、xvalue、prvalue | 只读借用,可延长临时对象生命周期 |
T&& | xvalue、prvalue | 可消耗入口,通常进入移动构造、移动赋值或转移资源路径 |
这个表的关键是“通常”二字背后的检查动作。引用绑定只给出语法许可;函数体是否真正转移资源,要看它是否调用 move constructor、move assignment、std::swap、释放旧资源或把资源句柄写入目标对象。
3.3 std::move 与 moved-from 状态
std::move 的工作定义很短:它把实参转换成对应类型的 xvalue 表达式。std::move 不移动内存,不调用构造函数,不修改对象成员。cppreference 的 std::move 页面也把它描述为产生 xvalue,等价于转换到右值引用类型;真正的资源转移由后续接收这个 xvalue 的操作完成。
对 Buffer 来说,std::move 和移动构造之间的关系可以用一个状态流表示。
图中的关键边界是 B 到 C。B 只改变表达式类别;C 才执行资源转移。把这个边界拆开后,很多 STL 代码会变得可读:std::move_iterator、std::move 算法、容器扩容中的 std::move_if_noexcept 都是在构造目标元素时触发后续操作。
moved-from 状态是移动之后源对象的状态。标准库类型的一般要求是 valid but unspecified state:对象类不变量成立,可以析构,可以重新赋值,可以调用没有前置条件的成员函数;具体值没有稳定承诺。C++ draft 的 lib.types.movedfrom 给出了这个库级要求。
对 Buffer,移动构造把 src.data_ 设为 nullptr,把 src.size_ 设为 0。这是这个教学类型选择的 moved-from 表示。标准库的 std::string、std::vector、std::unique_ptr 各自有自己的状态承诺;能否读取大小、能否访问元素、能否解引用指针,必须看对应接口的前置条件。
Buffer src(1024);
Buffer dst(std::move(src));
src = Buffer(256); // 重新赋值成立
src.size(); // size() 没有资源访问前置条件
不要把 moved-from 对象理解成“已经析构”。它仍然处在生命周期内,析构函数仍会运行。移动构造必须把源对象调整到析构安全的状态,否则两个对象会释放同一块资源。Buffer 的移动构造用 std::exchange 清空源对象,就是为了让源对象析构时 delete[] nullptr 成立。
移动赋值比移动构造多一个责任:目标对象已经持有旧资源。移动赋值先释放目标旧资源,再接管源对象资源,最后把源对象置于可析构状态。自移动赋值也要有确定行为;本章示例用 if (this == &other) 保持对象原状,标准库类型的自移动赋值通常以 valid but unspecified state 作为库级边界。
阅读代码时可以用三步判断 moved-from 对象:第一,看源对象是否仍在生命周期内;第二,看类型文档或实现是否给出具体 moved-from 承诺;第三,只调用满足前置条件的操作,或者直接重新赋值。这个顺序能防止把“可析构”误扩展成“值保持原样”。
3.4 forwarding reference、引用折叠与 std::forward
forwarding reference 解决的是模板包装函数丢失实参类别的问题。普通函数参数一旦有名字,在函数体中表达式就是 lvalue;如果包装函数直接把参数传给下一层,原调用点的 lvalue 或 rvalue 信息会丢失。std::forward 根据模板参数 T 恢复这个信息。
先看问题形状:
void sink(const Buffer&);
void sink(Buffer&&);
template <class T>
void bad_wrapper(T&& arg) {
sink(arg); // arg 是有名字的表达式,因此按 lvalue 传递
}
template <class T>
void good_wrapper(T&& arg) {
sink(std::forward<T>(arg));
}
在 bad_wrapper(Buffer(1024)) 中,模板参数 T 会推导为 Buffer,函数形参类型是 Buffer&&,但函数体中的表达式 arg 是 lvalue,所以调用 sink(const Buffer&)。good_wrapper 使用 std::forward<T>(arg) 后,如果原实参是 rvalue,就把它转回 xvalue;如果原实参是 lvalue,就继续以 lvalue 传递。
forwarding reference 的形式有严格条件。函数模板参数写成 T&&,并且 T 是同一个函数模板的 cv-unqualified 类型参数时,它才是 forwarding reference。const T&& 是普通右值引用;类模板参数 T 出现在成员函数 A<T>::f(T&&) 中时,T 已经由类模板确定,也不是这个成员函数自己的 forwarding reference。
template <class T>
void f(T&& x); // x 是 forwarding reference
template <class T>
void g(const T&& x); // x 是右值引用,带 const,不能完美转发可修改资源
template <class T>
struct Box {
void set(T&& x); // x 是右值引用,T 来自类模板
template <class U>
void emplace(U&& x); // x 是 forwarding reference,U 来自成员函数模板
};
引用折叠规则支撑 std::forward。模板推导可能产生“引用的引用”这种中间类型,语言规则会把它折叠成合法引用类型。核心规则可以压缩成一句:只有 && 和 && 组合得到 &&,其余组合都得到 &。
| 中间形式 | 折叠结果 | 典型来源 |
|---|---|---|
T& & | T& | lvalue 实参传入 T&& 模板形参 |
T& && | T& | std::forward<T> 处理 lvalue 推导结果 |
T&& & | T& | 类型别名或 traits 组合 |
T&& && | T&& | rvalue 实参传入 T&& 模板形参 |
用实参推导看会更直接:
Buffer b(1024);
good_wrapper(b); // T 推导为 Buffer&,forward 后仍是 lvalue
good_wrapper(Buffer(2048)); // T 推导为 Buffer,forward 后是 xvalue
std::forward<T>(arg) 的关键输入是 T。写成 std::forward<Buffer>(arg) 会强制按 rvalue 转发,写成 std::forward<T>(arg) 才能把调用点类别保留下来。模板代码里常见的 std::forward<Args>(args)... 也遵循同一规则,每个形参单独保留自己的类别。
STL 的 emplace_back、make_unique、函数对象包装器和 allocator construct 路径大量依赖这个机制。它们的目标是把调用者给出的构造参数原样送到目标对象构造点:lvalue 参数继续借用或复制,rvalue 参数进入移动路径,const 限定也保留下来。
3.5 move constructor、move assignment 与 noexcept
move constructor 用一个 rvalue 来源构造新对象;move assignment 用一个 rvalue 来源改写已有对象。二者都要维护三个对象状态:目标对象的新状态、源对象的 moved-from 状态、异常路径上的资源状态。noexcept 会把这个局部类型设计连接到 STL 容器的异常安全策略。
Buffer 的移动构造最小职责是接管资源并清空源对象。
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)),
size_(std::exchange(other.size_, 0)) {}
这段代码不分配内存,也不复制数组元素。它只复制指针值和大小,再把 other 置为空状态。noexcept 在这里有事实基础:std::exchange、指针赋值、整数赋值都不会抛出异常。把 noexcept 写在移动构造上,是向标准库算法和容器声明“用 move 迁移这个类型不会破坏异常安全计划”。
移动赋值需要处理目标旧资源。
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) {
return *this;
}
delete[] data_;
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
return *this;
}
赋值入口面对的是两个已经存在的对象。目标对象 *this 可能已经持有一块数组,因此移动赋值先释放旧资源,再接管新资源。源对象 other 被清空后仍可析构。这里的自赋值分支把同一对象赋给自己时的行为固定下来,减少资源句柄被提前释放后再次读取的风险。
如果一个类型的移动构造可能抛异常,std::vector 扩容时会遇到难题。扩容通常需要分配新存储,再把旧元素迁移到新存储。如果已经移动了前几个元素,后面某个元素移动抛出异常,旧存储中的前几个元素可能已经进入 moved-from 状态。此时容器很难把旧 vector 恢复到调用前的完整值状态。
std::move_if_noexcept 就服务这个分支。它根据类型特征返回 T&& 或 const T&:当 T 的移动构造是 noexcept,或者这个类型没有可用拷贝构造时,返回可移动入口;否则返回只读左值引用,让容器优先复制旧元素。cppreference 的 std::move_if_noexcept 页面也明确说明它常用于把移动语义和 strong exception guarantee 结合起来。
template <class T>
void relocate_one(T* target, T& source) {
::new (target) T(std::move_if_noexcept(source));
}
这段简化代码说明了选择点。source 是旧存储中的元素,表达式 source 是 lvalue。std::move_if_noexcept(source) 根据 T 的异常特征决定构造目标时走 move 还是 copy。目标是让“新存储构造失败”时旧存储仍保持可用,从而支持回滚。
move constructor 和 move assignment 的设计顺序可以固定为四步:先写清类型拥有什么资源;再写清源对象移动后满足哪些可用操作;再判断移动过程是否可能抛异常;最后把真实不抛异常的移动操作标记为 noexcept。这个顺序比先追求性能更可靠,因为 STL 容器会把 noexcept 当作异常安全决策输入。
3.6 move 语义对 STL 容器扩容的影响
std::vector 的扩容路径把本章规则全部串起来。vector 维护一段连续存储;当插入后大小超过容量时,它需要申请更大的连续存储,把旧元素构造到新存储中,再销毁旧元素并释放旧存储。C++ draft 的 vector.modifiers 规定,发生 reallocation 时元素引用、指针和迭代器都会失效,并给出插入异常安全边界。
常见实现形状可以简化成下面的伪代码。它展示对象生命周期路径,不代表 libstdc++、libc++ 或 MSVC STL 的真实源码。
// 伪代码:展示 vector 扩容时的对象迁移形状
new_storage = allocate(new_capacity);
constructed = 0;
try {
for each old element e:
construct(new_storage + constructed, std::move_if_noexcept(e));
++constructed;
construct(new_storage + constructed, new_element_arguments...);
} catch (...) {
destroy constructed elements in new_storage;
deallocate(new_storage);
throw;
}
destroy old elements;
deallocate(old_storage);
commit new_storage as vector state;
这条路径涉及三类状态。旧存储保存调用前 vector 的元素;新存储保存扩容后的候选状态;vector 对象自身的三指针或等价状态要到 commit 阶段才切换。只要 commit 没发生,异常路径就优先清理新存储并保留旧状态。
元素迁移阶段会使用本章前面讲过的表达式类别。旧元素 e 是具名对象,表达式是 lvalue。容器要把它作为移动来源时,需要 std::move(e) 或 std::move_if_noexcept(e) 形成 xvalue。是否真正调用 Buffer(Buffer&&),由这个表达式和目标构造函数共同决定。
对于本章的 Buffer,移动构造是 noexcept,所以 vector 扩容可以安全选择 move。每个旧 Buffer 的指针被转移到新存储中的对应对象,旧存储中的 Buffer 被置为空状态。全部构造成功后,容器销毁旧存储中的 moved-from 元素并释放旧存储。这个路径的资源转移成本是指针和大小的赋值,数组元素本身没有被逐个复制。
如果元素类型的移动构造可能抛异常且拷贝构造可用,vector 通常倾向复制旧元素。复制旧元素时,旧存储保持原值;新存储构造失败后,容器销毁已经构造的新元素并释放新存储,旧 vector 仍可以保留调用前状态。std::move_if_noexcept 让这条策略在泛型代码中表达出来。
如果元素类型只能移动,且移动构造可能抛异常,容器就失去“复制保留旧值”这条回滚路径。标准允许在特定条件下放宽强异常保证,效果可能变成 unspecified effects。工程上,这类元素放入 vector 后,扩容、插入、批量构造的异常安全边界就要重新评估。
扩容还改变引用和迭代器状态。元素搬到新地址后,旧地址上的对象被销毁;原来指向旧元素的指针、引用和 iterator 都失效。move 语义降低了迁移资源的成本,但它不会保持元素地址稳定。地址稳定性属于容器存储模型问题,不能由元素的移动构造解决。
从 STL 工程角度看,判断 vector<T> 扩容行为可以按这个顺序:先看是否触发 reallocation;再看 T 是否可拷贝、可移动;再看 T 的 move constructor 是否 noexcept;再看异常发生时旧存储是否还能保持原值;最后判断迭代器、引用和指针是否失效。这个顺序把语言规则、对象状态、异常安全和容器存储模型连接到同一条检查链。
最小自检任务
阅读下面代码,判断每个调用点的重载选择、src 的状态边界,以及 std::vector<Item> 扩容时更可能选择 move 还是 copy。只需要根据本章规则推断,不需要打开本地 STL 源码。
#include <utility>
#include <vector>
struct Item {
Item() = default;
Item(const Item&);
Item(Item&&) noexcept(false);
};
void sink(const Item&);
void sink(Item&&);
template <class T>
void wrapper(T&& arg) {
sink(arg);
sink(std::forward<T>(arg));
}
int main() {
Item src;
sink(src); // A
sink(std::move(src)); // B
wrapper(src); // C
wrapper(Item{}); // D
std::vector<Item> v;
v.emplace_back();
v.emplace_back(); // E:假设这里触发扩容
}
答案要点
A 中表达式 src 是 lvalue,所以选择 sink(const Item&)。B 中 std::move(src) 是 xvalue,所以选择 sink(Item&&);std::move 本身没有修改 src,如果 sink(Item&&) 内部移动了它,src 进入该类型定义的 moved-from 状态。
C 中 wrapper(src) 让 T 推导为 Item&。函数体里的 arg 是 lvalue,所以第一句选择 sink(const Item&);std::forward<T>(arg) 在 T = Item& 时仍产生 lvalue,所以第二句也选择 sink(const Item&)。
D 中 wrapper(Item{}) 让 T 推导为 Item。函数体里的 arg 仍是 lvalue,所以第一句选择 sink(const Item&);std::forward<Item>(arg) 产生 xvalue,所以第二句选择 sink(Item&&)。
E 中 Item 有拷贝构造,移动构造标注为可能抛异常。vector 扩容迁移旧元素时,为了保留强异常安全,通常会通过 std::move_if_noexcept 选择 copy 路径。这个判断不改变 reallocation 的迭代器、引用和指针失效结论。
本章知识点总结
- 表达式类别:value category 属于表达式属性,和对象存储位置、声明类型共同影响引用绑定与重载选择。
- lvalue 身份:lvalue 表达式能定位到对象或函数身份,因此具名对象和具名右值引用变量在表达式中通常按 lvalue 处理。
- rvalue 入口:prvalue 和 xvalue 共同构成 rvalue,它们可以绑定到右值引用并进入可移动接口。
- 引用绑定:
T&、const T&、T&&分别表达可修改借用、只读借用和可消耗入口。 - std::move:
std::move只把表达式转换成 xvalue,资源转移发生在后续移动构造、移动赋值或接收函数内部。 - moved-from:moved-from 对象仍在生命周期内,标准库类型通常保证 valid but unspecified state。
- 完美转发:forwarding reference 配合
std::forward<T>可以保留调用点的 lvalue 或 rvalue 类别。 - 引用折叠:只有
&&与&&组合保留右值引用,其余引用组合都会折叠成左值引用。 - 移动构造:move constructor 负责接管资源并把源对象调整到可析构、可重新赋值的状态。
- 移动赋值:move assignment 还要处理目标对象旧资源,并维护自赋值和异常边界。
- noexcept 决策:真实不抛异常的移动操作应标记
noexcept,因为 STL 容器会把它作为迁移策略输入。 - move_if_noexcept:
std::move_if_noexcept在可安全移动时返回 rvalue 入口,在需要保留回滚能力时返回 copy 入口。 - vector 扩容:vector reallocation 会迁移元素、销毁旧元素并切换存储状态,元素 move 能降低迁移成本。
- 迭代器失效:move 语义不会保持 vector 元素地址稳定,发生 reallocation 时旧引用、指针和迭代器失效。