深度解读《深度探索C++对象模型》之返回值优化

这篇具有很好参考价值的文章主要介绍了深度解读《深度探索C++对象模型》之返回值优化。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文和全部的文章列表。

没有启用返回值优化时,怎么从函数内部返回对象

当在函数的内部中返回一个局部的类对象时,是怎么返回对象的值的?请看下面的代码片段:

class Object {}

Object foo() {
    Object b;
    // ...
	return b;
}

Object a = foo();

对于上面的代码,是否一定会从foo函数中拷贝对象到对象a中,如果Object类中定义了拷贝构造函数的话,拷贝构造函数是否一定会被调用?答案是要看Object类的定义和编译器的实现策略有关。我们细化一下代码来进一步分析具体的表现行为,请看下面的代码:

#include <cstdio>

class Object {
public:
    Object() {
        printf("Default constructor\n");
        a = b = c = d = 0;
    }
    int a;
    int b;
    int c;
    int d;
};

Object foo() {
    Object p;
    p.a = 1;
    p.b = 2;
    p.c = 3;
    p.d = 4;
    return p;
}

int main() {
    Object obj = foo();
    printf("%d, %d, %d, %d\n", obj.a, obj.b, obj.c, obj.d);

    return 0;
}

编译成对应的汇编代码,看一下是怎么从foo函数中返回一个对象的,下面节选main和foo函数的汇编代码:

foo():														# @foo()
    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    lea     rdi, [rbp - 16]
    call    Object::Object() [base object constructor]
    mov     dword ptr [rbp - 16], 1
    mov     dword ptr [rbp - 12], 2
    mov     dword ptr [rbp - 8], 3
    mov     dword ptr [rbp - 4], 4
    mov     rax, qword ptr [rbp - 16]
    mov     rdx, qword ptr [rbp - 8]
    add     rsp, 16
    pop     rbp
    ret
main:															# @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     dword ptr [rbp - 4], 0
    call    foo()
    mov     qword ptr [rbp - 24], rax
    mov     qword ptr [rbp - 16], rdx
    mov     esi, dword ptr [rbp - 24]
    mov     edx, dword ptr [rbp - 20]
    mov     ecx, dword ptr [rbp - 16]
    mov     r8d, dword ptr [rbp - 12]
    lea     rdi, [rip + .L.str]
    mov     al, 0
    call    printf@PLT
    xor     eax, eax
    add     rsp, 32
    pop     rbp
    ret

从汇编代码中看到,在foo函数内部构造了一个Object类的对象(第5、6行),然后对它的成员进行赋值(第7行到第10行),最后通过将对象的值拷贝到rax和rdx寄存器中作为返回值返回(第11、12行)。在main函数中的第22、23代码,将返回值从rax和rdx寄存器中拷贝到栈空间中,这里没有构造对象,直接采用拷贝的方式拷贝内容,可见在这种情况下编译器是直接拷贝对象内容的方式来返回一个局部对象的。

启用返回值优化的条件和编译器的实现分析

如果Object类中有定义了一个拷贝构造函数,在这种情况下表现行为又是怎样的?在上面从C++代码中加入拷贝构造函数:

Object(const Object& rhs) {
    printf("Copy constructor\n");
    memcpy(this, &rhs, sizeof(Object));
}

编译运行,输出结果如下:

Default constructor
1, 2, 3, 4

神奇的是拷贝构造函数被没有如预期地被调用,甚至查看汇编代码都没有生成拷贝构造函数的代码(因为没有调用,编译器优化掉了)。我们再来看看foo和main函数的汇编代码,看看和上面的汇编代码有什么区别。

foo():                           # @foo()
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     qword ptr [rbp - 24], rdi       # 8-byte Spill
    mov     rax, rdi
    mov     qword ptr [rbp - 16], rax       # 8-byte Spill
    mov     qword ptr [rbp - 8], rdi
    call    Object::Object() [base object constructor]
    mov     rdi, qword ptr [rbp - 24]       # 8-byte Reload
    mov     rax, qword ptr [rbp - 16]       # 8-byte Reload
    mov     dword ptr [rdi], 1
    mov     dword ptr [rdi + 4], 2
    mov     dword ptr [rdi + 8], 3
    mov     dword ptr [rdi + 12], 4
    add     rsp, 32
    pop     rbp
    ret
main:															# @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32
    mov     dword ptr [rbp - 4], 0
    lea     rdi, [rbp - 24]
    call    foo()
    mov     esi, dword ptr [rbp - 24]
    mov     edx, dword ptr [rbp - 20]
    mov     ecx, dword ptr [rbp - 16]
    mov     r8d, dword ptr [rbp - 12]
    lea     rdi, [rip + .L.str]
    mov     al, 0
    call    printf@PLT
    xor     eax, eax
    add     rsp, 32
    pop     rbp
    ret

