深度解讀《深度探索C++物件模型》之返回值最佳化

iShare_爱分享發表於2024-04-19

接下來我將持續更新“深度解讀《深度探索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愛分享並關注,以便在內容更新時直接向您推送。

相關文章