深度解读《深度探索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爱分享并关注,以便在内容更新时直接向您推送。

posted @ 2024-04-19 10:54  iShare_爱分享  阅读(329)  评论(1编辑  收藏  举报