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

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 【C++篇】C++类与对象深度解析(六):全面剖析拷贝省略、RVO、NRVO优化策略

C++类与对象

前言

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

👍 点赞、收藏与分享:觉得这篇文章对你有帮助吗?别忘了点赞、收藏并分享给更多的小伙伴哦!你们的支持是我不断进步的动力!

🚀分享给更多人:如果你觉得这篇文章对你有帮助,欢迎分享给更多对C++感兴趣的朋友,让我们一起进步!

C++ 作为一门底层高效语言,在设计时便考虑到了性能和资源管理。程序员在编写代码时,常常面临对象的频繁创建与销毁,尤其是在函数返回值的传递过程中,可能会触发多次对象的拷贝构造或移动操作。为了减少这些不必要的拷贝,C++ 编译器会采用一些优化技术,如 拷贝省略(Copy Elision)、返回值优化(Return Value Optimization,RVO)和 命名返回值优化(Named Return Value Optimization,NRVO)。

读者须知

RVO 与 NRVO 的启用条件

虽然 RVO 和 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(启用优化)控制优化行为。

在本篇中主要使用VS2019和VS2022来进行比较,因为实际情况的复杂性,以及编译器版本的不同,甚至同一大版本中小版本的不同更新的VS都存在一定的差异,本篇输出结果示例仅作参考,更多的是让读者通过不同优化的比较来理解现代编译器在提升程序效率所做的改进


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 也被销毁。

image.png

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

image.png

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() 析构函数被调用

解释

  • 局部对象 aaf2 函数内创建,并通过两次拷贝构造传递给 a2
  • 三次析构函数分别销毁局部对象 aa、临时对象和最终返回的 a2

image.png

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
  • 整个过程调用了一次拷贝构造,并在 a2aa 被销毁时分别调用析构函数。

image.png

2.3.3 激进 RVO 的情况(Visual Studio 2022)

Visual Studio 2022 实现了更加激进的 RVO 优化。编译器直接在 a2 的内存空间中构造对象 aa,完全跳过拷贝构造。其实就是下文讲的NRVO

输出结果

A(int a) 构造函数被调用, _a = 5
~A() 析构函数被调用

解释

  • aa 直接在 a2 的内存空间中构造,避免了临时对象和拷贝构造。
  • 最终只需要调用一次析构函数来销毁 a2

image.png

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() 析构函数被调用

解释

  • 局部变量 af3 中创建,并通过两次拷贝构造传递给 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 针对命名局部变量的优化,能够在返回命名变量时避免临时对象和拷贝构造。
  • Visual Studio 20192022 的 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() 析构函数被调用

解释

  • 对象 aa1aa2 分别通过构造函数创建。
  • 赋值操作需要实际复制 aa2 的数据到 aa1 中,因此必须调用赋值运算符。

image.png

赋值操作在 C++ 中并没有优化空间,因为在赋值操作时,目标对象已经存在,编译器必须实际执行数据复制的过程,而不能通过拷贝省略来进行优化。


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++类与对象深度解析(六):全面剖析拷贝省略、RVO、NRVO优化策略的内容啦,各位大佬有什么问题欢迎在评论区指正,或者私信我也是可以的啦,您的支持是我创作的最大动力!❤️

