ToB企服应用市场:ToB评测及商务社交产业平台

标题: 深度解读《深度探索C++对象模型》之返回值优化 [打印本页]

作者: 钜形不锈钢水箱    时间: 2024-5-17 19:09
标题: 深度解读《深度探索C++对象模型》之返回值优化
接下来我将连续更新“深度解读《深度探索C++对象模型》”系列,敬请等待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文和全部的文章列表。
没有启用返回值优化时,怎么从函数内部返回对象

当在函数的内部中返回一个局部的类对象时,是怎么返回对象的值的?请看下面的代码片断:
  1. class Object {}
  2. Object foo() {
  3.     Object b;
  4.     // ...
  5.         return b;
  6. }
  7. Object a = foo();
复制代码
对于上面的代码,是否肯定会从foo函数中拷贝对象到对象a中,如果Object类中界说了拷贝构造函数的话,拷贝构造函数是否肯定会被调用?答案是要看Object类的界说和编译器的实现战略有关。我们细化一下代码来进一步分析具体的表现行为,请看下面的代码:
  1. #include <cstdio>
  2. class Object {
  3. public:
  4.     Object() {
  5.         printf("Default constructor\n");
  6.         a = b = c = d = 0;
  7.     }
  8.     int a;
  9.     int b;
  10.     int c;
  11.     int d;
  12. };
  13. Object foo() {
  14.     Object p;
  15.     p.a = 1;
  16.     p.b = 2;
  17.     p.c = 3;
  18.     p.d = 4;
  19.     return p;
  20. }
  21. int main() {
  22.     Object obj = foo();
  23.     printf("%d, %d, %d, %d\n", obj.a, obj.b, obj.c, obj.d);
  24.     return 0;
  25. }
复制代码
编译成对应的汇编代码,看一下是怎么从foo函数中返回一个对象的,下面节选main和foo函数的汇编代码:
  1. foo():                                                                                                                # @foo()
  2.     push    rbp
  3.     mov     rbp, rsp
  4.     sub     rsp, 16
  5.     lea     rdi, [rbp - 16]
  6.     call    Object::Object() [base object constructor]
  7.     mov     dword ptr [rbp - 16], 1
  8.     mov     dword ptr [rbp - 12], 2
  9.     mov     dword ptr [rbp - 8], 3
  10.     mov     dword ptr [rbp - 4], 4
  11.     mov     rax, qword ptr [rbp - 16]
  12.     mov     rdx, qword ptr [rbp - 8]
  13.     add     rsp, 16
  14.     pop     rbp
  15.     ret
  16. main:                                                                                                                        # @main
  17.     push    rbp
  18.     mov     rbp, rsp
  19.     sub     rsp, 32
  20.     mov     dword ptr [rbp - 4], 0
  21.     call    foo()
  22.     mov     qword ptr [rbp - 24], rax
  23.     mov     qword ptr [rbp - 16], rdx
  24.     mov     esi, dword ptr [rbp - 24]
  25.     mov     edx, dword ptr [rbp - 20]
  26.     mov     ecx, dword ptr [rbp - 16]
  27.     mov     r8d, dword ptr [rbp - 12]
  28.     lea     rdi, [rip + .L.str]
  29.     mov     al, 0
  30.     call    printf@PLT
  31.     xor     eax, eax
  32.     add     rsp, 32
  33.     pop     rbp
  34.     ret
复制代码
从汇编代码中看到,在foo函数内部构造了一个Object类的对象(第5、6行),然后对它的成员进行赋值(第7行到第10行),最后通过将对象的值拷贝到rax和rdx寄存器中作为返回值返回(第11、12行)。在main函数中的第22、23代码,将返回值从rax和rdx寄存器中拷贝到栈空间中,这里没有构造对象,直接采用拷贝的方式拷贝内容,可见在这种情况下编译器是直接拷贝对象内容的方式来返回一个局部对象的。
启用返回值优化的条件和编译器的实现分析

如果Object类中有界说了一个拷贝构造函数,在这种情况下表现行为又是怎样的?在上面从C++代码中加入拷贝构造函数:
  1. Object(const Object& rhs) {
  2.     printf("Copy constructor\n");
  3.     memcpy(this, &rhs, sizeof(Object));
  4. }
复制代码
编译运行,输出结果如下:
  1. Default constructor
  2. 1, 2, 3, 4
复制代码
神奇的是拷贝构造函数被没有如预期地被调用,甚至查看汇编代码都没有生成拷贝构造函数的代码(由于没有调用,编译器优化掉了)。我们再来看看foo和main函数的汇编代码,看看和上面的汇编代码有什么区别。
  1. foo():                           # @foo()
  2.     push    rbp
  3.     mov     rbp, rsp
  4.     sub     rsp, 32
  5.     mov     qword ptr [rbp - 24], rdi       # 8-byte Spill
  6.     mov     rax, rdi
  7.     mov     qword ptr [rbp - 16], rax       # 8-byte Spill
  8.     mov     qword ptr [rbp - 8], rdi
  9.     call    Object::Object() [base object constructor]
  10.     mov     rdi, qword ptr [rbp - 24]       # 8-byte Reload
  11.     mov     rax, qword ptr [rbp - 16]       # 8-byte Reload
  12.     mov     dword ptr [rdi], 1
  13.     mov     dword ptr [rdi + 4], 2
  14.     mov     dword ptr [rdi + 8], 3
  15.     mov     dword ptr [rdi + 12], 4
  16.     add     rsp, 32
  17.     pop     rbp
  18.     ret
  19. main:                                                                                                                        # @main
  20.     push    rbp
  21.     mov     rbp, rsp
  22.     sub     rsp, 32
  23.     mov     dword ptr [rbp - 4], 0
  24.     lea     rdi, [rbp - 24]
  25.     call    foo()
  26.     mov     esi, dword ptr [rbp - 24]
  27.     mov     edx, dword ptr [rbp - 20]
  28.     mov     ecx, dword ptr [rbp - 16]
  29.     mov     r8d, dword ptr [rbp - 12]
  30.     lea     rdi, [rip + .L.str]
  31.     mov     al, 0
  32.     call    printf@PLT
  33.     xor     eax, eax
  34.     add     rsp, 32
  35.     pop     rbp
  36.     ret
