移動語意是 C++11 最重要的革新之一。它讓程式能「搬走」資源而非「複製」,把處理大型物件(容器、字串、檔案緩衝)的成本從 O(n) 降到 O(1)。理解移動語意,是理解現代 C++ 為什麼又快又安全的關鍵。
學習目標
讀完本章後,你應該能夠:
- 區分值類別(value categories):lvalue / prvalue / xvalue 與其上位概念 glvalue / rvalue
- 說明左值參考(
&)與右值參考(&&)的差異與綁定規則 - 親手寫出正確的移動建構子與移動賦值運算子(以
String/Buffer為例) - 解釋「
std::move其實只是一個 cast」,以及移動何時會自動發生 - 完整實作 Rule of Five,並知道何時該用 Rule of Zero
- 說明為什麼移動操作要標
noexcept(與vector擴容的關係) - 區分轉發參考(
T&&)與右值參考,掌握std::forward與完美轉發 - 套用參考摺疊(reference collapsing)規則
- 避開常見陷阱:使用已被移動的物件、
return std::move(local)
一、值類別(Value Categories)
1.1 從「左值/右值」開始
最直觀的二分法:
int x = 42; // x 是左值(lvalue):有名字、有位址
// 42 是右值(rvalue):臨時值、沒有名字
int y = x + 1; // x + 1 是右值(運算結果是臨時的);y 是左值
| 特性 | 左值(lvalue) | 右值(rvalue) |
|---|---|---|
| 有名字 | 是 | 否(通常) |
可取位址(&) |
是 | 否 |
| 生命週期 | 持續到作用域結束 | 通常在表達式結束後消失 |
| 例子 | 變數、參考、*p |
字面值、臨時物件、a+b、傳值回傳 |
簡單法則:能放在 = 左邊、能取位址的,通常是左值;只能放右邊的臨時值是右值。
1.2 完整的值類別樹(進階,但值得認識)
C++11 起其實有三個基本類別,與兩個複合類別:
expression
/ \
glvalue rvalue
/ \ / \
lvalue xvalue prvalue
- lvalue(左值):有身份、不可移動。如
x、*p、arr[i]。 - prvalue(純右值):「初始化用的值」,沒有身份。如
42、a+b、Widget()。 - xvalue(將亡值,eXpiring value):有身份、可以被移動。如
std::move(x)、回傳T&&的函式。 - glvalue = lvalue + xvalue(generalized lvalue,「有身份」)。
- rvalue = prvalue + xvalue(「可以被移動」)。
直覺記法: - 有身份(有名字/位址) → glvalue - 可以被偷(移動) → rvalue - xvalue 同時有身份又可被偷 —— 這正是
std::move(x)做的事:把一個 lvalue「貼上可被偷的標籤」。
std::string s = "hello";
std::string a = s; // s 是 lvalue → 複製
std::string b = std::move(s); // std::move(s) 是 xvalue → 移動
std::string c = std::string(); // std::string() 是 prvalue → 移動(或省略)
二、左值參考 vs 右值參考
int a = 10;
int& lref = a; // 左值參考:綁定左值
// int& bad = 42; // ✗ 非 const 左值參考不能綁右值
const int& cref = 42; // ✓ const 左值參考可綁右值(延長臨時物件壽命)
int&& rref = 42; // 右值參考:綁定右值
// int&& bad2 = a; // ✗ 右值參考不能綁左值
int&& rref2 = std::move(a); // ✓ std::move 把左值轉成右值(xvalue)
綁定規則總表:
| 參考型別 | 可綁左值? | 可綁右值? |
|---|---|---|
T& |
✔(僅可變左值) | ✘ |
const T& |
✔ | ✔ |
T&& |
✘ | ✔ |
右值參考的意義:它讓我們能「偵測」到一個即將被銷毀的臨時物件,於是可以放心「偷走」它的內部資源,而不必複製。
關鍵陷阱:右值參考變數本身是左值!因為它有名字、可取位址。
cpp void f(int&& r) { // r 是右值參考 // r 在這裡是左值(有名字) g(r); // 呼叫 g(int&) —— 左值版本 g(std::move(r)); // 要再 move 才會呼叫 g(int&&) }
三、移動建構子與移動賦值(以 Buffer 為例)
考慮一個自管理動態記憶體的類別。複製要逐位元組搬,移動只要「換名牌」。
#include <cstring>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t n)
: data_(new char[n]), size_(n) {}
~Buffer() { delete[] data_; } // 1. 解構子
Buffer(const Buffer& other) // 2. 複製建構(昂貴:深拷貝)
: data_(new char[other.size_]), size_(other.size_) {
std::memcpy(data_, other.data_, size_);
}
Buffer& operator=(const Buffer& other) { // 3. 複製賦值
if (this != &other) {
char* p = new char[other.size_]; // 先配置,成功才換(強例外保證)
std::memcpy(p, other.data_, other.size_);
delete[] data_;
data_ = p;
size_ = other.size_;
}
return *this;
}
Buffer(Buffer&& other) noexcept // 4. 移動建構(便宜:偷指標)
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr; // 來源進入安全的空狀態
other.size_ = 0;
}
Buffer& operator=(Buffer&& other) noexcept { // 5. 移動賦值
if (this != &other) {
delete[] data_; // 釋放自身舊資源
data_ = other.data_; // 偷走來源資源
size_ = other.size_;
other.data_ = nullptr; // 來源清空
other.size_ = 0;
}
return *this;
}
std::size_t size() const { return size_; }
private:
char* data_;
std::size_t size_;
};
移動的核心動作只有兩步:(1) 把來源的指標抄過來;(2) 把來源的指標清成 nullptr。第二步至關重要 —— 否則來源解構時會 delete 同一塊記憶體,造成重複刪除。
複製 vs 移動圖解
複製(深拷貝): 移動(偷指標):
src ─► [ AAAA ] src ─► [ AAAA ] ──┐
dst ─► [ AAAA ](新配置+memcpy) dst ─► ───────────┘(接手)
src ─► nullptr(清空)
成本 O(n) 成本 O(1)
四、std::move —— 它只是一個 cast!
std::move 不移動任何東西。它只是把一個左值「轉型」成右值參考(xvalue),讓重載解析挑中移動版本。等價實作大致是:
template <typename T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<std::remove_reference_t<T>&&>(t);
}
std::string s = "Hello";
std::string t = std::move(s);
// 步驟:1) std::move(s) 把 s 轉成 std::string&&(xvalue)
// 2) 觸發 std::string 的「移動建構子」
// 3) s 的內部緩衝被搬走,s 進入「有效但未指定」狀態
注意事項
- 被移動後的物件處於 valid but unspecified(有效但未指定) 狀態:可安全解構、可重新賦值,但不應假設其內容。
- 不要對
const物件std::move:移動需要修改來源,但const不可改,於是std::move(const T)會悄悄退回複製。cpp const std::string cs = "x"; std::string d = std::move(cs); // 實際上是複製!(move 失效)
五、移動何時會「自動」發生?
你不必每次都寫 std::move。在這些情境,編譯器會自動選擇移動:
- 以右值(臨時物件)初始化或賦值:
cpp std::string make(); std::string s = make(); // make() 是 prvalue → 移動(或被省略) v.push_back(std::string("hi")); // 臨時 string → 移動 - 回傳區域變數:
return local;會先嘗試省略(RVO/NRVO),不行才當作右值移動 —— 你不需要寫std::move。 - 具名物件需要明確
std::move:cpp std::string named = "hi"; v.push_back(named); // 左值 → 複製 v.push_back(std::move(named)); // 明確要求移動
規則:對「之後還要用」的具名物件不要 move;對「之後不再用」的具名物件,move 它以省下一次複製。
六、Rule of Five 與 Rule of Zero
6.1 Rule of Five
只要你手動定義了下列五個之一(通常因為類別管理了裸資源),就應該慎重考慮全部五個:
| 特殊成員函式 | 簽名 |
|---|---|
| 解構子 | ~T() |
| 複製建構子 | T(const T&) |
| 複製賦值 | T& operator=(const T&) |
| 移動建構子 | T(T&&) noexcept |
| 移動賦值 | T& operator=(T&&) noexcept |
WHY:一旦你自訂了解構子,編譯器對移動成員的自動生成會被抑制(且複製成員的自動生成被視為已棄用)。漏掉移動成員 → 該類別退化成只會複製,悄悄損失效能;漏掉複製成員 → 可能無法複製或行為錯誤。
6.2 Rule of Zero(更好的目標)
如果類別的每個成員本身都已正確管理自己的資源(std::string、std::vector、std::unique_ptr、std::shared_ptr……),就一個特殊成員都別寫,讓編譯器自動生成全部五個。
class Person { // Rule of Zero —— 不需要任何特殊成員
std::string name_;
std::vector<int> scores_;
std::unique_ptr<Address> addr_;
}; // 複製/移動/解構全部由編譯器正確生成
Core Guidelines C.20:If you can avoid defining default operations, do.(能不定義特殊成員就不要定義 —— 即 Rule of Zero。) C.21:If you define or =delete any copy, move, or destructor function, define or =delete them all.(Rule of Five。)
七、noexcept 與 vector 擴容
移動建構/賦值應該標 noexcept,原因關乎效能與正確性。
std::vector 擴容時要把舊元素搬到新的、更大的緩衝區。它面臨抉擇:用移動還是複製?
- 若元素的移動建構是 noexcept → vector 用移動(快)。
- 若移動可能拋例外 → 為了維持強例外保證(擴容失敗時舊資料不能損毀),vector 會退回複製(慢)。
struct Slow { Slow(Slow&&); }; // 移動可能拋 → vector 擴容用「複製」
struct Fast { Fast(Fast&&) noexcept; }; // 移動 noexcept → vector 擴容用「移動」
WHY 移動失敗無法回復? 若搬到一半第 k 個元素的移動拋例外,前 k-1 個來源元素已被掏空、無法還原,
vector就無法回到原狀。複製則不會破壞來源,所以可安全回復。因此「可能拋的移動」對vector而言不安全,只好不用。標
noexcept前請確保你的移動真的不會拋(通常只是指標交換,本就不該拋)。
可用 std::move_if_noexcept 表達這個策略,或直接信任標準容器替你處理。
八、複製省略:RVO 與 NRVO
編譯器常能完全省略複製/移動,直接在目的地建構物件 —— 這叫複製省略(copy elision)。
std::vector<int> createVector() {
std::vector<int> v = {1, 2, 3, 4, 5};
return v; // NRVO:直接在呼叫端建構,零複製零移動
}
auto result = createVector(); // 高效!
- RVO(Return Value Optimization):回傳無名臨時物件(prvalue)的省略。C++17 起對 prvalue 保證省略(mandatory copy elision)。
- NRVO(Named RVO):回傳具名區域變數的省略。屬於編譯器的「允許但不強制」最佳化。
千萬不要寫
return std::move(local);:cpp std::vector<int> bad() { std::vector<int> v = {1,2,3}; return std::move(v); // ✗ 阻止 NRVO,反而強制一次移動,更慢! }直接return v;即可 —— 編譯器會優先省略,省不掉時也會自動當右值移動。
九、轉發參考與完美轉發
9.1 轉發參考(Forwarding Reference)≠ 右值參考
T&& 的意義取決於上下文:
template <typename T>
void foo(T&& arg); // 轉發參考(forwarding/universal reference)—— T 由呼叫端推導
void bar(int&& arg); // 右值參考 —— 型別固定,只接受右值
只有在「模板參數推導」的 T&&(或 auto&&)才是轉發參考。std::vector<int>&&、Widget&& 都是普通右值參考。
9.2 參考摺疊(Reference Collapsing)
當參考的參考出現時(透過模板/typedef),依下表摺疊:
| 組合 | 結果 |
|---|---|
T& & |
T& |
T& && |
T& |
T&& & |
T& |
T&& && |
T&& |
口訣:只要出現一個 &,結果就是 &;唯有 && && 才是 &&。
這正是轉發參考能「同時接左值與右值」的機制:
- 傳左值 int x:T 推導為 int&,arg 型別 int& && → 摺疊成 int&。
- 傳右值 42:T 推導為 int,arg 型別 int&&。
9.3 std::forward 與完美轉發
包裝函式若想「原封不動」把引數的值類別轉交下去,要用 std::forward<T>:
template <typename T>
void wrapper(T&& arg) {
target(std::forward<T>(arg)); // 左值轉發為左值、右值轉發為右值
}
對比若不轉發:
template <typename T>
void bad(T&& arg) {
target(arg); // arg 是左值 → 永遠呼叫 target 的左值版本,右值資訊遺失
}
std::forward<T>(arg) 的行為:
- T 推導為 U&(傳入左值)→ 轉發成左值
- T 推導為 U(傳入右值)→ 轉發成右值
9.4 應用:工廠與 emplace
template <typename T, typename... Args>
std::unique_ptr<T> my_make_unique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
vec.emplace_back(std::forward<Args>(args)...); // 直接在容器內就地建構
std::movevsstd::forward:std::move無條件轉成右值(用於你確定要移動的右值參考);std::forward有條件地保留原值類別(只用於轉發參考)。不要混用。
十、Move-only 型別
有些型別只能移動、不能複製:std::unique_ptr、std::thread、std::fstream、std::future……。它們用 move-only 表達「資源不可被兩個物件同時擁有」。
auto p = std::make_unique<int>(42);
auto q = std::move(p); // ✓ 移動
// auto r = q; // ✗ 編譯錯誤:unique_ptr 不可複製
std::vector<std::unique_ptr<int>> v;
v.push_back(std::make_unique<int>(1)); // ✓ 臨時物件,自動移動
你也可以把自己的類別設計成 move-only:
class Handle {
public:
Handle(const Handle&) = delete; // 禁止複製
Handle& operator=(const Handle&) = delete;
Handle(Handle&&) noexcept = default; // 允許移動
Handle& operator=(Handle&&) noexcept = default;
};
十一、最佳實踐(Best Practices)
- 能不寫特殊成員就不寫(Rule of Zero);要寫就寫齊(Rule of Five)。
- 移動建構/賦值一律標
noexcept(且要名副其實),讓容器能用移動最佳化。 return local;不要加std::move,把省略的機會留給編譯器。- 對「之後不再用」的具名左值才
std::move;移動後不要再讀它的內容。 - 轉發參考用
std::forward,右值參考用std::move,不要搞混。 - 同一個轉發參考只
forward一次(forward 之後可能已被掏空)。 - 參數設計表達意圖:傳值=接管、
const T&=唯讀借用、T&&(模板)=完美轉發。 - 不要對
const物件std::move(會退回複製,白費力氣)。
Core Guidelines 參考:C.66(移動操作標
noexcept)、F.18(「將被移動」的參數用X&&並std::move)、F.19(「轉發」的參數用TP&&並std::forward)、ES.56(只在真的需要轉移擁有權時才std::move)。
十二、常見錯誤與陷阱
- 移動後存取來源:被
std::move後只能解構或重新賦值,讀內容是邏輯錯誤。 std::move一個const物件:不會移動,悄悄退回複製。return std::move(local);:阻止 NRVO,反而多一次移動。- 移動成員忘記標
noexcept:vector擴容退回複製,效能默默變差。 - 混淆轉發參考與右值參考:
T&&(模板推導)是轉發參考;Widget&&是右值參考。 - 移動建構子忘記清空來源指標:來源解構時重複
delete。 - 把
std::move當成「會加速」的咒語亂加:對基本型別(int)move 等於複製;對const無效;亂 move 還可能破壞後續邏輯。 - 對轉發參考用
std::move而非std::forward:會把呼叫端的左值也強制偷走,造成意外。
重點整理
| 概念 | 要點 |
|---|---|
| 值類別 | glvalue=有身份;rvalue=可移動;xvalue 兩者皆是 |
| 左值/右值參考 | & 綁左值、const& 皆可、&& 綁右值 |
| 右值參考是左值 | 具名的 T&& 變數本身是左值,要再 move |
| 移動建構/賦值 | 偷指標 + 清空來源,O(1) |
std::move |
只是 cast,不實際移動;對 const 無效 |
| 自動移動 | 臨時物件、return local; 會自動移動 |
| Rule of Five / Zero | 要寫就寫齊;能不寫最好 |
noexcept |
移動標 noexcept → vector 擴容用移動 |
| RVO / NRVO | 編譯器省略複製;別用 std::move 回傳 |
| 轉發參考 | 模板 T&& + std::forward 保留值類別 |
| 參考摺疊 | 有 & 就是 &,唯 && && 為 && |
| move-only | unique_ptr、thread 等只移不複製 |
練習題
全部可用
g++ -std=c++17 -Wall編譯。建議在類別的每個特殊成員裡印一行訊息,觀察實際呼叫了哪個。
練習 1(基礎):判斷值類別與重載
寫四個重載:void show(int&)、void show(const int&)、void show(int&&),再寫一個 int n = 5;,分別呼叫 show(n)、show(5)、show(n + 1)、show(std::move(n)),預測並驗證各自呼叫哪一個版本。
- 提示:
n是左值、5與n+1是 prvalue、std::move(n)是 xvalue(rvalue)。
練習 2(基礎):完整的 MyString(Rule of Five)
實作一個管理 char* 的 MyString,寫齊五個特殊成員,每個都印訊息。用一連串操作(複製建構、移動建構、複製賦值、移動賦值)證明各觸發了正確的成員。
- 提示:移動建構/賦值記得
noexcept,並把來源data_設為nullptr、size_設為 0。
練習 3(中級):複製 vs 移動的效能差
建立一個 std::vector<std::string>(數萬個長字串),分別用「複製整個 vector」與「std::move 整個 vector」並用 <chrono> 量測時間,印出兩者差異與被移動後 source.size()。
- 提示:複製是 O(n)、移動是 O(1);被移動後
source通常變空,但這屬「有效但未指定」,僅供觀察。
練習 4(中級):noexcept 對 vector 擴容的影響
寫兩個幾乎相同的類別,差別只在移動建構子有無 noexcept。各放進一個會多次擴容的 vector(不 reserve),在複製建構與移動建構印訊息,觀察哪個類別在擴容時呼叫了「複製」、哪個呼叫了「移動」。
- 提示:移動建構沒有
noexcept的那個,擴容時會改用複製建構。
練習 5(中級):完美轉發日誌包裝器
寫 template<class F, class... A> decltype(auto) logAndCall(const std::string& name, F&& f, A&&... a),記錄呼叫資訊與耗時後,用 std::forward 完美轉發給 f 並回傳其結果。用它包裝一個 lambda 加法與一個字串串接驗證。
- 提示:用
std::forward<F>(f)(std::forward<A>(a)...);回傳型別用decltype(auto)保留參考性。
練習 6(挑戰):move-only 的 ScopeGuard
實作一個 move-only 的 ScopeGuard:建構時接收一個可呼叫物件,解構時呼叫它;支援移動(移動後來源不再執行動作),禁止複製,並提供 dismiss() 取消。
- 提示:用一個
bool active_旗標;移動建構時把來源的active_設為 false;複製成員= delete。
練習 7(挑戰):通用 create<T> 工廠
用轉發參考與 std::forward 實作 template<class T, class... Args> T create(Args&&... args),能對「左值參數」呼叫複製建構、對「右值參數」呼叫移動建構。設計一個同時有複製與移動建構子(各印訊息)的 Widget 驗證之。
- 提示:傳左值時
Args推導為U&,forward出左值 → 複製;傳右值時 → 移動。
對應程式碼檔案
| 檔案 | 說明 |
|---|---|
| move_semantics.cpp | lvalue/rvalue、移動建構/賦值、Rule of Five、std::move 本質、RVO/NRVO、效能比較 |
| perfect_forwarding.cpp | 轉發參考、參考摺疊、std::forward、工廠模式、emplace_back vs push_back |