Chuyển tới nội dung chính

Sao chép đối tượng: sao chép sâu vs nông

Khi một đối tượng giữ con trỏ tới vùng nhớ động, việc sao chép trở nên nguy hiểm. Mặc định C++ chỉ sao chép nông (copy từng bit), nên hai đối tượng sẽ cùng trỏ vào một vùng nhớ, sửa cái này hỏng cái kia, và khi hủy sẽ delete hai lần cùng một chỗ (lỗi double free). Giải pháp: tự viết copy constructor để sao chép sâu.

Đặt vấn đề

Trong Java hay Python, gán một đối tượng cho biến khác chỉ tạo thêm một tên gọi cho cùng một đối tượng. C++ làm ngược lại: Mang b = a; tạo ra một đối tượng mới hoàn toàn. Đây là điểm mạnh của C++, nhưng cũng là nơi sinh nhiều lỗi nhất cho người mới, vì trình biên dịch âm thầm sinh hộ bạn một bản sao mà bạn không hề gõ dòng nào.

Bản sao mặc định đó chép từng trường thành viên. Với int hay double thì hoàn toàn đúng: hai đối tượng có hai con số riêng. Nhưng với một trường kiểu int*, chép trường nghĩa là chép địa chỉ, không chép thứ mà địa chỉ đó trỏ tới. Hai đối tượng cầm chung một mảnh giấy ghi cùng một địa chỉ nhà, mỗi bên tưởng ngôi nhà là của riêng mình.

Hậu quả có hai. Một là dữ liệu hỏng chéo: bạn sửa b thì a cũng đổi theo, dù mã nguồn không hề chạm tới a. Hai là double free: cả hai hủy tử cùng gọi delete[] trên một địa chỉ, lần thứ hai tác động lên vùng nhớ đã trả về hệ thống. Đây là hành vi không xác định, và nó có thể chạy đúng trên máy bạn suốt kỳ thực hành rồi sập trên máy chấm bài.

Ý tưởng cốt lõi

  • Sao chép nông (mặc định): copy giá trị con trỏ → hai đối tượng chia sẻ cùng vùng nhớ.
  • Sao chép sâu: cấp vùng nhớ riêng cho bản sao rồi chép từng phần tử.
  • Copy constructor Mang(const Mang& o) chạy khi tạo một đối tượng từ một đối tượng khác.

Mã nguồn

#include <iostream>
using namespace std;

class Mang {
public:
int* du_lieu;
int n;
Mang(int n) : n(n) { du_lieu = new int[n]; } // cấp vùng nhớ động
Mang(const Mang& o) : n(o.n) { // copy constructor: SÂU
du_lieu = new int[n]; // vùng nhớ RIÊNG
for (int i = 0; i < n; i++)
du_lieu[i] = o.du_lieu[i]; // chép từng phần tử
}
~Mang() { delete[] du_lieu; }
};

int main() {
Mang a(2);
a.du_lieu[0] = 10;
Mang b = a; // gọi copy constructor -> sao chép SÂU
b.du_lieu[0] = 99; // chỉ đổi b, a giữ nguyên
}

Thử ngay: xem sao chép đối tượng chạy từng bước

