Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

記憶體管理與 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〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。