从汇编代码中看到,foo函数内部中不再构造一个局部对象然后初始化后再将这个对象拷贝返回,而是传递了一个对象的地址给foo函数(第24、25行),foo函数对传递过来的这个对象进行构造(第5到第9行),然后对对象的成员进行赋值(第12到15行),foo函数结束之后,在main函数中就可以直接使用这个被构造和赋值后的对象了,第26到29行就是取各成员的值然后调用printf函数打印出来。也就是说原先的代码被编译器改写了,如下面的伪代码所示:

Object obj = foo();
// 将被改成:
Object obj;	// 这里不需要调用默认构造函数
foo(obj);

// 相应地foo函数将被改写定义:
void foo(Object& obj) {
    obj.Object::Object();	// 调用Object的默认构造函数
    obj.a = 1;
    obj.b = 2;
    obj.c = 3;
    obj.d = 4;
    return;
}

看起来像是拷贝构造函数的加入激活了编译器NRV(Named Return Value)优化,为什么有拷贝构造函数的存在就会触发NRV优化呢?原因就是既然程序中定义了拷贝构造函数,根据我们之前的分析,说明是要处理拷贝大块的内存空间等之类的操作,不仅仅是普通的数据成员的拷贝,如果只是拷贝数据成员可以不必定义拷贝构造函数,编译器会采用更高效的逐成员拷贝的方法,编译器内部就可以帮程序员做好了,所以有拷贝构造函数的存在就说明有需要低效的拷贝动作,那么就要想办法消除掉拷贝的操作,那么启用NRV优化就是一项提高效率的做法了。

那么是不是只有存在拷贝构造函数编译器才会启用NRV优化呢?我们继续来修改代码,类中加入一个大数组,同时把拷贝构造函数去掉:

class Object {
public:
    Object() {
        printf("Default constructor\n");
        a = b = c = d = 0;
    }
    int a;
    int b;
    int c;
    int d;
    int buf[100];
};

这样修改之后的汇编代码跟之前的基本一样(汇编代码跟上面基本一样就没贴了),有区别的地方就是对象占用的内存空间变大了,这说明没有定义拷贝构造函数的情况下编译器也有可能启用了NRV优化,在对象占用的内存空间较大的时候,这时不再适合使用寄存器来传送对象的内容了,如果采用栈空间来返回结果的话,会涉及到内存的拷贝,效率较低,所以启用NRV优化则有效率上的提升。

启用返回值优化后的效率提升

那么启用NRV优化与不启用优化,两者之间的效率对比究竟差了多少?我们还是以上面的例子来测试,默认情况下编译器是开启了这个优化的,如果想要禁用这个优化,可以在编译时加入-fno-elide-constructors选项关闭它。为了不影响效率,把打印都去掉,在main函数中加入时间计时,下面是完整的代码:

#include <cstdio>
#include<chrono>
using namespace std::chrono;

class Object {
public:
    Object() {}
    int a;
    int b;
    int c;
    int d;
    int buf[100];
};

Object foo(int i) {
    Object p;
    p.a = 1;
    p.b = 2;
    p.c = 3;
    p.d = 4;
    p.buf[0] = i;
    p.buf[99] = i;
    return p;
}

int main() {
    auto start = system_clock::now();
    for (auto i = 0; i < 10000000; ++i) {
        Object a = foo(i);
    }
    auto end = system_clock::now();
    auto duration = duration_cast<milliseconds>(end-start);
    printf("spend %lldms\n", duration.count());

    return 0;
}

下面是在我的Apple M1机器上的测试结果,每种情况都是取测试10次然后取平均值。

启用NRV优化 未启用NRV优化
56.3ms 186.7

未优化的时间多花了130.4ms,时间上是启用优化后的时间的3倍多。

返回值优化的缺点

从测试结果来看,NRV优化看起来很美好,那么NRV优化是否一切都完美无缺呢?其实NRV优化也存在一些不足或者说不尽如人意的地方:

  • 是否开启了NRV优化的问题,NRV优化并不是C++标准中规定的东西,各家编译器的实现未必一定支持它,或者说启用它的条件和规则也不尽相同,例如clang或者g++,像我上面提到的那两种情况下就会开启优化,微软的Visual Studio编译器则默认不会启用,需要设置优化选项之后才会启用。所以写一些跨平台的代码的时候需要注意一下,做到心中有数。
  • 未能启用NRV优化的情况,NRV优化并非在所有的情况下、所有的代码中都能够启用,可能在某些条件限制下编译器不能够启用优化,比如代码逻辑太复杂的情况下。
  • 优化不是预期的需求,优化可能在无声无息中完成了,但是却有可能不是你想要的结果,比如你期待在拷贝构造函数中做一些事情,然后在析构函数中做相反的一些事情,但是拷贝构造函数并未如预期中的被调用了,导致了程序运行的错误。

