Skip to main content

Chapter 3: Move Semantics and Rvalue References

移动语义解决的主问题是:当一个对象持有堆内存、文件句柄、套接字、互斥量这类资源时,C++ 如何在保持对象生命周期规则成立的前提下,把资源所有权从一个对象转移到另一个对象,并让 STL 容器在扩容、插入和返回临时对象时减少不必要的深拷贝。

本章把移动语义放到 STL 工程路径中理解。读完后应能定位一个表达式的 value category,判断它会绑定到左值引用还是右值引用,解释 std::movestd::forward 对重载选择的影响,追踪 move constructor、move assignment、noexceptstd::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_iteratorstd::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::stringstd::vectorstd::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_backmake_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::movestd::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_noexceptstd::move_if_noexcept 在可安全移动时返回 rvalue 入口,在需要保留回滚能力时返回 copy 入口。
  • vector 扩容:vector reallocation 会迁移元素、销毁旧元素并切换存储状态,元素 move 能降低迁移成本。
  • 迭代器失效:move 语义不会保持 vector 元素地址稳定,发生 reallocation 时旧引用、指针和迭代器失效。