C++移動操作,RVO和NRVO
本文討論了何時C++會自動進行移動操作,並且說明了複製消除,RVO和NRVO優化。
移動操作主要參考了cppreference 的這個說明,
優化部分的主要的參考來自於stack overflow 的這篇文章。
移動操作
移動操作有關的函式
和移動操作相關的類函式有兩個:
移動建構函式:
A(A&& rhs);
移動賦值運算子:
A& operator=(A&& rhs);
注意這兩個函式的引數型別都不是const
,這也是C++預設會生成的函式宣告。
移動建構函式用於在構造型別的時候使用:
A a1; // 使用std::move強制進行移動 A a2 = std::move(a1); 或 A a2(std::move(a1));
而移動賦值運算子就是在賦值的時候進行移動:
A a1;
A a2;
a1 = std::move(a2); // 使用move進行強制移動
何時自動宣告移動建構函式和賦值移動建構函式
隱式的移動建構函式將會在可以被生成且滿足如下所有條件的情況下自動生成:
- 沒有使用者宣告的 複製建構函式
- 沒有使用者宣告的 複製賦值運算子(即
operator=(const A&)
這類) - 沒有使用者宣告的 移動賦值運算子(即
operator=(A&&)
這類) - 沒有使用者宣告的 解構函式
所謂可以被生成的意思是滿足以下所有條件:
- 類中沒有不能移動的非靜態成員
- 繼承時,基類可以被移動
- 繼承時,基類的建構函式可以
而移動賦值運算子的產生條件也差不多,只不過將沒有宣告的 移動賦值建構函式改成沒有使用者宣告 移動建構函式即可。
總之,這兩個函式生成的條件就一句話:除了普通的建構函式外(指預設建構函式和帶其他引數的建構函式),不得宣告任何其他的建構函式,operator=函式和解構函式。
何時自動移動
使用std::move
是一種強制的,顯式的移動。但是C++很多時候為了效率會自動幫我們移動。主要的規則其實就是所有的右值都會進行移動,如果不能移動,進行拷貝。但是為了嚴謹,我們還是擺出cppreference上的規則:
- 初始化的時候使用
std::move()
:T a = std::move(b)
或者T a(std::move(b));
std::move()
,不然會呼叫複製建構函式。 - 函式實參傳遞的時候使用
std::move()
:func(std::move(a))
- 函式返回時,如:
class A {};
A CreateA() {
return A();
}
// call
A a = CreateA();
的時候,使用A()
產生的變數會首先移動到CreateA()
函式產生的返回值中,這個時候這個返回值是一個臨時變數(我們記為temp
),接下來就是執行這段程式碼:A a = temp
,然後temp是臨時變數, 會再次呼叫A
的移動建構函式給a
變數。
前兩個是屬於顯式的移動,最後一種就是隱式移動。移動賦值運算子的規則也是一樣,只有等號右邊是臨時變數就會自動呼叫。
複製消除,RVO和NRVO
雖然C++對移動操作定義的很明確,但編譯器卻並不總是按照這個定義去做。因為編譯器中有三個重要的優化經常會減少拷貝,甚至是移動操作。
在GCC和Clang下可以新增-fno-elide-constructors
選項來關閉這三種優化。
複製消除
來看一看下面程式碼:
class C {
public:
C() {}
C(const C&) { std::cout << "A copy was made.\n"; }
C(C&& rhs) { std::cout << "A move was made.\n"; }
};
C f() {
return C();
}
int main() {
std::cout << "Hello World!\n";
C obj = f();
}
這裡建議在C++17標準下編譯,因為C++17起所有的複製消規則除被寫在語言規範內,大部分編譯器應該都會做這件事。我的Clang++ 12.0.5上的執行結果僅僅是輸出了一行Hello World
:
Hello World!
按照上面的規則,函式在返回的時候會進行移動,也就是說在f()
的呼叫內,會先移動給臨時變數,然後臨時變數再移動給obj
,但是這裡什麼都沒發生,沒有任何的移動和拷貝,obj
就像憑空出現了一樣。
在C++17起,複製消除是強制執行的,而C++11中是看編譯器心情。
在如下條件下會進行復制消除:
- 在return語句中,return的值是和函式返回值型別一樣的右值。型別一樣是為了防止隱式轉換,否則會產生新的變數從而阻止移動,右值是因為C++自動移動只能對右值操作。
- 在變數初始化的時候,初始化表示式是右值。如:
class A{};
A f() { return A(); } // 這裡是第一種情況,會自動複製消除
// call
A a = f(); // 這裡函式返回值的臨時變數到a的過程中的移動也會被消除
這也就解釋了為什麼上面的程式碼沒有呼叫任何的拷貝,移動函數了。
RVO和NRVO
RVO是Return Value Optimization(返回值優化)的簡寫,而NRVO是Named Return Value Optimization(命名返回值優化)的簡寫。這兩個優化是複製消除的常見形式。
通過他們的名字就可以看出,這是在函式返回的時候做的優化。
RVO是指在函式返回一個臨時變數時的優化,具體的優化如下:
// 原本的函式
T CreateT(int value) {
return T(value);
}
T a = CreateT(10);
// 優化後的函式(虛擬碼):
void CreateT(T& v, int value) {
v.T::T(value); // 直接在內部進行構造
}
即通過將要接收函式返回值的物件以引用的形式放入函式內部初始化,這樣就避免了一次移動/拷貝。
而NRVO則是更加寬泛的RVO。對於如下的程式碼可以執行NRVO:
T CreateT(int values) {
T t(value);
return t;
}
編譯器也會優化成上面RVO優化的樣子。