Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

記憶體管理與 RAII

C++ 記憶體管理與 RAII

從堆疊與堆積、new/delete 的雙人舞,到智慧指標與 RAII 慣用法——學會讓編譯器替你保證「記憶體一定被歸還」,並一窺所有權與移動語意如何撐起現代 C++ 的零成本抽象。

一個會「漏水」的程式:當 1000 次點擊吃掉你的記憶體

想像你正在寫一個影像處理工具。使用者每按一次「載入圖片」,你就向系統要一塊記憶體放原始像素;按一次「關閉圖片」,理應把那塊記憶體還給系統。某天有人連續開關了 1000 次,工具愈跑愈慢,最後直接被作業系統強制終止。

如果你寫過 Python,你大概沒遇過這種事——Python 有垃圾回收(garbage collection),物件沒人用了就會自動清掉。但在 C++ 裡,記憶體的歸還是你的責任。要了那塊記憶體卻忘了還,它就一直佔著不放,這就是「記憶體洩漏(memory leak)」。

這篇文章要帶你弄懂 C++ 怎麼管理記憶體,以及現代 C++ 用什麼慣用法(RAII、智慧指標)把「記得歸還」這件煩人又容易出錯的事,交給編譯器自動處理。相較於 Python 把記憶體當成隱形的背景服務,C++ 把它攤在你眼前——這既是負擔,也是 C++ 能寫出極致高效程式的根本原因。

C++ 記憶體管理與 RAII概念示意圖

堆疊 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::vectorstd::stringstd::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 到深夜:

  1. newdelete 不成對,或 new[]delete 配置陣列卻用 delete 而非 delete[],是未定義行為。最佳解法:根本別手動 new,改用容器或智慧指標。

  2. 重複釋放(double free)。 對同一塊記憶體 delete 兩次會當掉。delete 後把指標設為 nullptr(對 nullptrdelete 是安全的無動作)可降低風險。

  3. 使用已釋放的記憶體(懸空指標,dangling pointer)。 delete p 後又去讀 *p,記憶體可能已經被別的資料佔用,讀到的是垃圾。

  4. 回傳區域變數的位址。 int* f() { int x = 5; return &x; } ——x 在堆疊上,函式結束就消失了,回傳的位址指向一塊不再有效的記憶體。要回傳就用堆積(或更好的:直接回傳值,讓編譯器最佳化)。

  5. 以為 shared_ptr 萬無一失。 循環參考會讓參考計數永不歸零,照樣洩漏。互相引用時其中一方改用 weak_ptr

貫穿這五條的黃金守則只有一句:現代 C++ 裡,你幾乎不該手寫 newdelete 把它們交給標準容器與智慧指標,這些 bug 大半會從你的人生消失。

重點回顧

  • 堆疊變數綁定作用域、自動回收、速度快;堆積記憶體手動配置、生命週期自由、需手動歸還。
  • newdeletenew[]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 走的兩條截然不同卻同樣自洽的道路。

AI 共讀助教正在陪你讀:C++ 記憶體管理與 RAII
嗨!我是這篇文章的共讀助教,只根據〈C++ 記憶體管理與 RAII〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。