复制代码
从汇编代码中看到,foo函数内部中不再构造一个局部对象然后初始化后再将这个对象拷贝返回,而是传递了一个对象的地址给foo函数(第24、25行),foo函数对传递过来的这个对象进行构造(第5到第9行),然后对对象的成员进行赋值(第12到15行),foo函数结束之后,在main函数中就可以直接使用这个被构造和赋值后的对象了,第26到29行就是取各成员的值然后调用printf函数打印出来。也就是说原先的代码被编译器改写了,如下面的伪代码所示:
  1. Object obj = foo();
  2. // 将被改成:
  3. Object obj;        // 这里不需要调用默认构造函数
  4. foo(obj);
  5. // 相应地foo函数将被改写定义:
  6. void foo(Object& obj) {
  7.     obj.Object::Object();        // 调用Object的默认构造函数
  8.     obj.a = 1;
  9.     obj.b = 2;
  10.     obj.c = 3;
  11.     obj.d = 4;
  12.     return;
  13. }
复制代码
看起来像是拷贝构造函数的加入激活了编译器NRV(Named Return Value)优化,为什么有拷贝构造函数的存在就会触发NRV优化呢?原因就是既然程序中界说了拷贝构造函数,根据我们之前的分析,说明是要处理拷贝大块的内存空间等之类的操纵,不仅仅是平凡的数据成员的拷贝,如果只是拷贝数据成员可以不必界说拷贝构造函数,编译器会采用更高效的逐成员拷贝的方法,编译器内部就可以帮程序员做好了,所以有拷贝构造函数的存在就说明有需要低效的拷贝动作,那么就要想办法消撤除拷贝的操纵,那么启用NRV优化就是一项进步效率的做法了。
那么是不是只有存在拷贝构造函数编译器才会启用NRV优化呢?我们继续来修改代码,类中加入一个大数组,同时把拷贝构造函数去掉:
  1. class Object {
  2. public:
  3.     Object() {
  4.         printf("Default constructor\n");
  5.         a = b = c = d = 0;
  6.     }
  7.     int a;
  8.     int b;
  9.     int c;
  10.     int d;
  11.     int buf[100];
  12. };
复制代码
这样修改之后的汇编代码跟之前的基本一样(汇编代码跟上面基本一样就没贴了),有区别的地方就是对象占用的内存空间变大了,这说明没有界说拷贝构造函数的情况下编译器也有可能启用了NRV优化,在对象占用的内存空间较大的时间,这时不再适合使用寄存器来传送对象的内容了,如果采用栈空间来返回结果的话,会涉及到内存的拷贝,效率较低,所以启用NRV优化则有效率上的提拔。
启用返回值优化后的效率提拔

那么启用NRV优化与不启用优化,两者之间的效率对比毕竟差了多少?我们还是以上面的例子来测试,默认情况下编译器是开启了这个优化的,如果想要禁用这个优化,可以在编译时加入-fno-elide-constructors选项关闭它。为了不影响效率,把打印都去掉,在main函数中加入时间计时,下面是完整的代码:
  1. #include <cstdio>
  2. #include<chrono>
  3. using namespace std::chrono;
  4. class Object {
  5. public:
  6.     Object() {}
  7.     int a;
  8.     int b;
  9.     int c;
  10.     int d;
  11.     int buf[100];
  12. };
  13. Object foo(int i) {
  14.     Object p;
  15.     p.a = 1;
  16.     p.b = 2;
  17.     p.c = 3;
  18.     p.d = 4;
  19.     p.buf[0] = i;
  20.     p.buf[99] = i;
  21.     return p;
  22. }
  23. int main() {
  24.     auto start = system_clock::now();
  25.     for (auto i = 0; i < 10000000; ++i) {
  26.         Object a = foo(i);
  27.     }
  28.     auto end = system_clock::now();
  29.     auto duration = duration_cast<milliseconds>(end-start);
  30.     printf("spend %lldms\n", duration.count());
  31.     return 0;
  32. }
复制代码
下面是在我的Apple M1机器上的测试结果,每种情况都是取测试10次然后取平均值。
启用NRV优化未启用NRV优化56.3ms186.7未优化的时间多花了130.4ms,时间上是启用优化后的时间的3倍多。
返回值优化的缺点

从测试结果来看,NRV优化看起来很美好,那么NRV优化是否统统都完美无缺呢?其实NRV优化也存在一些不足大概说不尽如人意的地方:
总之,需要做到对编译器背后的行为有深入的理解,就能做到胸有定见,写出既高效又安全的代码。
如果您感爱好这方面的内容,请在微信上搜索公众号iShare爱分享并关注,以便在内容更新时直接向您推送。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4