【C++11】move建構函式和std::move
如果說新的語言特性使得過去的最佳實踐不再成立的話,我想move建構函式和std::move所代表的move語義應該算其中一個。
在解釋move引起的變化之前,這裡先定義一個支援自定義move操作的類
class Foo { public: explicit Foo(int value) : value_{value} { std::cout << "Foo(int)\n"; } // copy Foo(const Foo &foo) : value_{foo.value_} { std::cout << "Foo(copy)\n"; } // copy assignment Foo &operator=(const Foo &foo) = delete; // move Foo(Foo &&foo) { std::cout << "Foo(move)\n"; value_ = foo.value_; foo.value_ = 0; } // move assignment Foo &operator=(Foo &&foo) { std::cout << "Foo(move assignment)\n"; value_ = foo.value_; foo.value_ = 0; return *this; } int value() const { return value_; } void set_value(int value) { value_ = value; } ~Foo() { std::cout << "~Foo(" << value_ << ")\n"; } friend std::ostream &operator<<(std::ostream &os, const Foo &foo) { os << "Foo(" << foo.value_ << ")"; return os; } private: int value_; };
注意建構函式 Foo(Foo&& foo) ,和一般的reference的標記 & 不同,這裡有兩個 & 符號。其次,引數沒有加const。
move建構函式要做的事情,是把輸入引數所擁有的內容移動到自己的例項中,類似資料所有權轉移。具體來說
Foo f1{1}; Foo f2 = std::move(f1}; // f1 becomes Foo{0} // f2 becomes Foo{1}
上述程式碼中,f1的資料被轉移到了f2中,f1中的資料不再可用(這裡被置為0)。
這樣做有什麼用呢?第一個想到的就是程式碼中資料所有權的轉移。
在給出所有權轉移的例子之前,複習一下C++中的copy和borrow。
void with_operation1(Foo foo) { } void with_operation2(Foo &foo) { std::cout << foo.value() << std::endl; foo.set_value(2); } void with_operation3(const Foo &foo) { std::cout << foo.value() << std::endl; // foo.set_value(2); } int main() { Foo foo{1}; // copy with_operation1(foo); // borrow with_operation2(foo); // borrow with_operation3(foo); return 0; }
with_operation1中Foo會被複制,對於其他語言背景的人來說,這是必須瞭解和注意的。
with_operation2和with_operation3中Foo不會被複制,根據是否有const來決定是否可以修改引數中的Foo。
事情看起來很完美?似乎沒有引入move語義的必要?
考慮一個FooWrapper
class FooWrapper { public: explicit FooWrapper(Foo&& foo): foo_{std::move(foo)} {} private: Foo foo_; }; int main() { Foo foo{1}; FooWrapper wrapper{std::move(foo)}; // wrapper.foo_ moved here return 0; }
在構造了Foo之後,需要把所有權轉給FooWrapper。
在沒有move之前,你可能會考慮指標。但是有了move之後,你可以通過std::move間接轉移資料內容達到所有權轉移的效果。轉移之後main函式內棧上構造的Foo也可以安全銷燬。
如果你執行上述程式,可以得到以下結果
Foo(int) Foo(move) ~Foo(1) ~Foo(0)
也就說,FooWrapper中的foo_和main函式中的foo是兩個不同的變數,std::move觸發了資料轉移,間接達到了所有權轉移。
這裡為什麼一直強調是資料所有權的轉移呢?原因是變數本身沒有被move,這點很重要。所以變數的解構函式仍舊會被呼叫。假如你的變數中包含指標的話,不能簡單地複製一下就結束,需要置原變數的指標為nullptr。
舉個例子
class IntPointerHolder { public: explicit IntPointerHolder(int *ip) : ip_{ip} {} // no copy IntPointerHolder(const IntPointerHolder &) = delete; // no copy assignment IntPointerHolder &operator=(const IntPointerHolder &) = delete; // move IntPointerHolder(IntPointerHolder &&holder) { ip_ = holder.ip_; holder.ip_ = nullptr; } // no move assignment IntPointerHolder &operator=(IntPointerHolder &&holder) = delete; ~IntPointerHolder() { delete ip_; } private: int *ip_; };
注意move建構函式裡設定holder.ip_為nullptr的地方。這是必須做的,否則兩個變數(原變數和目標變數)的解構函式都會被呼叫,造成double free問題。
上述程式碼其實在標準庫中對應有一個unique_ptr(C++11引入),可以達到完全一樣的效果。unique_ptr不支援copy,只支援move,可以幫助你寫出所有權唯一的程式碼。比如說
class SimpleIntArray { public: explicit SimpleIntArray(size_t length): array_{new int[length]}, length_{length} { for(int i = 0; i < length; ++i) { array_[i] = 0; } } void debug() { std::cout << length_ << ' '; if(array_) { for(int i = 0; i < length_; i++) { std::cout << array_[i] << ' '; } } std::cout << std::endl; } private: std::unique_ptr<int[]> array_; size_t length_; }; int main() { SimpleIntArray array1{3}; array1.debug(); SimpleIntArray array2 = std::move(array1); array1.debug(); array2.debug(); return 0; }
輸出結果為
注意程式碼中沒有定義move建構函式,編譯器預設生成的move建構函式中會逐個move成員變數,不支援move操作的成員變數會回退到copy操作。
可以想到,如果SimpleIntArray直接操作指標,並且要處理資料所有權轉移的話會是一件比較麻煩的事情。相比之下這裡通過unique_ptr非常簡單地實現了資料轉移和指標管理。
這裡注意一點,從輸出來看,預設生成的move建構函式在處理length_的轉移時,並沒有置為0。嚴格來說,move之後的原資料,內部狀態如何是不確定的。所以理論上不應該去呼叫。硬要解決的話,這裡可以自定義實現move建構函式,寫一個直接move的length型別或者編碼實踐要求。
回到之前FooWrapper的程式碼,可以看到有兩個std::move,如果問是否可以改成一個,結論是可以,不過個人不推薦。這裡重要的是理解std::move做了什麼,為什麼需要std::move。
class FooWrapper { public: explicit FooWrapper(Foo&& foo): foo_{std::move(foo)} {} private: Foo foo_; }; int main() { Foo foo{1}; FooWrapper wrapper{std::move(foo)}; // wrapper.foo_ moved here return 0; }
FooWrapper wrapper{std::move(foo)}; 這行程式碼中,std::move是因為FooWrapper的建構函式的引數是 Foo&& ,換句話說要求是一個rvalue。這裡不具體展開什麼是rvalue。只要知道對於在棧上構造的foo來說,必須通過std::move轉換為FooWrapper需要的Foo&&。
作為參考,臨時的Foo可以不使用std::move
FooWrapper wrapper{Foo{1}};
可以看到,臨時變數的情況下只有一個FooWrapper內部的std::move(具體編碼中,如果不確定是否需要std::move,可以先不加,看編譯器是否報錯)。
因為外層的std::move只是起到轉換為rvalue的作用,所以理論上不會觸發move建構函式。事實上也是這樣的,實際觸發move建構函式的是 foo_{std::move(foo)} 這句。注意,這裡如果不加 std::move,呼叫的會是copy建構函式。
小結一下
- Wrapper的建構函式中使用std::move
- 呼叫Wrapper建構函式的地方看情況使用std::move,比如棧上分配的變數
老實說,要完全講清楚什麼時候用std::move必須完全理解rvalue,但是看cppreference 上的定義一頭霧水。所以個人覺得,常見pattern+自己試錯可能是最好的。
C++11引入的move語義很重要的一個原因,個人認為是標準庫增加了對於move的支援。你想利用好新版本的功能,而不是固守舊版本的最佳實踐的話,有必要了解move帶來的影響。本篇的最後,分析一下對於常規的函式輸入和輸出的影響。
首先是返回值
Foo makeFoo() { return Foo{1}; } int main() { // Foo(int) // copy/move is omitted Foo foo = makeFoo(); return 0; }
你可能沒有看到std::move也沒有看到move建構函式被呼叫,原因是編譯器的“建構函式消除”優化啟用了。如果你關閉了這個優化,可以看到move建構函式被呼叫。假如你禁用move建構函式的話,copy建構函式被呼叫。
順便說一句,C++中的函式的返回值是否可以是一個物件?個人覺得,對於一個類似factory一樣返回函式內棧上分配的物件的函式的話,由於“建構函式消除”優化的關係,和通過方法傳入其實沒有太大區別。即使沒有“建構函式優化”,預設使用move而不是copy。但是如果你說的是異常處理,並且不想用C++預設的exception機制的話,那就是另外一回事情了。
回到move語義對函式的影響,之前的with_operation系列其實還有move版本
void with_operation4(Foo&& foo) { } void with_operation5(Foo&& foo) { foo.set_value(2); Foo foo2 = std::move(foo); std::cout << foo2 << std::endl; } int main() { Foo f1{1}; Foo f2 = std::move(f1); // f1 become Foo(0) now // with_operation4(f2); with_operation4(std::move(f2)); with_operation5(std::move(f2)); return 0; }
注意with_operation4雖然要求輸入是Foo&&,但是函式內部沒有通過std::move轉移資料,所以main函式中的f2沒有任何變化。相對的,with_operation5中轉移了資料,所以f2資料不再有效。
考慮一個問題,假如你希望某個函式接管某個變數的資料所有權,該怎麼定義函式?
答案其實很明顯,上面的幾段程式碼中都出現了,使用 T&& 這種形式
void some_function(Foo&& foo) { Foo bar = std::move(foo); } int main() { Foo foo{1}; some_function(std::move(foo)); some_function(Foo{2}); }
這裡 some_function(Foo&) 肯定不行,對於 some_function(Foo{2}) 是無法編譯通過的。
some_function(Foo)可以編譯通過,但是 some_function(foo) 是複製, some_function(std::move(foo)) 呼叫時觸發一次move,some_function中又move,結果有兩次move。
綜上所述,對於有資料轉移要求的函式,使用 T&& 這種形式。
最後一個問題,對於builder這種類(Builder設計模式),如何定義build方法?
builder這種類,很典型的從類中向外資料轉移。在瞭解了move對於返回值的影響之後,具體可以怎麼寫呢?
class FooBuilder { public: explicit FooBuilder(int i): foo_{i} {} Foo build() { return std::move(foo_); } Foo& build2() { return foo_; } Foo build3() { return foo_; } Foo&& build4() { return std::move(foo_); } private: Foo foo_; }; int main() { FooBuilder builder1{1}; builder1.build().set_value(-1); // move, ok FooBuilder builder2{2}; Foo foo2 = builder2.build(); // move, ok FooBuilder builder3{3}; Foo foo3 = builder3.build2(); // copy FooBuilder builder4{4}; Foo foo4 = builder4.build3(); // copy FooBuilder builder5{5}; builder5.build4().set_value(-1); // not moved here Foo foo5 = builder5.build4(); return 0; }
個人在嘗試了4種情況後,認為第一種即返回值是Foo,內部用std::move的方式 最好。
build2結果是引用,除了賦值時會copy之外,還存在可以通過build2修改內部foo_的問題。
build3純粹是copy。
build4在賦值時move,但是存在通過build4修改內部foo_的問題。
最後build在賦值時move,不存在通過build修改內部foo_的問題。
總結
C++11引入的move語義帶來很多變化,個人認為理解move語義對於寫好C++11的程式碼很重要。希望我的分析對各位有用。