右值參考、移動語意與完美轉發練習題 (Practice - Rvalue References, Move Semantics, and Perfect Forwarding)
Related Concepts
- 06-Move-Semantics-And-Perfect-Forwarding/01-Std-Move-And-Std-Forward
- 06-Move-Semantics-And-Perfect-Forwarding/02-Universal-References
- 06-Move-Semantics-And-Perfect-Forwarding/03-Move-Vs-Forward-Usage
- 06-Move-Semantics-And-Perfect-Forwarding/04-Overloading-On-Universal-References
- 06-Move-Semantics-And-Perfect-Forwarding/05-Alternatives-To-Overloading
- 06-Move-Semantics-And-Perfect-Forwarding/06-Reference-Collapsing
- 06-Move-Semantics-And-Perfect-Forwarding/07-Move-Operation-Realities
- 06-Move-Semantics-And-Perfect-Forwarding/08-Perfect-Forwarding-Failure-Cases
| 關鍵字 | 答案 |
|---|---|
std::move 在執行期做什麼 |
什麼都不做——純編譯期無條件 rvalue cast |
std::forward 何時轉型 |
只有原始引數是 rvalue 時才轉成 rvalue(條件式 cast) |
const 物件 + std::move |
move 請求靜默退化成 copy(const rvalue 綁不上 T&&) |
| universal reference 成立條件 | 型別推導 + 形式恰好 T&&(const T&&、vector<T>&& 都不算) |
| rvalue ref vs universal ref 該套什麼 | rvalue reference → std::move;universal reference → std::forward |
return std::move(區域變數) |
破壞 RVO,反而更慢——不要寫 |
| 對 universal reference 重載 | 幾乎總是錯——它是 C++ 最貪婪的函式,exact match 吸走呼叫 |
| 需要轉發 + 重載(非 ctor / ctor) | tag dispatch / std::enable_if 約束模板 |
| reference collapsing 口訣 | 任一方是 & → &;只有 && + && → && |
| move 悲觀三假設 | not present、not cheap(std::array、SSO)、not used(無 noexcept) |
| 完美轉發五大失敗案例 | braced initializer、0/NULL、僅宣告的整數 static const 成員、多載函式名/模板名、bitfield |
Question 1 - std::move 的本質 [recall]
同事在 code review 說:「這裡呼叫
std::move,執行期會把資料搬到新物件。」請指出這句話的錯誤,並說明std::move與std::forward各自真正做的事。
std::move 不搬移任何東西:它只是把引數無條件 static_cast 成 rvalue(更貼切的名字是 rvalue_cast);std::forward 則是條件式 cast——只有當引數當初以 rvalue 初始化時才轉成 rvalue。
兩者在執行期都不做任何事,不產生任何可執行碼;真正的搬移由之後被選中的 move 建構子/move 賦值運算子完成。
Question 2 - const 成員的 move 陷阱 [application]
你寫了
explicit Annotation(const std::string text) : valuemove(text) {},程式編譯、執行都正常,但效能量測顯示成員初始化仍是複製。為什麼?該怎麼修正?
std::move(text) 的結果是 const std::string 型別的 rvalue:它無法綁定 move ctor 的 string&&(non-const),卻能綁定 copy ctor 的 const string&(lvalue-reference-to-const 可綁 const rvalue),因此靜默呼叫 copy 建構子。
這是刻意設計:move 會修改來源物件,語言不允許 const 物件被傳給可能修改它的函式(const-correctness)。
修正:把參數的 const 拿掉(explicit Annotationstring text)——想被搬移的物件不要宣告成 const。
Question 3 - universal reference 判別 [recall]
請說明一個
type&&宣告要成為 universal reference 必須同時滿足的兩個條件,並各舉一個「看似T&&卻是 rvalue reference」的反例。
兩個條件缺一不可:(1) 發生型別推導(函式模板參數或 auto&&);(2) 宣告形式恰好是 T&&(type&&,不能有任何額外修飾)。
反例:void f(Widget&& param) 無型別推導 → rvalue reference;template<typename T> void fvector<T>&& param) 形式不是精確 T&& → rvalue reference;template<typename T> void f(const T&& param 多了 const → rvalue reference。
universal reference 的最終身分由初始器決定:lvalue 初始化 → 表現為 lvalue reference;rvalue 初始化 → 表現為 rvalue reference。
Question 4 - push_back vs emplace_back [recall]
std::vector的push_back(T&& x)與emplace_back(Args&&... args)都寫在模板類別裡且形式都是type&&,為何前者是 rvalue reference、後者是 universal reference?
關鍵在呼叫時是否發生型別推導。push_back 的 T 在 vector<Widget> 實體化時早已固定(實體化後簽名就是 push_back(Widget&&)),呼叫時無推導 → rvalue reference。
emplace_back 的 Args 是獨立於 T 的模板參數包,每次呼叫都要重新推導 → universal reference。
教訓:「身處模板中」不等於「有型別推導」;名稱也不必是 T,重點在形式與推導。
Question 5 - move 與 forward 的分工 [recall]
寫一個類別同時有
Widget(Widget&& rhs)與template<typename T> void setName(T&& newName),轉發它們的參數時分別該用std::move還是std::forward?若同一參考在函式內要使用多次呢?
rhs 是 rvalue reference——一定綁到可移動物件,無條件用 std::move;newName 是 universal reference——可能綁到 lvalue,必須用有條件轉型的 std::forward<T>。
對 universal reference 用 std::move 是大忌:呼叫端傳入區域變數(lvalue)會被搬空、值變 unspecified。
多次使用時,只對最後一次使用套 std::move / std::forward,確保物件在用完前不被搬走(罕見情境需改用 std::move_if_noexcept,見 Item 14)。
Question 6 - return std::move 與 RVO [analysis]
為了「幫編譯器最佳化」,同事把
Widget makeWidget() { Widget w; ...; return w; }改成return std::move(w);。請分析這為何反而更糟:RVO 的兩個成立條件是什麼?編譯器若不做 copy elision,標準又規定了什麼?
RVO 成立的兩個條件:(1) 區域物件的型別與函式回傳型別相同;(2) return 的就是該區域物件本身。
return std::move(w) 回傳的是參考(cast 的結果)而非區域物件本身,違反條件 (2) → 排除 RVO,強制執行一次 move;原寫法 return w 則可能零 copy 零 move。
而且標準規定:RVO 條件成立但編譯器不做 copy elision 時,必須把回傳物件隱含視為 rvalue(等同自動套 std::move)——手寫 std::move 完全沒有好處,只會限制編譯器的最佳化選項。by-value 參數雖不適用 RVO,回傳時也會被隱含 move,同樣不要手寫。
對照:回傳綁定到 rvalue/universal reference 參數的物件時(如 operator+(Matrix&& lhs, ...)),才應該在 return 中套 std::move / std::forward。
Question 7 - 最貪婪的函式 [recall]
為什麼說「接受 universal reference 的函式是 C++ 中最貪婪的函式」?這對「把 universal reference 函式與其他函式放在同一組重載」有什麼影響?
universal reference 模板能對幾乎任何型別的引數實體化出 exact match,而重載決議中 exact match 勝過需要 promotion 或轉換的匹配,因此它會吸走遠多於預期的呼叫。
典型症狀:T&& 版與 int 版並存時,傳入 short/std::size_t 等會走模板版(exact match 勝過 short→int promotion),然後在深處爆出難懂的編譯錯誤。
結論(Item 26):避免對 universal reference 重載;「幾乎任何型別」的少數例外是 Item 30 的轉發失敗案例。
Question 8 - 消失的 copy 建構子 [application]
Person有 perfect-forwarding ctortemplate<typename T> explicit Person(T&& n)與編譯器生成的 copy/move ctor。Person p("Nancy"); auto cloneOfP(p);竟然編譯失敗;但改成const Person cp("Nancy"); auto cloneOfP(cp);又成功。請解釋兩者差異。
p 是 non-const lvalue:模板可實體化出 Person(Person&),是 exact match;copy ctor 需要幫 p 加上 const 才能匹配 → forwarding ctor 勝出,接著嘗試用 Person 初始化 std::string 成員 → 編譯錯誤。
cp 是 const lvalue:copy ctor Person(const Person&) 是 exact match,模板也能實體化出相同簽名,兩者平手——此時規則是 normal function 優先於模板實體化 → 呼叫 copy ctor,成功。
延伸:derived class(如 SpecialPerson)以慣用寫法實作 copy/move ctor 時,傳給 base 的引數型別是 SpecialPerson,會被 base 的 forwarding ctor 攔截(hijack),同樣編譯失敗。
Question 9 - 五種替代方案 [recall]
Item 27 提供哪五種「在 universal reference 上重載」的替代方案?哪些支援 perfect forwarding?哪個是 constructor 情境的唯一可行解?
五種方案:(1) 放棄重載(改函式名);(2) pass by const T&;(3) pass by value;(4) tag dispatch;(5) std::enable_if 約束模板。
前三招為參數指定具體型別、不支援 perfect forwarding;後兩招保留 universal reference、支援 perfect forwarding。
constructor 情境:改名不可行(ctor 名稱固定)、tag dispatch 會被編譯器生成的 copy/move ctor 繞過,因此需要轉發時唯一可行解是 std::enable_if(背後機制是 SFINAE);不需要轉發時 pass by value 也是好選擇。
Question 10 - enable_if 條件演進 [analysis]
為
Person的 perfect-forwarding ctor 撰寫std::enable_if條件時,為何!std::is_same<Person, T>不夠?為何加了std::decay_t還不夠、最後要用std::is_base_of?完整版還要再排除什麼?
第一層問題:以 lvalue 的 Person 呼叫時,T 被推導為 Person&,is_same<Person, Person&> 為 false → 條件擋不住。因此要先 std::decay_t<T> 剝除 reference 與 cv 限定再比較。
第二層問題:SpecialPerson 的 copy/move ctor 把 SpecialPerson 引數傳給 base,decay 後仍不是 Person → forwarding ctor 又被啟用且 exact match 勝過 derived-to-base 轉換。因此把 is_same 換成 std::is_base_of(is_base_of<T, T> 為 true,自身與衍生類一併排除)。
完整版還要 && !std::is_integral<std::remove_reference_t<T>>::value,讓整數引數改走 Person(int) 重載;並可加 static_assertstring, T>::value, ... 改善錯誤訊息(注意 std::decay 還會把陣列/函式型別退化為指標)。
Question 11 - reference collapsing 規則 [recall]
使用者能否宣告 reference to reference?請寫出 reference collapsing 的塌縮規則,並列出它發生的四種 context。
使用者不能宣告(auto& & rx = x; 是編譯錯誤);只有編譯器能在特定 context 產生 reference to reference 並塌縮。
規則:任一方是 lvalue reference(&)→ 結果是 lvalue reference;只有 && + && → rvalue reference。
四種 context:(1) template instantiation;(2) auto 型別生成;(3) typedef / alias declaration 的建立與使用;(4) decltype。
這也是 std::forward 的原理:lvalue 使 T 推導為 Widget&,static_cast<T&&> 塌縮成 Widget&(不轉型);rvalue 使 T = Widget,cast 成 Widget&&(轉成 rvalue)。
Question 12 - move 的悲觀假設 [recall]
Item 29 要你假設 move 操作「不存在、不便宜、不會被使用」。請各舉一個具體情境(提示:C++98 舊型別、
std::array/ SSO、noexcept),並說明何時可以不必悲觀。
不存在:未為 C++11 改寫的型別沒有 move;宣告了 copy 操作或解構子的 class 也不會自動生成 move(Item 17)——std::move 之後默默呼叫 copy,無警告。
不便宜:std::array 資料存在物件內,move 是線性時間 O(n)(對比 std::vector 搬指標 O(1));SSO 之下的短 std::string 存在內部緩衝區,move 不比 copy 快。
不會被使用:需要 strong exception safety guarantee 的操作(如 vector 擴容),move 未宣告 noexcept 時編譯器被迫用 copy。
另外 move 來源幾乎必須是 rvalue。但型別已知且特性穩定時不必假設——直接查證該型別的 move 支援即可放心依賴。
Question 13 - 轉發失敗五大案例 [recall]
請列出完美轉發的五大失敗案例,並說明「轉發失敗」的統一定義與兩種根本模式。
統一定義:f(expr) 做一件事、fwd(expr) 做另一件事,即轉發失敗。兩種根本模式:(1) 模板型別推導失敗(編譯錯誤);(2) 推導出「錯誤」型別(實體化失敗,或呼叫到與直接呼叫不同的多載)。
五大案例:braced initializers(non-deduced context)、0/NULL 當空指標(推導成 int)、僅宣告的整數 static const 資料成員(無定義 → 連結失敗)、多載函式名與模板名(名稱無型別可推導)、bitfields(非 const reference 不得繫結)。
emplace 系列與 make_shared / make_unique 都是 variadic 完美轉發函式,全數繼承這些失敗案例。
Question 14 - 轉發失敗的修復 [application]
void f(const std::vector<int>& v);直接呼叫f({1,2,3})成功,但fwd({1,2,3})編譯失敗;fwdMinVals(僅宣告的static const std::size_t MinVals = 28;)則編譯成功卻連結失敗。請分別解釋原因並給出修復方式。
braced initializer:直接呼叫時編譯器看得到 f 的參數型別,可把 {1,2,3} 隱式轉換成暫時 std::vector<int>;經 fwd 則需先對引數推導型別,而 braced initializer 傳給非 std::initializer_list 模板參數屬 non-deduced context,禁止推導。修復:auto il = {1, 2, 3};(auto 推導為 std::initializer_list<int>)再 fwd(il)。
static const 成員:編譯器對其做 const propagation,宣告即可用值;但 fwd 的參數是 universal reference,傳 reference 形同取址,必須有真實記憶體 → 缺定義即連結失敗(部分實作不強制,但不可移植)。修復:在實作檔補上 const std::size_t Widget::MinVals;(不重複初始值)。
| 模式 | 核心答案 | 相關題目 |
|---|---|---|
| move/forward 本質 | 都是純編譯期 cast:move 無條件、forward 有條件;執行期零成本 | Q1, Q2 |
| const 陷阱 | const 物件的 move 請求靜默退化成 copy | Q2 |
| universal reference 判別 | 型別推導 + 精確 T&& 形式,缺一即 rvalue reference |
Q3, Q4 |
| move/forward 分工 | rvalue ref → std::move;universal ref → std::forward;最後一次使用才套 |
Q5 |
| RVO 守則 | 符合 RVO 的區域變數絕不套 std::move/std::forward |
Q6 |
| 貪婪重載 | universal reference 重載吸走預期外呼叫;forwarding ctor 劫持 copy/move | Q7, Q8 |
| 替代方案 | 不需轉發:改名/const T&/傳值;需轉發:tag dispatch;ctor:enable_if |
Q9, Q10 |
| reference collapsing | 任一 & 即 &;四種 context;std::forward 的底層機制 |
Q11 |
| move 現實 | not present / not cheap / not used;型別已知可查證 | Q12 |
| 轉發失敗 | 五大案例+各自 workaround(auto il、nullptr、補定義、函式指標、複製 bitfield) |
Q13, Q14 |