Sao chép sâu: mỗi đối tượng một vùng nhớ riêng C++
1class Mang {
2public:
3 int* du_lieu;
4 int n;
5 Mang(int n) : n(n) { du_lieu = new int[n]; }
6 Mang(const Mang& o) : n(o.n) {
7 du_lieu = new int[n];
8 for (int i=0;i<n;i++) du_lieu[i]=o.du_lieu[i];
9 }
10 ~Mang() { delete[] du_lieu; }
11};
12int main() {
13 Mang a(2);
14 a.du_lieu[0] = 10;
15 Mang b = a;
16 b.du_lieu[0] = 99;
17}
Ngăn xếp stack
main()
#1Mang a
du_lieu→ #H1
n2
Bộ nhớ động heap
#H1int[2]
[0]?
[1]?
Tạo a(2): constructor cấp một vùng nhớ động trên heap (#H1) gồm 2 int, a.du_lieu trỏ vào đó.
1/5

Hàm dựng sao chép được gọi lúc nào

Hàm dựng sao chép (copy constructor) có chữ ký Mang(const Mang& o). Nó chạy mỗi khi một đối tượng mới ra đời từ một đối tượng đã có. Sinh viên thường chỉ nhớ trường hợp đầu tiên trong danh sách dưới đây, rồi ngạc nhiên khi thấy nó chạy ở những chỗ còn lại:

  1. Khởi tạo một biến mới: Mang b = a; hoặc Mang b(a);
  2. Truyền tham số theo giá trị: void f(Mang m) gọi bằng f(a)
  3. Trả về theo giá trị từ hàm (trong nhiều trường hợp trình biên dịch tối ưu bỏ được bước này)
  4. Đưa phần tử vào container: v.push_back(a)

Hãy chú ý điểm 2. Chỉ cần quên một dấu & ở tham số hàm là bạn đã tạo ra một bản sao đầy đủ ở mỗi lần gọi. Với một mảng một triệu phần tử, đó là một triệu phép chép mỗi lần gọi hàm, hoàn toàn vô ích.

Ví dụ dưới đây in ra đúng lúc từng hàm được gọi, hãy tự chạy để thấy trình tự:

#include <iostream>
#include <string>
#include <utility>
using namespace std;

struct Ho {
string ten;

explicit Ho(string t) : ten(move(t)) {
cout << "constructor: " << ten << "\n";
}
Ho(const Ho& o) : ten(o.ten) { // copy constructor
cout << "copy constructor: " << ten << "\n";
}
Ho& operator=(const Ho& o) { // copy assignment
ten = o.ten;
cout << "operator=: " << ten << "\n";
return *this;
}
};

void theoGiaTri(Ho h) { cout << " trong ham: " << h.ten << "\n"; }
void theoThamChieu(const Ho& h) { cout << " trong ham: " << h.ten << "\n"; }

int main() {
Ho a("A"); // constructor: A
Ho b = a; // copy constructor: A (b la doi tuong MOI)
Ho c("C"); // constructor: C
c = a; // operator=: A (c da ton tai tu truoc)
theoGiaTri(a); // copy constructor: A (ban sao cho tham so)
theoThamChieu(a); // khong co ban sao nao
}

Sự khác nhau giữa dòng Ho b = a; và dòng c = a; là điểm dễ nhầm nhất trong cả bài. Cả hai đều có dấu bằng, nhưng chúng gọi hai hàm khác nhau:

  • Ho b = a;khởi tạo. Đối tượng b chưa từng tồn tại, nên hàm dựng sao chép chạy.
  • c = a;gán. Đối tượng c đã tồn tại và đang giữ tài nguyên riêng, nên toán tử gán chạy.

Toán tử gán sao chép

Chính vì c đã tồn tại, toán tử gán phải làm nhiều việc hơn hàm dựng sao chép. Hàm dựng bắt đầu từ một đối tượng trống, chỉ cần cấp vùng nhớ rồi chép. Toán tử gán bắt đầu từ một đối tượng đang có sẵn vùng nhớ cũ, nên nó phải:

  1. Đề phòng tự gán (a = a).
  2. Dọn vùng nhớ cũ, nếu không sẽ rò rỉ.
  3. Cấp vùng nhớ mới và chép dữ liệu sang.
  4. Trả về *this để cho phép gán chuỗi kiểu x = y = z.

Bỏ sót bước 2 là lỗi rò rỉ bộ nhớ. Bỏ sót bước 1 là lỗi nghiêm trọng hơn nhiều, xem phần dưới.

Bẫy tự gán

Xét một operator= viết ẩu, không kiểm tra tự gán:

Mang& operator=(const Mang& o) {
delete[] du_lieu; // giai phong vung nho cua CHINH MINH
n = o.n;
du_lieu = new int[n];
for (int i = 0; i < n; i++)
du_lieu[i] = o.du_lieu[i]; // o.du_lieu da bi delete o tren!
return *this;
}

Khi gọi a = a, thì o chính là a. Dòng delete[] du_lieu vừa xóa luôn vùng nhớ mà vòng lặp sắp đọc từ đó. Ta đang chép dữ liệu từ một vùng nhớ đã trả về hệ thống, kết quả là rác hoặc sập.

Chuyện a = a nghe có vẻ ngớ ngẩn nên chẳng ai viết, nhưng nó xảy ra gián tiếp rất thường xuyên: ds[i] = ds[j] khi i bằng j, hoặc *p = *q khi hai con trỏ cùng trỏ một chỗ. Luôn chống tự gán.

Cách sửa trực tiếp là thêm dòng canh if (this == &o) return *this; ngay đầu hàm, đúng như bài tập 2 phía dưới. Ngoài ra còn một lối viết gọn hơn, gọi là copy and swap: cho operator= nhận tham số theo giá trị (Mang& operator=(Mang o)), khi đó bản sao đã được hàm dựng sao chép tạo sẵn, việc còn lại chỉ là hoán đổi con trỏ của bản sao với con trỏ của mình. Vùng nhớ cũ của mình rơi vào o và được dọn tự động khi o hết đời ở cuối hàm. Cách này an toàn với tự gán mà không cần dòng canh nào, đổi lại nó luôn tạo một bản sao ngay cả khi ta có thể tránh.

Xem chạy từng bước: sao chép nông và lỗi double free

Bản trace ở đầu bài là sao chép sâu: bản sao có vùng nhớ riêng (#H2). Bản trace sau đây là mặt trái, sao chép nông mặc định khi lớp giữ con trỏ thô mà không tự viết copy constructor. Hãy chú ý: không có #H2 nào được cấp, hai đối tượng cùng trỏ một vùng nhớ, sửa cái này hỏng cái kia, và khi hủy thì delete[] chạy hai lần trên cùng địa chỉ.

Sao chép nông: hai đối tượng dùng chung một vùng nhớ C++
1#include <iostream>
2using namespace std;
3class MangNong {
4public:
5 int* du_lieu;
6 int n;
7 MangNong(int n) : n(n) { du_lieu = new int[n]; }
8 // no copy constructor: the compiler makes a shallow copy
9 ~MangNong() { delete[] du_lieu; }
10};
11int main() {
12 MangNong a(2);
13 a.du_lieu[0] = 10;
14 MangNong b = a; // shallow copy: b.du_lieu == a.du_lieu
15 b.du_lieu[0] = 99; // this corrupts a too
16}
Ngăn xếp stack
main()
#1MangNong a
du_lieu→ #H1
n2
Bộ nhớ động heap
#H1int[2]
[0]?
[1]?
Tạo a(2): hàm dựng cấp một vùng nhớ động #H1 gồm 2 int, a.du_lieu trỏ vào đó.
1/6

Rule of three

Quy tắc số 3 phát biểu: nếu bạn cần tự viết một trong ba hàm sau, gần như chắc chắn bạn cần viết cả ba.

HàmChạy khi nàoBỏ quên thì sao
Hủy tử ~Mang()đối tượng hết đờirò rỉ bộ nhớ
Hàm dựng sao chép Mang(const Mang&)tạo đối tượng mới từ đối tượng cũsao chép nông, double free
Toán tử gán operator=(const Mang&)gán vào đối tượng đã tồn tạisao chép nông, rò rỉ, double free

Lý do của quy tắc rất trực tiếp: nếu bạn phải viết hủy tử, nghĩa là lớp của bạn sở hữu một tài nguyên thô (bộ nhớ new, một tệp đang mở, một kết nối mạng). Mà đã sở hữu tài nguyên thì mọi đường mà tài nguyên đó có thể bị nhân đôi hay bị ghi đè đều phải được xử lý, và ba con đường đó chính là ba hàm trên. Viết một, quên hai, là để lại đúng hai lỗ hổng.

Rule of five, thêm phép di chuyển

Từ C++11, ngoài sao chép còn có di chuyển (move). Sao chép nghĩa là "làm một bản thứ hai cho anh, tôi vẫn giữ bản của tôi". Di chuyển nghĩa là "tôi đưa hẳn ruột của tôi cho anh, tôi thành rỗng". Di chuyển nhanh hơn hẳn vì không cấp phát và không chép byte nào, chỉ đổi vài con trỏ. Nó được dùng khi nguồn là một đối tượng tạm sắp bị hủy, hoặc khi bạn nói rõ bằng std::move(x) rằng bạn không cần x nữa.

Vì vậy quy tắc số 3 mở rộng thành rule of five: hủy tử, hàm dựng sao chép, toán tử gán sao chép, cộng thêm hai hàm di chuyển. Hai hàm mới có chữ ký dùng dấu &&. Lớp Buffer dưới đây khai báo đủ cả năm:

class Buffer {
int* p;
int n;
public:
explicit Buffer(int n) : p(new int[n]), n(n) {}
~Buffer() { delete[] p; } // 1. destructor

Buffer(const Buffer& o) : p(new int[o.n]), n(o.n) { // 2. copy ctor
for (int i = 0; i < n; i++) p[i] = o.p[i];
}
Buffer& operator=(const Buffer& o) { // 3. copy assign
if (this == &o) return *this;
int* moi = new int[o.n]; // allocate first
for (int i = 0; i < o.n; i++) moi[i] = o.p[i];
delete[] p; // only then drop the old block
p = moi;
n = o.n;
return *this;
}

Buffer(Buffer&& o) noexcept : p(o.p), n(o.n) { // 4. move ctor: cuop ruot cua o
o.p = nullptr; // leave the source empty but valid
o.n = 0;
}
Buffer& operator=(Buffer&& o) noexcept { // 5. move assign
if (this == &o) return *this;
delete[] p; // drop what we hold
p = o.p; n = o.n; // take theirs
o.p = nullptr; o.n = 0;
return *this;
}
};

Điểm bắt buộc: hàm di chuyển phải để đối tượng nguồn ở trạng thái hợp lệ và hủy được, ở đây là p = nullptr (gọi delete[] nullptr là hợp lệ và không làm gì). Sau Buffer c = std::move(a); thì c giữ dữ liệu cũ của a, còn a trở thành rỗng nhưng vẫn hủy được an toàn.

Hãy để ý thứ tự trong operator= sao chép ở trên: cấp vùng nhớ mới trước, rồi mới delete[] vùng cũ. Đây là một mẹo về an toàn ngoại lệ. Nếu new ném ngoại lệ, đối tượng vẫn còn nguyên vẹn đúng như trước khi gán, thay vì rơi vào trạng thái đã xóa vùng cũ mà chưa có vùng mới.

Khi nào cần tự viết, khi nào không

Câu trả lời có thể làm bạn nhẹ nhõm: hầu hết các lớp không cần viết hàm nào trong năm hàm trên. Đó là rule of zero. Nếu mọi thành viên của lớp đều là kiểu tự quản lý tài nguyên (std::string, std::vector, std::unique_ptr) thì các phiên bản mặc định do trình biên dịch sinh ra đã hoàn toàn đúng, vì chúng gọi sao chép hoặc di chuyển của từng thành viên, mà từng thành viên đã tự làm đúng phần của mình. Một lớp chỉ chứa string ten;vector<int> diem; không cần hủy tử, không cần hàm dựng sao chép, không cần operator=, vậy mà sao chép nó vẫn là sao chép sâu và vẫn an toàn.

Tiêu chí thực dụng là:

Lớp của bạnNên làm gì
Chỉ chứa int, double, string, vector, unique_ptrkhông viết gì, rule of zero
Có con trỏ thô newdeleteviết đủ rule of three, thêm move nếu cần tốc độ
Quản lý tệp, socket, khóa (lock)rule of five, hoặc bọc tài nguyên vào một lớp RAII riêng
Không được phép có hai bản, ví dụ một kết nối duy nhấtcấm sao chép bằng = delete

Học sao chép sâu bằng tay là để hiểu cơ chế, còn trong mã sản xuất, cách tốt nhất gần như luôn là dùng std::vector thay cho new int[n]. Bạn viết ít hơn, và không thể mắc lỗi double free.

Cấm sao chép bằng = delete

Có những đối tượng mà bản sao không có nghĩa. Một kết nối cơ sở dữ liệu, một tệp đang mở, một khóa đồng bộ: nhân đôi chúng ra là chuyện vô lý hoặc nguy hiểm. Với những lớp đó, thay vì viết một hàm dựng sao chép nửa vời, hãy nói thẳng với trình biên dịch rằng sao chép bị cấm:

#include <iostream>
#include <string>
#include <utility>
using namespace std;

class KetNoi {
string dia_chi;
public:
explicit KetNoi(string dia_chi) : dia_chi(move(dia_chi)) {
cout << "Mo ket noi toi " << this->dia_chi << "\n";
}

KetNoi(const KetNoi&) = delete; // cam sao chep
KetNoi& operator=(const KetNoi&) = delete; // cam gan sao chep

KetNoi(KetNoi&&) = default; // van cho phep di chuyen
KetNoi& operator=(KetNoi&&) = default;

void truyVan(const string& cau_lenh) const {
cout << "[" << dia_chi << "] " << cau_lenh << "\n";
}
};

void dungKetNoi(const KetNoi& k) { // by reference: no copy needed
k.truyVan("SELECT 1");
}

int main() {
KetNoi k("db://dau.edu.vn");
dungKetNoi(k);

// KetNoi k2 = k; // LOI BIEN DICH: copy constructor da bi delete
KetNoi k3 = std::move(k); // hop le: chuyen quyen so huu
k3.truyVan("SELECT 2");
}

= delete biến một lỗi logic thành lỗi biên dịch. Đây là điều rất đáng mong muốn: thà chương trình không dịch được còn hơn nó dịch được rồi nhân đôi một kết nối và gây hỏng dữ liệu lúc chạy. Chính std::unique_ptr cũng được thiết kế đúng theo cách này, nó cấm sao chép nhưng cho phép di chuyển, để đảm bảo mỗi vùng nhớ chỉ có duy nhất một chủ sở hữu.

Khai báo một hàm làm biến mất hàm khác

C++ có một luật ngầm hay gây bất ngờ: khi bạn tự khai báo hàm dựng di chuyển hoặc toán tử gán di chuyển, trình biên dịch sẽ không sinh hàm dựng sao chép và toán tử gán sao chép mặc định nữa. Tương tự, khi bạn tự viết hủy tử, các hàm di chuyển mặc định sẽ không được sinh ra, và mọi phép di chuyển sẽ âm thầm rơi về phép sao chép chậm hơn.

Cách phòng tránh: khi đã đụng vào một trong năm hàm đó, hãy khai báo tường minh cả năm, dùng = default cho những hàm bạn muốn giữ hành vi mặc định và = delete cho những hàm bạn muốn cấm. Đọc mã sẽ rõ ràng, và không có hàm nào biến mất sau lưng bạn.

Bài tập thực hành

Bài tập 1: thêm phương thức set và get an toàn

Lớp Mang hiện cho phép truy cập trực tiếp du_lieu[i], dễ gây ghi ra ngoài phạm vi. Hãy thêm hai phương thức set(int i, int gia_tri)get(int i) có kiểm tra chỉ số i nằm trong khoảng hợp lệ [0, n). Nếu chỉ số sai thì in một thông báo lỗi và không truy cập vùng nhớ. Viết main minh họa một lần gọi hợp lệ và một lần gọi sai chỉ số.

Lời giải chi tiết

Ý tưởng: bọc thao tác truy cập trong phương thức để kiểm tra biên trước khi chạm vào con trỏ. Khi i < 0 hoặc i >= n thì coi là chỉ số sai, ta dừng lại và báo lỗi thay vì để chương trình ghi đè vùng nhớ lạ. get trả về một giá trị canh chừng (ví dụ -1) khi chỉ số sai để gọi viên biết kết quả không hợp lệ.

#include <iostream>
using namespace std;

class Mang {
public:
int* du_lieu;
int n;
Mang(int n) : n(n) { du_lieu = new int[n]; }
Mang(const Mang& o) : n(o.n) {
du_lieu = new int[n];
for (int i = 0; i < n; i++)
du_lieu[i] = o.du_lieu[i];
}
~Mang() { delete[] du_lieu; }

// safe write with bounds checking
void set(int i, int gia_tri) {
if (i < 0 || i >= n) {
cout << "Loi: chi so " << i << " ngoai pham vi\n";
return;
}
du_lieu[i] = gia_tri;
}

// safe read with bounds checking
int get(int i) const {
if (i < 0 || i >= n) {
cout << "Loi: chi so " << i << " ngoai pham vi\n";
return -1;
}
return du_lieu[i];
}
};

int main() {
Mang a(2);
a.set(0, 10); // valid index
cout << a.get(0) << "\n"; // prints 10
a.set(5, 99); // invalid index -> error message
}

Ý chính: kiểm tra biên trong phương thức giúp ngăn ghi và đọc ra ngoài vùng nhớ đã cấp.

Bài tập 2: viết copy assignment operator= sao chép sâu

Lớp Mang đã có copy constructor sao chép sâu, nhưng chưa có toán tử gán operator=. Hãy viết Mang& operator=(const Mang& o) sao chép sâu cho đúng: giải phóng vùng nhớ cũ, cấp vùng nhớ mới rồi chép từng phần tử. Nhớ xử lý trường hợp tự gán (a = a) và trả về *this để cho phép gán chuỗi.

Lời giải chi tiết

Các bước cần làm trong operator=:

  1. Kiểm tra tự gán: nếu this == &o thì trả về ngay, tránh việc giải phóng vùng nhớ rồi đọc lại chính nó.
  2. Giải phóng vùng nhớ cũ bằng delete[] du_lieu để không rò rỉ bộ nhớ.
  3. Cập nhật n, cấp vùng nhớ mới với kích thước o.n.
  4. Chép từng phần tử từ o.du_lieu sang du_lieu.
  5. Trả về *this.
#include <iostream>
using namespace std;

class Mang {
public:
int* du_lieu;
int n;
Mang(int n) : n(n) { du_lieu = new int[n]; }
Mang(const Mang& o) : n(o.n) {
du_lieu = new int[n];
for (int i = 0; i < n; i++)
du_lieu[i] = o.du_lieu[i];
}

// deep copy assignment
Mang& operator=(const Mang& o) {
if (this == &o) return *this; // guard against self-assignment
delete[] du_lieu; // release old memory
n = o.n;
du_lieu = new int[n]; // allocate fresh memory
for (int i = 0; i < n; i++)
du_lieu[i] = o.du_lieu[i]; // copy element by element
return *this;
}

~Mang() { delete[] du_lieu; }
};

int main() {
Mang a(2);
a.du_lieu[0] = 10;
Mang b(2);
b = a; // calls operator=, deep copy
b.du_lieu[0] = 99; // changes only b
cout << a.du_lieu[0] << " " << b.du_lieu[0] << "\n"; // prints 10 99
}

Ý chính: operator= phải tự dọn vùng nhớ cũ, chống tự gán và trả về *this thì sao chép sâu mới đầy đủ và an toàn.

Đây là bản kinh điển, dễ đọc và đã đúng nhờ dòng canh tự gán. Muốn chặt chẽ hơn nữa về ngoại lệ, hãy đảo thứ tự như lớp Buffer ở phần rule of five: cấp vùng nhớ mới trước, chép xong rồi mới delete[] vùng cũ.

Bài tập 3: minh họa lỗi double free của sao chép nông mặc định

Hãy tạo một lớp MangNong cố tình bỏ copy constructor và operator= tự viết, chỉ giữ con trỏ int* du_lieu cùng destructor delete[]. Viết một main tạo MangNong a, rồi MangNong b = a (dùng sao chép nông mặc định). Giải thích tại sao khi hai đối tượng hủy sẽ gây lỗi double free, rồi sửa lại bằng cách thêm copy constructor sao chép sâu.

Lời giải chi tiết

Khi không tự viết copy constructor, trình biên dịch sinh ra bản mặc định chỉ chép giá trị con trỏ (sao chép nông). Sau MangNong b = a, cả a.du_lieub.du_lieu cùng trỏ vào một vùng nhớ. Khi b hủy, destructor gọi delete[] lên vùng đó; khi a hủy, destructor lại gọi delete[] lên đúng vùng đã giải phóng, gây lỗi double free.

Phiên bản lỗi (sao chép nông mặc định):

#include <iostream>
using namespace std;

class MangNong {
public:
int* du_lieu;
int n;
MangNong(int n) : n(n) { du_lieu = new int[n]; }
// no copy constructor: compiler generates a shallow copy
~MangNong() { delete[] du_lieu; } // both objects delete the SAME memory -> double free
};

int main() {
MangNong a(2);
a.du_lieu[0] = 10;
MangNong b = a; // shallow copy: a.du_lieu and b.du_lieu point to the same memory
} // a and b destroyed -> delete[] runs twice on one block

Bản sửa: thêm copy constructor sao chép sâu để mỗi đối tượng có vùng nhớ riêng, nên mỗi delete[] tác động lên một vùng khác nhau.

#include <iostream>
using namespace std;

class MangNong {
public:
int* du_lieu;
int n;
MangNong(int n) : n(n) { du_lieu = new int[n]; }
MangNong(const MangNong& o) : n(o.n) { // deep copy fixes double free
du_lieu = new int[n]; // separate memory block
for (int i = 0; i < n; i++)
du_lieu[i] = o.du_lieu[i];
}
~MangNong() { delete[] du_lieu; }
};

int main() {
MangNong a(2);
a.du_lieu[0] = 10;
MangNong b = a; // deep copy: b gets its own memory
} // each delete[] frees a different block, no double free

Ý chính: sao chép nông khiến hai đối tượng chia sẻ một vùng nhớ và giải phóng nó hai lần; copy constructor sao chép sâu tách vùng nhớ ra nên loại bỏ lỗi double free.

Bài tập 4: một lớp cấm sao chép nhưng cho phép di chuyển

Viết lớp TepGhi mô phỏng một tệp đang mở: nó giữ một std::string* noi_dung cấp bằng new. Hãy cấm sao chép bằng = delete, nhưng vẫn cho phép di chuyển bằng cách tự viết hàm dựng di chuyển và toán tử gán di chuyển. Trong main, chứng minh rằng TepGhi b = a; không biên dịch được (để nó trong chú thích) còn TepGhi b = std::move(a); thì chạy.

Lời giải chi tiết

Một tệp đang mở không nên có hai bản, vì hai đối tượng cùng đóng một tệp sẽ gây lỗi giống hệt double free. Nhưng chuyển quyền sở hữu thì hợp lý: sau khi di chuyển, đối tượng nguồn không còn giữ tệp nữa, nên chỉ còn đúng một chủ.

Các bước:

  1. Khai báo TepGhi(const TepGhi&) = delete;TepGhi& operator=(const TepGhi&) = delete;
  2. Hàm dựng di chuyển: cướp con trỏ của nguồn rồi gán nullptr cho nguồn.
  3. Toán tử gán di chuyển: chống tự gán, delete cái của mình, cướp của nguồn, xóa nguồn.
  4. Hủy tử: delete noi_dung. Gọi delete nullptr là hợp lệ nên không cần kiểm tra thêm.
#include <iostream>
#include <string>
#include <utility>
using namespace std;

class TepGhi {
string* noi_dung; // owned resource
public:
explicit TepGhi(const string& s) : noi_dung(new string(s)) {}

TepGhi(const TepGhi&) = delete; // copying a file handle makes no sense
TepGhi& operator=(const TepGhi&) = delete;

TepGhi(TepGhi&& o) noexcept : noi_dung(o.noi_dung) { // move ctor
o.noi_dung = nullptr; // source gives up ownership
}

TepGhi& operator=(TepGhi&& o) noexcept { // move assign
if (this == &o) return *this;
delete noi_dung; // drop what we hold
noi_dung = o.noi_dung; // take theirs
o.noi_dung = nullptr;
return *this;
}

~TepGhi() { delete noi_dung; } // delete nullptr is safe

void in() const {
if (noi_dung) cout << *noi_dung << "\n";
else cout << "(tep rong, da bi di chuyen di)\n";
}
};

int main() {
TepGhi a("diem_hoc_ky.csv");
a.in(); // diem_hoc_ky.csv

// TepGhi b = a; // LOI BIEN DICH: copy ctor da bi delete

TepGhi b = std::move(a); // hop le: chuyen quyen so huu
b.in(); // diem_hoc_ky.csv
a.in(); // (tep rong, da bi di chuyen di)
}

Ý chính: = delete chặn bản sao vô nghĩa ngay từ lúc biên dịch, còn phép di chuyển vẫn cho ta chuyển quyền sở hữu tài nguyên một cách an toàn.

Bài tập 5: viết lại lớp Mang theo rule of zero

Hãy viết lại lớp Mang sao cho nó không cần hàm dựng sao chép, không cần operator=, không cần hủy tử, mà sao chép sâu vẫn đúng. Gợi ý: thay int* du_lieu bằng std::vector<int>, và ném std::out_of_range khi chỉ số sai.

Lời giải chi tiết

std::vector đã tự cài đặt sẵn cả năm hàm của rule of five một cách chính xác. Khi Mang chỉ chứa một vector, các hàm mặc định do trình biên dịch sinh ra sẽ gọi thẳng các hàm của vector, nên bản sao mặc định của Mang chính là bản sao sâu, và hủy tử mặc định cũng dọn sạch bộ nhớ.

#include <iostream>
#include <stdexcept>
#include <vector>
using namespace std;

class Mang {
vector<int> du_lieu; // vector owns the memory, so we own nothing raw
public:
explicit Mang(int n) : du_lieu(n, 0) {}

// no copy ctor, no operator=, no destructor: the defaults are already correct

void set(int i, int gia_tri) {
if (i < 0 || i >= static_cast<int>(du_lieu.size()))
throw out_of_range("chi so ngoai pham vi");
du_lieu[i] = gia_tri;
}
int get(int i) const {
if (i < 0 || i >= static_cast<int>(du_lieu.size()))
throw out_of_range("chi so ngoai pham vi");
return du_lieu[i];
}
};

int main() {
Mang a(2);
a.set(0, 10);

Mang b = a; // sao chep SAU tu dong, do vector lo
b.set(0, 99); // chi doi b
cout << a.get(0) << " " << b.get(0) << "\n"; // 10 99

try {
a.get(5);
} catch (const out_of_range& e) {
cout << "Loi: " << e.what() << "\n";
}
}

Ý chính: bản dùng con trỏ thô cần thêm ba hàm và vẫn có nguy cơ double free. Bản dùng vector không cần hàm nào, ngắn hơn, và không thể sai. Hãy tự viết sao chép sâu để hiểu cơ chế, rồi dùng vector khi làm việc thật.

Tự kiểm tra

"Quy tắc số 3" (Rule of Three) là gì?

Nếu một lớp cần tự viết một trong ba thứ: destructor, copy constructor, hoặc copy assignment (operator=), thì hầu như chắc chắn cần viết cả ba. Lý do: lớp đang quản lý tài nguyên thô (như con trỏ new), nên mọi đường sao chép và hủy đều phải xử lý tài nguyên đó nhất quán.

Câu hỏi tự kiểm

Kiểm tra nhanh: sao chép sâu vs nông0/4 đúngchưa trả lời
  1. 1Với lớp giữ con trỏ int*, bản sao mặc định (sao chép nông) gây hậu quả gì khi cả hai đối tượng bị hủy?
  2. 2Hai dòng Mang b = a; và c = a; (trong đó c đã tồn tại từ trước) gọi hàm nào?
  3. 3Vì sao operator= sao chép sâu phải kiểm tra tự gán bằng dòng if (this == &o) return *this;?
  4. 4Theo rule of zero, một lớp chỉ chứa std::string và std::vector<int> cần tự viết mấy hàm trong năm hàm sao chép và di chuyển?

Tóm tắt

Những điều cần mang theo sau bài này:

  • Mang b = a;khởi tạo, gọi hàm dựng sao chép. b = a;gán, gọi operator=. Hai chuyện khác nhau, hai hàm khác nhau.
  • Bản sao mặc định do trình biên dịch sinh ra là sao chép nông: nó chép giá trị con trỏ, không chép vùng nhớ được trỏ tới. Hậu quả là dữ liệu hỏng chéo và double free.
  • Sao chép sâu nghĩa là cấp vùng nhớ riêng cho bản sao rồi chép nội dung sang.
  • operator= phải chống tự gán, dọn vùng nhớ cũ, cấp vùng mới, và trả về *this. Kỹ thuật copy and swap cho ta cả ba thứ đầu một cách tự động.
  • Rule of three: cần một trong ba (hủy tử, hàm dựng sao chép, gán sao chép) thì cần cả ba. Rule of five thêm hàm dựng di chuyển và gán di chuyển.
  • Rule of zero là đích đến thực tế: dùng std::string, std::vector, std::unique_ptr thì không phải viết hàm nào cả, và không thể mắc lỗi bộ nhớ.
  • = delete cấm sao chép ngay lúc biên dịch, dùng cho những tài nguyên mà bản sao là vô nghĩa như kết nối, tệp, khóa đồng bộ.
Ghi nhớ

Đối tượng giữ con trỏ tới bộ nhớ động thì sao chép nông gây chia sẻ nguy hiểm (hỏng dữ liệu + double free). Viết copy constructor sao chép sâu để mỗi bản sao có vùng nhớ riêng. Nhớ Rule of Three.