從裸 new/delete 到一個會擁有的物件
在 C 裡你寫 `p = malloc(n); ...; free(p);`,並把「要記得釋放」的責任扛在腦袋裡。在 C++ 裡對應的裸寫法是 `T *p = new T; ...; delete p;`,而它有著一模一樣的危險:只要 `new` 與 `delete` 之間有任何提早 return 或丟出例外,`delete` 就永遠不會執行,你就製造了一個記憶體洩漏。本階梯前兩篇給了我們解藥——RAII 把清理綁在範圍結束,而移動語意讓我們把資源從一個物件交給另一個物件而不必複製。智慧指標正是把這兩個想法打包成一個「長得像指標」的物件,讓你在所有原本用裸擁有指標的地方都能改用它。
關鍵的轉變是用詞:別再問「這個指標還有效嗎?」,改問「這塊配置是誰擁有的?」所有權指的正是一項責任——恰好呼叫一次解構式並釋放記憶體的義務。一個裸 `T *` 對所有權什麼都沒說;它可能擁有那個物件,也可能只是在看別人擁有的東西。智慧指標把這個區別放進型別本身,於是你讀宣告時答案就自己浮現了。
unique_ptr:唯一擁有者,零開銷
`std::unique_ptr<T>` 是預設的智慧指標,也是你九成情況會伸手去拿的那一個。它模型化的是「獨佔所有權」:恰好一個 unique_ptr 擁有該物件,而當這個 unique_ptr 被解構時,它會對所指物件呼叫 `delete`。正因為擁有者永遠只有一個,這個型別不能被複製——複製會造出第二個擁有者,埋下未來的重複釋放。你只能移動它,移動會把裸指標搬出去,並讓來源端留下 nullptr。
std::unique_ptr<Widget> a = std::make_unique<Widget>(42); std::unique_ptr<Widget> b = std::move(a); // ownership moves: a is now nullptr // auto c = b; // ERROR: copy is deleted -- only one owner allowed // memory layout of a unique_ptr<T> (default deleter): // sizeof(unique_ptr<T>) == sizeof(T*) // just one pointer, 8 bytes on x86-64 // address held inside, e.g. 0x55a0b3c41e90
這裡正是讓 C++ 成為系統語言的關鍵:使用預設的 deleter 時,unique_ptr 的大小恰好等於一個裸指標(在 x86-64 上是 8 個位元組),而產生的機器碼與手寫的 `new`/`delete` 完全相同。沒有參考計數、沒有隱藏的配置、沒有執行期的記帳。這就是零開銷原則在運作——你得到自動清理,而它不會讓你多付任何你自己寫 `delete` 時不會付的成本。請優先用 `std::make_unique<T>(args)` 而非裸 `new`:它對例外安全,即使建構參數丟出例外也不會洩漏。
shared_ptr:多個擁有者,一個參考計數
有時候所有權真的是共享的:程式裡好幾個部分都需要某個物件保持存活,而沒有任何單一一個知道自己會是最後用完它的人。這正是 std::shared_ptr<T> 的用途。複製一個 shared_ptr 是被允許的,而每個複本都是共同擁有者。訣竅是一個隱藏的控制區塊,裡面放著一個參考計數:每次複製把計數加一,每次解構減一,當計數歸零時物件就被解構並釋放。誰放掉了最後一個參考,誰就負責清理。
這份能力並非免費,而且誠實面對成本很重要。一個 shared_ptr 有兩個指標那麼寬(物件加上控制區塊),所以是 16 個位元組而非 8 個。請用 `std::make_shared<T>(args)`,它會做單一一次配置,把物件與計數並排放在一起,這比分開配置更快、對快取也更友善。又因為複製可能發生在不同的執行緒上,計數是用原子操作更新的——原子的遞增與遞減比單純的 `count++` 明顯更貴。經驗法則是:先伸手拿 unique_ptr,只有在所有權真的是共享時才升級到 shared_ptr,而不是因為它用起來方便。
循環、weak_ptr 與非擁有檢視
參考計數有一個著名的失敗模式:循環。如果節點 A 持有一個指向 B 的 shared_ptr,而 B 又持有一個指回 A 的 shared_ptr,那麼即使程式其餘部分已經忘了它們兩個,彼此仍然把對方的計數撐在一。計數永遠不會歸零,解構式永遠不會執行,於是你洩漏了——以計數為基礎的機制無法靠自己回收循環。這是「不使用追蹤式垃圾收集器」的代價,而它是一個真實、反覆出現的臭蟲,不是腳註。
解藥是 std::weak_ptr<T>,它是一個對共享物件的非擁有觀察者。weak_ptr 指向控制區塊但不提升強參考計數,所以它無法讓物件保持存活。要使用它你呼叫 `.lock()`,它會原子地檢查物件是否還在,若在就交給你一個暫時的 shared_ptr;若物件已經消失,`.lock()` 會回傳一個空指標而不是懸空。打破循環的方法是讓恰好一個方向變成弱的——典型作法是子節點指回父節點的反向指標用 weak_ptr,而父節點指向子節點的指標維持 shared。
還有第三種、其實更常見、卻被初學者過度設計的情況:一個函式只是需要看一眼某個物件,根本不擁有它。這時的答案不是智慧指標——傳一個普通的參考 `const T&`、一個裸 `T *`,或像 span 這樣的非擁有檢視。在現代 C++ 裡,只要一個裸指標的意思是「我在借用,不是擁有」,它就完全正當。智慧指標是給擁有者用的;其他所有人都該持有一個借來的檢視,並信任某個擁有者正讓它保持存活。
實務上如何選擇
一旦你把所有權說出口,選擇就變得機械化。對任何堆積物件跑一遍這份簡短的檢查清單,正確的指標型別幾乎每次都會自己掉出來。注意前兩個答案就涵蓋了絕大多數真實程式碼;shared_ptr 是例外,不是預設。
- 這東西在堆積上到底有沒有擁有者?如果它住在堆疊上、或顯然由別人擁有,就用普通參考或裸指標當作借來的檢視——不要用智慧指標。
- 同一時間是否恰好只有一個擁有者?用 unique_ptr。在函式之間以移動交接所有權;它是最便宜也最清楚的預設。
- 所有權是否真的是共享、且事先無法知道哪個是最後一個擁有者?用 make_shared,並接受 16 個位元組的大小與原子計數的成本。
- 會不會有兩個 shared_ptr 互指而形成循環?把其中一個方向改成 weak_ptr,並在每次使用前呼叫 .lock()。
退一步看看你得到了什麼。藉由選擇指標型別,你把程式的所有權政策直接寫進了型別裡,讓編譯器替你強制執行:一個不可複製的 unique_ptr 讓「第二個擁有者」變成編譯錯誤,而一個 shared_ptr 讓「最後一個離開時釋放」變成自動的。這正是第一篇那筆 RAII 的交易,如今套用到堆積上——也正是下一篇要建立在其上的東西,屆時我們會用樣板把這些抽象變得泛用,且不付出任何執行期成本。