2016-10-01 31 views
2

只有少數文章聲稱通過價值傳遞可以提高性能(如果函數將會複製副本)按參數值傳遞參數時的奇怪行爲

我從來沒有真正想過如何通過價值觀來實現傳值。當你這樣做的時候,棧上會發生什麼:F v = f(g(h()))?

經過一番思考後,我得出結論,我會以這樣的方式實現它,即g()返回的值是在f()期望的位置創建的。所以,基本上,沒有複製/移動構造函數調用 - f()將簡單地獲取g()返回的對象的所有權,並在執行離開f()的作用域時將其銷燬。 g()相同 - 它將獲得h()返回的對象的所有權並在返回時銷燬它。

唉,編譯器似乎不同意。下面是測試代碼:

#include <cstdio> 

using std::printf; 

struct H 
{ 
    H() { printf("H ctor\n"); } 
    ~H() { printf("H dtor\n"); } 
    H(H const&) {} 
// H(H&&) {} 
// H(H const&) = default; 
// H(H&&) = default; 
}; 

H h() { return H(); } 

struct G 
{ 
    G() { printf("G ctor\n"); } 
    ~G() { printf("G dtor\n"); } 
    G(G const&) {} 
// G(G&&) {} 
// G(G const&) = default; 
// G(G&&) = default; 
}; 

G g(H) { return G(); } 

struct F 
{ 
    F() { printf("F ctor\n"); } 
    ~F() { printf("F dtor\n"); } 
}; 

F f(G) { return F(); } 

int main() 
{ 
    F v = f(g(h())); 
    return 0; 
} 

在MSVC 2015年它的產量正是我期望:

H ctor 
G ctor 
H dtor 
F ctor 
G dtor 
F dtor 

但如果你註釋掉的拷貝構造函數,它看起來是這樣的:

H ctor 
G ctor 
H dtor 
F ctor 
G dtor 
G dtor 
H dtor 
F dtor 

我懷疑刪除用戶提供的拷貝構造函數會導致編譯器生成移動構造函數,這反過來會導致不必要的「移動」,無論問題的大小如何,都不會消失(嘗試添加1MB數組作爲成員變量)。即編譯器更喜歡「移動」,以至於它根本沒有做任何事情而選擇它。

這似乎是一個MSVC中的錯誤,但我真的很想有人解釋(和/或證明)這裏發生了什麼。這是問題#1。

現在,如果你嘗試GCC 5.4.0輸出根本就沒有任何意義:

H ctor 
G ctor 
F ctor 
G dtor 
H dtor 
F dtor 

者H已到被創建的F之前被銷燬! H是g()的範圍的局部!請注意,使用構造函數在這裏對GCC沒有影響。

和MSVC一樣 - 看起來像是一個bug,但有人可以解釋/證明這裏發生了什麼嗎?這是問題#2。

真的很愚蠢的是,經過多年的C++專業工作,我遇到了類似這樣的問題......經過近四十年的編譯器仍然不能就如何傳遞值達成一致?

+0

以防萬一 - 我知道RVO是什麼......我非常瞭解C++。但是我找不到這兩個問題的好答案 –

+2

您使用了哪些優化級別? 「編譯器不能同意」,複製構造器ellision是*優化* - 所以不管它是否發生都是一個QOI問題(並且程序的正確性不能依賴於它)。 –

+0

優化lvls不起作用。找不到關於cctor/mctor elision標準的任何內容,這將解釋這裏發生的事情。請注意,我們正在討論將正確類型的* rvalues *作爲參數傳遞給另一個函數 - 我預計這裏沒有任何額外的副本(或移動)... –

回答

0

M.M和艾哈邁德的答案都向我發送正確的方向,但他們都不完全正確。所以,我選擇寫下來下面一個合適的回答...

  • 函數調用,並在C++中返回具有以下語義:作爲函數的參數傳遞
    • 值被複製到功能範圍和功能被調用
    • 返回值被複製到來電者的範圍,被破壞(當我們到達返回完整表達的結束),並執行離開功能範圍

