C++ 記憶體管理與 RAII
從堆疊與堆積、new/delete 的雙人舞,到智慧指標與 RAII 慣用法——學會讓編譯器替你保證「記憶體一定被歸還」,並一窺所有權與移動語意如何撐起現代 C++ 的零成本抽象。
一個會「漏水」的程式:當 1000 次點擊吃掉你的記憶體
想像你正在寫一個影像處理工具。使用者每按一次「載入圖片」,你就向系統要一塊記憶體放原始像素;按一次「關閉圖片」,理應把那塊記憶體還給系統。某天有人連續開關了 1000 次,工具愈跑愈慢,最後直接被作業系統強制終止。
如果你寫過 Python,你大概沒遇過這種事——Python 有垃圾回收(garbage collection),物件沒人用了就會自動清掉。但在 C++ 裡,記憶體的歸還是你的責任。要了那塊記憶體卻忘了還,它就一直佔著不放,這就是「記憶體洩漏(memory leak)」。
這篇文章要帶你弄懂 C++ 怎麼管理記憶體,以及現代 C++ 用什麼慣用法(RAII、智慧指標)把「記得歸還」這件煩人又容易出錯的事,交給編譯器自動處理。相較於 Python 把記憶體當成隱形的背景服務,C++ 把它攤在你眼前——這既是負擔,也是 C++ 能寫出極致高效程式的根本原因。

