學習目標

讀完本章後,你應該能夠:

  1. 理解各種建構子的種類與用法(預設、帶參數、委託、轉換、explicit
  2. 掌握成員初始化列表的語法、初始化順序與「為何優先於賦值」
  3. 理解解構子的角色與解構順序
  4. 掌握拷貝建構子與拷貝賦值運算子,分辨深拷貝 vs 淺拷貝
  5. 深入理解 Rule of Three / Five / Zero
  6. 理解 RAII 哲學,並知道它為何是 C++ 資源管理的基石
  7. 學會使用 = default= delete
  8. 對移動語意有初步概念(詳見 Ch17)

一、為什麼需要建構子與解構子?(WHY)

物件有「生命週期」:誕生 → 使用 → 消亡。兩個關鍵時刻需要特別處理:

  • 誕生時:成員該被設成什麼初始值?若有資源(記憶體、檔案、鎖)要不要取得?
  • 消亡時:取得的資源要不要釋放?否則就會洩漏。

如果沒有建構子,物件成員可能是垃圾值;如果沒有解構子,開啟的檔案、配置的記憶體永遠不會被回收。建構子與解構子讓「初始化」與「清理」自動發生,這正是 C++ 相較於 C 在資源安全上的最大優勢,也通往本章最重要的觀念——RAII。


二、建構子(Constructor)

2.1 什麼是建構子?

建構子是在物件建立時自動呼叫的特殊成員函式,用於初始化物件狀態。

  • 名稱與類別相同
  • 沒有回傳型別(連 void 都沒有)
  • 可以多載(提供多種建立方式)

2.2 預設建構子(Default Constructor)

不需要任何參數即可呼叫的建構子:

class Point {
public:
    double x;
    double y;
    Point() : x(0.0), y(0.0) {}   // 預設建構子
};

Point p;     // 呼叫預設建構子(注意:不要寫 Point p();)

重要規則:如果你定義了任何建構子,編譯器就不會自動生成預設建構子。若仍需要它,可用 Point() = default; 明確要求。

2.3 帶參數的建構子

class Point {
public:
    double x, y;
    Point(double x, double y) : x(x), y(y) {}
};

Point p(3.0, 4.0);
Point q{3.0, 4.0};   // 大括號形式(推薦,可防窄化)

2.4 建構子多載

提供多種建立物件的方式:

class Date {
    int year, month, day;
public:
    Date() : year(2000), month(1), day(1) {}
    Date(int y) : year(y), month(1), day(1) {}
    Date(int y, int m, int d) : year(y), month(m), day(d) {}
};

Date d1;             // 2000/1/1
Date d2(2026);       // 2026/1/1
Date d3(2026, 6, 15);

2.5 委託建構子(Delegating Constructor,C++11)

一個建構子可以呼叫同類別的另一個建構子,避免重複的初始化邏輯:

class Date {
    int year, month, day;
public:
    Date(int y, int m, int d) : year(y), month(m), day(d) {
        // 真正的初始化邏輯只寫一次
    }
    Date() : Date(2000, 1, 1) {}        // 委託給上面的建構子
    Date(int y) : Date(y, 1, 1) {}      // 委託
};

委託建構子讓「共用的初始化程式碼」集中在一個建構子裡,其餘的轉呼叫它。注意:委託與成員初始化列表不能並存於同一個建構子(要嘛委託、要嘛自己初始化成員)。

2.6 轉換建構子與 explicit

單一參數的建構子預設是「轉換建構子」,會被用於隱式型別轉換,這常常不是你要的:

class Meter {
    double value;
public:
    Meter(double v) : value(v) {}   // 沒有 explicit
};

void run(Meter m);
run(5.0);          // 😱 5.0 被悄悄轉成 Meter!可能不是你的本意
Meter m = 5.0;     // 也能隱式轉換

加上 explicit 可禁止這種隱式轉換,只允許明確建構:

class Meter {
    double value;
public:
    explicit Meter(double v) : value(v) {}
};

Meter m1(5.0);     // ✓ 直接初始化
Meter m3{5.0};     // ✓ 直接初始化
// Meter m2 = 5.0; // ✗ 編譯錯誤!禁止隱式轉換
// run(5.0);       // ✗ 編譯錯誤

指引(C++ Core Guidelines C.46):預設把單一參數的建構子宣告為 explicit,除非你刻意要提供隱式轉換。


三、成員初始化列表(Member Initializer List)

3.1 語法

ClassName(params) : member1(value1), member2(value2) { /* body */ }

3.2 為什麼「必須」使用初始化列表?

以下情況只能用初始化列表,無法在建構子本體中賦值:

  1. const 成員:常數一旦建立就不能再賦值。
  2. 參考成員:參考必須在誕生時就綁定對象。
  3. 沒有預設建構子的成員物件:本體賦值前它得先被預設建構,但它沒有預設建構子。
class Example {
    const int id;
    std::string& nameRef;
    SomeClass obj;   // 假設 SomeClass 沒有預設建構子

public:
    Example(int id, std::string& name, int val)
        : id(id), nameRef(name), obj(val) {}   // 三者都「必須」在此初始化
};

3.3 為什麼「應該優先」使用初始化列表(即使非必要)?

考慮成員是 std::string

// 寫法 A:在本體賦值
class Bad {
    std::string name;
public:
    Bad(const std::string& n) {
        name = n;   // 先「預設建構」name(空字串),再「賦值」覆蓋 → 兩步驟
    }
};

// 寫法 B:初始化列表
class Good {
    std::string name;
public:
    Good(const std::string& n) : name(n) {}   // 直接用 n 建構 name → 一步到位
};

寫法 A 會先預設建構成員、再賦值,做了兩次工。寫法 B 直接以正確的值建構,更有效率。

指引(C++ Core Guidelines C.49):偏好在初始化列表中初始化,而非在建構子本體中賦值。

3.4 初始化順序(重要陷阱)

成員的初始化順序取決於在類別中的宣告順序而非初始化列表中書寫的順序!

class Tricky {
    int a;   // 宣告在前 → 先初始化
    int b;   // 宣告在後 → 後初始化
public:
    Tricky(int val) : b(val), a(b + 1) {}   // ⚠ 危險!
    // 雖然列表中 b 寫在前面,但 a 會「先」初始化(因 a 宣告在前)
    // 此時 b 尚未初始化,a = b + 1 使用了垃圾值!
};

-Wall 時編譯器會警告 -Wreorder正確做法:讓初始化列表的順序與宣告順序一致。


四、解構子(Destructor)

4.1 基本概念

解構子在物件生命週期結束時自動呼叫,用於釋放資源(關檔、釋放記憶體、解鎖…)。

class FileWrapper {
    FILE* file;
public:
    FileWrapper(const char* path) { file = std::fopen(path, "r"); }
    ~FileWrapper() {                        // 解構子:名稱前加 ~
        if (file) std::fclose(file);        // 釋放資源
    }
};

特性:名稱是 ~ClassName、無參數、無回傳值、不可多載(每個類別只有一個解構子)。

4.2 解構子的呼叫時機

  • 區域(自動)變數離開作用域時。
  • delete 一個動態配置的物件時。
  • 容器被銷毀時,其中的元素一併被解構。
  • 程式結束時,static / 全域物件被解構。

4.3 解構順序:與建構相反(LIFO)

{
    Logger a("A");   // 第 1 個建構
    Logger b("B");   // 第 2 個建構
    Logger c("C");   // 第 3 個建構
}   // 解構順序:C → B → A(後建構者先解構,後進先出)
建構:  A ──► B ──► C
                     │
作用域結束           ▼
解構:  A ◄── B ◄── C

成員物件的解構順序也是宣告順序的相反。


五、拷貝控制(Copy Control)

5.1 拷貝建構子

用一個既有物件來初始化新物件時呼叫:

class MyClass {
public:
    MyClass(const MyClass& other);   // 拷貝建構子
};

觸發時機: - MyClass b = a; - MyClass b(a); - 以傳值方式傳入函式參數 - 以傳值方式回傳(可能被編譯器最佳化省略,copy elision)

5.2 拷貝賦值運算子

已存在的物件賦予另一個物件的值時呼叫:

class MyClass {
public:
    MyClass& operator=(const MyClass& other);
};

MyClass a, b;
b = a;   // 呼叫拷貝賦值運算子(注意:MyClass b = a; 是拷貝「建構」)

5.3 深拷貝 vs 淺拷貝(核心觀念)

當類別包含裸指標成員時,編譯器自動生成的拷貝只會複製「指標的值」(淺拷貝),導致兩個物件指向同一塊記憶體

淺拷貝(危險):
   物件 a            物件 b
  ┌────────┐        ┌────────┐
  │ data ──┼───┐    │ data ──┼───┐
  └────────┘   │    └────────┘   │
               ▼                  ▼
            ┌──────────────────────┐
            │   同一塊堆積記憶體     │   ← a、b 都指向它!
            └──────────────────────┘
   → a 與 b 解構時各 delete 一次 → 同一塊記憶體被釋放兩次 → 💥 double free
深拷貝(正確):
   物件 a            物件 b
  ┌────────┐        ┌────────┐
  │ data ──┼──┐     │ data ──┼──┐
  └────────┘  ▼     └────────┘  ▼
        ┌────────┐        ┌────────┐
        │ 記憶體1 │        │ 記憶體2 │   ← 各自獨立、內容相同
        └────────┘        └────────┘

深拷貝實作:配置新記憶體並複製內容

class DynamicArray {
    int* data;
    int size;
public:
    DynamicArray(int n) : data(new int[n]), size(n) {}

    // 深拷貝建構子
    DynamicArray(const DynamicArray& other)
        : data(new int[other.size]), size(other.size) {
        std::copy(other.data, other.data + size, data);
    }

    // 深拷貝賦值(含自我賦值檢查)
    DynamicArray& operator=(const DynamicArray& other) {
        if (this == &other) return *this;     // 自我賦值檢查
        delete[] data;                         // 釋放舊資源
        size = other.size;
        data = new int[size];
        std::copy(other.data, other.data + size, data);
        return *this;
    }

    ~DynamicArray() { delete[] data; }
};

copy-and-swap 慣用手法:拷貝賦值還有一種更安全的寫法,先建立副本再 swap,能同時提供例外安全與自我賦值安全。詳見 copy_control.cpp


六、Rule of Three / Five / Zero

6.1 Rule of Three(C++98)

如果你需要自定義以下任何一個,通常三個都需要

  1. 解構子
  2. 拷貝建構子
  3. 拷貝賦值運算子

原因:會需要自訂解構子,通常代表類別管理了某種資源(如裸指標);那麼預設的淺拷貝就會出錯,因此拷貝建構與拷貝賦值也得一起自訂。

6.2 Rule of Five(C++11)

加入移動語意後,特殊成員函式變成五個:

  1. 解構子
  2. 拷貝建構子
  3. 拷貝賦值運算子
  4. 移動建構子
  5. 移動賦值運算子

移動語意允許「竊取」來源物件的資源(而非複製),效率更高(詳見 Ch17)。一旦你自訂了拷貝/解構,編譯器就不會自動生成移動操作,需要時得自己補上。

你自訂了… 編譯器自動生成的其他特殊成員
解構子 拷貝操作仍生成但已棄用;移動操作不生成
拷貝建構/賦值 移動操作不生成
移動建構/賦值 拷貝操作被隱式刪除

6.3 Rule of Zero(最佳實踐)

指引(C++ Core Guidelines C.20):能不自訂特殊成員函式就不要自訂。

盡量讓類別由「會自我管理的型別」組成(std::stringstd::vectorstd::unique_ptr…)。這樣編譯器自動生成的拷貝、移動、解構都會是正確的,你一個都不用寫:

class Person {
    std::string name;        // 自我管理記憶體
    std::vector<int> scores; // 自我管理記憶體
    // 不需要解構子、拷貝、移動 —— 編譯器全幫你做對了!
};

把前面 DynamicArray 的裸指標換成 std::vector<int>,整個 Rule of Three 就消失了。這是現代 C++ 的首選做法。


七、= default 與 = delete

class MyClass {
public:
    MyClass() = default;                          // 明確要求編譯器生成預設版本
    MyClass(const MyClass&) = delete;             // 禁止拷貝
    MyClass& operator=(const MyClass&) = delete;  // 禁止拷貝賦值
};
  • = default:明確要求生成「編譯器版本」,意圖清楚(如定義了其他建構子後想把預設建構子加回來)。
  • = delete:明確禁止某操作。常用於「不該被複製」的型別(如管理唯一資源者:檔案 handle、鎖、unique_ptr)。嘗試使用被 delete 的函式會編譯錯誤。

八、RAII(Resource Acquisition Is Initialization)

8.1 核心思想

把資源的取得與釋放,綁定到物件的生命週期上。

  • 建構子中取得資源(開檔、配置記憶體、上鎖)。
  • 解構子中釋放資源(關檔、釋放記憶體、解鎖)。
  • 利用 C++「物件離開作用域必定呼叫解構子」的保證,確保資源一定被釋放。
{
    FileWrapper f("data.txt");   // 建構子開檔
    // ... 使用檔案 ...
    if (error) throw std::runtime_error("oops");   // 即使這裡丟例外…
}   // 離開作用域,解構子自動關檔 —— 連例外路徑都不會漏!

8.2 為什麼 RAII 如此重要?

對比手動管理:

// ❌ 手動管理:例外或提前 return 都會洩漏
void bad() {
    int* p = new int[100];
    risky();              // 若這裡丟例外,下面的 delete 永遠不執行 → 洩漏
    delete[] p;
}

// ✓ RAII:解構子保證執行
void good() {
    std::vector<int> v(100);   // 自動管理
    risky();                    // 即使丟例外,v 的解構子仍會釋放記憶體
}   // 不需要手動 delete

指引(C++ Core Guidelines R.1):用 RAII 物件自動管理資源生命週期。這是 C++ 例外安全與避免洩漏的根本手段。

8.3 標準函式庫中的 RAII

RAII 無所不在: - std::unique_ptr / std::shared_ptr — 自動管理動態記憶體 - std::lock_guard / std::unique_lock — 自動管理鎖 - std::fstream — 自動關閉檔案 - std::vector / std::string — 自動管理緩衝區


九、移動語意預習(Ch17 詳述)

當來源物件是個「即將消失」的臨時物件時,與其「複製」它的資源,不如直接「搬走」更有效率。這就是移動語意

std::vector<int> makeBig() {
    std::vector<int> v(1000000);
    return v;          // 回傳:移動而非複製,不會逐一複製百萬個元素
}

std::string a = "hello";
std::string b = std::move(a);   // 把 a 的緩衝區「搬」給 b,a 變成空殼

移動建構子/移動賦值運算子接收 T&&(右值參考),竊取來源的資源指標並把來源置空。它把 Rule of Three 擴充成 Rule of Five。完整內容(右值參考、std::move、完美轉發、noexcept)將在 Ch17 移動語意與右值參考 深入講解。

現階段只要記得:移動 = 轉移資源所有權,比複製快;而 Rule of Zero(用標準容器)能讓你免費獲得正確的移動行為。


重點整理

概念 說明
預設建構子 無參數;定義了其他建構子後可用 = default 加回
委託建構子 一個建構子呼叫同類別的另一個建構子,集中初始化邏輯
explicit 防止單參數建構子被用於隱式轉換
初始化列表 const、參考、無預設建構子的成員「必須」用;其餘也「應優先」用
初始化順序 宣告順序,非列表書寫順序
解構子 物件銷毀時自動呼叫;解構順序與建構相反(LIFO)
拷貝建構子 vs 拷貝賦值 前者「初始化新物件」,後者「賦值給既有物件」
深拷貝 配置新記憶體並複製內容,避免 double free
淺拷貝 只複製指標值,導致共享記憶體(危險)
Rule of Three 解構子、拷貝建構子、拷貝賦值運算子,要嘛全寫要嘛都不寫
Rule of Five 再加移動建構子、移動賦值運算子
Rule of Zero 用標準容器,讓編譯器自動生成正確版本(首選
= default / = delete 明確要求 / 禁止某特殊成員函式
RAII 資源取得即初始化,綁定物件生命週期,保證釋放

常見錯誤與陷阱

  1. 定義了帶參數建構子卻忘了預設建構子:編譯器不再自動生成,MyClass obj; 會失敗。用 MyClass() = default; 明確加回。

  2. 初始化列表順序與宣告順序不同:實際初始化依宣告順序,若有依賴關係會讀到未初始化的值(-Wreorder 會警告)。

  3. 淺拷貝導致 double free:有裸指標成員時必須實作深拷貝(Rule of Three),或改用智慧指標 / 容器(Rule of Zero)。

  4. 拷貝賦值運算子忘記自我賦值檢查a = a; 若先 delete 再複製,會釋放掉自己的資源後再讀它(或用 copy-and-swap 從根本避免)。

  5. 單參數建構子未加 explicit:可能造成意外的隱式轉換,產生難以追蹤的 bug。

  6. 在解構子中拋出例外:解構子若在堆疊展開(stack unwinding)期間拋例外會導致 std::terminate。解構子應宣告為(隱式)noexcept 並避免拋例外。

  7. 以為回傳值一定會呼叫拷貝建構子:現代編譯器有 copy elision / RVO,可能完全省略拷貝。

  8. 手動 new/delete 管理資源:易漏、易在例外路徑出錯。優先用 RAII(智慧指標、容器)。


練習題

以下練習由淺入深。建議搭配 constructors.cppcopy_control.cppraii.cpp 對照學習。

練習 1(基礎):Time 類別與委託建構子

設計一個 Time 類別(時、分、秒),提供:預設建構子(00:00:00)、三參數建構子、以及一個只給「秒數」的單參數建構子(用委託建構子換算成時分秒)。提供 toString() const 格式化輸出為 HH:MM:SS提示:單參數建構子可 Time(int totalSec) : Time(totalSec/3600, (totalSec%3600)/60, totalSec%60) {}

練習 2(基礎):explicit 實驗

設計一個 Celsius 類別包裝溫度值,分別測試「有加 explicit」與「沒加」時,Celsius c = 25.0;void f(Celsius); f(25.0); 的編譯結果差異,並用註解記錄觀察。 提示:把 explicit 註解掉前後各編譯一次。

練習 3(中級):初始化列表與 const 成員

設計一個 Student 類別,包含 const int idstd::string& schoolRef 成員,示範為何「必須」使用初始化列表。再加一個故意「宣告順序與初始化順序不一致」的版本,觀察 -Wall-Wreorder 警告。 提示:參考成員需要一個外部的 std::string 物件來綁定。

練習 4(中級):IntArray 與 Rule of Three

實作一個 IntArray 類別,內部用裸指標 int* 管理動態陣列。完整實作 Rule of Three:解構子、深拷貝建構子、深拷貝賦值運算子(含自我賦值檢查)。寫測試驗證拷貝後兩物件的 data 位址不同、修改其一不影響另一。 提示:用 printAddress() 印出 data 指標位址來驗證深拷貝。

練習 5(中級):RAII Timer

設計一個 Timer 類別:建構時用 <chrono> 記錄起始時間,解構時印出存活期間(毫秒)。在不同作用域中建立它,觀察解構自動觸發計時輸出。 提示auto start = std::chrono::steady_clock::now();,解構時再取一次 now() 相減。

練習 6(挑戰):Rule of Three → Rule of Zero

把練習 4 的 IntArray 改寫成內部使用 std::vector<int>,刪掉所有手寫的解構子、拷貝建構子、拷貝賦值運算子,驗證程式行為完全正確。寫一段註解說明為什麼這樣就安全(Rule of Zero)。 提示:改完後類別應該「一個特殊成員函式都不用寫」。

練習 7(挑戰):簡易 String 類別

設計一個 String 類別模擬 std::string 的部分功能,內部用 char* 管理緩衝區。實作完整 Rule of Three、length()c_str(),以及 operator+(字串串接)。用 valgrind 或 AddressSanitizer 確認沒有記憶體洩漏與 double free。 提示operator+ 需配置「兩字串長度 + 1」的新緩衝區並複製內容;別忘了結尾的 '\0'。編譯時可加 -fsanitize=address 檢查。


對應程式碼檔案

  • constructors.cpp — 各種建構子、初始化列表、委託建構子、explicit= default/= delete、初始化順序、解構子
  • copy_control.cpp — 拷貝建構子、拷貝賦值、深拷貝 vs 淺拷貝、Rule of Three、copy-and-swap、自我賦值
  • raii.cpp — RAII 概念、檔案/記憶體/鎖的資源管理、簡易智慧指標、例外安全