C++ 物件拷貝的風險與對策:從淺拷貝到深拷貝

對於剛接觸 C++ 的新手來說,記憶體管理往往是最容易踩雷的地方。它不像 Java 或 Python 那樣,會在背後自動協助管理記憶體;在 C++ 中,我們必須時時留意記憶體的配置與釋放,才能避免程式變得不穩定,甚至造成記憶體洩漏。

Picture 類別

本文章會重複以 Picture 類別物件,示範可能會導致懸空指標、重複刪除等等執行期錯誤的程式碼。 在 Picture 類別物件中,像素資料儲存在 heap 空間。當 Picture 物件生命週期結束後,要負責釋放它在 heap 空間的像素資料。

以下為 Picture 類別的定義 :

class Picture {
public:
	// 影像寬、高
	int w, h;

	// 動態配置的 int 陣列,共有 w * h 個像素
	int* pixels;

	// 給定高和寬,建立 Picture 物件
	Picture(int w, int h) : w(w), h(h) { pixels = new int[w * h]; }

	// 釋放 this->pixels 的空間
	~Picture() { delete[] pixels; } 
};

記憶體洩漏 Memory Leak

程式向系統申請一塊記憶體,之後卻沒有釋放,導致那塊記憶體被白白佔據。也就是借了記憶體,卻忘記歸還。

Example :

int* p = new int(5);

當我們在 heap 配置一塊記憶體,後續卻沒有寫 :

delete p;

那這塊記憶體就不會被釋放,如果這種事情一值發生,當程式執行越久,吃掉的記憶體會越來越多。

拷貝物件

在 C++ 中,物件的拷貝分為「淺拷貝 Shallow Copy」和「深拷貝 Deep Copy」,選擇對的拷貝方式非常重要,可以避免記憶體相關的錯誤,並適當地管理資源。

淺拷貝 Shallow Copy : 快、風險高

  • 只會複製各個物件成員的值;若成員中包含指標,則複製的是指標儲存的位址,而不是指標所指向的資料,因此不同物件會共享同一塊記憶體。
  • 若不同物件的指標成員指向同一塊記憶體,則其中一個物件對資料的修改,會連帶影響其他物件。
  • 這可能導致懸空指標 ( Dangling Pointer ) 和重複刪除 ( Double Deletion ) 等執行期錯誤。
Shallow Copy
圖 (一)、若成員中包含指標,則複製的是指標儲存的位址,而不是指標所指向的資料,因此不同物件會共享同一塊記憶體。其中一個物件對資料的修改,會連帶影響其他物件,並可能造成懸空指標 ( Dangling Pointer ) 和重複刪除 ( Double Deletion ) 等執行期錯誤。

深拷貝 Deep Copy : 慢、較安全

  • 建立新物件時,不僅拷貝所有成員變數的值,還為動態配置的資料另外開一塊獨立的記憶體。
  • 原始與拷貝後的物件會各自擁有獨立的資料,不會共享同一塊動態記憶體。
  • 因為使用了自訂的拷貝建構子,所以其中一個物件的變動不會連帶影響另一個物件。
deep copy
圖 (二)、建立新物件時,不僅拷貝所有成員變數的值,還為動態配置的資料另外開一塊獨立的記憶體。也就是說,原始與拷貝後的物件會各自擁有獨立的資料,不會共享同一塊動態記憶體。

懸空指標 Dangling Pointer

指標還存著某個記憶體位置,但那塊記憶體其實已經失效。簡單來說就是,指標還在,但是它指向的物件已經死了。

Case 1 : delete 後指標沒有設定為 nullptr

int* p = new int(10); // 動態配置記憶體
delete p; // 釋放記憶體
cout << *p << endl; // 危險 : 讀取已經失效的記憶體,直接 crash

Case 2 : 使用區域變數的地址

變數 a 是區域變數,存在於 stack 空間。當區塊結束後,a 的生命週期也結束,那塊 stack 空間不再屬於它。最後 p 將指向一塊失效的記憶體位置。

int* p = nullptr;

{ 
    int a = 1;
    p = &a; // 儲存區域變數地址
}

cout << *p << endl; // 危險

重複刪除 Double Deletion

同一塊動態配置的記憶體被釋放兩次以上。這很危險,因為第一次 delete 之後,那塊記憶體就失效了,第二次會造成不預期的結果。

Case 1 : 兩個指標指向同一塊記憶體,卻都拿去釋放

int* p1 = new int(10);
int* p2 = p1; // p2 和 p1 指向同一塊記憶體

delete p1;
delete p2; // 出事 : double deletion

Case 2 : 淺拷貝導致兩個物件共用同一塊動態記憶體

在這個案例中,因為 Picture 類別沒有自己寫 copy constructor,預設拷貝會把成員值直接抄過去,所以 picture1.pixelspicture2.pixels 指向同一塊 heap 記憶體。

離開 main() 時,picture2 解構,pixels 被釋放。接著 picture1 解構,嘗試 delete 一塊失效的 heap 空間,直接出事。