堆疊 vs 堆積:兩種完全不同的記憶體
C++ 的變數住在兩個不同的地方,搞懂它們的差異是後面一切的基礎。
堆疊(stack) 是函式呼叫時自動配置的記憶體。你宣告一個區域變數,它就在堆疊上;函式結束,它自動消失。速度極快(只是移動一個指標),但生命週期綁死在「作用域(scope)」上。
#include <iostream>
void demo() {
int x = 42; // x 在堆疊上
int arr[3] = {1, 2, 3}; // 整個陣列也在堆疊上
std::cout << x << "\n";
} // 離開這個 { } 作用域,x 和 arr 自動被回收
堆積(heap) 是你「手動」向系統索取的記憶體。它不綁定作用域,你說什麼時候還、它才什麼時候還。彈性很大,但代價是——你必須記得還。
相較於 Python,這個區分對你是透明的:Python 裡 x = 42 背後其實是個堆積上的物件,你從來不需要關心它住哪裡。C++ 則逼你做選擇,而這個選擇直接決定效能與正確性。
什麼時候非用堆積不可?兩種情況:(1) 資料太大,堆疊放不下(堆疊通常只有幾 MB);(2) 物件的生命週期必須超出建立它的那個函式(例如一個函式建立物件、回傳給呼叫者繼續用)。
new 與 delete:手動配置的雙人舞
C++ 用 new 向堆積要記憶體,用 delete 歸還。它們必須成對出現,像跳雙人舞,少一個就出錯。
#include <iostream>
int main() {
int* p = new int(10); // 向堆積要一個 int,初值 10
std::cout << *p << "\n"; // 用 * 解參考(dereference)取值
*p = 20;
std::cout << *p << "\n";
delete p; // 歸還這塊記憶體
p = nullptr; // 好習慣:避免懸空指標
return 0;
}
// 輸出:
// 10
// 20
注意 int* 這個型別——它是「指向 int 的指標(pointer)」,存的不是值本身,而是值在記憶體裡的位址。Python 沒有顯式指標的概念,但 C++ 把位址當成一等公民,讓你能直接操作記憶體佈局。
配置陣列要用 new[],歸還則要用對應的 delete[],不能混用:
int* nums = new int[5]{1, 2, 3, 4, 5}; // 配置 5 個 int 的連續空間
// ... 使用 nums[0] ~ nums[4] ...
delete[] nums; // 注意是 delete[],不是 delete
用 delete 去釋放 new[] 配置的記憶體是未定義行為(undefined behavior)——程式可能當掉、可能正常、可能默默損壞資料,這是 C++ 最棘手的一類 bug。
記憶體洩漏:忘記歸還的後果
回到開頭那個會「漏水」的程式。問題的本質長這樣:
void leaky() {
int* data = new int[1000000]; // 要了 4 MB
// ... 做了一些事 ...
return; // 糟糕!沒有 delete[],這 4 MB 永遠回不來
}
int main() {
for (int i = 0; i < 1000; ++i) {
leaky(); // 每次呼叫漏 4 MB,跑完漏掉 4 GB
}
}
每次呼叫 leaky(),那塊記憶體的「位址」隨著區域變數 data 一起在函式結束時消失了——但記憶體本身還被佔著,你已經沒有任何指標能找到它、自然也無法 delete 它。這就是洩漏。
更陰險的是「中途 return」與「例外」造成的洩漏:
void tricky(bool error) {
int* p = new int[100];
if (error) {
return; // 這條路徑忘了 delete[],洩漏!
}
delete[] p; // 只有 error == false 時才會執行到
}
人是會犯錯的。只要程式有多條離開路徑(return、break、例外丟出),就有人會漏掉某一條的清理。這正是 RAII 要解決的核心問題。
RAII:讓資源管理自動化的核心慣用法
RAII 是 C++ 最重要的設計慣用法,全名是 Resource Acquisition Is Initialization(資源取得即初始化)。名字有點拗口,但核心想法極其優雅:
把資源的生命週期,綁定到一個堆疊物件的生命週期上。
還記得堆疊變數會在離開作用域時自動被回收嗎?RAII 就是利用這個機制。我們做一個物件,讓它在「建構時取得資源」、在「解構時釋放資源」。這樣只要這個物件離開作用域,C++ 保證會呼叫它的解構函式(destructor),資源就被自動清理——無論你是正常 return、中途 break,還是丟出例外。
#include <iostream>
class IntArray {
private:
int* data_;
size_t size_;
public:
// 建構子:取得資源
IntArray(size_t n) : data_(new int[n]), size_(n) {
std::cout << "配置 " << n << " 個 int\n";
}
// 解構子:釋放資源(離開作用域時自動呼叫)
~IntArray() {
delete[] data_;
std::cout << "釋放記憶體\n";
}
int& operator[](size_t i) { return data_[i]; }
size_t size() const { return size_; }
};
void use() {
IntArray arr(5); // 建構:自動配置
arr[0] = 100;
std::cout << arr[0] << "\n";
} // 離開作用域:自動呼叫 ~IntArray(),記憶體保證被釋放
int main() {
use();
return 0;
}
// 輸出:
// 配置 5 個 int
// 100
// 釋放記憶體
關鍵在於:就算 use() 裡丟出例外或中途 return,~IntArray() 還是會被執行。 你不再需要在每條離開路徑手動寫 delete[],編譯器替你保證了清理。這就是 RAII 的威力——把「容易忘」變成「不可能忘」。
C++ 標準函式庫裡的 std::vector、std::string、std::fstream 全都是 RAII 物件。你用 std::vector<int> v(1000) 時,它在背後 new、在解構時 delete,你完全不必操心。現代 C++ 的第一守則就是:能用標準容器就別自己 new/delete。
智慧指標:標準庫替你寫好的 RAII
自己寫 RAII 類別很好,但每種資源都寫一個太累。C++11 起,標準庫提供了智慧指標(smart pointer),它們本身就是管理「堆積記憶體」這個資源的 RAII 包裝。引入 <memory> 就能用。
unique_ptr:獨占所有權
std::unique_ptr 代表「我獨自擁有這塊記憶體」。它不能被複製(避免兩個指標都以為自己負責 delete),離開作用域時自動釋放。
#include <iostream>
#include <memory>
struct Widget {
int id;
Widget(int i) : id(i) { std::cout << "建立 Widget " << id << "\n"; }
~Widget() { std::cout << "銷毀 Widget " << id << "\n"; }
};
void use() {
std::unique_ptr<Widget> w = std::make_unique<Widget>(7);
std::cout << "使用 Widget " << w->id << "\n";
} // w 離開作用域,自動 delete,無需手寫
int main() {
use();
return 0;
}
// 輸出:
// 建立 Widget 7
// 使用 Widget 7
// 銷毀 Widget 7
請優先用 std::make_unique<T>(...) 建立,而不是 unique_ptr<T>(new T(...))——前者更安全、語意更清楚。unique_ptr 的成本幾乎為零:它的大小就跟一個裸指標一樣,沒有額外負擔,這是 C++「零成本抽象(zero-cost abstraction)」哲學的體現。
shared_ptr:共享所有權
有時一塊資源確實需要被多方共用,沒人能單獨決定何時釋放。這時用 std::shared_ptr,它內部維護一個參考計數(reference count):每多一個 shared_ptr 指向它,計數 +1;每少一個,計數 −1;計數歸零時才真正釋放。
#include <iostream>
#include <memory>
int main() {
auto a = std::make_shared<int>(42);
std::cout << "計數 = " << a.use_count() << "\n"; // 1
{
auto b = a; // 共享同一塊記憶體,計數 +1
std::cout << "計數 = " << a.use_count() << "\n"; // 2
} // b 離開作用域,計數 −1
std::cout << "計數 = " << a.use_count() << "\n"; // 1
return 0;
} // a 離開作用域,計數歸零,記憶體釋放
// 輸出:
// 計數 = 1
// 計數 = 2
// 計數 = 1
shared_ptr 很方便,但別濫用:維護參考計數有時間與空間成本,而且若兩個物件互相用 shared_ptr 指對方,計數永遠不歸零,就會造成洩漏(這時需要 std::weak_ptr 打破循環)。預設用 unique_ptr,確實需要共享時才升級成 shared_ptr。
動手寫一段:用智慧指標重寫漏水程式
把開頭那個會漏水的迴圈,用 unique_ptr 重寫,洩漏問題自動消失:
#include <iostream>
#include <memory>
#include <vector>
class Resource {
int id_;
public:
Resource(int id) : id_(id) {}
~Resource() { /* 假設這裡釋放某種昂貴資源 */ }
int id() const { return id_; }
};
std::unique_ptr<Resource> make_resource(int id) {
return std::make_unique<Resource>(id); // 所有權「移動」給呼叫者
}
int main() {
int total = 0;
for (int i = 0; i < 1000; ++i) {
auto r = make_resource(i); // 取得所有權
total += r->id();
} // 每圈結束,r 自動釋放——不漏一滴
std::cout << "處理了 1000 個資源,id 總和 = " << total << "\n";
return 0;
}
// 輸出:
// 處理了 1000 個資源,id 總和 = 499500
注意 make_resource 回傳一個 unique_ptr——既然 unique_ptr 不能複製,它怎麼回傳出來?答案是「移動(move)」,這正是下面深入段要談的所有權語意。
常見錯誤:初學者最容易踩的雷
整理幾個寫 C++ 記憶體時最高頻的錯誤,每一條都曾讓無數人 debug 到深夜:
-
new/delete不成對,或new[]配delete。 配置陣列卻用delete而非delete[],是未定義行為。最佳解法:根本別手動new,改用容器或智慧指標。 -
重複釋放(double free)。 對同一塊記憶體
delete兩次會當掉。delete後把指標設為nullptr(對nullptr做delete是安全的無動作)可降低風險。 -
使用已釋放的記憶體(懸空指標,dangling pointer)。
delete p後又去讀*p,記憶體可能已經被別的資料佔用,讀到的是垃圾。 -
回傳區域變數的位址。
int* f() { int x = 5; return &x; }——x在堆疊上,函式結束就消失了,回傳的位址指向一塊不再有效的記憶體。要回傳就用堆積(或更好的:直接回傳值,讓編譯器最佳化)。 -
以為
shared_ptr萬無一失。 循環參考會讓參考計數永不歸零,照樣洩漏。互相引用時其中一方改用weak_ptr。
貫穿這五條的黃金守則只有一句:現代 C++ 裡,你幾乎不該手寫 new 和 delete。 把它們交給標準容器與智慧指標,這些 bug 大半會從你的人生消失。
重點回顧
- 堆疊變數綁定作用域、自動回收、速度快;堆積記憶體手動配置、生命週期自由、需手動歸還。
new/delete、new[]/delete[]必須成對且對應,否則洩漏或未定義行為。- RAII 把資源生命週期綁到堆疊物件上,靠解構函式保證清理——即使遇到例外也不漏。
unique_ptr(獨占、零成本)是預設選擇;shared_ptr(共享、有參考計數成本)僅在真需共享時使用。- 相較於 Python 的垃圾回收,C++ 把記憶體控制權交還給你,RAII 則讓這份控制權不再是負擔。
深入探討(研究所視角)
RAII 與例外安全(exception safety)
RAII 真正的價值,在例外處理面前才完全展現。考慮一個函式中途丟出例外時的「堆疊展開(stack unwinding)」——C++ 會逆序解構所有已建立的堆疊物件。對裸指標來說,這毫無幫助(指標被銷毀,但它指的記憶體沒被釋放);但對 RAII 物件,解構函式會在展開過程中被呼叫,資源因此被正確清理。
#include <memory>
#include <stdexcept>
void risky() {
auto buf = std::make_unique<int[]>(1000); // RAII
process(buf.get()); // 假設這裡可能丟例外
// 即使上一行丟出例外,堆疊展開時 buf 的解構子仍會釋放記憶體
}
這帶出例外安全的層級概念。一個操作可以提供不同強度的保證:基本保證(basic guarantee)——出例外後物件仍處於有效(雖可能改變)的狀態、無資源洩漏;強保證(strong guarantee)——出例外後狀態如同操作從未發生(commit-or-rollback);不丟保證(nothrow)——絕不丟例外。RAII 是達成「基本保證」的地基:只要每個資源都被 RAII 物件管理,「無洩漏」這條就自動成立。標準庫的 std::vector::push_back 提供強保證,正是建立在這套機制之上。
值得一提的是,解構函式不應丟出例外。若在堆疊展開過程中(已經有一個例外在飛)解構子又丟一個例外,C++ 會直接呼叫 std::terminate 終止程式。所以 RAII 類別的清理邏輯必須是 nothrow 的——這也是為什麼智慧指標的解構從不丟例外。
所有權語意與移動語意(move semantics)初探
前面留了個問題:unique_ptr 不能複製,怎麼從函式回傳?這要從「所有權(ownership)」這個 C++11 引入的核心概念說起。
C++ 把物件的「值」分成兩類:lvalue(有名字、可定址、之後還會用到的物件)與 rvalue(臨時的、即將消亡的值,例如函式回傳的暫存物件)。當來源是個 rvalue——反正它馬上要被銷毀——我們就不必「複製」它的資源,可以直接「偷」過來,把它內部的指標轉移給新物件,再把來源置空。這就是移動(move)。
#include <memory>
#include <utility>
std::unique_ptr<int> source() {
auto p = std::make_unique<int>(99);
return p; // p 是即將消亡的區域變數,編譯器自動「移動」而非複製
}
int main() {
std::unique_ptr<int> a = source(); // 透過移動取得所有權
std::unique_ptr<int> b = std::move(a); // 顯式移動:所有權從 a 轉給 b
// 此後 a 為空(nullptr),b 持有資源
return 0;
}
std::move 其實不「移動」任何東西——它只是把一個 lvalue 強制轉型成 rvalue 參考(T&&),告訴編譯器「這個物件我不要了,你可以偷它的內臟」。實際的轉移發生在被呼叫的移動建構子(move constructor)或移動賦值運算子裡。
對 unique_ptr 而言,移動就是「把內部的裸指標交給新物件、把自己設成 nullptr」,成本是 $O(1)$——只搬一個指標,不碰它指向的資料。這正解釋了「獨占所有權」如何能在函式間流動:所有權像接力棒一樣被傳遞,任何時刻恰好一個 unique_ptr 持有它,永遠不會有兩個指標爭著釋放同一塊記憶體。
移動語意的影響遠超出智慧指標。一個 std::vector<std::string> 在被回傳或塞進另一個容器時,過去 C++03 得逐一深複製(deep copy)所有字串、成本 $O(n)$;有了移動語意,整個內部緩衝區的所有權一次轉移、成本降到 $O(1)$。這是 C++11 對效能最重大的貢獻之一,也是現代 C++ 程式碼能夠「值語意(value semantics)」風格地大量回傳大型物件、卻不犧牲效能的根本原因。
把這條線索拉到最高處,你會發現 C++ 記憶體管理的整個現代圖景是一致的:用所有權語意明確「誰負責這塊資源」,用 RAII 保證「負責者離場時資源被歸還」,用移動語意讓「責任能在物件間零成本轉移」。 三者合起來,C++ 才得以在不犧牲手動控制與極致效能的前提下,達到接近自動記憶體管理的安全性——這正是它與 Python 走的兩條截然不同卻同樣自洽的道路。