Standard C++ 基础回炉|第 1 课(1-3):函数返回对象、移动语义与 RVO/NRVO
通过对象生命周期跟踪实验,理解函数返回对象时的移动构造、拷贝消除,以及 C++17 下 RVO 与 NRVO 的区别。
Standard C++ 基础回炉|第 1 课(1-3):函数返回对象、移动语义与 RVO/NRVO
1. 学习目标
通过一个 Tracker 类观察对象在函数返回过程中的生命周期,理解:
- 普通构造、拷贝构造、移动构造
- 拷贝赋值与移动赋值
std::move的基本作用- 函数返回局部对象时发生了什么
- RVO 与 NRVO 的区别
- C++17 对 prvalue 返回值的规则
-fno-elide-constructors为什么只能关闭部分拷贝消除- moved-from 对象是什么状态
- 为什么通常不要写
return std::move(local);
2. 实验代码
#include <iostream>
#include <string>
#include <utility>
class Tracker {
public:
explicit Tracker(std::string name)
: name_(std::move(name)) {
log("construct");
}
Tracker(const Tracker& other)
: name_(other.name_ + ".copy") {
log("copy construct");
}
Tracker(Tracker&& other) noexcept
: name_(std::move(other.name_)) {
log("move construct");
}
Tracker& operator=(const Tracker& other) {
name_ = other.name_ + ".copy-assigned";
log("copy assign");
return *this;
}
Tracker& operator=(Tracker&& other) noexcept {
name_ = std::move(other.name_);
log("move assign");
return *this;
}
~Tracker() {
log("destroy");
}
private:
void log(const char* action) const {
std::cout << action << ": "
<< (name_.empty() ? "<moved-from>" : name_)
<< '\n';
}
std::string name_;
};
Tracker make_named_object() {
Tracker value{"named-result"};
return value;
}
Tracker make_temporary_object() {
return Tracker{"temporary-result"};
}
int main() {
std::cout << "=== named object ===\n";
Tracker first = make_named_object();
std::cout << "\n=== temporary object ===\n";
Tracker second = make_temporary_object();
std::cout << "\n=== end main ===\n";
}
3. <utility> 的作用
代码中:
#include <utility>
主要是为了使用:
std::move()
<utility> 中常见的工具包括:
std::movestd::forwardstd::swapstd::pairstd::make_pair
当前阶段重点掌握 std::move 和后续的 std::forward。
需要特别注意:
std::move本身并不会真正移动资源,它主要进行类型转换,使对象能够匹配移动构造、移动赋值等接受右值引用的操作。
4. Tracker 类在观察什么
Tracker 的目的不是实现业务功能,而是通过日志观察对象生命周期。
它定义了:
操作 函数
普通构造 Tracker(std::string)
拷贝构造 Tracker(const Tracker&)
移动构造 Tracker(Tracker&&)
拷贝赋值 operator=(const Tracker&)
移动赋值 operator=(Tracker&&)
析构 ~Tracker()
例如:
Tracker a{"AAA"};
Tracker b = a;
Tracker c = std::move(a);
分别可能触发:
construct: AAA
copy construct: AAA.copy
move construct: AAA
5. 构造和赋值一定要区分
5.1 拷贝构造
Tracker a{"AAA"};
Tracker b = a;
此时 b 原本不存在。
这里是在创建一个新对象,所以调用:
Tracker(const Tracker&);
5.2 拷贝赋值
Tracker a{"AAA"};
Tracker b{"BBB"};
b = a;
此时 b 已经存在。
这里只是把新的内容赋给已有对象,因此调用:
Tracker& operator=(const Tracker&);
5.3 移动构造
Tracker a{"AAA"};
Tracker b = std::move(a);
b 原本不存在,所以调用移动构造:
Tracker(Tracker&&);
5.4 移动赋值
Tracker a{"AAA"};
Tracker b{"BBB"};
b = std::move(a);
b 已经存在,所以调用移动赋值:
Tracker& operator=(Tracker&&);
总结:
代码 操作
Tracker b = a; 拷贝构造
Tracker b = std::move(a); 移动构造
b = a; 拷贝赋值
b = std::move(a); 移动赋值
核心判断:
新对象正在产生,就是构造;对象已经存在,就是赋值。
6. 第一个实验:返回有名字的局部对象
函数:
Tracker make_named_object() {
Tracker value{"named-result"};
return value;
}
调用:
Tracker first = make_named_object();
value 是一个有名字的局部对象。
直觉上可能认为过程是:
构造 value
↓
return value
↓
移动到函数返回结果
↓
初始化 first
但默认编译时实际输出:
=== named object ===
construct: named-result
没有看到:
move construct
原因是编译器进行了 NRVO。
7. NRVO:Named Return Value Optimization
NRVO 全称:
Named Return Value Optimization
即:
有名返回值优化。
针对:
Tracker value{"named-result"};
return value;
这种返回有名字局部对象的场景。
编译器可以避免先创建一个独立的 value,然后再把它移动到最终对象。
概念上原本是:
函数内部 value
↓
move
↓
main 中 first
进行 NRVO 后,可以理解成:
直接在 first 最终占用的存储位置构造对象
效果近似:
Tracker first{"named-result"};
所以:
- 没有额外拷贝
- 没有额外移动
- 没有中间对象的析构
只看到一次:
construct: named-result
8. 使用 -fno-elide-constructors 关闭可选拷贝消除
实验中另一个程序使用类似:
g++ main.cpp -std=c++17 -fno-elide-constructors -o no_elide.exe
运行得到:
=== named object ===
construct: named-result
move construct: named-result
destroy: <moved-from>
这三行非常重要。
8.1 第一步:构造 value
执行:
Tracker value{"named-result"};
输出:
construct: named-result
此时:
value.name_ = "named-result"
8.2 第二步:return value
执行:
return value;
由于 NRVO 被关闭,需要把 value 的内容交给函数返回结果。
这里可以使用隐式移动,因此调用:
Tracker(Tracker&& other) noexcept;
输出:
move construct: named-result
资源关系可以概念化为:
value.name_
"named-result"
↓
move
↓
返回对象.name_
"named-result"
8.3 第三步:value 析构
移动完成后,函数即将结束。
局部变量:
value
仍然是一个存在的 C++ 对象,因此必须正常析构。
在当前 std::string 实现和本次运行中,它的 name_
移动后为空,因此日志输出:
destroy: <moved-from>
完整过程:
value 创建
↓
construct: named-result
return value
↓
移动构造返回对象
↓
move construct: named-result
value 被移动过
↓
函数结束
↓
value 析构
↓
destroy: <moved-from>
9. moved-from 对象是什么
执行:
Tracker b = std::move(a);
之后,a 并没有被销毁。
它仍然是一个合法对象。
对于标准库类型,移动后的对象通常处于:
valid but unspecified state
即:
对象仍然有效,可以被析构、重新赋值等,但通常不能依赖它当前保存的具体值。
本次实验中:
std::string
移动后恰好为空,因此:
name_.empty()
返回 true,日志打印:
<moved-from>
但不能把它背成:
std::move之后字符串一定为空。
更准确的理解是:
移动后的对象仍然合法,但其具体内容通常不应继续依赖。
10. 第二个实验:直接返回临时对象
函数:
Tracker make_temporary_object() {
return Tracker{"temporary-result"};
}
调用:
Tracker second = make_temporary_object();
这里与:
Tracker value{"named-result"};
return value;
有一个关键区别。
value 是:
有名字的局部对象。
而:
Tracker{"temporary-result"}
是一个 prvalue(纯右值)。
11. C++17 的直接构造规则
默认程序输出:
=== temporary object ===
construct: temporary-result
关闭可选拷贝消除后:
=== temporary object ===
construct: temporary-result
仍然完全一样。
没有:
move construct
这是因为从 C++17 开始,这种场景下不会要求:
创建临时 Tracker
↓
move
↓
second
而是直接在最终目标对象的存储位置构造。
可以概念化理解成:
Tracker second{"temporary-result"};
所以只需要一次普通构造。
12. 为什么 -fno-elide-constructors 关不掉它
这是本实验最关键的区别之一。
对于:
Tracker value{"named-result"};
return value;
NRVO 是可选优化。
因此:
-fno-elide-constructors
可以让我们观察到没有 NRVO 时的移动过程。
但是:
return Tracker{"temporary-result"};
在 C++17 的对应场景中,直接构造最终对象是语言规则的一部分。
因此即使指定:
-fno-elide-constructors
仍然不会人为制造一个无意义的临时对象再移动一次。
13. 两种返回方式对比
有名字的局部对象
Tracker make_named_object() {
Tracker value{"named-result"};
return value;
}
默认情况下:
construct
通常进行 NRVO。
关闭 NRVO 后:
construct
move construct
destroy moved-from
直接返回 prvalue
Tracker make_temporary_object() {
return Tracker{"temporary-result"};
}
C++17 下:
construct
即使使用:
-fno-elide-constructors
仍然是:
construct
14. 本次实际输出分析
default.exe
=== named object ===
construct: named-result
=== temporary object ===
construct: temporary-result
=== end main ===
destroy: temporary-result
destroy: named-result
named object
发生 NRVO:
value
↓
直接构造到 first 的最终位置
所以没有移动构造。
temporary object
C++17 下直接在最终对象位置构造。
所以同样没有移动构造。
15. no_elide.exe 输出分析
=== named object ===
construct: named-result
move construct: named-result
destroy: <moved-from>
=== temporary object ===
construct: temporary-result
=== end main ===
destroy: temporary-result
destroy: named-result
named object
NRVO 被关闭:
构造 value
↓
移动到返回结果
↓
value 析构
因此:
construct
move construct
destroy moved-from
temporary object
C++17 的直接构造规则仍然生效:
construct
没有额外移动。
16. 为什么最后先析构 second
main():
Tracker first = make_named_object();
Tracker second = make_temporary_object();
构造顺序:
first
↓
second
同一作用域内的局部对象通常按照构造顺序的逆序析构。
因此:
second
↓
first
最终输出:
destroy: temporary-result
destroy: named-result
17. RVO 与 NRVO 的理解
广义上,RVO(Return Value Optimization)表示返回值相关的拷贝消除。
常见讨论可以区分为:
NRVO
Tracker func() {
Tracker obj;
return obj;
}
obj 是有名字的局部变量。
这是:
Named Return Value Optimization
NRVO 不是所有情况下都强制执行。
直接返回 prvalue
Tracker func() {
return Tracker{};
}
C++17 起,对这种典型场景直接构造最终结果,不需要先构造一个独立临时对象再移动。
学习时常会把这种现象称作 RVO,但需要知道:
C++17 对 prvalue 的语义已经发生变化,它不仅仅是"编译器觉得可以所以优化一下"。
18. 不要随便 return std::move(obj)
看到:
return obj;
很容易产生一个错误想法:
既然移动比拷贝快,那写成
std::move岂不是更快?
例如:
Tracker func() {
Tracker obj{"test"};
return std::move(obj);
}
通常不推荐这么写。
原因是:
return obj;
可能满足 NRVO 条件。
而:
return std::move(obj);
表达式不再是直接返回那个局部变量本身,通常会破坏 NRVO 的适用条件,从而真的产生一次移动构造。
所以通常应该写:
return obj;
让编译器进行 NRVO;即使没有 NRVO,符合条件时也可以进行隐式移动。
19. 面试回答
Q1:什么是 RVO/NRVO?
可以回答:
RVO 是函数返回对象时的拷贝消除机制。NRVO 是针对有名字局部变量的返回值优化,例如创建局部对象
obj后return obj。编译器可以直接在最终返回对象的存储位置构造obj,避免额外的拷贝或移动。C++17 对直接返回 prvalue 的典型场景提供了语言层面的保证,而 NRVO 本身仍不是普遍强制的。
Q2:为什么定义了移动构造函数却没有执行?
可以回答:
因为编译器可能进行了返回值优化。如果对象可以直接在最终目标位置构造,就根本不存在"从中间对象移动到目标对象"的过程,因此移动构造函数不会被调用。
Q3:std::move 会真正移动对象吗?
可以回答:
不会。
std::move本质上主要是类型转换,把表达式转换为可供移动操作匹配的右值形式。真正的资源转移发生在移动构造函数或移动赋值函数内部。
Q4:对象被 move 后还能使用吗?
可以回答:
对象仍然存在,也必须正常析构。对于标准库对象,移动后通常保证处于有效但未指定的状态,可以重新赋值或析构,但不应该依赖它当前保存的具体值。
Q5:函数返回局部对象时应该写 std::move 吗?
可以回答:
通常不应该。直接
return obj可以让编译器进行 NRVO;即使没有 NRVO,符合条件时也可以隐式移动。显式写return std::move(obj)反而可能阻止 NRVO。
20. 核心结论
本知识点最终需要记住:
- 构造和赋值不是一回事。
- 移动构造用于创建新对象,移动赋值用于已有对象。
std::move本身不移动资源。- 被 move 的对象仍然存在,最终仍要析构。
- 移动后的标准库对象通常处于 valid but unspecified state。
- 返回有名字的局部对象可能触发 NRVO。
- 关闭 NRVO 后,
return value通常可以观察到隐式移动。 - C++17 直接返回 prvalue 时,可以直接构造最终对象。
- 因此
-fno-elide-constructors也无法让这类 C++17 强制规则消失。 - 不要为了"优化"随便写
return std::move(local)。 - 能直接构造最终对象时,比先构造再移动更加彻底。
21. 推荐继续实验
在现有代码基础上修改:
Tracker make_named_object() {
Tracker value{"named-result"};
return std::move(value);
}
分别运行:
g++ main.cpp -std=c++17 -o default.exe
和:
g++ main.cpp -std=c++17 -fno-elide-constructors -o no_elide.exe
观察:
move construct
是否出现。
然后继续对比:
return value;
与:
return std::move(value);
这样可以直接验证:
显式
std::move并不总是优化,有时反而会阻止 NRVO。