在C++的性能优化工具箱里,复制消除(Copy Elision) 是一项至关重要的编译期技术。它并非新概念,自C++11起就被广泛讨论和应用。但其地位在后续标准中发生了根本性变化:C++17将其在特定场景下的语义从“可选的优化”提升为“强制性的语言要求”;而最新的C++23标准则通过P2266提案,进一步扫清了其应用中的障碍,让规则更加精炼和直观。
1. 核心概念:什么是复制消除?
复制消除(copy elision) 是编译器的一项优化技术,其核心目标在于避免创建不必要的临时对象,从而消除不必要的拷贝/移动构造函数调用。
在C++的语义中,对象的初始化和传递可能涉及多个步骤的临时对象创建与销毁,这带来了运行时的开销。复制消除允许编译器在不改变程序可观测行为的前提下,将多个对象的构造“合并”为一个。
在C++17之前,这只是一项纯粹的优化。自C++17起,在特定场景下,它成为语言标准强制性的要求。这意味着即使拷贝/移动构造函数有可观测的其它作用(如打印日志等功能),编译器也必须省略其调用,程序的行为将基于优化后的结果来定义。
2. 场景演进:从优化到强制要求
为了更好地理解这项技术,我们将其典型应用场景分类,并追踪其在各C++标准中的变迁。
2.1 场景一:RVO和NRVO
返回值优化 (Return Value Optimization, RVO) 与 命名返回值优化 (Named Return Value Optimization, NRVO) 这是最经典和有效的优化场景。
1)RVO说明:
当函数返回一个纯右值(prvalue),例如一个临时对象或一个类类型的字面量时,编译器可以直接在调用方的存储位置上构造该对象,省去一次临时对象的构造和一次拷贝/移动。
// C++11 示例
#include<iostream>
class Widget {
public:
explicit Widget(int id) : id_(id) {
std::cout << "constructor called." << std::endl;
}
Widget(const Widget& other) : id_(other.id_) {
std::cout << "copy constructor called." << std::endl;
}
private:
int id_;
};
Widget createWidget(){
return Widget(42); // 纯右值: Widget(42)
}
int main(){
Widget w = createWidget(); // RVO允许将 w 的存储位置直接传递给 createWidget
// 理想优化:仅调用一次 Widget(int) 构造函数
}
运行结果:
[root@VM-0-13-opencloudos tmp_code]# g++ -std=c++11 tmp.cpp
[root@VM-0-13-opencloudos tmp_code]# ./a.out
constructor called.
[root@VM-0-13-opencloudos tmp_code]#
2)NRVO说明:
当函数返回一个局部变量(左值) 时,编译器可以尝试将该变量直接构造在调用方的存储位置上。
// C++11 示例 (NRVO是优化,非强制)
#include<iostream>
class Widget {
public:
explicit Widget(int id) : id_(id) {
std::cout << "constructor called. addr:" << this << std::endl;
}
Widget(const Widget& other) : id_(other.id_ + 100) {
std::cout << "copy constructor called. addr:" << this << std::endl;
}
Widget(Widget&& other) noexcept : id_(other.id_ + 200) {
std::cout << "move constructor called. addr:" << this << std::endl;
}
~Widget() {
std::cout << "destructor called." << std::endl;
}
private:
int id_;
};
// 可能触发 NRVO 的函数
Widget createWidgetNRVO(int base_id){
Widget localWidget(base_id); // 具名局部对象(左值)
std::cout << "localWidget addr: " << &localWidget << "\n";
return localWidget; // 返回局部左值 → 可能触发 NRVO
}
int main(){
std::cout << "==== NRVO 开启时 ====\n";
{
Widget w1 = createWidgetNRVO(1); // 可能直接构造在 w 的位置
}
std::cout << "\n==== NRVO 关闭时 ====\n";
// 编译选项: g++ -fno-elide-constructors ...
{
Widget w2 = createWidgetNRVO(2); // 强制拷贝/移动
}
}
开启时的运行结果:
[root@VM-0-13-opencloudos tmp_code]# g++ -std=c++11 tmp.cpp
[root@VM-0-13-opencloudos tmp_code]# ./a.out
==== NRVO 开启时 ====
constructor called. addr:0x7fffb7dee2cc
localWidget addr: 0x7fffb7dee2cc
destructor called.
从上面的结果可以看到,对象直接在 w1 的存储位置构造,无额外拷贝/移动操作。
关闭时的运行结果:
[root@VM-0-13-opencloudos tmp_code]# g++ -std=c++11 tmp.cpp -fno-elide-constructors
[root@VM-0-13-opencloudos tmp_code]# ./a.out
==== NRVO 关闭时 ====
constructor called. addr:0x7fffa6a21f9c
localWidget addr: 0x7fffa6a21f9c
move constructor called. addr:0x7fffa6a21fcc
destructor called.
move constructor called. addr:0x7fffa6a21fc0
destructor called.
destructor called.
从上面的结果可以看到,触发两次移动构造(若未定义移动构造则使用拷贝构造),产生额外临时对象。
3)关键变化(C++17):
对于RVO(返回纯右值),复制消除成为强制性要求。这意味着 Widget w = createWidget(); 中,w 的构造函数(Widget(int))调用是保证发生的,而任何可能存在的拷贝/移动构造函数调用是保证不发生的。即使拷贝/移动构造函数有打印语句,也不会输出。
NRVO在C++17中仍然是非强制性的优化。
2.2 场景二:临时对象初始化
#include<iostream>
class Test {
public:
Test(const char* str = "\0") {
std::cout << "Constructor called\n";
}
Test(const Test &other) {
std::cout << "Copy constructor called\n";
}
Test(Test&& other) noexcept {
std::cout << "Move constructor called\n";
}
};
int main(){
Test obj = "copy me"; // 拷贝初始化
// C++11起,编译器被允许(并普遍)将语义转换为 Test obj("copy me");
// 输出: Constructor called
// Copy/Move constructor 被消除
}
开启优化和关闭优化后的运行结果:
[root@VM-0-13-opencloudos tmp_code]# g++ -std=c++11 tmp.cpp
[root@VM-0-13-opencloudos tmp_code]# ./a.out
Constructor called
[root@VM-0-13-opencloudos tmp_code]#
[root@VM-0-13-opencloudos tmp_code]# g++ -std=c++11 tmp.cpp -fno-elide-constructors
[root@VM-0-13-opencloudos tmp_code]# ./a.out
Constructor called
Move constructor called
底层原理:
语句 Test obj = "copy me"; 在语义上需要两步:
- 将
"copy me" 通过 Test(const char*) 转换为一个临时纯右值 Test 对象。
- 用这个临时对象初始化
obj。编译器发现源端是一个与目标类型相同的纯右值,且初始化方式是拷贝/移动初始化,因此可以直接用该纯右值初始化目标 obj,相当于直接调用 Test obj("copy me")。这在C++17后,对于此类纯右值初始化,同样是强制性的复制消除。
2.3 场景三:throw 和 catch 表达式
在抛出和捕获异常对象时,标准也允许进行复制消除,以避免在异常处理路径中产生额外的拷贝。但是,这也是非强制要求。参考下面例子:
#include<iostream>
#include<stdexcept>
class Noisy {
public:
Noisy() {
std::cout << "构造对象 @" << this << std::endl;
}
Noisy(const Noisy&) {
std::cout << "拷贝构造对象 @" << this << std::endl;
}
Noisy(Noisy&&) {
std::cout << "移动构造对象 @" << this << std::endl;
}
~Noisy() {
std::cout << "析构对象 @" << this << std::endl;
}
};
void throwException(){
Noisy localObj; // 局部对象
std::cout << "抛出前对象地址: " << &localObj << std::endl;
throw localObj; // 标准允许复制消除
}
int main(){
try {
throwException();
} catch (const Noisy& e) { // 引用捕获避免额外拷贝
std::cout << "捕获时对象地址: " << &e << std::endl;
}
}
开启默认优化的运行结果:
[root@VM-0-13-opencloudos tmp_code]# g++ -std=c++17 tmp.cpp
[root@VM-0-13-opencloudos tmp_code]# ./a.out
构造对象 @0x7ffef162104f
抛出前对象地址: 0x7ffef162104f
移动构造对象 @0x319be740
析构对象 @0x7ffef162104f
捕获时对象地址: 0x319be740
析构对象 @0x319be740
3. 深入原理:为什么能消除?
要理解C++17的强制性变化,需要引入临时对象实质化(Temporary Materialization) 的概念。
在C++11/14中,一个纯右值(prvalue)表达式(如前面例子中的 Widget(42) 或函数返回的纯右值)的求值结果,传统上被认为是一个“临时对象”。拷贝消除是跳过这个临时对象的创建。
而在C++17,纯右值表达式不再是一个对象,它仅仅是一个用于初始化对象的“蓝图”或“指令集”。只有当一个纯右值被需要用作一个泛左值(glvalue)时(例如需要取其地址、绑定到引用,或像C++14那样作为源进行拷贝初始化),才会发生“实质化”,从而临时性地创建一个临时对象(这个临时对象是一个亡值 xvalue)。
C++17强制性复制消除的原理:在像 Widget w = Widget(42); 或 Widget w = createWidget(); 这样的语句中,右边的纯右值被用来初始化左边的对象 w。根据C++17标准,这个纯右值将直接用于初始化 w,而不会先“实质化”为一个临时对象再拷贝/移动给 w。因此,拷贝/移动构造函数从一开始就没有被调用的可能性,这是语义的一部分,而不是事后优化。
4. C++23的进一步精炼:P2266复制消除的仿射规则
C++17的强制复制消除解决了一个大问题,但仍有一个痛点。考虑以下代码:
// C++17 示例
struct NonMovable {
NonMovable() = default;
NonMovable(NonMovable&&) = delete; // 显式删除移动构造
};
NonMovable make(){
return NonMovable{};
}
int main(){
NonMovable nm = make(); // 在C++17/20中,这是合法的!
}
这个例子在C++17/20是合法的。为什么?因为根据强制复制消除规则,make() 返回的纯右值直接用于构造 nm,根本不会尝试调用 NonMovable(NonMovable&&),所以即使它被删除了也没关系。这看起来很好。
但问题出现在更复杂的场景中,比如涉及条件分支的返回:
// C++17/20 有问题的示例
NonMovable make(bool condition){
NonMovable a, b;
if (condition) {
return a; // 可能返回 a
} else {
return b; // 可能返回 b
}
// NRVO在此无法工作,因为返回哪个对象在运行时决定。
}
在C++17/20中,return a; 语句需要将左值 a 转换为返回的纯右值。这个转换过程在标准中需要尝试移动构造(尽管最终可能被消除)。但我们的 NonMovable 类型删除了移动构造函数,导致这段代码在C++17/20下无法编译。
编译报错信息如下:

