C++的返回值優化(RVO,Return Value Optimization)

前言

大家都知道“過早的優化是萬惡之源”這句話,然而我相信其中的大多數人都不知道自己是不是在做過早的優化。我也無法準確的定義什麼叫做“過早的優化”,但我相信這“過早的優化”要麼是得不償失的,要麼乾脆是有害無利的。今天我就想舉個我認爲是“過早的優化”的例子。

從函數返回值

爲了從一個函數得到運行結果,常規的途徑有兩個:通過返回值和通過傳入函數的引用或指針(當然還可以通過全局變量或成員變量,但我覺得這算不上是什麼好主意)。

通過傳給函數一個引用或指針來承載返回值在很多情況下是無可厚非的,畢竟有時函數需要將多個值返回給用戶。除了這種情況之外,我覺得應當儘量做到參數作爲函數輸入,返回值作爲函數輸出(這不是很自然的事情嗎?)。然而,我們總能看到一些“突破常規”的做法:

首先定義Message類:

struct Message
{
    int a;
    int b;
    int c;
    int d;
    int e;
    int f;
};

爲了從某個地方(比如一個隊列)得到一個特定Message對象,有些人喜歡寫一個這樣的getMessage:

void getMessage(Message &msg); // 形式1

雖然只有一個返回值,但仍然是通過傳入函數的引用返回給調用者的。

爲什麼要這樣呢?“嗯,爲了提高性能。你知道,要是這樣定義函數,返回Message對象時必須要構造一個臨時對象,這對性能有影響。”

Message getMessage(); // 形式2

我們先不討論這帶來了多少性能提升,先看看形式1相對形式2帶來了哪些弊端。我認爲有兩點:

1. 可讀性變差

略(我希望你能和我一樣認爲這是顯而易見的)。

2. 將對象的初始化劃分成了兩個步驟

調用形式1時,你必然要這樣:

Message msg;     // S1
getMessage(msg); // S2

這給維護者帶來了犯錯的機會:一些需要在S2語句後面對msg進行的操作有可能會被錯誤的放在S1和S2之間。
如果是形式2,維護者就不可能犯這種錯誤:

Message msg = getMessage();

好,現在我們來看性能,形式2真的相對形式1性能更差嗎?對於下面的代碼:

#include <stdio.h>

struct Message
{
    Message()
    { 
        printf("Message::Message() is called\n"); 
    }
    Message(const Message &)
    {
        printf("Message::Message(const Message &msg) is called\n");
    }
    Message& operator=(const Message &)
    {
        printf("Message::operator=(const Message &) is called\n");
    }
    ~Message()
    {
        printf("Message::~Message() is called\n");
    }
    int a;
    int b;
    int c;
    int d;
    int e;
    int f;
};

Message getMessage()
{
    Message result;
    result.a = 0x11111111;

    return result;
}

int main()
{
    Message msg = getMessage();
    return 0;
}

你認爲運行時會輸出什麼呢?是不是這樣:

Message::Message() is called
Message::Message(const Message &msg) is called
Message::~Message() is called
Message::~Message() is called

其中,第一行是臨時對象result構造時打印,第二行是將臨時對象賦給msg時打印,第三行是臨時對象result析構時打印,第四行是msg析構時打印。

然而使用GCC 7.3.0版本使用O0(即關閉優化)編譯上述代碼後,運行結果爲:

Message::Message() is called
Message::~Message() is called

並沒有像預期的輸出那樣。

如果使用MSVC2017編譯,且關閉優化(/Od),確實可以得到預期輸入,但是一旦打開優化(/O2),輸出就和GCC的一樣了。

我們看看實際上生成了什麼代碼(使用GCC編譯):

(gdb) disassemble main
Dump of assembler code for function main():
   0x0000000000000776 <+0>:	push   %rbp
   0x0000000000000777 <+1>:	mov    %rsp,%rbp
   0x000000000000077a <+4>:	push   %rbx
   0x000000000000077b <+5>:	sub    $0x28,%rsp
   0x000000000000077f <+9>:	mov    %fs:0x28,%rax
   0x0000000000000788 <+18>:	mov    %rax,-0x18(%rbp)
   0x000000000000078c <+22>:	xor    %eax,%eax
   0x000000000000078e <+24>:	lea    -0x30(%rbp),%rax             #將棧上地址-0x30(%rbp)傳給getMessage函數
   0x0000000000000792 <+28>:	mov    %rax,%rdi
   0x0000000000000795 <+31>:	callq  0x72a <getMessage()>
   0x000000000000079a <+36>:	mov    $0x0,%ebx
   0x000000000000079f <+41>:	lea    -0x30(%rbp),%rax
   0x00000000000007a3 <+45>:	mov    %rax,%rdi
   0x00000000000007a6 <+48>:	callq  0x7e4 <Message::~Message()>
   0x00000000000007ab <+53>:	mov    %ebx,%eax
   0x00000000000007ad <+55>:	mov    -0x18(%rbp),%rdx
   0x00000000000007b1 <+59>:	xor    %fs:0x28,%rdx
   0x00000000000007ba <+68>:	je     0x7c1 <main()+75>
   0x00000000000007bc <+70>:	callq  0x5f0 <__stack_chk_fail@plt>
   0x00000000000007c1 <+75>:	add    $0x28,%rsp
   0x00000000000007c5 <+79>:	pop    %rbx
   0x00000000000007c6 <+80>:	pop    %rbp
   0x00000000000007c7 <+81>:	retq   
