移動語意是 C++11 最重要的革新之一。它讓程式能「搬走」資源而非「複製」,把處理大型物件(容器、字串、檔案緩衝)的成本從 O(n) 降到 O(1)。理解移動語意,是理解現代 C++ 為什麼又快又安全的關鍵。


學習目標

讀完本章後,你應該能夠:

  1. 區分值類別(value categories):lvalue / prvalue / xvalue 與其上位概念 glvalue / rvalue
  2. 說明左值參考(&)與右值參考(&&)的差異與綁定規則
  3. 親手寫出正確的移動建構子與移動賦值運算子(以 String/Buffer 為例)
  4. 解釋「std::move 其實只是一個 cast」,以及移動何時會自動發生
  5. 完整實作 Rule of Five,並知道何時該用 Rule of Zero
  6. 說明為什麼移動操作要標 noexcept(與 vector 擴容的關係)
  7. 區分轉發參考(T&&)與右值參考,掌握 std::forward 與完美轉發
  8. 套用參考摺疊(reference collapsing)規則
  9. 避開常見陷阱:使用已被移動的物件、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*parr[i]
  • prvalue(純右值):「初始化用的值」,沒有身份。如 42a+bWidget()
  • 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。在這些情境,編譯器會自動選擇移動:

  1. 以右值(臨時物件)初始化或賦值cpp std::string make(); std::string s = make(); // make() 是 prvalue → 移動(或被省略) v.push_back(std::string("hi")); // 臨時 string → 移動
  2. 回傳區域變數return local; 會先嘗試省略(RVO/NRVO),不行才當作右值移動 —— 你不需要寫 std::move
  3. 具名物件需要明確 std::movecpp 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::stringstd::vectorstd::unique_ptrstd::shared_ptr……),就一個特殊成員都別寫,讓編譯器自動生成全部五個。

class Person {                 // Rule of Zero —— 不需要任何特殊成員
    std::string name_;
    std::vector<int> scores_;
    std::unique_ptr<Address> addr_;
};  // 複製/移動/解構全部由編譯器正確生成

Core Guidelines C.20If you can avoid defining default operations, do.(能不定義特殊成員就不要定義 —— 即 Rule of Zero。) C.21If you define or =delete any copy, move, or destructor function, define or =delete them all.(Rule of Five。)


七、noexceptvector 擴容

移動建構/賦值應該標 noexcept,原因關乎效能與正確性。

std::vector 擴容時要把舊元素搬到新的、更大的緩衝區。它面臨抉擇:用移動還是複製? - 若元素的移動建構是 noexceptvector 用移動(快)。 - 若移動可能拋例外 → 為了維持強例外保證(擴容失敗時舊資料不能損毀),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 xT 推導為 int&arg 型別 int& && → 摺疊成 int&。 - 傳右值 42T 推導為 intarg 型別 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::move vs std::forwardstd::move 無條件轉成右值(用於你確定要移動的右值參考);std::forward 有條件地保留原值類別(只用於轉發參考)。不要混用。


十、Move-only 型別

有些型別只能移動、不能複製std::unique_ptrstd::threadstd::fstreamstd::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)

  1. 能不寫特殊成員就不寫(Rule of Zero);要寫就寫齊(Rule of Five)。
  2. 移動建構/賦值一律標 noexcept(且要名副其實),讓容器能用移動最佳化。
  3. return local; 不要加 std::move,把省略的機會留給編譯器。
  4. 對「之後不再用」的具名左值才 std::move;移動後不要再讀它的內容。
  5. 轉發參考用 std::forward,右值參考用 std::move,不要搞混。
  6. 同一個轉發參考只 forward 一次(forward 之後可能已被掏空)。
  7. 參數設計表達意圖:傳值=接管、const T&=唯讀借用、T&&(模板)=完美轉發。
  8. 不要對 const 物件 std::move(會退回複製,白費力氣)。

Core Guidelines 參考:C.66(移動操作標 noexcept)、F.18(「將被移動」的參數用 X&&std::move)、F.19(「轉發」的參數用 TP&&std::forward)、ES.56(只在真的需要轉移擁有權時才 std::move)。


十二、常見錯誤與陷阱

  1. 移動後存取來源:被 std::move 後只能解構或重新賦值,讀內容是邏輯錯誤。
  2. std::move 一個 const 物件:不會移動,悄悄退回複製。
  3. return std::move(local);:阻止 NRVO,反而多一次移動。
  4. 移動成員忘記標 noexceptvector 擴容退回複製,效能默默變差。
  5. 混淆轉發參考與右值參考T&&(模板推導)是轉發參考;Widget&& 是右值參考。
  6. 移動建構子忘記清空來源指標:來源解構時重複 delete
  7. std::move 當成「會加速」的咒語亂加:對基本型別(int)move 等於複製;對 const 無效;亂 move 還可能破壞後續邏輯。
  8. 對轉發參考用 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_ptrthread 等只移不複製

練習題

全部可用 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 是左值、5n+1 是 prvalue、std::move(n) 是 xvalue(rvalue)。

練習 2(基礎):完整的 MyString(Rule of Five)

實作一個管理 char*MyString,寫齊五個特殊成員,每個都印訊息。用一連串操作(複製建構、移動建構、複製賦值、移動賦值)證明各觸發了正確的成員。

  • 提示:移動建構/賦值記得 noexcept,並把來源 data_ 設為 nullptrsize_ 設為 0。

練習 3(中級):複製 vs 移動的效能差

建立一個 std::vector<std::string>(數萬個長字串),分別用「複製整個 vector」與「std::move 整個 vector」並用 <chrono> 量測時間,印出兩者差異與被移動後 source.size()

  • 提示:複製是 O(n)、移動是 O(1);被移動後 source 通常變空,但這屬「有效但未指定」,僅供觀察。

練習 4(中級):noexceptvector 擴容的影響

寫兩個幾乎相同的類別,差別只在移動建構子有無 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