JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

值語意、移動語意,與五法則

上一篇講了 RAII 如何把一份資源的生命週期,綁在一個物件的作用域上。這一篇問下一個問題:當你複製或回傳這樣一個物件時,那些位元組——以及它所擁有的那塊堆積記憶體——會發生什麼事?值語意、移動語意與五法則,是 C++ 給出的三個答案;它們合起來,正是讓你把一塊緩衝區傳來傳去幾乎不花成本的東西。

「值」是什麼,以及為什麼預設的複製會咬人

從你在 C 裡早就信任的東西開始:一個普通的 `int x = 7;` 是一個。它住在某些位元組裡,當你寫 `int y = x;`,那些位元組被複製了——現在 `y` 是一個獨立的 7,對 `x` 一無所知。改其中一個,另一個毫髮無傷。這就是值語意,而它在 C++ 裡是每個型別的預設:賦值與傳遞引數會複製物件,而不是共用它。你剛爬完的這一階,教過你一個類別可以透過 RAII 持有一份資源——一塊堆積記憶體、一個檔案描述符、一把鎖。令人不安的問題是:當物件擁有其中之一時,「複製物件」究竟該是什麼意思。

想像一個迷你的字串類別,它擁有一塊堆積緩衝區:它存了一個 `char *data` 和一個 `size_t len`,在建構式裡用 `new[]` 配置,在解構式裡用 `delete[]` 釋放——教科書般的 RAII。現在寫 `Str b = a;`。如果 C++ 做那件「顯而易見」的事、去複製物件的位元組,它複製的是那個指標值 `data`——而不是它指向的緩衝區。於是你有了兩個 `Str` 物件,它們的 `data` 欄位握著同一個位址。這就是淺層複製的陷阱。這兩個物件根本一點都不獨立;它們共用著一塊堆積記憶體,而它倆都自以為擁有它。

這個臭蟲在作用域離場時引爆。當 `b` 的解構式執行,它呼叫 `delete[] data`,釋放那塊緩衝區。當 `a` 的解構式執行,它對同一個位址呼叫 `delete[]`——一個決定性、必然發生的重複釋放,那是未定義行為。而若其中任一物件曾被改動,另一個會透過那個共用指標看見那個改變。所以「逐成員複製」這個預設,對任何擁有資源的類別來說,恰恰是錯的。語言並沒有壞掉;它只是不知道你的 `char *data` 是一個「擁有的」指標,而非一個「借來看的」視圖。你得告訴它。

深層複製修好了正確性——卻暴露出一筆成本

你第一個想到的修法是深層複製:當你複製一個 `Str`,就配置一塊全新的緩衝區、把字元複製進去,於是每個物件都擁有自己那塊。在 C++ 裡,這是靠寫一個複製建構式和一個複製賦值運算子做到的——這是編譯器原本會替你產生的特殊成員函式中的兩個。複製建構式為一塊與來源同長的新緩衝區跑 `new[]`、把位元組複製過去;複製賦值運算子做一樣的事,但得先釋放它自己的舊緩衝區(並防範 `a = a;` 的情況)。現在 `Str b = a;` 產生兩個真正分開的字串,每個解構式恰好釋放自己那塊一次,重複釋放消失了。

正確了——但現在去感受那筆成本。考慮一個函式 `Str make_label() { Str s("..."); ... ; return s; }`。以值回傳 `s`,以及呼叫端 `Str label = make_label();`,在概念上想把整塊緩衝區從函式裡複製出來。或者看一個 `Str` 的增長中向量上的 `push_back`:當向量重新配置時,每一個元素都得被複製到新的儲存區,每次複製都意味著一次全新的 `new[]` 與一次逐位元組的複製。對一個百萬位元組大小的字串而言,這是一百萬位元組的配置與複製——而在那個回傳的情境裡,來源 `s` 反正馬上就要被銷毀了。我們深層複製了一塊緩衝區,只為了一瞬間之後就把原本那個丟掉。那份浪費,正是移動語意要填補的縫。

移動:偷走那塊緩衝區,而不是複製它

核心想法在這裡。當一次複製的來源是一個暫存值、或即將消亡時,複製它的緩衝區毫無意義——我們大可直接拿走那塊緩衝區。移動語意正是這個:不去配置新的一塊、不去複製位元組,而是讓目的端偷走來源的指標與長度,然後把來源的指標設成 `nullptr`、長度設成 0,這樣來源的解構式什麼都不會釋放。沒有 `new[]`、沒有位元組複製——就只是三個指標大小的賦值。一個百萬位元組的字串「移動」起來,不管它多大,都只要寥寥幾條指令。那塊緩衝區根本沒移動過;移動的只是它的所有權

// move constructor: take, then null out the source
Str(Str&& other) noexcept
    : data(other.data), len(other.len)   // steal
{
    other.data = nullptr;                // leave source empty
    other.len  = 0;                      // its dtor frees nothing
}
一個移動建構式偷走指標、把來源歸零——不配置、不複製位元組。