这违背了直觉,因为直观上如果编译器能够进行NRVO,就不应该需要移动操作。
C++23的解决方案 (P2266): 该提案引入了更精细的“仿射规则”。它规定:当一个函数返回一个局部左值对象(即NRVO候选对象)时,首先尝试执行复制消除(NRVO)。只有在复制消除不可行(例如因为多个返回路径指向不同对象)的情况下,才会回退到要求移动或拷贝操作。
因此,在上面的 make(bool) 例子中,编译器会首先尝试为 a 和 b 执行NRVO。虽然由于控制流复杂,NRVO可能失败,但标准的重载决议规则会因此改变——它现在会基于“如果尝试NRVO失败,需要回退到移动/拷贝”这个前提来检查移动构造函数的可用性。这修复了上述不直观的行为,使得复制消除的规则更加一致和强大。了解这些底层规则,有助于你在云栈社区的C/C++板块与更多开发者进行深入探讨。
5. 如何观察与调试:关闭优化
尽管标准有强制部分,但编译器仍提供选项来关闭优化,这对理解对象生命周期和调试构造函数副作用至关重要。这个开关在前面的例子中,其实已经多次演示过了。
# 使用GCC或Clang关闭复制消除
g++ -std=c++17 -fno-elide-constructors your_code.cpp -o your_program
# 编译并运行
./your_program
# 输出将显示所有“原本可能”发生的构造函数和移动/拷贝构造函数调用。
重要提示:
使用 -fno-elide-constructors 后观察到的行为,是C++17之前的、未优化的语义模型。在C++17及之后的标准下,对于强制复制消除的场景(如RVO),即使使用此标志,编译器仍可能遵循标准而省略调用,或生成不同的诊断信息。此标志主要帮助我们理解优化背后的传统模型。
6. 总结
1) 依赖优化,但理解语义:自C++17起,可以放心依赖RVO等强制性复制消除来编写返回大对象的函数,无需担心性能损失。这是C++“零开销抽象”哲学的体现。
2) 移动语义是补充:对于NRVO无法覆盖的场景(如返回函数参数、返回成员变量、复杂控制流下的不同对象),一个高效且不抛异常的移动构造函数 (noexcept) 是保障性能和安全性的关键。C++23的P2266使得这种依赖更加合理。
3) 避免副作用:不要在拷贝/移动构造函数中,编写有关键逻辑副作用的代码(如资源计数、重要日志),因为它们的调用可能会被标准允许或强制性地消除。对象的生命周期管理应依赖于构造函数和析构函数。
4) 拥抱现代C++ :在代码中,优先使用返回值而非输出参数来返回对象。现代C++标准及其编译器的优化能力,使得这种写法既清晰又高效。
通过从C++11的编译器优化,到C++17的语义强制,再到C++23的规则精炼,复制消除技术展现了C++语言在保持向后兼容的同时,不断向更安全、更高效、更直观方向演进的过程。理解其底层原理,有助于我们编写出更符合现代C++范式的高性能代码。如果你想了解更多关于编译器或系统底层原理的知识,可以访问云栈社区的计算机基础板块,那里有丰富的相关讨论和资源。