當涉及到在IA-32樣結構變得非常明顯的是,這些拷貝不需要實施這種 - 它是微不足道的堆棧(對於返回值)分配初始化空間和限定在函數調用約定這樣它知道在哪裏構建返回值。

與參數傳遞相同 - 如果我們將rvalue作爲函數參數傳遞,編譯器可以直接創建該右值,這樣它將被創建的權利是(隨後調用的)函數期望它是。

我想這是爲什麼copy elision被引入到標準(並且在C++ 17中被強制使用)的主要原因。

我對複製elision一般很熟悉,以前閱讀this page。不幸的是我錯過了兩件事:

  1. 事實上,這也適用於用rvalue初始化函數參數(C++ 11 12.8。P32):

時)還沒有被綁定到一個參考 (12.2臨時類對象將被複制/移動到具有相同 CV-不合格型,複製/移動一個類對象操作可以通過 直接構建臨時對象到 省略副本的目標可以省略/移動

  • 當複製省略踢它影響在一個非常特殊的方式對象壽命:
  • 當滿足特定條件時,一種實現被允許省略 一個類對象的複製/移動結構,即使在複製/移動 構造和/或析構函數爲對象有​​副作用。在這種情況下,該實現將被省略的複製/移動操作的源和目標視爲簡單地將 引用到同一對象的兩種不同方式,並且當對象的銷燬發生在 時間的晚些時候時這兩個對象在沒有優化的情況下將被銷燬 。複製/移動操作的這個省音,稱爲 複製省略,在下列情況下是允許的(這可以 結合消除多個副本)

    這就解釋了GCC輸出 - 我們通過一些右值成函數,複製elision開始,我們最終得到一個對象通過兩種不同的方式被引用,並且它們的壽命=最長(這是我們F v = ...表達式中的一個臨時生命)。所以,基本上,GCC輸出完全符合標準。

    現在,這也意味着MSVC不符合標準!它成功地應用了兩個副本,但結果對象的生存期太短。

    第二個MSVC輸出符合標準 - 它應用了RVO,但決定不應用copy elision作爲函數參數。我仍然認爲這是MSVC中的一個錯誤,儘管從標準的角度來看代碼是可以的。

    謝謝M.M和Ahmad的推動我朝着正確的方向前進。

    現在關於標準強制實施壽命規則的小吼 - 我認爲它只是用於只有與RVO。

    唉,當應用於函數參數的副本時,它並沒有多大意義。事實上,結合C++ 17強制性複製省略規則它允許瘋狂的這樣的代碼:

    T bar(); 
    T* foo(T a) { return &a; } 
    
    auto v = foo(bar())->my_method(); 
    

    此規則力噸至僅在充分表達的端部被破壞。這段代碼將在C++ 17中變得正確。這是醜陋的,不應該在我看來被允許。另外,你最終會在調用者方(而不是函數內部)銷燬這些對象 - 不必要地增加代碼的大小,並且使給定的函數是否不合適的過程複雜化。

    換句話說,我個人更喜歡MSVC輸出#1(最「自然」)。應該禁止MSVC輸出#2和GCC輸出。我不知道這樣的想法可以出售給C++標準委員會......

    編輯:顯然是C++ 17的壽命的臨時將成爲「未指定」從而使MSVC的行爲。語言中又一個不必要的黑暗角落。他們應該只是強制MSVC的行爲。

    +0

    它是有問題的函數參數的生命週期(不是臨時生命週期),它將在C++ 17(未指定)中實現。你使用'&a'的例子,無論代碼是否正確,它都將被實現。 (所以這個構造不應該出現在可移植的代碼中)。另外,在之前的文章中,你說MSVC不符合標準,但事實上(它在返回時立即破壞參數)。 –

    +0

    @ M.M MSVC case#1在C++ 11中不符合標準(很早就銷燬對象)。 copy-elision的每個定義都包含臨時參數和函數參數。是的,我的'&a'示例將在C++ 17中展示'未指定'行爲(除非他們更改了勘誤表)。 –

    +0

    MSVC案例1是C++ 11中的標準投訴。函數參數不是臨時的。不,你用'&a'的例子不會表現出未指定的行爲。它是實現定義的行爲。 –

    2

    此行爲是由於稱爲copy elision的優化技術。簡而言之,你提到的所有輸出都是有效的!是的!因爲這種技術是(唯一的)允許修改程序的行爲。有關更多信息,請參見What are copy elision and return value optimization?

    4

    對於按值傳遞參數,該參數是該函數的局部變量,並且它是從函數調用的相應參數初始化的。

    按值返回時,有一個值爲返回值。這由return表達式的「參數」初始化。它的生命週期直到包含函數調用的完整表達式結束。

    此外還有一個優化copy elision可以適用於少數情況下。的那些情況下,兩個適用於由值返回:

    • 如果返回值是由相同類型的另一個目的初始化,則相同的存儲器位置可用於這兩個對象,和複製/移動步驟跳過(在允許或不允許的情況下有一些條件)
    • 如果調用代碼使用返回值初始化相同類型的對象,則可以爲返回值和目標對象使用相同的內存位置,並且複製/移動步驟被跳過。 (這裏「相同類型的對象」包括功能參數)。

    這兩種方法都可以同時應用。另外,從C++ 14開始,複製elision對於編譯器是可選的。

    在您的通話f(g(h())),這裏是對象的列表(沒有複製省略):

    1. H默認構造由return H();
    2. H,該返回值的h(),是拷貝構造來自(步驟1)。
    3. ~H(步驟1)
    4. H,參數g,是從(步驟2)複製構建的。
    5. G默認構造由return G();
    6. G,所述返回值的g(),距離(步驟5)拷貝構造。
    7. ~G(步驟5)
    8. ~H(步驟4)(見下文
    9. G,的f的參數,是從(步驟6)拷貝構造。
    10. F默認構造由return F();
    11. F,所述返回值的f(),距離(步驟10)移動構建的。
    12. ~F(步驟10)
    13. ~G(步驟9)(見下文
    14. F v距離(步驟11)移動構建的。
    15. ~F~G~H(步驟2,6,11)被破壞 - 我認爲有三個
    16. ~F(步驟14)

    對於複製省略,步驟的沒有所需排序1+ 2 + 3可以組合成「返回值h()是默認構造的」。對於5 + 6 + 7和10 + 11 + 12也是如此。然而,也可以將2 + 4自己組合成「g的參數是從1複製構建的」,並且還可以同時應用這兩個部分,從而給出「參數g是默認構造的」。

    因爲copy elision是可選的,所以您可能會看到來自不同編譯器的不同結果。這並不意味着有一個編譯器錯誤。你會很高興聽到在C++ 17中有一些複製方案是強制性的。

    如果包含移動構造函數的輸出文本,則在第二個MSVC情況下的輸出將更具啓發性。我猜想在第一個MSVC案例中,它執行了上面提到的兩個同時發生的事件,而第二個案例省略了「2 + 4」和「6 + 9」。

    低於:gcc和clang會延遲函數參數的破壞,直到包含函數調用的全表達式結束。這就是你的gcc輸出與MSVC不同的原因。

    從C++ 17起草過程開始,它是實現定義的這些破壞發生在我在列表中還是在完整表達式結尾處。可以認爲,在早期公佈的標準中沒有充分說明。 See here作進一步討論。

    +0

    不知何故我錯過了你的編輯...無論如何,我不同意「追溯應用」 - 行爲是由C++ 11標準定義的。 MSVC不符合規定。但我很高興標準將被改爲允許MSVC實施 - 正確的舉措。接下來的正確舉措是禁止任何其他的實現(因爲複製elision變成強制性的)。 –

    +0

    @ C.M。缺陷報告被認爲是追溯適用於缺陷被提交的文件。一個更着名的例子是C++ 11文本指定了A a;一個const&b {a};'應該複製構造一個臨時的'b'綁定,而不是直接綁定。 –

    +0

    你從哪裏得到這些信息?我從來沒有聽說過這個 - 是否也適用於C++ 98? –