Visual Studio:優化了復制/移動省略
蝎子
為了能發文,標題中的復制/移動省略是 Copy/Move Elision 的硬翻譯,請各位大大海涵。下文中我會同時使用這兩種術語。
Visual Studio 中 Copy/Move Elision 的變化
在 Visual Studio 2022 版本 17.4 預覽版 3 中,我們顯著增加了適用于Copy/Move Elision 情況的數量,并讓用戶能夠更好地控制是否啟用這些轉換。
Copy/Move Elision 是什么?
當 C++ 函數中的 return 關鍵字后跟非內置類型的表達式時,執行該 return 語句會將表達式的結果復制到調用函數的返回槽(Return Slot)中。為此,將調用非內置類型的復制或移動構造函數。然后,作為退出函數的一部分,將調用函數局部變量的析構函數,可能包括 return 關鍵字后面的表達式中命名的任何變量。
C++ 規范允許編譯器直接在調用函數的返回槽中構造返回的對象,從而省略作為返回的一部分執行的復制或移動構造函數。與大多數其他優化不同,這種轉換允許對程序的輸出產生可觀察的影響 – 即復制或移動構造函數以及關聯的析構函數可以少調用一次。
Visual Studio 中的 Copy/Move Elision
C++ 標準要求在將返回值初始化為 return 語句的一部分時(例如,當返回類型為 Foo 的函數返回返回 Foo()時),編譯器需要執行 Copy/Move Elision。Microsoft Visual C++ 編譯器始終根據需要對返回語句執行 Copy/Move Elision,而不管傳遞給編譯器的標志如何。此行為保持不變。
在 Visual Studio 17.4 預覽版 3 中對可選 Copy/Move Elision 的更改
當返回的值為命名變量時,編譯器可能會省略復制或移動,但不是必需的。C++ 標準仍要求為命名的返回變量定義復制或移動構造函數,即使編譯器在所有情況下都省略了構造函數。在 Visual Studio 2022 版本 17.4 預覽版 3 之前,當禁用優化(例如使用 /Od 編譯器標志或使用了 #pragma optimize(“”,off))時,編譯器將僅執行強制Copy/Move Elision。使用 /O2 標志,編譯器將通過簡單的控制流為優化的函數執行可選的Copy/Move Elision。
從 Visual Studio 2022 版本 17.4 預覽版 3 開始,我們為開發人員提供了與新的 /Zc:nrvo 編譯器標志保持一致的選項。默認情況下,當使用 /O2 標志、/permissive- 編譯代碼時,或者在為 /std:c++20 或更高版本進行編譯時,將傳遞 /Zc:nrvo 標志。通過此標志后,將盡可能執行復制和移動省略。我們希望在將來的版本中默認啟用 /Zc:nrvo。另外,開發者還可以使用 /Zc:nrvo- 標志顯式禁用可選的Copy/Move Elision。請注意,無法禁用強制型的Copy/Move Elision。
在 Visual Studio 2022 版本 17.4 預覽版 3 中,當使用 /Zc:nrvo、/O2、/permissive-或 /std:c++20 或更高版本的標志啟用可選復制/移動省略時,我們還增加了Copy/Move Elision的位置。
可選 Copy/Move Elision 的示例
可選 Copy/Move Elision 的最簡單示例是以下函數:Foo SimpleReturn() {Foo result;return result;}
在這種情況下,如果傳遞了 /O2 標志,則早期版本的 MSVC 編譯器已將結果的復制或移動到返回槽中。在 Visual Studio 2022 版本 17.4 預覽版 3 中,如果傳遞了 /permissive-、/std:c++20 或更高版本或 /Zc:nrvo 標志,也會省略復制或移動,如果傳遞了 /Zc:nrvo- 標志,則保留復制或移動。
從 Visual Studio 2022 版本 17.4 預覽版 3 開始,如果將 /O2、/permissive-、/std:c++20 或更高版本或 /Zc:nrvo 標志傳遞給編譯器,而 /Zc:nrvo- 標志未傳遞到編譯器,我們現在在以下其他情況下執行復制/移動省略。
在循環中返回
Foo ReturnInALoop(int iterations) {for (int i = 0; i < iterations; ++i) {Foo result;if (i == (iterations / 2)) {return result;}}}結果對象將在循環的每次迭代開始時正確構造,并在每次迭代結束時銷毀。在返回結果的迭代中,退出函數時不會調用其析構函數。當返回的對象超出該函數的范圍時,函數的調用方將銷毀該對象。
在異常處理中返回
如果傳遞了 /O2、/permissive-、/std:c++20 或更高版本,或者傳遞了 /Zc:nrvo 標志,而 /Zc:nrvo- 標志未傳遞,則結果對象的復制或移動現在將被省略。我們現在還可以妥善處理更復雜的情況,例如:
結果對象將在調用方函數的返回槽中構造,并且在成功返回時不會為其調用復制/移動構造函數或析構函數。引發異常時,是否析構結果對象取決于向編譯器傳遞哪些異常處理標志。默認情況下,不會發生堆棧展開,因此不會調用析構函數。但是,如果使用 /EHs、/EHa 或 /EHr 標志啟用了堆棧展開異常處理,則 goto Label1 將導致調用結果的析構函數,因為它跳轉到初始化結果之前。無論哪種方式,當再次到達表達式 Foo 結果時,將在返回槽中再次構造對象。
復制具有默認參數的構造函數
現在,我們可以正確檢測到具有默認參數的復制或移動構造函數仍然是復制或移動構造函數,因此可以在上述情況下被省略。具有默認參數的復制構造函數如下所示:structStructWithCopyConstructorDefaultParam {int X;
對NRVO的限制
盡管 MSVC 編譯器現在在更多情況下執行Copy/Move Elision,但并不總是能夠執行它。若要了解為什么會這樣,請考慮以下函數:
復制省略構造要在返回槽中返回的對象,但在這種情況下,應在返回槽中構造哪個對象?為了在返回結果A時省略結果A的副本,必須在返回槽中構造它。但是,如果條件為真,則需要在銷毀結果 A 之前在返回槽中構造結果 B。無法對兩個路徑執行復制省略。
我們目前選擇避免在函數中的所有路徑上執行可選的Copy/Move Elision,如果在任何路徑上它是不可能的的話。但是,對內聯決策、死代碼消除和其他優化的更改可能會更改Copy/Move Elision的可能性。因此,編寫依賴于命名變量的Copy/Move Elision的某些行為的代碼是不安全的,除非使用 /Zc:nrvo- 禁用了所有可選的Copy/Move Elision。
只要啟用了堆棧展開異常處理或未引發異常,仍然可以安全地假定每個構造函數調用都有匹配的析構函數調用。
總結
寫著舊時代的 C++,一直都為如何高性能地返回一個對象發愁。沒錯,正是在下。