編譯器怎麼知道它「被允許偷、而不是複製」?透過第二種參照。一個普通的 `Str&` 繫結到一個會繼續活著的具名物件——所謂的左值。一種新的、寫作 `Str&&` 的右值參照,只繫結到那些暫存的、或被明確標記為可拋棄的東西。每當來源是一個右值時,編譯器就挑移動建構式:一個像 `make_label()` 回傳值那樣的暫存值,或任何你承諾過不再使用的值。所以「以值回傳一個區域變數」、以及「從一個暫存值建構」都會自動移動——上一節那個浪費的深層複製,悄悄變成了一次廉價的指標偷取,而你的呼叫端一個字都不用改。

當你確實握著一個你已用完的具名物件——比方一個你想交出去、又不想複製的區域變數 `s`——你用 std::move 來主動加入。儘管叫這個名字,`std::move` 什麼都沒移動、也不產生任何程式碼:它純粹是一個轉型,從你的左值產生一個 `Str&&`,告訴編譯器「把這個當成可拋棄的,移動建構式可以從它身上偷」。在 `Str b = std::move(a);` 之後,`b` 的位元組是 `a` 過去所擁有的,而 `a` 處於一個「有效但空」的狀態:你可以賦值給它、或讓它被銷毀,但你不能假設它還握著它的舊值。那條「被移走之後」的規則,是這裡唯一一個真正的利刃,值得把它明說。

五法則——以及更好的零法則

退一步,數一數一個「擁有資源」的類別需要什麼。要釋放它的緩衝區,它需要一個解構式。要正確複製,它需要一個複製建構式和一個複製賦值運算子(深層複製、不重複釋放)。要廉價地移動,它需要一個移動建構式和一個移動賦值運算子(偷走並歸零)。那是五個特殊成員函式,而「它們是成套出現的」這個觀察,就是五法則:如果你的類別手動管理一份資源、而你寫了其中任何一個,你幾乎肯定得把五個全都想一遍,因為編譯器替其餘那幾個產生的預設,會是那個會重複釋放的、錯誤的淺層複製行為。

歷史值得用一句話交代:在 C++11 之前,這類函式只有四個(解構式、複製建構式、複製賦值,外加有時被算進來的預設建構式),而當時的指引是「三法則」。C++11 加入了移動建構式與移動賦值,把三變成了五。那些數字是個記憶口訣,不是法律;真正的內容是「對一個擁有資源的型別,這些特殊成員是彼此相依的,所以要把它們一起設計」。

現在來到那句點題、也是真正的忠告:該寫的特殊成員函式,最佳數量是。這就是零法則。與其手動管理一個原始的 `char *data`、把五個函式全寫一遍,不如把資源握在一個本身就已經正確自我管理的型別裡——一個 `std::vector<char>`、一個 `std::string`、或一個智慧指標(正是下一篇的主題)。那些函式庫型別內建了正確的複製、移動與銷毀。一個只由這種成員組成的類別,五個一個都不需要:編譯器產生的預設恰好做對的事,因為每個成員早已會自我深層複製、移動與釋放。五法則告訴你「當你真的非得親手管理資源時」該怎麼做;零法則則告訴你:把程式碼安排成你幾乎永遠不必如此。

為什麼這是「零開銷」的交易,以及接下來是什麼

退遠一點,看看「值加移動」替你換到了什麼。你得以用一種乾淨的風格寫程式——以值傳遞物件、以值回傳、把它們存進容器——彷彿一切都是廉價的 `int`,而底下那些堆積緩衝區卻從不被白白複製。把一個百萬位元組的字串從函式裡回傳出來,成本是三個指標賦值,而不是一次百萬位元組的複製。這就是運作中的零開銷原則:一種更安全、以值為本的風格,編譯下來就是你在 C 裡會手寫的同一套「搬指標」,沒有垃圾回收器、也沒有執行期的記帳。成本只付過一次,付在那五個(或零個)特殊成員的設計裡。

把整個生命週期慢慢走一遍,好把它釘牢:

  1. 一個 `Str s("hello")` 建構式跑 `new[]`,於是 `s` 擁有一塊堆積記憶體——RAII 在建構時確立所有權。
  2. `Str t = s;`(複製一個具名物件)跑複製建構式:一次全新的 `new[]` 加一次位元組複製,於是 `t` 和 `s` 各自獨立。
  3. `Str u = make_label();`(來源是一個暫存值)跑移動建構式:`u` 偷走暫存值的指標,不配置、不複製位元組。
  4. `Str v = std::move(s);` 把 `s` 轉型成右值,於是移動建構式從它身上偷;之後 `s` 處於「有效但空」,不可再讀取它的舊值。
  5. 在作用域離場時,每個解構式對它真正擁有的那塊執行一次——對被移走的那個是 `nullptr`——於是每塊緩衝區恰好被釋放一次,沒有重複釋放、也沒有洩漏。

留意上面每一步所倚靠的:一個「早已正確地做複製、移動與釋放」的智慧成員型別。那正是下一篇的主題。std::unique_ptrstd::shared_ptr 是標準函式庫對「給我一個擁有堆積記憶體、又遵守五法則的型別,好讓我永遠不必親自動手」這句話的回答。有了它們,零法則成為常態,而你剛學會的那套親手寫五個函式的舞步,就退化成一件罕見的事——只在你自己寫一個底層容器時才會去動用。