目錄 Link to heading
什麼是 Rule of Three? Link to heading
Rule of Three(三法則) 是 C++ 中一個重要的設計原則,它指出:
如果一個類別需要自定義以下三者之一,那麼它很可能需要自定義所有三個:
- 解構子(Destructor)
- 複製建構子(Copy Constructor)
- 複製賦值運算子(Copy Assignment Operator)
這個原則的核心概念是:如果一個類別需要管理資源(如動態記憶體、檔案句柄、網路連線等),就必須明確定義這三個特殊成員函式,以確保資源的正確管理。
為什麼需要 Rule of Three? Link to heading
當類別擁有指標成員變數或其他需要手動管理的資源時,編譯器自動產生的複製建構子和複製賦值運算子只會進行淺複製(Shallow Copy),這會導致:
- 記憶體洩漏(Memory Leak)
- 重複釋放(Double Free)
- 懸空指標(Dangling Pointer)
問題範例:違反 Rule of Three Link to heading
讓我們看一個沒有遵守 Rule of Three 的例子:
#include <iostream>
#include <cstring>
class BadString {
private:
char* data;
size_t length;
public:
// 建構子:分配記憶體
BadString(const char* str = "") {
length = strlen(str);
data = new char[length + 1];
strcpy(data, str);
std::cout << "建構 BadString: " << data << "\n";
}
// 解構子:釋放記憶體
~BadString() {
std::cout << "解構 BadString: " << data << "\n";
delete[] data;
}
// 沒有定義複製建構子!
// 沒有定義複製賦值運算子!
void print() const {
std::cout << "內容: " << data << "\n";
}
};
int main() {
BadString str1("Hello");
str1.print();
std::cout << "\n--- 複製建構 ---\n";
BadString str2 = str1; // 使用編譯器產生的複製建構子
str2.print();
std::cout << "\n--- 離開作用域 ---\n";
// 程式崩潰!str1 和 str2 指向同一塊記憶體
// 解構時會重複釋放同一塊記憶體(Double Free)
return 0;
}
執行結果(可能崩潰) Link to heading
建構 BadString: Hello
內容: Hello
--- 複製建構 ---
內容: Hello
--- 離開作用域 ---
解構 BadString: Hello
解構 BadString: Hello ← 重複釋放!程式崩潰
*** Error in `./program': double free or corruption
問題分析 Link to heading
初始狀態 (str1):
str1.data → [H][e][l][l][o][\0]
淺複製後 (str2 = str1):
str1.data ─┐
├→ [H][e][l][l][o][\0] ← 兩個指標指向同一塊記憶體!
str2.data ─┘
解構時:
1. str2 解構:delete[] 釋放記憶體
2. str1 解構:delete[] 再次釋放已釋放的記憶體!崩潰!
正確做法:遵守 Rule of Three Link to heading
#include <iostream>
#include <cstring>
class GoodString {
private:
char* data;
size_t length;
public:
// 建構子
GoodString(const char* str = "") {
length = strlen(str);
data = new char[length + 1];
strcpy(data, str);
std::cout << "建構: " << data << " (位址: " << (void*)data << ")\n";
}
// 1. 解構子
~GoodString() {
std::cout << "解構: " << data << " (位址: " << (void*)data << ")\n";
delete[] data;
}
// 2. 複製建構子(深複製)
GoodString(const GoodString& other) {
length = other.length;
data = new char[length + 1]; // 分配新記憶體
strcpy(data, other.data); // 複製內容
std::cout << "複製建構: " << data << " (位址: " << (void*)data << ")\n";
}
// 3. 複製賦值運算子
GoodString& operator=(const GoodString& other) {
std::cout << "複製賦值: " << other.data << "\n";
// 檢查自我賦值
if (this == &other) {
return *this;
}
// 釋放舊資源
delete[] data;
// 複製新資源(深複製)
length = other.length;
data = new char[length + 1];
strcpy(data, other.data);
return *this;
}
void print() const {
std::cout << "內容: " << data << " (位址: " << (void*)data << ")\n";
}
};
int main() {
GoodString str1("Hello");
str1.print();
std::cout << "\n--- 複製建構 ---\n";
GoodString str2 = str1; // 呼叫複製建構子
str2.print();
std::cout << "\n--- 複製賦值 ---\n";
GoodString str3("World");
str3 = str1; // 呼叫複製賦值運算子
str3.print();
std::cout << "\n--- 離開作用域 ---\n";
// 正常解構,每個物件都有自己的記憶體
return 0;
}
執行結果 Link to heading
建構: Hello (位址: 0x1234)
內容: Hello (位址: 0x1234)
--- 複製建構 ---
複製建構: Hello (位址: 0x5678) ← 新的記憶體位址
內容: Hello (位址: 0x5678)
--- 複製賦值 ---
建構: World (位址: 0x9abc)
複製賦值: Hello
內容: Hello (位址: 0x9abc)
--- 離開作用域 ---
解構: Hello (位址: 0x9abc) ← 每個物件有自己的記憶體
解構: Hello (位址: 0x5678)
解構: Hello (位址: 0x1234)
深複製 vs 淺複製 Link to heading
淺複製(Shallow Copy) Link to heading
編譯器預設產生的複製行為,只複製指標的值(位址),不複製指標指向的內容:
// 編譯器產生的淺複製
GoodString(const GoodString& other)
: data(other.data), // 只複製指標!
length(other.length)
{
}
淺複製:
str1.data → [記憶體內容]
↑
str2.data ────┘ 兩個指標指向同一塊記憶體
深複製(Deep Copy) Link to heading
手動實作的複製行為,分配新的記憶體並複製內容:
// 手動實作的深複製
GoodString(const GoodString& other) {
length = other.length;
data = new char[length + 1]; // 分配新記憶體
strcpy(data, other.data); // 複製內容
}
深複製:
str1.data → [記憶體內容 1]
str2.data → [記憶體內容 2] 各自擁有獨立的記憶體
複製賦值運算子的實作細節 Link to heading
複製賦值運算子的實作需要注意幾個重點:
1. 檢查自我賦值 Link to heading
GoodString& operator=(const GoodString& other) {
// 必須檢查!否則會先刪除自己的資源
if (this == &other) {
return *this;
}
// ... 賦值邏輯
}
為什麼需要檢查?
GoodString str("Hello");
str = str; // 自我賦值
// 如果沒有檢查,會發生:
delete[] data; // 刪除自己的資料
strcpy(data, other.data); // other.data 已經被刪除了!懸空指標!
2. Copy-and-Swap idiom(進階技巧) Link to heading
一個更安全、更簡潔的實作方式:
class GoodString {
private:
char* data;
size_t length;
public:
// ... 建構子、解構子、複製建構子
// swap 函式
void swap(GoodString& other) noexcept {
std::swap(data, other.data);
std::swap(length, other.length);
}
// 複製賦值運算子(使用 Copy-and-Swap)
GoodString& operator=(const GoodString& other) {
// 建立副本(複製建構)
GoodString temp(other);
// 交換內容
swap(temp);
// temp 解構時會自動釋放舊資源
return *this;
}
};
優點:
- 自動處理自我賦值:因為先建立副本
- 例外安全:如果複製建構失敗,原物件不受影響
- 程式碼簡潔:邏輯清晰易懂
何時需要 Rule of Three? Link to heading
需要的情況 Link to heading
動態記憶體管理
class DynamicArray { int* data; // 需要 delete[] };檔案句柄
class FileHandle { FILE* file; // 需要 fclose() };網路連線
class Socket { int sockfd; // 需要 close() };互斥鎖
class MutexGuard { pthread_mutex_t* mutex; // 需要 unlock() };
不需要的情況 Link to heading
如果類別只包含:
- 內建型別(int, double, etc.)
- 已經妥善管理資源的類別(std::string, std::vector, std::unique_ptr)
則不需要自定義這三個函式,使用編譯器預設的即可:
class Point {
double x, y; // 內建型別,不需要特殊處理
// 使用編譯器預設的複製/賦值/解構
};
class Person {
std::string name; // std::string 會管理自己的記憶體
std::vector<int> ages; // std::vector 會管理自己的記憶體
// 使用編譯器預設的複製/賦值/解構
};
現代 C++ 的建議 Link to heading
C++11 之後:優先使用智慧指標 Link to heading
使用 RAII(Resource Acquisition Is Initialization)和智慧指標,讓編譯器自動管理資源:
#include <memory>
#include <cstring>
class ModernString {
private:
std::unique_ptr<char[]> data; // 智慧指標自動管理記憶體
size_t length;
public:
ModernString(const char* str = "")
: length(strlen(str)),
data(std::make_unique<char[]>(length + 1))
{
strcpy(data.get(), str);
}
// 不需要自定義解構子!unique_ptr 會自動釋放
// 複製建構子(仍需手動實作深複製)
ModernString(const ModernString& other)
: length(other.length),
data(std::make_unique<char[]>(length + 1))
{
strcpy(data.get(), other.data.get());
}
// 複製賦值運算子
ModernString& operator=(const ModernString& other) {
if (this != &other) {
length = other.length;
data = std::make_unique<char[]>(length + 1);
strcpy(data.get(), other.data.get());
}
return *this;
}
};
Rule of Zero Link to heading
Rule of Zero 是現代 C++ 的理想目標:
盡可能不要自定義任何特殊成員函式,讓編譯器自動產生。
達成方式:
- 使用
std::unique_ptr、std::shared_ptr管理記憶體 - 使用
std::string代替char* - 使用
std::vector代替 raw arrays - 使用 RAII 包裝器類別管理資源
class BestPractice {
std::string name;
std::vector<int> data;
std::unique_ptr<Resource> resource;
// 不需要定義任何特殊成員函式!
// 編譯器自動產生的版本已經正確處理了所有資源
};
實務建議 Link to heading
DO(應該做的) Link to heading
有疑慮時,明確定義或明確刪除
class MyClass { public: // 明確表示意圖 MyClass(const MyClass&) = default; MyClass& operator=(const MyClass&) = default; ~MyClass() = default; };無法複製的類別,明確禁止
class NonCopyable { public: NonCopyable() = default; NonCopyable(const NonCopyable&) = delete; NonCopyable& operator=(const NonCopyable&) = delete; };
DON’T(不應該做的) Link to heading
不要只定義其中一個或兩個
class Bad { ~Bad() { delete[] data; } // 錯誤!沒有定義複製建構子和賦值運算子 // 使用預設的會造成 double free private: char* data; };不要忘記檢查自我賦值
Bad& operator=(const Bad& other) { delete[] data; // 危險! data = new char[size]; // 如果 this == &other,data 已經被刪除了 }不要在現代 C++ 中還使用 raw pointers 管理資源
class OldStyle { int* data; // 不好!應該用 std::unique_ptr<int> };
小結 Link to heading
Rule of Three 的核心概念:
- 定義時機:需要管理資源時(動態記憶體、檔案、連線等)
- 三者缺一不可:
- 解構子:釋放資源
- 複製建構子:深複製資源
- 複製賦值運算子:先釋放舊資源,再深複製新資源
- 現代做法:
- 優先使用智慧指標和 RAII
- 追求 Rule of Zero
- 無法複製的類別使用
=delete
- 安全檢查:
- 賦值運算子必須檢查自我賦值
- 考慮使用 Copy-and-Swap idiom
記住:如果你需要自定義解構子,很可能也需要自定義複製建構子和複製賦值運算子!