目录
相关文章
|
25天前
|
C++ 芯片
【C++面向对象——类与对象】Computer类(头歌实践教学平台习题)【合集】
声明一个简单的Computer类,含有数据成员芯片(cpu)、内存(ram)、光驱(cdrom)等等,以及两个公有成员函数run、stop。只能在类的内部访问。这是一种数据隐藏的机制,用于保护类的数据不被外部随意修改。根据提示,在右侧编辑器补充代码,平台会对你编写的代码进行测试。成员可以在派生类(继承该类的子类)中访问。成员,在类的外部不能直接访问。可以在类的外部直接访问。为了完成本关任务,你需要掌握。
64 19
|
25天前
|
存储 编译器 数据安全/隐私保护
【C++面向对象——类与对象】CPU类(头歌实践教学平台习题)【合集】
声明一个CPU类,包含等级(rank)、频率(frequency)、电压(voltage)等属性,以及两个公有成员函数run、stop。根据提示,在右侧编辑器补充代码,平台会对你编写的代码进行测试。​ 相关知识 类的声明和使用。 类的声明和对象的声明。 构造函数和析构函数的执行。 一、类的声明和使用 1.类的声明基础 在C++中,类是创建对象的蓝图。类的声明定义了类的成员,包括数据成员(变量)和成员函数(方法)。一个简单的类声明示例如下: classMyClass{ public: int
45 13
|
2月前
|
存储 算法 安全
基于红黑树的局域网上网行为控制C++ 算法解析
在当今网络环境中,局域网上网行为控制对企业和学校至关重要。本文探讨了一种基于红黑树数据结构的高效算法,用于管理用户的上网行为,如IP地址、上网时长、访问网站类别和流量使用情况。通过红黑树的自平衡特性,确保了高效的查找、插入和删除操作。文中提供了C++代码示例,展示了如何实现该算法,并强调其在网络管理中的应用价值。
|
2月前
|
安全 编译器 C++
C++ `noexcept` 关键字的深入解析
`noexcept` 关键字在 C++ 中用于指示函数不会抛出异常,有助于编译器优化和提高程序的可靠性。它可以减少代码大小、提高执行效率,并增强程序的稳定性和可预测性。`noexcept` 还可以影响函数重载和模板特化的决策。使用时需谨慎,确保函数确实不会抛出异常,否则可能导致程序崩溃。通过合理使用 `noexcept`,开发者可以编写出更高效、更可靠的 C++ 代码。
46 1
|
2月前
|
存储 程序员 C++
深入解析C++中的函数指针与`typedef`的妙用
本文深入解析了C++中的函数指针及其与`typedef`的结合使用。通过图示和代码示例,详细介绍了函数指针的基本概念、声明和使用方法,并展示了如何利用`typedef`简化复杂的函数指针声明,提升代码的可读性和可维护性。
95 1
|
3月前
|
设计模式 安全 数据库连接
【C++11】包装器:深入解析与实现技巧
本文深入探讨了C++中包装器的定义、实现方式及其应用。包装器通过封装底层细节,提供更简洁、易用的接口,常用于资源管理、接口封装和类型安全。文章详细介绍了使用RAII、智能指针、模板等技术实现包装器的方法,并通过多个案例分析展示了其在实际开发中的应用。最后,讨论了性能优化策略,帮助开发者编写高效、可靠的C++代码。
54 2
|
3月前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
148 5
|
3月前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
149 4
|
25天前
|
编译器 数据安全/隐私保护 C++
【C++面向对象——继承与派生】派生类的应用(头歌实践教学平台习题)【合集】
本实验旨在学习类的继承关系、不同继承方式下的访问控制及利用虚基类解决二义性问题。主要内容包括: 1. **类的继承关系基础概念**:介绍继承的定义及声明派生类的语法。 2. **不同继承方式下对基类成员的访问控制**:详细说明`public`、`private`和`protected`继承方式对基类成员的访问权限影响。 3. **利用虚基类解决二义性问题**:解释多继承中可能出现的二义性及其解决方案——虚基类。 实验任务要求从`people`类派生出`student`、`teacher`、`graduate`和`TA`类,添加特定属性并测试这些类的功能。最终通过创建教师和助教实例,验证代码
47 5
|
25天前
|
存储 算法 搜索推荐
【C++面向对象——群体类和群体数据的组织】实现含排序功能的数组类(头歌实践教学平台习题)【合集】
1. **相关排序和查找算法的原理**:介绍直接插入排序、直接选择排序、冒泡排序和顺序查找的基本原理及其实现代码。 2. **C++ 类与成员函数的定义**:讲解如何定义`Array`类,包括类的声明和实现,以及成员函数的定义与调用。 3. **数组作为类的成员变量的处理**:探讨内存管理和正确访问数组元素的方法,确保在类中正确使用动态分配的数组。 4. **函数参数传递与返回值处理**:解释排序和查找函数的参数传递方式及返回值处理,确保函数功能正确实现。 通过掌握这些知识,可以顺利地将排序和查找算法封装到`Array`类中,并进行测试验证。编程要求是在右侧编辑器补充代码以实现三种排序算法
36 5

推荐镜像

更多