C++11 右值引用与移动语义
主题:C++11 新特性之右值引用(Rvalue Reference)与移动语义(Move Semantics)
关联知识链:C++ 标准演进 → 左值/右值 → 引用类型 → 移动构造/移动赋值 → 完美转发 → 智能指针
一、C++11 的发展历史
1.1 C++11 的里程碑意义
- C++11 是 C++ 标准化的重要里程碑,距 C++98 标准(1998-12-14 发布)过去 13 年后才完成一次完整的大版本更新
- 开发代号 "C++0x"(因标准编号 14882 于 200x 年间制定而得名),2004 年工作组重启标准修订(JTC3),至 2011 年 12 月正式发布,历经多轮工作草案与投票
- C++11 引入大量新特性,优化了语言表达,显著提升了 C++ 的灵活性与表达能力,是近 20 年来对 C++ 影响最深远的修订
- C++ 标准由 ISO/IEC JTC1/SC22 下属的 WG21 委员会 负责制定与维护
1.2 各版本发布时间线
| 标准 | 发布时间 | ISO 编号 | 主要特性 |
|---|---|---|---|
| C++98 | 1998-12-14 | ISO/IEC 14882:1998 | 第一个 ISO/IEC C++ 标准:类与模板、命名空间、异常、RTTI、STL、std::string、iostream |
| C++11 | 2011-12 | ISO/IEC 14882:2011 | 右值引用、移动语义、auto、lambda、enum class、智能指针、nullptr、范围 for、变参模板 |
| C++14 | 2014-12-12 | ISO/IEC 14882:2014 | 对 C++11 的修订(非大版本):泛型 lambda、变量模板、std::make_unique、return 自动推导、数字字面量分隔符 ' |
| C++17 | 2017-12-11 | ISO/IEC 14882:2017 | 结构化绑定、if 带初始化语句、折叠表达式、std::optional/any/variant、std::string_view、并行算法(TS 转正)、inline 变量 |
| C++20 | 2020-11-11 | ISO/IEC 14882:2020 | 模块(Modules)、协程(coroutine)、概念(concepts)、三向比较运算符 <=>、ranges、consteval/constinit |
| C++23 | 2023-10 | ISO/IEC 14882:2023 | 反射、std::expected/expected<T,E>、std::print/format、简化 #include、std::mdspan |
| C++26 | 进行中 | --- | 虚拟继承继承器、std::generator、atomic<std::shared_ptr> 等 |
要点:
- C++11 是"大版本",C++14 定位是修订版(修 bug + 小特性),C++17/20/23 恢复大版本节奏,此后基本保持约 3 年一个大版本
- 每个版本都是对前一版本的超集(基本保持向后兼容),老代码新标准可编译
1.3 C++11 新特性速览
C++11 相比旧版本变化很大,主要围绕旧有标准进行优化、语法简化与类型明确化:
- 右值引用(
T&&)与移动语义 ------ 本篇主角 auto关键字与类型推导 :函数参数/返回值、decltype- lambda 表达式 :
[](参数) -> 返回类型 { 函数体 },可捕获上下文 - 强类型枚举(
enum class) :枚举值不泄漏到外层作用域,默认类型为int - 智能指针 :
std::unique_ptr/std::shared_ptr/std::weak_ptr+std::make_unique(C++14) nullptr取代NULL:类型安全的空指针,可重载- 初始化列表(
std::initializer_list) :统一初始化语法{...} - 范围 for 循环 :
for (auto x : container) - 变参模板(Variadic Templates) :
template<class... Args> constexpr:编译期常量表达式,C++11 中函数体必须单条 returnstatic_assert:编译期断言thread_local:线程局部存储alignas/alignof:内存对齐= default/= delete:显式控制编译器生成行为explicit扩展 :explicit operator bool()noexcept:异常安全承诺,影响编译器优化与容器行为
二、C++11 中的初始化列表与对象初始化
2.1 初始化列表(initializer_list)
- C++11 为复杂类型的管理提供了更友好的语法,初始化列表是其中一项
- 底层实现:一个只读 的
T数组 + 起止迭代器,编译器用临时对象实现 - 核心成员函数:
begin()/end()/size()/max_size()/empty()
cpp
class Complex {
public:
double real, imag;
Complex(double r, double i) : real(r), imag(i) {}
};
int main() {
Complex c{1.0, 2.0}; // 列表初始化
int sum = 0;
for (int i : {1, 2, 3, 4, 5}) // 展开为临时 initializer_list<int>
{
sum += i;
}
}
- 成员函数的对应关系:构造函数
Complex、析构函数~Complex、数据成员real/imag、迭代器begin/end、容量查询size/max_size/empty
2.2 成员初始化顺序的坑
- 成员按声明顺序初始化,而不是初始化列表的书写顺序
- 初始化列表不能跳过的成员:引用成员、
const成员、没有默认构造函数的类类型成员、静态成员 - 正确写法是把初始化放进列表,而不是在构造函数体内赋值(赋值前构造已完成,浪费且可能出错)
cpp
class A {
public:
A(int x, int y) : y_(y), x_(x) { } // 书写顺序 x_ 在前
private:
int x_; // 但按声明顺序,x_ 先构造!
int y_;
};
2.3 构造函数中的引用参数
- 常用
const T&接收外部对象:避免拷贝,同时不承诺修改 - 注意
const T&既可以绑定左值也可以绑定右值(右值允许绑定到 const 左值引用,且延长临时对象生命周期) T&&只能绑定非 const 右值;const T&&可绑定 const 右值(实际中很少使用)
cpp
class Date {
public:
Date(const string& data); // 左值右值均可接收
void print();
private:
int year, month, day;
};
2.4 拷贝初始化 vs 直接初始化的区别
cpp
std::string s1("hello"); // 直接初始化:调 string(const char*)
std::string s2 = "hello"; // 看似拷贝初始化,实际也调 string(const char*)
std::string s3 = s1; // 拷贝初始化:调拷贝构造(C++11 中若 s1 是右值则可能调移动构造)
- 只有当目标类型与源类型不同(或源是字面量/转换而来的表达式)时,才会走"先临时对象再拷贝"的路径(且可能被 RVO 优化掉)
三、右值引用和移动语义
3.1 左值和右值
左值(lvalue)
- 表达式求值结果可以被取地址 (
&expr合法),可以放在赋值语句左侧 - 表示一个有名称的内存位置,通常与变量名关联
- 例子:变量
a、数组元素arr[0]、函数返回的引用、++x(前置自增返回左值引用)、x = y整体是左值(返回x的引用)
右值(rvalue)
- 表达式求值结果不能被取地址,通常无法放在赋值号左侧
- 表示临时对象或无名称的内存位置,表达式求值结束后通常被销毁
- 例子:字面量
1、std::string("abc")、x + y、x++(后置自增返回临时值)、std::move(x)(结果是右值)
判断技巧:能取地址 &expr 的就是左值;不能的就是右值。
cpp
int a = 10;
&a; // OK,a 是左值
&10; // 编译错误,10 是右值
&(a + b); // 编译错误,结果是右值
补充(C++17 更精确的三分法,面试加分项):
- prvalue(纯右值):字面量、非引用类型返回的函数返回值
- xvalue (将亡值):
std::move的结果、引用类型函数的返回值------"快要死掉"的对象,移动语义的优先目标 - lvalue:变量名等
- 右值 = prvalue + xvalue
临时对象生命周期延长:
cpp
const std::string& s = "hello"; // 临时 string 被 const 引用延长到 s 的作用域
std::string&& t = "hello"; // 右值引用也能延长(C++11 起)
3.2 左值引用和右值引用
- C++11 之前,引用(
&)只能绑定左值,临时对象的销毁时机固定,编译器必须为"通过值传递"做防御性拷贝 - C++11 引入右值引用
T&&,专门用于绑定右值,让"转移资源"成为可能 const T&左值引用仍可绑定右值(延长临时对象生命周期),但只能读不能写
| 引用类型 | 记号 | 可绑定对象 | 典型用途 |
|---|---|---|---|
| 左值引用 | T& |
仅左值 | 修改对象、传参避免拷贝 |
| const 左值引用 | const T& |
左值 + 右值 | 只读传参 |
| 右值引用 | T&& |
仅非 const 右值 | 移动语义、完美转发 |
| const 右值引用 | const T&& |
仅 const 右值 | 实际场景很少 |
注意:右值引用变量本身是左值------因为引用变量有名字、可取地址:
cpp
int&& r = 5; // 绑定右值
int& lr = r; // 合法!r 是命名变量,本身就是左值
3.3 左值引用与右值引用的区别
左值引用(&):
- 只能绑定到左值(加
const后也可绑定右值) - 用于传递对象引用、允许修改对象
右值引用(&&):
- 只能绑定到右值(临时对象、表达式结果、
std::move结果) - 用于实现移动语义,高效转移资源
- 配合
std::move可把左值显式转换为右值引用
3.4 std::move 与 std::forward
std::move
cpp
template <class T>
typename remove_reference<T>::type&& move(T&& arg) {
return static_cast<remove_reference<T>::type&&>(arg);
}
- 本质:无条件把参数 cast 为右值引用 (剥掉引用后重新加
&&),本身不执行任何移动操作,只是"贴标签" - 标签贴完后,编译器才有机会选择移动构造/移动赋值
- 移动后源对象处于合法但未知 的状态:成员可读不可依赖(指针类成员一般为
nullptr),不应再解引用或重复移动
常见误区:
- "move 就是快拷贝" ------ 错,move 是资源转移,源对象被掏空
- "调用了 std::move 就一定走移动" ------ 不一定:对象是 const、参数按值且没有移动构造、目标没有移动操作符时,仍会拷贝或直接构造
std::forward
cpp
template <class T>
T&& forward(remove_reference_t<T>& arg) noexcept {
return static_cast<T&&>(arg);
}
- 本质:保留参数原有值类别的条件 cast(模板推导 + 引用折叠)
- 用于模板函数中把参数"原样"转发给另一个函数,不丢失引用性与 const 性
- 必须配合万能引用(转发引用)
T&&(T是模板参数时)使用 std::move(x)永远得到右值;std::forward<T>(x)在 T 被推导为A&时得到左值、推导为A时得到右值
cpp
template <class T>
void process(T&& arg) {
do_something(std::forward<T>(arg)); // 左值进来还是左值,右值进来还是右值
}
引用折叠规则(forward 的数学基础)
| T 推导结果 | T&& 折叠 |
结果 |
|---|---|---|
U& |
U& && |
U&(左值) |
U |
U && |
U&&(右值) |
规则:两个左值引用叠一起还是左值引用,其余都是右值引用。
3.5 使用场景
左值引用:
- 函数参数:需要修改对象、或避免大型对象拷贝时
- 函数返回对象引用
右值引用:
- 移动构造函数参数(
T(T&&)) - 移动赋值运算符参数(
T& operator=(T&&)) - 模板中支持完美转发(变参模板)
- 资源独占类的传递(
unique_ptr只能移动)
3.6 移动构造与移动赋值(资源窃取)
移动语义允许对象之间转移资源而非复制。核心操作:
cpp
#include <iostream>
#include <cstring>
class MyString {
char* data;
size_t size_;
public:
MyString() : data(nullptr), size_(0) {}
MyString(const char* str)
: size_(std::strlen(str)), data(new char[size_ + 1]) {
std::strcpy(data, str);
std::cout << "ctor: " << data << std::endl;
}
// 拷贝构造:深拷贝
MyString(const MyString& other)
: size_(other.size_), data(new char[other.size_ + 1]) {
std::strcpy(data, other.data ? other.data : "");
std::cout << "copy ctor: " << data << std::endl;
}
// 移动构造:窃取资源,源对象置空
MyString(MyString&& other) noexcept
: data(other.data), size_(other.size_) {
other.data = nullptr; // 防止 double free
other.size_ = 0;
std::cout << "move ctor: " << data << std::endl;
}
// 移动赋值
MyString& operator=(MyString&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
size_ = other.size_;
other.data = nullptr;
other.size_ = 0;
std::cout << "move assign: " << data << std::endl;
}
return *this;
}
~MyString() {
std::cout << "dtor, data = " << (data ? data : "(null)") << std::endl;
delete[] data;
}
};
int main() {
MyString s1("Hello World"); // ctor
MyString s2(std::move(s1)); // move ctor,s1 被掏空
s1 = MyString("again"); // 临时对象右值 → move assign
// 析构顺序:s1(此时持有 "again") → s2(持有 "Hello World")
}
要点:
- 移动构造的参数是右值引用
- 通常用于转移资源(指针、句柄、缓冲区),而不是复制
- 源对象必须置为可安全析构 的状态(一般为
nullptr+ 大小为 0),否则会 double free - 移动操作一般要标记
noexcept,否则vector扩容时会回退为拷贝
移动后源对象的状态约定 :合法但未知。标准库保证移动后的 unique_ptr 必为 nullptr,vector 必为空;自写类应约定源对象置空(指针 = nullptr、标志位归零)。
3.7 Rule of 3 / Rule of 5 / Rule of 0
编译器默认生成六个特殊成员函数:默认构造、拷贝构造、拷贝赋值、移动构造(C++11)、移动赋值(C++11)、析构。
C++11 的抑制规则:
| 你手写了什么 | 编译器停止自动生成 |
|---|---|
| 拷贝构造 / 拷贝赋值 / 析构函数 任意一个 | 移动构造、移动赋值(不会自动生成) |
| 移动构造 / 移动赋值 任意一个 | 拷贝赋值(拷贝构造仍生成) |
| 默认构造 | 无(C++11 中默认构造不影响其他特例) |
- Rule of 3(C++03):手写析构、拷贝构造、拷贝赋值三者之一,三者都要手写
- Rule of 5 (C++11):涉及动态资源的类,五个特例(析构 + 拷贝构造 + 拷贝赋值 + 移动构造 + 移动赋值)要么全手写、要么用
= delete/= default明确表态 - Rule of 0:优先把资源交给智能指针,类就不需要手写任何特例
3.8 完美转发与变参模板
cpp
template <class T, class U>
void forwarder(T&& a, U&& b) {
call(std::forward<T>(a), std::forward<U>(b)); // 原样转发
}
forwarder(1, std::string("x")); // 调用 call(1, 右值string),类别不丢失
完整示例:手写 make_unique(C++14 标准库实现几乎就是这段):
cpp
template <class T, class... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
- 完美转发让模板函数能把参数"原样"传给另一函数,不丢失引用/常量性
- 依赖右值引用 +
std::forward+ 万能引用(转发引用)三者配合 - 典型应用:
std::make_unique、std::forward_list::push_front、构造函数转发成员
3.9 常见坑与调试技巧
常见坑清单
std::move不移动任何东西,它只是 cast- 右值引用变量本身是左值:
int&& r = 5; int& lr = r;合法 const对象不能被移动(除非显式const_cast,一般不建议);对 const 左值调用std::move得到const T&&,只能绑定拷贝构造- 有用户声明的析构函数时,移动特例不会被自动合成,容器扩容会退化为拷贝
- 移动构造函数应声明
noexcept,否则vector扩容搬迁时选择拷贝(异常安全考虑) std::move(x)之后再用x是 UB 边缘行为(合法但未知状态)- 万能引用
T&&只有在T是模板参数(auto推导或显式模板参数)时才是"转发引用";T为具体类型时它就是普通右值引用
调试技巧:打印构造/移动/析构的轨迹
cpp
class Debug {
public:
Debug(const char* who) { printf("ctor(%s)\n", who); }
Debug(const Debug& o) { printf("copy(%.4s)\n", o.name_); }
Debug(Debug&& o) noexcept { printf("move(%.4s)\n", o.name_); }
~Debug() { printf("dtor(%.4s)\n", name_); }
private:
const char* name_;
};
int main() {
Debug a("a");
Debug b(std::move(a)); // 输出 move(a)
}
观察 vector 扩容:
cpp
std::vector<std::string> v;
v.reserve(2);
v.push_back(std::string("x"));
for (int i = 0; i < 20; ++i)
v.push_back(std::to_string(i)); // 多次扩容,观察 copy/move 次数
// 加 -O0 可看到移动;若类有用户定义析构且 move 未 noexcept,则全走拷贝
3.10 总结
- 左值 = 有名字的内存位置(可取地址);右值 = 临时对象/表达式结果(不可取地址)
- 左值引用
&绑定左值;右值引用&&绑定右值,是移动语义的载体 - 移动语义 = 移动构造 + 移动赋值,通过右值引用转移资源、规避深拷贝
std::move是"贴标签",std::forward是"保留原标签",两者都不是真正的移动- 完美转发 = 右值引用 +
std::forward+ 万能引用,在模板中无损传递参数 - 资源类设计遵循 Rule of 5 / Rule of 0,移动操作加
noexcept
四、主动回忆清单(自测)
- C++11 何时发布?开发代号是什么?由哪个委员会维护?
- C++11 与 C++98 间隔了多少年?为什么这么久?(修订重启、工作组流程)
- 左值和右值的判断标准是什么?举 3 个各自例子
-
x++和++x分别返回什么值类别? -
T&、const T&、T&&、const T&&分别能绑定哪些值类别? - 为什么
int&& r = 5; int& lr = r;合法? - 写出
std::move的源码,并说明它是否真的"移动"了资源 - 引用折叠的两种情况分别是什么?
-
std::forward为什么必须配合模板参数使用?对具体类型std::forward<int>(x)合法吗? - 移动构造完成后,源对象应处于什么状态?为什么必须把指针置
nullptr? -
const对象为什么不能被移动? - 移动构造函数加
noexcept有什么实际影响(vector 扩容行为)? - Rule of 5 是哪五个特例?写出抑制规则表
- 什么情况下写了移动构造但编译器仍然生成的是拷贝赋值?
- 手写
make_unique,说明std::forward<Args>(args)...每个部分的含义
五、知识链串联
资源管理演进:
裸指针(new/delete,易泄漏、易 double free)
↓
拷贝构造/拷贝赋值(深拷贝,O(n) 昂贵)
↓
移动构造/移动赋值(资源窃取,O(1) 转移)
↓
std::move / std::forward(控制值类别、完美转发)
↓
智能指针(unique_ptr 天然移动、shared_ptr 控制块 + weak_ptr 防循环)
↓
容器(vector 扩容时借助移动 + noexcept 回退机制搬迁元素)
上一篇:深拷贝三件套 → 本篇:移动语义 → 下一篇:智能指针
六、面试高频追问
-
问 :
string s("hi"); string t = s;这里发生了什么?答 :
s是左值,const string&形参绑定左值,走拷贝构造;若string有 move 且 s 是右值(如string t = make_string(...))则走移动。 -
问 :
std::move能省掉一次拷贝吗?答 :
move本身不拷贝不移动;它让表达式变为 xvalue,若目标类实现了移动构造/赋值则编译器选用移动(O(1) 指针转移),替代 O(n) 深拷贝。 -
问 :为什么
unique_ptr不能拷贝只能移动?答 :独占所有权语义,拷贝会导致两个指针 delete 同一块内存;拷贝构造/赋值被
= delete,只保留移动。 -
问 :容器扩容为什么优先移动、要求
noexcept?答:扩容需要搬元素到新内存。若移动可能抛异常,搬迁一半时旧内存析构 + 新内存析构会造成双析构或资源混乱;拷贝是"安全兜底",因此 move 不 noexcept 时回退到拷贝。
-
问 :C++17 前值类别为什么改成了 prvalue / xvalue / lvalue?
答 :C++11 的"左值/右值"二分无法表达
std::move结果的优先级(将亡值最优先移动、临时值次之)。C++17 三分法把"右值"拆成 prvalue 与 xvalue,让绑定规则(xvalue 优先于 prvalue 绑定T&&)更精确。
任何的批评和建议欢迎指出,我们共同进步!