End of assembler dump.
(gdb) disassemble getMessage 
Dump of assembler code for function getMessage():
   0x000000000000072a <+0>:	push   %rbp
   0x000000000000072b <+1>:	mov    %rsp,%rbp
   0x000000000000072e <+4>:	sub    $0x20,%rsp
   0x0000000000000732 <+8>:	mov    %rdi,-0x18(%rbp)                 #將main函數傳入的棧上地址保存到-0x18(%rbp)處
   0x0000000000000736 <+12>:	mov    %fs:0x28,%rax
   0x000000000000073f <+21>:	mov    %rax,-0x8(%rbp)
   0x0000000000000743 <+25>:	xor    %eax,%eax
   0x0000000000000745 <+27>:	mov    -0x18(%rbp),%rax             #將main函數傳入的棧上地址傳給Message::Message()函數
   0x0000000000000749 <+31>:	mov    %rax,%rdi
   0x000000000000074c <+34>:	callq  0x7c8 <Message::Message()>
   0x0000000000000751 <+39>:	mov    -0x18(%rbp),%rax
   0x0000000000000755 <+43>:	movl   $0x11111111,(%rax)
   0x000000000000075b <+49>:	nop
   0x000000000000075c <+50>:	mov    -0x18(%rbp),%rax
   0x0000000000000760 <+54>:	mov    -0x8(%rbp),%rdx
   0x0000000000000764 <+58>:	xor    %fs:0x28,%rdx
   0x000000000000076d <+67>:	je     0x774 <getMessage()+74>
   0x000000000000076f <+69>:	callq  0x5f0 <__stack_chk_fail@plt>
   0x0000000000000774 <+74>:	leaveq 
   0x0000000000000775 <+75>:	retq   
End of assembler dump.

可以看出來,在getMessage函數中構造的對象實際上位於main函數的棧幀上,並沒有額外構造一個Message對象。這是因爲開啓了所謂的返回值優化(RVO,Return Value Optimization)的緣故。你想得到的效果編譯器已經自動幫你完成了,你不必再犧牲什麼。

RVO

對於我們這些用戶來說,RVO並不是什麼特別複雜的機制,主流的GCC和MSVC均支持,也沒什麼特別需要注意的地方。它存在的目的是優化掉不必要的拷貝複製函數的調用,即使拷貝複製函數有什麼副作用,例如上面代碼中的打印語句,這可能是唯一需要注意的地方了。從上面的彙編代碼中可以看出來,在GCC中,其基本手段是直接將返回的對象構造在調用者棧幀上,這樣調用者就可以直接訪問這個對象而不必複製。

RVO是有限制條件的,在某些情況下無法進行優化,在一篇關於MSVC2005的RVO技術的文章中,提到了3點導致無法優化的情況:

1. 函數拋異常

關於這點,我是有疑問的。文章中說如果函數拋異常,開不開RVO結果都一樣。如果函數拋異常,無法正常的返回,我當然不會要求編譯器去做RVO了。

2. 函數可能返回具有不同變量名的對象

例如:

Message getMessage_NoRVO1(int in)
{
    Message msg1;
    msg1.a = 1;

    Message msg2;
    msg2.a = 2;

    if (in % 2)
    {
        return msg1;
    }
    else
    {
        return msg2;
    }
}

經過驗證,在GCC上確實也是這樣的,拷貝構造函數被調用了。但這種情況在很多時候應該都是可以通過重構避免的。

Message::Message() is called
Message::Message() is called
Message::Message(const Message &msg) is called
Message::~Message() is called
Message::~Message() is called
Message::~Message() is called

3. 函數有多個出口

例如:

Message getMessage_NoRVO2(int in)
{
    Message msg;
    if (in % 2)
    {
        return msg;
    }
    msg.a = 1;
    return msg;
}

這個在GCC上驗證發現RVO仍然生效,查看彙編發現只有一個retq指令,多個出口被優化成一個了。


參考文檔:

https://en.wikipedia.org/wiki/Copy_elision#cite_ref-moreeffcpp_6-1

https://en.cppreference.com/w/cpp/language/copy_elision

https://docs.microsoft.com/en-us/previous-versions/ms364057(v=vs.80)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章