当前位置: 首页 > news >正文

【C++篇】类与对象深度解析(六):全面剖析拷贝省略、RVO、NRVO优化策略

文章目录

前言

💬 欢迎讨论:如果你在学习过程中有任何问题或想法,欢迎在评论区留言,我们一起交流学习。你的支持是我继续创作的动力!

👍 点赞、收藏与分享:觉得这篇文章对你有帮助吗?别忘了点赞、收藏并分享给更多的小伙伴哦!你们的支持是我不断进步的动力!
🚀分享给更多人:如果你觉得这篇文章对你有帮助,欢迎分享给更多对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);  // 创建对象 aa1f1(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 的情况下,返回值会经历多次拷贝操作:

  1. 在 f2() 内部创建局部对象 aa
  2. 创建一个临时对象,将 aa 拷贝到这个临时对象中。
  3. 最后将临时对象拷贝给 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 会经历以下拷贝过程:

  1. 在 f3 函数内创建局部对象 a
  2. 创建一个临时对象,将 a 拷贝到这个临时对象中。
  3. 最后将临时对象拷贝到 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 a3 = 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 的编译器在复杂场景中的表现更为出色。
在涉及对象赋值的场景中,由于目标对象已经存在,因此无法通过 RVONRVO 进行优化。

现代编译器已经能够通过 别名分析 和 对象生命周期分析 实现高度智能的优化。程序员不需要显式地进行优化,只需合理设计函数返回结构,编译器会自动帮助完成优化。

如果你希望了解更多编译器优化的底层机制,可以查阅 cppreference RVO文档 和 MSVC优化指南。

相信通过这篇文章你对C++类与对象高级部分的有了初步的了解。如果此篇文章对你学习C++有帮助,期待你的三连,你的支持就是我创作的动力!!!

下一篇文章再会.


http://www.mrgr.cn/news/53675.html

相关文章:

  • 开头的例子的理解
  • @moohng/postcss-px2vw,如何配置响应式参数?
  • 从珀莱雅看美妆品牌如何借势双11,实现销量与口碑双赢
  • 意外断电 导致docker 部署禅道 的mariadb 启动报错
  • 后台管理员登录实现--系统篇
  • python+大数据+基于热门视频的数据分析研究【内含源码+文档+部署教程】
  • 从头预训练一只迷你 LLaMA 3_llama3 预训练预处理
  • 电影评论网站开发:Spring Boot技术指南
  • 人工智能研究创造出新型蛋白质
  • Redis数据持久化机制详解
  • 中国商飞社招校招前程无忧智鼎题库:IQCAT思维能力测验真题通关技巧
  • linux卸载数据库(最为完整的卸载方式)
  • RabbitMQ如何保证消息不丢失?
  • FFmpeg源码:av_sat_add64_c、av_sat_sub64_c函数分析
  • 外星人木乃伊---我的收藏
  • 《月光下的约定》
  • CVTE Android面试题及参考答案(100道题)
  • 使用exe4j打包jar包生成exe文件,GUI应用详细使用教程
  • 【YOLOv10改进[损失函数]】使用结合InnerIoU和Focaler的各种损失函数助力YOLOv10更优秀
  • 智慧钢厂可视化平台:钢铁生产的数字化转型
  • sentinel原理源码分析系列(六)-统计指标
  • 活着就好20241019
  • linux安装mysql数据库(最完整的yum源安装)
  • leetcode hot100 之【LeetCode 42. 接雨水】 java实现
  • day-69 使二进制数组全部等于 1 的最少操作次数 II
  • 微调小型Llama 3.2(十亿参数)模型取代GPT-4o