int main() {
	Picture picture1(10, 10);
	Picture picture2 = picture1; // 出事
}

懸空指標和重複刪除的關係

這兩個常常一起出現,首先因為記憶體被釋放,某個指標變成懸空指標 ( dangling pointer ),再用那個懸空指標做 delete,最後導致重複刪除 ( double deletion )。所以這有點像連環車禍,兩個事件並非完全獨立。

int* p1 = new int(10);
int* p2 = p1;

delete p1;   // 之後 p2 變成 dangling pointer
delete p2;   // 再刪一次,就變成 double delete

傳遞參數間接導致的 Double Deletion

以值傳遞 ( call by value ) 方式傳入物件時,會產生一份新的物件拷貝;若類別未正確實作拷貝建構子,預設淺拷貝可能使原物件與參數物件共享同一塊動態記憶體,進而在解構時造成 double deletion。

void func1(Picture picture) {
	cout << "func1" << endl;
}

int main() {
	Picture picture(10, 10);
	func1(picture); // 出事
}

使用 C++ 標準容器時的物件複製問題

在這個案例中,因為 push_back 會把 picture 複製一份到 vector 容器中,但 Picture 類別沒有自己寫一個 copy constructor,所以會使用預設淺拷貝。

這會導致 picture.pixelsvector 中物件的 pixels 指向同一塊動態記憶體。最後 main() 結束時,兩個物件各自執行 delete[] pixels 時,就會出事,造成 double deletion。

int main() { 
    vector<Picture> pictures; 
    Picture picture(10, 10); 
    pictures.push_back(picture); // 出事
}

如何避免懸空指標或重複刪除?

本文章舉例了非常多,跟物件有關,可能造成懸空指標、重複刪除造成的錯誤,那我們該怎麼避免呢?

  • 重寫 copy constructor
  • 重寫 operator=
  • 參數傳遞方式改為 call by reference

重寫 Copy Constructor

如果不重寫 copy constructor,編譯器預設只會做淺拷貝,把指標位置直接拷貝過去。所以重寫 copy constructor 的目的,就是讓新物件建立自己的資源,也就是深拷貝

Picture p2 = p1;
Picture p3(p1);

重寫 Operator=

當一個已經存在的物件,被另一個同類物件指定內容時,會呼叫 operator=。如果 class 中有 const 成員,通常無法完成一般的指定操作,因此禁止使用 operator= 會比較安全。

Picture p1(10, 10);
Picture p2(20, 20);
p2 = p1; // 等同於 p2.operator=(p1)
// operator= 通常會 return *this,也就是 p2 自己的參考
// 所以 ( p2 = p1 ) 也是有回傳值的

在這個案例中,是把 p1 的內容指定給 p2。所以重寫 operator= 的目的,是優先處理 p2 原本的資源,在正確複製 p1 的資源,避免記憶體洩漏、淺拷貝或是 double deletion。

完成 Picture 類別,避免懸空指標或重複刪除

重寫 copy constructor 為深拷貝版本

為了深拷貝一份新的 Picture 物件,會直接複製原始物件的寬、高數值。而像素資料的部分,為了不與原始物件共用同一塊記憶體,需要動態配置另一個 heap 空間,並複製所有原始物件的像素資料到自己身上。

如此一來,原始與拷貝後的物件會各自擁有獨立的資料,不會共享同一塊動態記憶體,大大增加安全性。

Picture(const Picture& picture) : w(picture.w), h(picture.h) {
	pixels = new int[w * h];
	for (int i = 0; i < w * h; ++i) pixels[i] = picture.pixels[i];
}

重寫 operator= 為深拷貝版本

首先最重要的是,確定是不是自己指定給自己? 否則下面 delete[] pixels 會先把自己的資料刪掉,後面再從自己身上複製資料,就出事了。

Picture& operator=(const Picture& picture) {
	if (this != &picture) { // 確定是不是自己指定給自己
		delete[] pixels; // 刪除自己的資料
		w = picture.w; 
		h = picture.h;
		pixels = new int[w * h];
		for (int i = 0; i < w * h; ++i) pixels[i] = picture.pixels[i];
	}
	return *this;
}

善用現代 C++ 的解決方案

在實務開發中,通常不建議手動使用 newdelete 管理記憶體,因為這類寫法容易導致淺拷貝、重複刪除等問題。現代 C++ 提供了更安全的做法,例如使用 std::vector 來管理動態陣列:

int* pixels 更換成 :

std::vector<int> pixels;

std::vector 會自動管理記憶體的配置與釋放,並且在複製物件時進行深拷貝,因此可以避免手動管理記憶體時常見的錯誤。

雖然現代 C++ 提供了 std::vector、智慧指標等工具來降低記憶體管理的風險,但這些工具只是封裝了底層機制,並沒有改變物件生命週期、所有權與拷貝行為等核心概念。若不了解這些基礎,即使使用現代寫法仍可能不小心產生錯誤。因此,學習傳統方式的目的,是為了理解資源管理的本質,更好地善用現代方法,而非鼓勵手動使用 newdelete

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *