钜形不锈钢水箱 发表于 2024-5-17 19:09:45

深度解读《深度探索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,
    call    Object::Object()
    mov   dword ptr , 1
    mov   dword ptr , 2
    mov   dword ptr , 3
    mov   dword ptr , 4
    mov   rax, qword ptr
    mov   rdx, qword ptr
    add   rsp, 16
    pop   rbp
    ret
main:                                                                                                                        # @main
    push    rbp
    mov   rbp, rsp
    sub   rsp, 32
    mov   dword ptr , 0
    call    foo()
    mov   qword ptr , rax
    mov   qword ptr , rdx
    mov   esi, dword ptr
    mov   edx, dword ptr
    mov   ecx, dword ptr
    mov   r8d, dword ptr
    lea   rdi,
    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 , rdi       # 8-byte Spill
    mov   rax, rdi
    mov   qword ptr , rax       # 8-byte Spill
    mov   qword ptr , rdi
    call    Object::Object()
    mov   rdi, qword ptr        # 8-byte Reload
    mov   rax, qword ptr        # 8-byte Reload
    mov   dword ptr , 1
    mov   dword ptr , 2
    mov   dword ptr , 3
    mov   dword ptr , 4
    add   rsp, 32
    pop   rbp
    ret
main:                                                                                                                        # @main
    push    rbp
    mov   rbp, rsp
    sub   rsp, 32
    mov   dword ptr , 0
    lea   rdi,
    call    foo()
    mov   esi, dword ptr
    mov   edx, dword ptr
    mov   ecx, dword ptr
    mov   r8d, dword ptr
    lea   rdi,
    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;
};这样修改之后的汇编代码跟之前的基本一样(汇编代码跟上面基本一样就没贴了),有区别的地方就是对象占用的内存空间变大了,这说明没有界说拷贝构造函数的情况下编译器也有可能启用了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;
};

Object foo(int i) {
    Object p;
    p.a = 1;
    p.b = 2;
    p.c = 3;
    p.d = 4;
    p.buf = i;
    p.buf = 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.3ms186.7未优化的时间多花了130.4ms,时间上是启用优化后的时间的3倍多。
返回值优化的缺点

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

[*]是否开启了NRV优化的问题,NRV优化并不是C++尺度中规定的东西,各家编译器的实现未必肯定支持它,大概说启用它的条件和规则也不尽相同,例如clang大概g++,像我上面提到的那两种情况下就会开启优化,微软的Visual Studio编译器则默认不会启用,需要设置优化选项之后才会启用。所以写一些跨平台的代码的时间需要注意一下,做到胸有定见。
[*]未能启用NRV优化的情况,NRV优化并非在所有的情况下、所有的代码中都可以或许启用,可能在某些条件限定下编译器不可以或许启用优化,好比代码逻辑太复杂的情况下。
[*]优化不是预期的需求,优化可能在无声无息中完成了,但是却有可能不是你想要的结果,好比你等待在拷贝构造函数中做一些事情,然后在析构函数中做相反的一些事情,但是拷贝构造函数并未如预期中的被调用了,导致了程序运行的错误。
总之,需要做到对编译器背后的行为有深入的理解,就能做到胸有定见,写出既高效又安全的代码。
如果您感爱好这方面的内容,请在微信上搜索公众号iShare爱分享并关注,以便在内容更新时直接向您推送。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: 深度解读《深度探索C++对象模型》之返回值优化