學習目標
讀完本章後,你應該能夠:
- 理解各種建構子的種類與用法(預設、帶參數、委託、轉換、
explicit) - 掌握成員初始化列表的語法、初始化順序與「為何優先於賦值」
- 理解解構子的角色與解構順序
- 掌握拷貝建構子與拷貝賦值運算子,分辨深拷貝 vs 淺拷貝
- 深入理解 Rule of Three / Five / Zero
- 理解 RAII 哲學,並知道它為何是 C++ 資源管理的基石
- 學會使用
= default與= delete - 對移動語意有初步概念(詳見 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 為什麼「必須」使用初始化列表?
以下情況只能用初始化列表,無法在建構子本體中賦值:
const成員:常數一旦建立就不能再賦值。- 參考成員:參考必須在誕生時就綁定對象。
- 沒有預設建構子的成員物件:本體賦值前它得先被預設建構,但它沒有預設建構子。
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)
如果你需要自定義以下任何一個,通常三個都需要:
- 解構子
- 拷貝建構子
- 拷貝賦值運算子
原因:會需要自訂解構子,通常代表類別管理了某種資源(如裸指標);那麼預設的淺拷貝就會出錯,因此拷貝建構與拷貝賦值也得一起自訂。
6.2 Rule of Five(C++11)
加入移動語意後,特殊成員函式變成五個:
- 解構子
- 拷貝建構子
- 拷貝賦值運算子
- 移動建構子
- 移動賦值運算子
移動語意允許「竊取」來源物件的資源(而非複製),效率更高(詳見 Ch17)。一旦你自訂了拷貝/解構,編譯器就不會自動生成移動操作,需要時得自己補上。
| 你自訂了… | 編譯器自動生成的其他特殊成員 |
|---|---|
| 解構子 | 拷貝操作仍生成但已棄用;移動操作不生成 |
| 拷貝建構/賦值 | 移動操作不生成 |
| 移動建構/賦值 | 拷貝操作被隱式刪除 |
6.3 Rule of Zero(最佳實踐)
指引(C++ Core Guidelines C.20):能不自訂特殊成員函式就不要自訂。
盡量讓類別由「會自我管理的型別」組成(std::string、std::vector、std::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 | 資源取得即初始化,綁定物件生命週期,保證釋放 |
常見錯誤與陷阱
-
定義了帶參數建構子卻忘了預設建構子:編譯器不再自動生成,
MyClass obj;會失敗。用MyClass() = default;明確加回。 -
初始化列表順序與宣告順序不同:實際初始化依宣告順序,若有依賴關係會讀到未初始化的值(
-Wreorder會警告)。 -
淺拷貝導致 double free:有裸指標成員時必須實作深拷貝(Rule of Three),或改用智慧指標 / 容器(Rule of Zero)。
-
拷貝賦值運算子忘記自我賦值檢查:
a = a;若先delete再複製,會釋放掉自己的資源後再讀它(或用 copy-and-swap 從根本避免)。 -
單參數建構子未加 explicit:可能造成意外的隱式轉換,產生難以追蹤的 bug。
-
在解構子中拋出例外:解構子若在堆疊展開(stack unwinding)期間拋例外會導致
std::terminate。解構子應宣告為(隱式)noexcept並避免拋例外。 -
以為回傳值一定會呼叫拷貝建構子:現代編譯器有 copy elision / RVO,可能完全省略拷貝。
-
手動
new/delete管理資源:易漏、易在例外路徑出錯。優先用 RAII(智慧指標、容器)。
練習題
以下練習由淺入深。建議搭配
constructors.cpp、copy_control.cpp、raii.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 id 與 std::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 概念、檔案/記憶體/鎖的資源管理、簡易智慧指標、例外安全