总之,需要做到对编译器背后的行为有深入的理解,就能做到心中有数,写出既高效又安全的代码。


如果您感兴趣这方面的内容,请在微信上搜索公众号iShare爱分享并关注,以便在内容更新时直接向您推送。文章来源地址https://www.toymoban.com/news/detail-855037.html

到了这里,关于深度解读《深度探索C++对象模型》之返回值优化的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 深度解读《深度探索C++对象模型》之数据成员的存取效率分析(三)

    接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文和全部的文章列表。 前面两篇请通过这里查看: 深度解读《深度探索C++对象模型》之数据成员的存取效率分析(一) 深度解读《深度探索

    2024年04月22日
    浏览(40)
  • 深度解读《深度探索C++对象模型》之数据成员的存取效率分析(二)

    接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文和全部的文章列表。 接下来的几篇将会讲解非静态数据成员的存取分析,讲解静态数据成员的情况请见上一篇:《深度解读《深度探索C+

    2024年04月22日
    浏览(42)
  • C++奇迹之旅:探索类对象模型内存的存储猜想

    上回我们学习了类的定义,初步了解了什么是类?类的定义,以及类的三个访问限定符: public , private , protected ,本小节将讲解类的实例化,类对象模型的猜想存储,及三种简单类的计算。 在 C++ 中,类的实例化是指创建一个类的对象。当我们定义了一个类之后,就可以根据

    2024年04月12日
    浏览(39)
  • C++学习笔记——返回对象

    当我们说一个函数返回对象时,意味着该函数的返回值是一个对象。这种情况下,函数可以通过创建对象的副本、返回对象的引用或者返回对象的指针来实现。 返回对象的副本: 当一个函数返回对象的副本时,函数内部会创建一个临时对象,并将其作为返回值。编译器会调

    2024年01月19日
    浏览(52)
  • AI大模型探索之路-训练篇3:大语言模型全景解读

    大规模语言模型(Large Language Models,LLM),也称大语言模型或大型语言模型,是一种由包含数百亿以上参数的深度神经网络构建的语言模型,通常使用自监督学习方法通过大量无标注文本进行训练。 语言模型旨在对于人类语言的内在规律进行建模,从而准确预测词序列中未来

    2024年04月26日
    浏览(51)
  • 深度探索 Elasticsearch 8.X:function_score 参数解读与实战案例分析

    在 Elasticsearch 中,function_score 可以让我们在查询的同时对搜索结果进行自定义评分。 function_score 提供了一系列的参数和函数让我们可以根据需求灵活地进行设置。 近期有同学反馈,function_score 的相关参数不好理解,本文将深入探讨 function_score 的核心参数和函数。 Elasticsear

    2024年02月14日
    浏览(44)
  • JVM内存模型深度解读

            JVM(Java Virtual Machine,Java虚拟机)对于Java开发者和运行 Java 应用程序而言至关重要。其重要性主要体现在跨平台性、内存管理和垃圾回收、性能优化、安全性和稳定性、故障排查与性能调优等方面。今天就下学习一下 JVM 的内存模型。         JVM 内存模型(

    2024年03月19日
    浏览(47)
  • 【优化技术专题】「性能优化系列」针对Java对象压缩及序列化技术的探索之路

    序列化和反序列化 序列化就是指把对象转换为字节码; 对象传递和保存时,保证对象的完整性和可传递性。把对象转换为有字节码,以便在网络上传输或保存在本地文件中; 反序列化就是指把字节码恢复为对象; 根据字节流中保存的对象状态及描述信息,通过反序列化重建

    2024年01月22日
    浏览(57)
  • C++通过JNI调用JAVA方法返回ArrayList对象

    运行效果:   JAVA实现: 获取系统已安装应用列表并返回ListString对象 C++ JNI实现:

    2024年02月11日
    浏览(47)
  • c++_learning-对象模型探索

    封装:隐藏在内部的实现, 对成员变量和成员函数进行封装,并不会带来任何额外的空间开销和执行期效率的影响。 只有在虚函数机制(支持执行期绑定,实现了多态)、虚继承(多重继承中保证基类在子类中拥有唯一的实例),才会体现出封装额外的成本。 继承:复用现

    2024年02月07日
    浏览(32)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包