前言
C++98/03 时代,临时对象(右值)在传递时只能拷贝,这导致了巨大的性能浪费。 比如返回一个 std::vector 或 std::string 时,编译器要先构造临时对象,再拷贝给调用者,最后销毁临时对象------三次构造/析构。
C++11 委员会(Bjarne Stroustrup 和 Howard Hinnant 主推)决定引入移动语义:让临时对象可以"偷"资源(把指针、内存直接转移),不需要拷贝。 官方目标:"让返回大对象、插入容器、swap 等操作达到零拷贝",同时保持向后兼容。 C++11 引入右值引用(&&),C++14/17 又完善了 std::move_if_noexcept、容器对移动的支持等。
左值 和 右值
先搞清楚 最基础的lvalue 和 rvalue的概念:
左值 lvalue
- 有名字
- 能取地址
- 能放在 = 左边
- 有持久生命周期
cpp
int a = 10; // a 是左值
string s = "abc"; // s 是左值
右值 rvalue
- 临时对象
- 没名字
- 不能取地址
- 马上就要销毁
cpp
10; // 右值
string("hello"); // 临时对象 → 右值
a + 10; // 表达式结果 → 右值
一句话区分:能取地址 & → 左值; 不能取地址 & → 右值
那么,右值无法取地址,那右值是无法修改的吗?
右值的核心定义是无持久身份、表达式结束后就销毁的临时对象,因此右值是可以修改的,只是它马上就要销毁,修改它通常没有意义,甚至会引发逻辑 bug。
cpp
// 右值std::string("hello")被修改了,编译100%通过
std::string("hello").append(" world");
其实右值还可以再拆为两类:
-
纯右值 prvalue
-
将亡值 xvalue
纯右值 prvalue特征:
-
无名临时、纯粹数值 / 临时构造结果
-
不能取地址
-
没有资源、不是 "快要消亡的已有对象"
cpp
42;
3.14;
1 + 2;
std::string("hi"); // 临时对象 prvalue
func(); // 值返回结果 prvalue
纯右值在C++17中有RVO优化,在后面讲。
将亡值 xvalue
定义:原本是左值,但被标记「即将消亡、可以偷资源」的值
典型来源:
-
std::move(左值) -
临时对象引用、数组 / 成员收尾即将销毁的值
cpp
std::string s = "hello";
// std::move 把左值 转为 将亡值 xvalue
std::string&& temp = std::move(s);
xvalue 特点:
-
属于 右值大类
-
有对象实体、有内存地址
-
语义:资源可以安全被「移动窃取」
-
C++17 对 xvalue 不保证强制 RVO!
右值引用
C++98 只能对 lvalue 做拷贝。
而C++11 引入右值引用 T&&,专门绑定 rvalue,让我们能"偷"它的资源。
cpp
void func(int& l) { std::cout << "lvalue\n"; }
void func(int&& r) { std::cout << "rvalue\n"; }
int main() {
int x = 42;
func(x); // 调用 lvalue 版本
func(42); // 调用 rvalue 版本!!
func(std::move(x)); // 强制变成 rvalue,调用 rvalue 版本
}
底层实现:
-
T&& 是引用绑定,作为函数参数绑定到 rvalue 时不会延长生命周期,临时对象仍会在表达式结束时销毁。
-
编译器会生成两个重载:拷贝构造函数const T& 和 移动构造函数T&&。
-
当传入 rvalue 时,优先调用移动版本,偷资源。
关于延长临时对象的生命周期
直接用 T&& 栈变量绑定临时 → 生命周期延长,和 const T& 效果一致。
cpp
std::string&& s = std::string("hello");
// 临时被延长,安全可用
函数参数 T&& 接收临时 → 不延长!临时按常规销毁
cpp
void func(std::string&& r)
{
// r 的外表类型:std::string&& 右值引用
// r 的值类别:r左值!
// 外层临时绑定函数形参后,临时生命周期不兜底延长
&r; // 可以取地址,左值
}
func(std::string("temp"));
// 语句结束,原本临时直接销毁
关于r本身是左值:
-
看有没有名字 / 能不能取地址,不看表面是不是
&& -
只要变量起了名字、能写
&x取地址 → 它就是 左值 lvalue -
可以理解为右值传参会降级,只要一个右值引用有了名字、能被取地址,它就是左值,和它绑定的原始对象是不是右值无关。
cpp
class Str{
public:
Str()=default;
Str(const Str&){cout<<"拷贝\n";}
Str(Str&&) {cout<<"移动\n";}
};
void tes1t(Str&& x){
Str s2 = x; // x 是左值 → 调用【拷贝构造】
}
void test2(Str&& x){
Str s2 = std::move(x); // 强转成右值 → 触发【移动构造】
}
test1(Str{});
// Str{} 是纯右值,绑定到形参 int&& x
很好理解,如果不这样设计会发生什么?
cpp
// 假设规则反过来:有名字的T&&还是右值
void test(Str&& x) {
Str s1 = x; // x是右值,直接移动,x被掏空置空
Str s2 = x; // 第二次用x,已经是空的了!未定义行为,直接崩溃
}
正因为 T&& 有名就变左值 ,才需要:std::forward 完美转发 + 通用引用保证原值类别原样传递,不丢右值属性,这个特性在后文会讲。
因此,看一个表达式是左值还是右值,永远只看它有没有名字、能不能取地址,和它的类型是不是&&没有任何关系。
另外,T&只能引用左值,T&&只能引用右值,而const T&既能引用左值又能引用右值,并且能延长临时对象的生命周期。const T&为什么能引用右值???
非 const 的
T&意味着「你可以修改这个引用指向的对象」,如果允许它绑定右值,会出现这种无意义甚至危险的代码,所以 C++ 标准直接禁止非 const 左值引用绑定右值。而
const T&是只读引用,你只能读取对象的值,不能修改它,哪怕绑定的是马上要销毁的临时对象,也只是安全读取,不会出现无意义的修改操作,更不会引发逻辑 bug。
移动语义 + std::move
拷贝:复制全部数据,慢 O (n)
移动:偷指针、偷内存,快 O (1)
假如有一段代码:
cpp
std::vector<int> createBigVector() {
std::vector<int> v(1000000); // 分配 100 万个 int 的空间(约 4MB)
// ... 填充数据
return v; // 关键:返回局部对象 v
}
-
C++98 的问题:由于
v是局部对象,函数返回时会触发拷贝构造函数:先将v的数据拷贝到一个 "临时返回对象",再将临时对象拷贝给main()中的big。两次拷贝 100 万 int,耗时且浪费内存(编译器可能会自行优化成只拷贝一次)。 -
C++11 的优化:编译器自动识别
v是 "即将销毁的局部对象",优先调用移动构造函数,而非拷贝,直接转移v内部的堆内存指针、大小、容量等资源给返回值,实现零拷贝,仅修改几个指针 / 整数。
移动构造函数长什么样?
cpp
class MyString {
char* data;
size_t len;
public:
// 移动构造函数(偷资源)
MyString(MyString&& other) noexcept
: data(other.data), len(other.len) {
other.data = nullptr; // 偷完后把对方置空
other.len = 0;
}
// 拷贝构造函数
MyString(const MyString& other) { ... }
};
移动做了什么?
-
把别人的指针拿过来
-
把别人置空
-
没有任何内存拷贝
std::move到底是什么?
std::move 不移动任何东西,它只是一个强转,把左值 → 转成右值。
本质就是static_cast<T&&>(x) (C++11引入的强制类型转换),告诉编译器这个变量以后不用了,转移其资源。
cpp
string a = "hello";
string b = std::move(a);
-
a本来是左值 -
move(a)→ 变成右值 -
于是
b调用移动构造偷走 a 的内存 -
a 变成空
再次提醒,move后的对象被置空,只能析构/赋值/再次置空。
noexcept
设计原因: C++98 的动态异常规范 throw() 运行时开销巨大、难以静态分析,已被废弃。C++11 引入 noexcept,目的是在编译期声明一个函数不会抛出异常,帮助编译器进行更激进的优化,尤其在容器 reallocate、移动语义等场景中至关重要。
底层原理:
-
noexcept 是编译期标记,告诉编译器该函数不会抛异常,或在特定条件下不抛。
-
如果函数标记了 noexcept 但实际抛出了异常,程序会直接调用 std::terminate()(崩溃)。
-
标准库容器如 std::vector在扩容时,只有移动构造函数是 noexcept 时,才会使用移动操作;否则为了强异常安全会退化为拷贝(O(N)),性能大幅下降
noexcept 最经典的应用场景。看一个自定义 MyVector 的例子:
cpp
class MyVector {
int* data;
size_t size;
public:
// 移动构造函数:必须加 noexcept!
MyVector(MyVector&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
// 移动赋值运算符:同理加 noexcept
MyVector& operator=(MyVector&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}
};
为什么必须加 noexcept?假设 std::vector<MyVector> 扩容:
-
若移动构造是
noexcept:直接移动旧元素到新内存(O (1) 指针交换); -
若没有
noexcept:编译器担心移动过程中抛异常,导致旧数据破坏,于是退化为拷贝构造(O (N) 逐元素复制),性能断崖式下跌。
C++11 之后,几乎所有标准库的移动操作都标记了 noexcept(如 std::string、std::vector、std::unique_ptr 的移动构造)。实际项目中,自定义资源类时遵循 "移动构造 + noexcept" 是标准写法。
std::move_if_noexcept
为了让 "移动或拷贝" 的选择更自动化,C++14 引入了 std::move_if_noexcept:
cpp
#include <vector>
#include <utility> // std::move_if_noexcept
struct MyType {
MyType() = default;
MyType(MyType&&) noexcept {} // 移动构造是 noexcept
MyType(const MyType&) {} // 拷贝构造
};
int main() {
std::vector<MyType> v;
MyType t;
// 若 MyType 的移动构造是 noexcept,就移动;否则拷贝
v.push_back(std::move_if_noexcept(t));
}
它的本质是:"优先移动,但不安全时就拷贝",是标准库容器内部的常用套路。
反面教材:只用 std::move
cpp
#include <iostream>
#include <utility>
// 类 B:移动构造不是 noexcept(不安全)
class B {
public:
B() = default;
B(B&&) { std::cout << "B 移动构造(危险!可能抛异常)\n"; throw 42; }
B(const B&) { std::cout << "B 拷贝构造(安全)\n"; }
};
// 自己写的通用转移函数:只用 std::move
template <typename T>
void transfer_object(T& src, T* dst) {
// 不管三七二十一,直接 move!
new (dst) T(std::move(src));
}
int main() {
B src;
alignas(B) char buffer[sizeof(B)];
B* dst = reinterpret_cast<B*>(buffer);
try {
transfer_object(src, dst); // 这里会调用移动构造,然后抛异常!
} catch (...) {
std::cout << "捕获异常,但 src 可能已经被破坏了!\n";
}
}
-
transfer_object没有做noexcept检查; -
直接
std::move会强制调用移动构造,哪怕它会抛异常; -
一旦抛异常,
src可能已经被 "偷" 走了一部分(比如指针被置空),数据就坏了
常见坑:
不要对 const 对象调用 std::move(const T&& 绑定不到移动构造函数)。
移动操作必须加 noexcept
自定义资源管理类(如字符串、智能指针、容器)时,移动构造 / 移动赋值必须加
noexcept, 否则标准库容器不会用移动逻辑,性能白给。
绝不要给 "可能抛异常" 的函数加 noexcept
如果函数里调用了可能抛异常的操作(如
new、动态类型转换、用户自定义回调),千万别标记noexcept,否则一旦抛异常,程序直接崩溃,连栈展开都不会做。
灵活使用 noexcept(noexcept(expr))
可以根据另一个表达式的
noexcept状态,动态决定当前函数的noexcept:
cpptemplate <typename T> void swap(T& a, T& b) noexcept(noexcept(a.swap(b))) { a.swap(b); }
std::move 在 return 语句中其实不需要,C++17返回临时对象强制零开销(GCE),返回局部变量自动最优(NRVO / 隐式移动),return 里永远不要写 std::move。
QA
移动语义和拷贝语义性能差多少?
移动通常只转移指针(O(1)),拷贝是 O(N)。返回大 vector 时,移动比拷贝快几百倍。
什么时候必须手动写 std::move?
局部变量传给别人(函数参数、赋值、插入容器)时。return 时通常不需要。
- 为什么移动构造函数要加
noexcept?
std::vector等容器在扩容时,为了保证 "强异常安全",即扩容失败时旧数据不破坏,只有移动构造函数是noexcept才会使用移动操作;否则会退化为拷贝构造,导致性能从 O (1) 暴跌到 O (N)。
std::string 小字符串优化(SSO)对移动有影响吗?
短字符串(<16字节)可能还是拷贝,超过了才是真正移动。
noexcept和旧的throw()有什么本质区别?
noexcept是编译期标记,零运行时开销,编译器可基于它做优化;
throw()是运行时检查,有巨大开销,且无法静态验证,已被废弃。
- 什么时候必须加
noexcept?
所有 "绝不抛异常" 的操作,尤其是:
移动构造函数、移动赋值运算符;
swap函数;析构函数(C++11 起析构函数默认是
noexcept)。
- 移动构造函数要加 noexcept,具体是怎么保护的?
std::vector 扩容的标准步骤(简化版伪代码)是这样的:
cpp
template <typename T>
void vector<T>::reallocate(size_t new_capacity) {
// 1. 先分配新内存(不碰旧数据!)
T* new_data = static_cast<T*>(operator new(new_capacity * sizeof(T)));
size_t i = 0;
try {
// 2. 在新内存里逐个构造元素(关键步骤!)
for (; i < size; ++i) {
// 这里决定是用移动构造还是拷贝构造!
construct_in_place(new_data + i, /* 源对象 */ old_data[i]);
}
} catch (...) {
// 3. 异常处理:如果构造失败,"回滚"------销毁已造的,释放新内存
for (size_t j = 0; j < i; ++j) {
(new_data + j)->~T();
}
operator delete(new_data);
throw; // 重新抛出异常,告诉用户扩容失败了
// 注意:旧数据 old_data 完全没碰过!依然完好!
}
// 4. 确认所有元素都构造成功了!才敢碰旧数据
// 销毁旧元素,释放旧内存
for (size_t j = 0; j < size; ++j) {
(old_data + j)->~T();
}
operator delete(old_data);
// 5. 最后:更新指针和大小(Commit!)
old_data = new_data;
capacity = new_capacity;
}
关键点:
-
旧数据
old_data只有在 "新内存里所有元素都 100% 构造成功" 之后,才会被销毁和释放; -
如果中间任何一步抛异常,直接进入
catch块:销毁新内存里已造的元素,释放新内存,然后跑路 ------旧数据old_data连碰都没碰过,绝对安全!
用一个具体的 MyString 类来举例,它有一个 char* data 指针:
cpp
class MyString {
char* data;
public:
// 移动构造:偷指针,把源对象置空
MyString(MyString&& other) /* 假设这里先不加noexcept,我们看看后果 */ {
data = other.data; // 偷!
other.data = nullptr;// 源对象空了!
}
// 拷贝构造:深拷贝,源对象不动
MyString(const MyString& other) {
data = new char[strlen(other.data) + 1];
strcpy(data, other.data); // 源对象 other.data 完全没碰!
}
~MyString() { delete[] data; }
};
情况 1:移动构造不是 noexcept
假设 MyString 的移动构造没加 noexcept,而且在移动第 3 个元素时(i=2),突然抛异常了:
cpp
// 扩容步骤 2:在新内存构造元素
for (i = 0; i < size; ++i) {
// 编译器一看:移动构造不是 noexcept,不敢用!
// 但如果我们"强行"用移动构造(比如手动 std::move),会发生什么?
new (new_data + i) MyString(std::move(old_data[i])); // 假设这里 i=2 时抛异常!
}
过程:
-
i=0:移动成功,old_data[0].data被置空,new_data[0]接管了资源; -
i=1:移动成功,old_data[1].data被置空; -
i=2:移动构造抛异常! -
进入
catch块:-
销毁
new_data[0]和new_data[1](它们的data被释放); -
释放
new_data; -
重新抛出异常。
-
-
回头看旧数据:
-
old_data[0].data已经是nullptr了! -
old_data[1].data也已经是nullptr了! -
旧数据已经被破坏了!
-
结果:扩容失败,旧数据也没了 ------ 两头空,违反了 "强异常安全"。
情况 2:移动构造是 noexcept
现在给 MyString 的移动构造加上 noexcept:
cpp
MyString(MyString&& other) noexcept {
data = other.data;
other.data = nullptr;
}
编译器一看:移动构造是 noexcept,承诺绝对不抛异常。
安全过程:
-
扩容步骤 2:放心大胆地用移动构造;
-
因为
noexcept,编译器知道这里绝对不会抛异常; -
所有元素都移动成功;
-
进入步骤 4:销毁旧元素,释放旧内存;
-
进入步骤 5:更新指针,扩容成功。
结果:O (1) 移动,速度极快,而且因为 noexcept 保证不抛异常,所以绝对安全。
情况 3:移动构造不是 noexcept,但我们退回到拷贝构造
编译器看既然移动构造不是 noexcept,那我就不用它了,用拷贝构造
cpp
// 扩容步骤 2:在新内存构造元素
for (i = 0; i < size; ++i) {
// 编译器自动选择:用拷贝构造!
new (new_data + i) MyString(old_data[i]); // 源对象 old_data[i] 完全不动!
}
假设拷贝第 3 个元素时(i=2)抛异常:
安全回滚过程:
-
i=0:拷贝成功,new_data[0]有了副本,old_data[0]完好无损; -
i=1:拷贝成功,old_data[1]完好无损; -
i=2:拷贝构造抛异常! -
进入
catch块:-
销毁
new_data[0]和new_data[1](释放副本); -
释放
new_data; -
重新抛出异常。
-
-
回头看旧数据:
-
old_data[0]、old_data[1]、old_data[2]...... 所有旧元素连碰都没碰过! -
旧数据完好无损!
-
结果:扩容失败,但旧数据还在 ------ 完美符合 "强异常安全",虽然性能是 O (N),但保命最重要。
Rule of Five/Rule of Zero原则
如果你的类里有需要自己释放的资源比如堆内存 new int、文件句柄、网络连接,就得小心处理:
-
拷贝对象时,不能只复制指针(浅拷贝),否则两个对象会指向同一块资源,析构时重复释放导致崩溃。
-
C++11 有了移动语义后,还要处理 "转移资源所有权" 的情况。
Rule of Five
如果你手动写了以下 5 个函数中的任意一个,说明你的类需要复杂的资源管理,那你必须把5 个都写全:
-
析构函数
-
拷贝构造函数
-
拷贝赋值运算符
-
移动构造函数(C++11:转移资源给新对象,原对象变空)
-
移动赋值运算符(C++11:转移资源给已存在的对象,原对象变空)
为什么要 "写一个就得写全"?
举个反例:假设你只写了析构函数(释放堆内存),但用编译器默认的拷贝构造函数:
cpp
class BadExample {
public:
int* data;
BadExample() : data(new int(42)) {} // 分配堆内存
~BadExample() { delete data; } // 只写了析构函数
// 编译器默认生成的拷贝构造:浅拷贝!
};
int main() {
BadExample a;
BadExample b = a; // 浅拷贝:b.data 和 a.data 指向同一块内存!
// 程序结束时,a 和 b 都析构,delete 同一块内存两次 → 崩溃!
}
Rule of Zero
不要自己手动管理资源!直接用标准库已经封装好的类比如 std::vector、std::string、std::unique_ptr、std::shared_ptr,这些类已经帮你写好了完美的析构、拷贝、移动函数,你什么都不用写!
为什么这是最佳实践?看对比:
手动管理资源(需要写 Rule of Five)
cpp
class ManualVector {
public:
int* data;
size_t size;
// 1. 构造函数
ManualVector(size_t s) : data(new int[s]), size(s) {}
// 2. 析构函数
~ManualVector() { delete[] data; }
// 3. 拷贝构造函数(深拷贝)
ManualVector(const ManualVector& other) {
size = other.size;
data = new int[size];
std::copy(other.data, other.data + size, data);
}
// 4. 拷贝赋值运算符
ManualVector& operator=(const ManualVector& other) {
if (this == &other) return *this;
delete[] data; // 先释放自己的资源
size = other.size;
data = new int[size];
std::copy(other.data, other.data + size, data);
return *this;
}
// 5. 移动构造函数
ManualVector(ManualVector&& other) noexcept {
data = other.data; // 直接转移指针
size = other.size;
other.data = nullptr; // 原对象变空,避免析构时重复释放
other.size = 0;
}
// 6. 移动赋值运算符
ManualVector& operator=(ManualVector&& other) noexcept {
if (this == &other) return *this;
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
return *this;
}
};
用 Rule of Zero,一行代码搞定
cpp
class SmartVector {
public:
std::vector<int> data; // 直接用标准库的 vector!
SmartVector(size_t s) : data(s) {}
// 什么析构、拷贝、移动都不用写!
// 编译器会自动调用 vector 的对应函数,完美!
};
什么时候必须手动写 std::move?
必须 move 的场景:
-
把局部变量转给别人
cppvoid f(vector<int> v); vector<int> v; f(std::move(v)); -
赋值转移
cppa = std::move(b);
不需要move的场景:
-
return局部变量
cppvector<int> f() { vector<int> v; return v; // 自动优化 → 移动 }
C++17:强制 RVO(返回值优化)
C++17 之后,返回临时对象 → 100% 不拷贝、不移动,直接构造在调用方,这种规则被称为GCE。
cpp
return string("hello");
在 C++17 之前,RVO(返回值优化)和 NRVO(具名返回值优化)只是编译器的 "可选优化"。
虽然几乎所有编译器在 Release 模式下都会做优化,但在语言规则上存在两个致命问题:
-
语法检查严格:即使编译器实际上会优化掉拷贝 / 移动,你的类必须有可访问的拷贝或移动构造函数(不能是
delete或private),否则编译报错。 -
Debug 模式的噩梦:在 Debug 模式下(为了调试),优化通常会关闭,导致原本应该高效的代码变得极慢。
以C++11/14为例子:
cpp
#include <iostream>
using namespace std;
class Widget {
public:
Widget() { cout << "构造函数" << endl; }
Widget(const Widget&) { cout << "拷贝构造" << endl; }
Widget(Widget&&) noexcept { cout << "移动构造" << endl; }
};
Widget createWidget() {
return Widget(); // 返回一个临时对象
}
int main() {
Widget w = createWidget();
// C++17前:理论上需要先构造临时对象,再移动构造给w
// 虽然编译器可能优化掉,但移动构造函数必须存在!
}
C++17 彻底改写了这一机制。核心在于重新定义了 "值类别"(Value Categories)。简单来说,C++17 引入了一个核心概念:纯右值(prvalue)不实质化(materialize)。核心法则:
当你返回一个纯右值(例如 return T(...))时:
-
没有临时对象。
-
不调用拷贝构造。
-
不调用移动构造。
-
直接在调用方的内存地址上构造对象。
这不仅是优化,这是语言层面的保证。
cpp
#include <iostream>
using namespace std;
class Widget {
public:
Widget() { cout << "构造函数" << endl; }
// 注意:我们把拷贝和移动构造都删除了!
Widget(const Widget&) = delete;
Widget(Widget&&) = delete;
};
Widget createWidget() {
return Widget(); // 返回纯右值
}
int main() {
Widget w = createWidget();
// C++17后:编译通过!且只打印一次 "构造函数"
// 即使没有拷贝/移动构造函数也没关系,因为根本用不到它们
}
即使你的类不可拷贝、不可移动,你依然可以通过值返回它,而且效率为 0。
为了实现这一点,编译器改变了调用约定。步骤解析:
-
调用方(Caller)负责分配内存:
main函数在自己的栈帧上提前为w分配好内存。 -
传递 "隐藏指针":
main调用createWidget时,偷偷把w的内存地址作为一个隐藏参数传了进去。 -
被调用方(Callee)直接构造:
createWidget内部的return Widget();不再是在自己的栈帧里构造对象,而是直接拿着那个隐藏指针,在main函数的地址上构造对象。
cpp
// 编译器眼中的 main
void main() {
Widget w; // 先不构造,只分配内存
createWidget(&w); // 把地址传进去
}
// 编译器眼中的 createWidget
void createWidget(Widget* __ret) {
new (__ret) Widget(); // 直接在传入的地址上构造(Placement new)
}
什么时候才 "强制"?
C++17 的强制复制消除不是万能的,它有严格的适用条件。
强制生效的情况(返回纯右值):
cpp
std::string func() {
return std::string("hello"); // 1. 返回临时对象
}
std::vector<int> func2() {
return {1, 2, 3}; // 2. 返回初始化列表(也是纯右值)
}
不强制的情况(NRVO):
如果你返回的是一个局部变量的名字(Named RVO),C++17 仍然不强制,这还是属于编译器的可选优化。
cpp
std::string func() {
std::string s = "hello";
return s; // 这是 NRVO,C++17 不强制保证
// 编译器可能优化,也可能调用移动构造
// 注意:此时类必须有可访问的移动/拷贝构造函数
}
NRVO(return 局部变量名)编译器行为:
-
能优化 → 直接 NRVO,0 拷贝 0 移动(最快)
-
不能优化 → 自动调用移动构造(很快)
-
没移动构造 → 才调用拷贝构造(慢)
千万不要在返回局部变量时加 std::move!
cpp
std::string func() {
std::string s = "hello";
return std::move(s); // 大错特错!
}
这会阻止 NRVO,强制编译器调用移动构造函数。在 C++17 中,返回纯右值是最好的,返回具名对象次之,返回 std::move 是最差的。std::move(s) → 左值转 xvalue 将亡值,直接彻底禁用 NRVO 优化机会,强制走移动构造函数 搬运资源,C++17 强制 GCE 只认 prvalue,xvalue 不享受特权。
移动构造是「廉价拷贝」,有指令开销;RVO 是「直接原地构造」,零搬运、零函数调用。