文章目录
前言
💬 欢迎讨论:如果你在学习过程中有任何问题或想法,欢迎在评论区留言,我们一起交流学习。你的支持是我继续创作的动力!
👍 点赞、收藏与分享:觉得这篇文章对你有帮助吗?别忘了点赞、收藏并分享给更多的小伙伴哦!你们的支持是我不断进步的动力!
🚀分享给更多人:如果你觉得这篇文章对你有帮助,欢迎分享给更多对C++感兴趣的朋友,让我们一起进步!
C++ 作为一门底层高效语言,在设计时便考虑到了性能和资源管理。程序员在编写代码时,常常面临对象的频繁创建与销毁,尤其是在函数返回值的传递过程中,可能会触发多次对象的拷贝构造或移动操作。为了减少这些不必要的拷贝,C++ 编译器会采用一些优化技术,如 拷贝省略(Copy Elision)、返回值优化(Return Value Optimization,RVO)和 命名返回值优化(Named Return Value Optimization,NRVO)。
读者须知
RVO 与NRVO的启用条件
虽然 RVO 和 NRVO 是编译器自动完成的优化,但是这些优化并不总是启用,具体取决于编译器的实现和配置。例如:
因此,尽管 RVO 是 C++ 标准的一部分,但 NRVO 则并不总是强制执行,尤其是在复杂场景下,不同的编译器版本可能表现出不同的优化行为。
- 在 C++17 之前,RVO 是一个可选优化,但在 C++17 标准之后,RVO 被强制启用,编译器必须在符合条件的情况下执行拷贝省略。
- NRVO 通常依赖于编译器的智能分析,虽然大多数现代编译器都能支持 NRVO,但其效果和激进程度因编译器和版本的不同而有所差异。
因此,尽管 RVO 是 C++ 标准的一部分,但 NRVO 则并不总是强制执行,尤其是在复杂场景下,不同的编译器版本可能表现出不同的优化行为。
如何确认优化是否启用?
你可以通过编译时的优化级别和编译器选项来控制 RVO 和 NRVO 的启用。通常使用 -O2 或 -O3 优化级别可以启用这些优化。如果你希望查看编译器具体是否执行了这些优化,可以通过以下方式进行检查:
- GCC:使用 -fno-elide-constructors 禁用拷贝省略。
- Clang:通过 -fno-elide-constructors 禁用拷贝省略。
- MSVC:Visual Studio 中可以通过 /Od(禁用优化)或 /O2(启用优化)控制优化行为。
1. 按值传递与拷贝省略
1.1 按值传递概念
在 C++ 中,按值传递意味着函数参数是通过创建实参对象的副本来传递的。通常会触发拷贝构造或移动构造函数。按值传递可以在函数内部修改参数副本,而不影响原始实参对象,也就是说形式参数无法影响实参,两个创建的对象指向不同空间。
当我们传递一个对象给函数时,编译器会为这个对象创建一个副本。这个副本的创建需要调用 拷贝构造函数,并且在函数执行结束后,该副本会被销毁,从而调用 析构函数。这一过程涉及到内存的分配与释放,对于庞大的对象,可能会导致性能下降,如加载缓慢甚至导致无法加载等问题。
1.2 示例代码
#include <iostream>
using namespace std;
class A {
public:
A(int a = 0) : _a1(a) {
cout << "A(int a) 构造函数被调用, _a = " << _a1 << endl;
}
A(const A& aa) : _a1(aa._a1) {
cout << "A(const A& aa) 拷贝构造函数被调用" << endl;
}
A(A&& aa) noexcept : _a1(aa._a1) {
cout << "A(A&& aa) 移动构造函数被调用" << endl;
}
~A() {
cout << "~A() 析构函数被调用" << endl;
}
private:
int _a1;
};
void f1(A aa) {} // 按值传递
int main() {
A aa1(10); // 创建对象 aa1
f1(aa1); // 按值传递,调用拷贝构造
return 0;
}
1.3 按值传递的性能影响
在上述代码中,按值传递会创建对象的副本,并调用 拷贝构造函数 或 移动构造函数,构造函数结束时,析构函数将会被调用。这一过程虽然实现了副本的值传递,但对于庞大对象集体,频繁的拷贝和析构会导致性能问题。
1.3.1 完全不优化
在没有任何优化的情况下,按值传递时会创建一个对象的副本,并调用拷贝构造函数。返回对象后,析构函数将被调用两次:一次是为原对象,另一次是为副本。
输出结果:
A(int a) 构造函数被调用, _a = 10
A(const A& aa) 拷贝构造函数被调用
~A() 析构函数被调用
~A() 析构函数被调用
解释:
- 对象
aa1
在主函数中通过构造函数被创建。 - 按值传递时,编译器调用了拷贝构造函数,为
aa1
创建了副本。 - 当函数
f1
执行结束后,副本被销毁,调用了析构函数。 - 当
main
函数结束时,原始对象aa1
也被销毁。
1.4 不同编译器下的优化表现
1.4.1 Visual Studio 2019普通优化
在 Visual Studio 2019 中,编译器在普通优化模式下,依然会调用拷贝构造函数。
输出结果:
A(int a) 构造函数被调用, _a = 10
A(const A& aa) 拷贝构造函数被调用
~A() 析构函数被调用
~A() 析构函数被调用
尽管编译器启用了部分优化,但在这种按值传递的情况下,仍然需要调用拷贝构造函数,并最终调用两次析构函数。
1.4.2 Visual Studio 2022激进优化
VS2022 的优化更加激进,它能够跳过对象的拷贝构造,直接传递原始对象的引用。通过内存重用和别名优化,编译器可以避免创建副本。
输出结果:
A(int a) 构造函数被调用, _a = 10
~A() 析构函数被调用
解释:
。在 VS2022 中,拷贝构造函数被优化掉,编译器直接将原对象 aa1 传递给函数 f1。也就是说,就是编译器检索发现函数里面aa不会修改,就在函数里面使用aa1即可,此时函数里面的aa就是aa1的别名
。无需创建副本,也不需要析构副本,只在 main 函数结束时销毁 aa1。
1.5 小结
- 按值传递通常会触发拷贝构造或移动构造,并在函数结束时触发析构函数。
- Visual Studio 2019 中,普通优化仍然会调用拷贝构造函数。
- Visual Studio 2022 的激进优化则可以跳过拷贝构造,避免副本的创建。
2. 返回值优化(RVO)
2.1 RVO 的概念
返回值优化(RVO) 是编译器的一种优化技术,它允许编译器在函数返回临时对象时,
直接在调用者的内存空间中构造该对象,避免不必要的拷贝或移动构造。
当函数返回一个局部临时对象时,通常会触发一次拷贝构造或移动构造,因为局部对象需要从函数内部复制到外部。然而,RVO 能够避免这种多余的拷贝或移动操作,编译器直接在调用者的内存空间中构造返回的对象。
2.2 示例代码
A f2() {
A aa(5);
return aa; // 返回局部临时对象
}int main() {
A a2 = f2(); // 接收返回值
return 0;
}
2.3 不同优化下的表现
2.3.1 完全不优化的情况
在没有启用 RVO 的情况下,返回值会经历多次拷贝操作:
- 在
f2()
内部创建局部对象aa
。 - 创建一个临时对象,将
aa
拷贝到这个临时对象中。 - 最后将临时对象拷贝给
a2
,并调用两次拷贝构造函数。
输出结果:
A(int a) 构造函数被调用, _a = 5
A(const A& aa) 拷贝构造函数被调用
A(const A& aa) 拷贝构造函数被调用
~A() 析构函数被调用
~A() 析构函数被调用
~A() 析构函数被调用
解释:
- 局部对象
aa
在f2
()函数内创建,并通过两次拷贝构造传递给a2
。 - 三次析构函数分别销毁局部对象
aa
、临时对象和最终返回的a2
。
2.3.2 启用 RVO 的情况(Visual Studio 2019)
在 Visual Studio 2019 中,编译器启用了 RVO 优化,避免了创建临时对象,直接将aa
拷贝给a2。
输出结果:
A(int a) 构造函数被调用, _a = 5
A(const A& aa) 拷贝构造函数被调用
~A() 析构函数被调用
~A() 析构函数被调用
解释:
- 编译器避免了临时对象的创建,但仍通过拷贝构造将
aa
传递给a2
。 - 整个过程调用了一次拷贝构造,并在
a2
和aa
被销毁时分别调用析构函数。
2.3.3 激进 RVO 的情况(Visual Studio 2022)
Visual Studio 2022 实现了更加激进的 RVO 优化。编译器直接在 a2
的内存空间中构造对象 aa
,完全跳过拷贝构造。其实就是下文讲的NRVO
输出结果:
A(int a) 构造函数被调用, _a = 5
~A() 析构函数被调用
解释:
aa
直接在a2
的内存空间中构造,避免了临时对象和拷贝构造。- 最终只需要调用一次析构函数来销毁
a2
。
2.4 小结
- RVO 主要用于返回临时对象的优化,能够在返回局部对象时避免多次拷贝。
- Visual Studio 2019 中启用了 RVO,减少了临时对象的创建,但仍会调用一次拷贝构造。
- Visual Studio 2022 则更加激进,完全避免了拷贝构造,直接在返回对象的目标内存空间中构造该对象。
3. 命名返回值优化(NRVO)
3.1 NRVO 的概念
命名返回值优化(NRVO) 是 RVO 的扩展,专门用于优化函数返回命名局部变量的情况。编译器会在调用者的内存空间中直接构造该命名对象,避免临时对象和拷贝操作。
NRVO 允许编译器在返回函数内的命名局部变量时进行优化,直接在目标对象的内存中构造该局部变量,而不是创建一个临时对象进行拷贝或移动。这一优化虽然不像 RVO 那样是 C++ 标准的强制要求,但大多数现代编译器都会尝试实现这种优化。
3.2 示例
A f3() {
A a(3);
return a; // 返回命名局部变量
}int main() {
A a2 = f3(); // 使用返回值
return 0;
}
解释:
在这段代码中,函数 f3
返回命名局部变量 a
。没有 NRVO 优化的情况下,a
会首先被拷贝到一个临时对象中,然后该临时对象会被拷贝到 a2
。
3.3 优化下的不同表现
3.3.1 完全不优化的情况
在没有 NRVO 优化的情况下,返回的命名对象 a
会经历以下拷贝过程:
- 在
f3
函数内创建局部对象a
。 - 创建一个临时对象,将
a
拷贝到这个临时对象中。 - 最后将临时对象拷贝到
a2
中。
输出结果:
A(int a) 构造函数被调用, _a = 3
A(const A& aa) 拷贝构造函数被调用
A(const A& aa) 拷贝构造函数被调用
~A() 析构函数被调用
~A() 析构函数被调用
~A() 析构函数被调用
解释:
- 局部变量
a
在f3
中创建,并通过两次拷贝构造传递给a2
。 - 由于没有启用 NRVO,因此返回值会触发两次拷贝构造和三次析构函数调用。
3.3.2 启用 NRVO 的情况(Visual Studio 2019 和 2022)
在 Visual Studio 2019 和 Visual Studio 2022 中,NRVO 技术的实现基本一致。局部对象 a
会直接在 a2
的内存空间中构造,没有临时对象和多余的拷贝操作。
输出结果:
A(int a) 构造函数被调用, _a = 3
~A() 析构函数被调用
解释:
- 通过 NRVO,编译器直接在
a2
的内存空间中构造局部对象a
,避免了拷贝构造。 - 整个过程只需要一次析构调用,销毁
a2
。
3.4 Visual Studio 2022 的优化
复杂场景中的 NRVO: Visual Studio 2022 在处理复杂的函数返回场景时,表现更为激进。例如在多层嵌套、条件判断等情况下,NRVO 依然有效,而某些编译器可能在复杂条件下无法实现优化。
复杂的 NRVO 示例:
A f4(bool flag) {
A a1(1);
A a2(2);
if (flag) {
return a1;
} else {
return a2;
}
}
int main() {
A a
3 = f4(true); // 使用返回值
return 0;
}
Visual Studio 2022 依然能够直接在 a3
的内存空间中构造返回值(无论是 a1
还是 a2
),而不会创建临时对象或额外的拷贝构造。并且这种情况下发现只需要返回a1
,那甚至可能会跳过a2
的创建。
输出结果:
A(int a) 构造函数被调用, _a = 1
~A() 析构函数被调用
3.5 小结
。NRVO 针对命名局部变量的优化,能够在返回命名变量时避免临时对象和拷贝构造。
。isual Studio 2019 和 2022 的 NRVO 实现基本一致,能够在大多数情况下避免拷贝构造。
。Visual Studio 2022 在处理复杂场景时的 NRVO 优化表现更为激进,即使在条件判断和嵌套场景中,也能有效避免额外的临时对象和拷贝。
4. 赋值操作无法优化的原因
4.1 赋值操作的本质
赋值操作与对象构造不同,它修改已经存在的对象,因此不能像RVO或NRVO那样进行优化。赋值操作必须真正执行对象状态的复制,无法通过跳过拷贝来优化。
在 C++ 中,赋值操作是将一个对象的内容复制到另一个对象中(与赋值运算符重载相似)。这与对象的构造不同,因为在赋值操作时,目标对象已经存在,不能通过构造优化来避免对象的状态复制。
4.2 示例:
A aa1(10);
A aa2(20);
aa1 = aa2; // 赋值操作
输出结果:
A(int a) 构造函数被调用, _a = 10
A(int a) 构造函数被调用, _a = 20
A& operator=(const A& aa) 赋值运算符被调用
~A() 析构函数被调用
~A() 析构函数被调用
解释:
- 对象
aa1
和aa2
分别通过构造函数创建。 - 赋值操作需要实际复制
aa2
的数据到aa1
中,因此必须调用赋值运算符。
5. Visual Studio 2019 vs Visual Studio 2022 编译器优化差异
5.1 编译器的工作原理
编译器在优化过程中,使用了别名分析和内存重用技术。在分析对象的使用模式后,编译器能够判断某些对象的拷贝是多余的,可以直接复用原始对象的内存地址。这种优化策略依赖于编译器对代码中对象生命周期的深层次分析。
5.2 为什么 VS2022 更加激进
VS2022 能够在更多复杂场景下进行优化,包括跨行优化、多层函数调用等。这是因为编译器能够更好地理解对象的生命周期,并通过对象生命周期分析来跳过冗余的拷贝操作。
两者比较示例:
A f4() {
A a1(1);
A a2(2);
return a1; // 返回局部变量
}int main() {
A a3 = f4();
return 0;
}
VS2019输出结果:
A(int a) 构造函数被调用, _a = 1
A(int a) 构造函数被调用, _a = 2
A(const A& aa) 拷贝构造函数被调用
~A() 析构函数被调用
~A() 析构函数被调用
~A() 析构函数被调用
在 VS2019 中,即使返回的是局部变量,仍会创建一个临时对象,然后通过拷贝构造将其传递给 a3
。
VS2022输出结果:
A(int a) 构造函数被调用, _a = 1
~A() 析构函数被调用
在 VS2022 中,编译器能够更好地分析对象生命周期,跳过了临时对象的创建,直接在 a3
的内存空间中构造返回的局部变量 a1
。
5.3 编译器的激进优化总结
。Visual Studio 2019 在大部分情况下能够启用 RVO 和 NRVO,但在某些复杂场景下仍需要额外的拷贝构造。
。Visual Studio 2022 的优化更加激进,通过更好的对象生命周期分析,能够避免更多不必要的拷贝操作,即使在复杂的函数调用和条件判断中,仍能高效地进行返回值优化。
6. 总结
通过本文,我们深入分析了 C++ 中编译器优化的几个重要方面,包括 返回值优化(RVO) 和 命名返回值优化(NRVO)。这些优化能够显著减少对象的拷贝构造和临时对象的创建,从而提升程序的执行效率。
。RVO 主要用于优化返回临时对象的场景,Visual Studio 2022 通过激进优化完全跳过了拷贝构造。
。NRVO 则用于优化返回命名局部变量的场景,Visual Studio 2019 和 2022 的 NRVO 实现基本一致,但 2022 的编译器在复杂场景中的表现更为出色。
在涉及对象赋值的场景中,由于目标对象已经存在,因此无法通过 RVO 或 NRVO 进行优化。
现代编译器已经能够通过 别名分析 和 对象生命周期分析 实现高度智能的优化。程序员不需要显式地进行优化,只需合理设计函数返回结构,编译器会自动帮助完成优化。
如果你希望了解更多编译器优化的底层机制,可以查阅 cppreference RVO文档 和 MSVC优化指南。
相信通过这篇文章你对C++类与对象高级部分的有了初步的了解。如果此篇文章对你学习C++有帮助,期待你的三连,你的支持就是我创作的动力!!!
下一篇文章再会.