Cấp phát động và con trỏ thông minh
Hãy tưởng tượng một chương trình quản lý sinh viên. Lúc biên dịch, bạn chưa biết người dùng sẽ nhập 3 hay 3000 sinh viên. Bạn cũng cần đối tượng sinh viên sống lâu hơn cái hàm đã tạo ra nó, để hàm in báo cáo còn dùng được. Hai câu hỏi ấy (bao nhiêu, và sống bao lâu) chính là lý do C++ có cấp phát động.
Biến cục bộ nằm trên ngăn xếp (stack): sinh ra khi vào khối lệnh, tự chết khi ra khỏi
khối, kích thước phải biết trước lúc biên dịch. Khi cần vượt qua hai giới hạn đó, ta xin bộ
nhớ từ vùng nhớ động (heap) bằng new. Nhưng new đi kèm nghĩa vụ: mỗi vùng nhớ xin
ra phải được trả lại đúng một lần bằng delete. Quên trả thì rò rỉ bộ nhớ, trả hai
lần thì chương trình sập, trả rồi vẫn dùng tiếp thì ta có một con trỏ treo, loại lỗi
nguy hiểm nhất vì nó thường không sập ngay mà âm thầm làm hỏng dữ liệu.
Lối thoát của C++ hiện đại là giao quyền sở hữu cho một con trỏ thông minh: một đối tượng
nhỏ nằm trên stack, ôm lấy con trỏ heap, và tự gọi delete trong hủy tử của chính nó. Ta
không phải nhớ nữa, trình biên dịch nhớ hộ.
Stack và heap khác nhau ở chỗ nào
| Ngăn xếp (stack) | Vùng nhớ động (heap) | |
|---|---|---|
| Ai giải phóng | Trình biên dịch, khi ra khỏi khối | Lập trình viên, bằng delete |
| Kích thước | Phải biết lúc biên dịch | Quyết định lúc chạy |
| Vòng đời | Gắn chặt với khối lệnh | Dài ngắn tùy ta |
| Rủi ro | Gần như không | Rò rỉ, hủy hai lần, con trỏ treo |
Nguyên tắc rút ra: ưu tiên stack. Thói quen new mọi thứ cho giống Java hay C# là một
thói quen tốn kém và nguy hiểm trong C++.
Thử ngay: xem cấp phát động chạy từng bước
Bấm ▶ Chạy (hoặc kéo thanh trượt) trước khi đọc tiếp. Chưa cần hiểu make_unique là
gì, chỉ cần so hai nửa của chương trình: nửa trên phải tự gọi delete, nửa dưới không gọi
dòng nào mà đối tượng vẫn được hủy đúng lúc. Các mục bên dưới sẽ giải thích cơ chế.
1class Node {2public:3 int v;4 Node(int v):v(v){ cout<<"tao "<<v<<"\n"; }5 ~Node(){ cout<<"huy "<<v<<"\n"; }6};7int main() {8 Node* p = new Node(1);9 delete p;10 {11 auto q = make_unique<Node>(2);12 }13}
tao 1
Ý tưởng cốt lõi
new Node(1)xin bộ nhớ trên heap, gọi hàm dựng, trả về địa chỉ của đối tượng.- Con trỏ
pchỉ là một biến trên stack giữ địa chỉ. Nó không phải là đối tượng. delete pgọi hủy tử rồi trả vùng nhớ về hệ thống. Sau dòng đópthành con trỏ treo.- Quên
deletethì rò rỉ, gọideletehai lần thì hỏng. Quản lý tay rất dễ sai. unique_ptr<Node> q = make_unique<Node>(2)sở hữu đối tượng và tựdeletekhiqra khỏi phạm vi, kể cả khi có ngoại lệ ném ra giữa chừng.
Mã nguồn
#include <iostream>
#include <memory>
using namespace std;
class Node {
public:
int v;
Node(int v) : v(v) { cout << "tao " << v << "\n"; }
~Node() { cout << "huy " << v << "\n"; }
};
int main() {
Node* p = new Node(1); // cấp phát thủ công trên heap
delete p; // PHẢI tự hủy
{
auto q = make_unique<Node>(2); // con trỏ thông minh
} // q ra khỏi khối -> tự hủy Node(2)
}
Ba cái bẫy của quản lý bộ nhớ bằng tay
Trước khi thấy con trỏ thông minh hay ở đâu, ta phải thấy rõ nó cứu ta khỏi cái gì.
Bẫy 1: rò rỉ bộ nhớ
#include <iostream>
using namespace std;
class Anh {
public:
Anh() { cout << "cap phat 10MB\n"; }
~Anh() { cout << "giai phong 10MB\n"; }
};
void xuLy(bool loi) {
Anh* a = new Anh();
if (loi) {
return; // BUG: leaves the function without calling delete
}
delete a; // only reached on the happy path
}
int main() {
xuLy(false); // prints both lines: fine
xuLy(true); // prints only "cap phat 10MB": 10MB leaked
return 0;
}
Lập trình viên ở đây không lười: anh ta có viết delete, chỉ là có một lối ra khác
của hàm mà anh ta không để ý. Hàm càng dài, càng nhiều if và return, xác suất bỏ sót càng
cao. Nếu giữa hàm có một ngoại lệ được ném ra, dòng delete lại bị nhảy qua lần nữa. Gốc rễ
là ở đó: quản lý tay không chống được các lối ra bất ngờ.
Bẫy 2 và bẫy 3: con trỏ treo và hủy hai lần
#include <iostream>
using namespace std;
int main() {
int* p = new int(42);
int* q = p; // q points to the SAME memory as p
delete p; // the memory goes back to the system
// cout << *q; // UNDEFINED BEHAVIOUR: q is dangling
// delete q; // UNDEFINED BEHAVIOUR: double free, likely a crash
p = nullptr; // good habit: mark the pointer as empty
return 0;
}
Sau delete p, vùng nhớ đã được trả lại nhưng giá trị trong p và q không đổi: chúng
vẫn giữ địa chỉ cũ. Đọc *q là hành vi không xác định, và điều tệ nhất là chương trình
thường vẫn chạy, vẫn in ra con số trông có vẻ đúng, cho tới ngày vùng nhớ ấy được cấp lại
cho đối tượng khác và dữ liệu bắt đầu sai lệch khó hiểu. Còn delete q thì trả cùng một vùng
nhớ hai lần, làm hỏng trình quản lý heap.
Cả hai bẫy quy về một câu hỏi: ai là chủ sở hữu vùng nhớ này, p hay q? Con trỏ thô
không trả lời được, vì hai con trỏ thô trông giống hệt nhau.
- Cấp phát bằng
new[]nhưng giải phóng bằngdelete(thiếu ngoặc vuông). - Gọi
deleterồi vẫn dùng lại con trỏ đó. Saudelete, hãy gánp = nullptrđể nếu lỡ dùng lại thì chương trình sập ngay tại chỗ (dễ tìm) thay vì hỏng ngầm. - Sao chép một đối tượng có con trỏ thành viên mà quên viết hàm dựng sao chép: hai đối tượng
cùng trỏ vào một vùng nhớ rồi cùng
deletenó, tức là hủy hai lần.
RAII: buộc tài nguyên vào vòng đời đối tượng
Lời giải của C++ cho cả ba cái bẫy có một cái tên: RAII (Resource Acquisition Is Initialization). Ý tưởng gọn trong một câu: gói tài nguyên vào một đối tượng trên stack, xin trong hàm dựng, trả trong hủy tử.
Vì sao mẹo này hiệu quả? Vì C++ bảo đảm một điều rất mạnh: khi luồng điều khiển rời khỏi một
khối lệnh, hủy tử của mọi đối tượng cục bộ trong khối chắc chắn được gọi, bất kể ta rời
đi bằng cách nào (chạy hết, return giữa chừng, break, hay một ngoại lệ được ném ra). Đặt
delete vào hủy tử tức là mượn chính bảo đảm đó của trình biên dịch, và không lối ra nào
lọt được nữa.
#include <iostream>
using namespace std;
// A hand made RAII wrapper: this is exactly what unique_ptr does inside.
class GiuInt {
int* p;
public:
explicit GiuInt(int v) : p(new int(v)) {} // acquire in the constructor
~GiuInt() { delete p; } // release in the destructor
int get() const { return *p; }
GiuInt(const GiuInt&) = delete; // forbid copying: one owner only
GiuInt& operator=(const GiuInt&) = delete;
};
void xuLy(bool loi) {
GiuInt g(42);
if (loi) {
return; // the destructor of g still runs: no leak
}
cout << g.get() << "\n";
}
int main() {
xuLy(false);
xuLy(true); // no leak either, even on this early return
return 0;
}
Hai dòng = delete rất đáng chú ý: chúng cấm sao chép GiuInt, và đó chính là cách trả
lời câu hỏi "ai là chủ sở hữu": chỉ có một chủ, không thể có hai. Đây đúng là triết lý
của unique_ptr, ta không cần tự viết lớp này nữa vì thư viện chuẩn đã có sẵn trong memory.
unique_ptr và shared_ptr: chọn cái nào
unique_ptr<T>: sở hữu độc quyền, đúng một chủ tại một thời điểm. Không sao chép được, chỉ chuyển giao bằngstd::move. Việc trình biên dịch từ chối sao chép nó là một tính năng: bẫy "hai chủ, hủy hai lần" trở nên không thể viết ra được.shared_ptr<T>: sở hữu chung qua một bộ đếm tham chiếu. Đối tượng chỉ bị hủy khi chủ cuối cùng buông ra. Đổi lại, tốn bộ nhớ cho bộ đếm và thời gian cập nhật nó.
Vì unique_ptr cấm sao chép, khi cần đưa quyền sở hữu từ nơi này sang nơi khác ta dùng
std::move. Câu lệnh auto q2 = std::move(q1) không nhân đôi đối tượng mà chuyển con
trỏ heap từ q1 sang q2, để lại q1 ở trạng thái rỗng. Nhờ vậy ở mọi thời điểm vẫn chỉ
có đúng một chủ, đúng lời hứa của unique_ptr, mà ta vẫn tự do bàn giao đối tượng qua lại
giữa các hàm. Đây là điểm khác căn bản so với con trỏ thô: con trỏ thô sao chép thoải mái
nên chẳng bao giờ nói được ai đang giữ trách nhiệm delete.
Mặc định dùng unique_ptr. Chỉ đổi sang shared_ptr khi vòng đời thật sự do nhiều nơi cùng
quyết định. Nếu chỉ cần nhiều chỗ cùng nhìn vào một đối tượng mà không ai sở hữu, hãy
truyền tham chiếu Node& hoặc con trỏ thô Node* không sở hữu: xem thì được, không được
delete.
Xem chạy từng bước: chuyển sở hữu bằng std::move
Bản trace ở đầu bài cho thấy unique_ptr tự hủy khi ra khỏi phạm vi. Bản dưới đây soi vào
phép std::move vừa nói tới, để thấy việc bàn giao quyền sở hữu diễn ra thế nào. Hãy chú ý
con trỏ nguồn a trở thành rỗng (nullptr) ngay sau khi di chuyển: đó chính là cách
unique_ptr giữ lời hứa chỉ có đúng một chủ tại mọi thời điểm.
1#include <iostream>2#include <memory>3using namespace std;4struct Node {5 int v;6 Node(int v) : v(v) {}7};8int main() {9 unique_ptr<Node> a = make_unique<Node>(7);10 unique_ptr<Node> b = std::move(a); // transfer ownership11 // a is now empty (nullptr)12}
Quy tắc: không dùng new trần
Trong C++ hiện đại, đừng gán kết quả của new thẳng cho một con trỏ thô. Cần đối tượng
trên heap thì dùng make_unique<T>(...), cần sở hữu chung thì make_shared<T>(...), cần một
dãy phần tử thì dùng vector<T> chứ đừng tự viết new T[n], cần chuỗi thì dùng string chứ
đừng quản lý char*. Bản thân vector và string cũng là lớp RAII: chúng new bên trong và
delete trong hủy tử của mình.
Nếu A giữ shared_ptr tới B và B giữ shared_ptr ngược lại tới A, bộ đếm của cả hai không
bao giờ về 0: cả hai rò rỉ dù đã dùng con trỏ thông minh. Lời giải là một trong hai chiều
phải dùng weak_ptr (con trỏ quan sát, không làm tăng bộ đếm).
Cắt chu trình bằng weak_ptr
Hãy xem chu trình ấy thành hình cụ thể. Một nút cha giữ con, một nút con muốn biết cha mình
là ai. Nếu cả hai chiều đều là shared_ptr, mỗi đối tượng bị chính đối tượng kia giữ lại,
bộ đếm không bao giờ tụt về 0, và cả hai rò rỉ. Cách sửa là hỏi: ai thật sự sở hữu ai?
Cha sở hữu con (chiều này dùng shared_ptr), còn con chỉ tham chiếu ngược tới cha mà
không sở hữu (chiều này dùng weak_ptr).
#include <iostream>
#include <memory>
using namespace std;
struct ConNode;
struct ChaNode {
shared_ptr<ConNode> con; // cha SỞ HỮU con
~ChaNode() { cout << "huy cha\n"; }
};
struct ConNode {
weak_ptr<ChaNode> cha; // con chỉ QUAN SÁT cha, không sở hữu
~ConNode() { cout << "huy con\n"; }
};
int main() {
auto cha = make_shared<ChaNode>();
auto con = make_shared<ConNode>();
cha->con = con;
con->cha = cha; // weak_ptr: does not bump the count
if (auto p = con->cha.lock()) // lock() promotes to shared_ptr if still alive
cout << "cha van song\n";
} // both are destroyed here: "huy cha" then "huy con"
weak_ptr không cho phép truy cập thẳng đối tượng, vì đối tượng có thể đã bị hủy mất rồi.
Muốn dùng, ta gọi lock(): nếu đối tượng còn sống, lock() trả về một shared_ptr tạm
thời giữ nó lại đủ lâu để ta thao tác an toàn; nếu đã chết, lock() trả về con trỏ rỗng và
ta biết ngay là không được đụng vào. Đó là điểm khác cốt lõi so với con trỏ thô treo: con
trỏ thô sau khi vùng nhớ chết vẫn trông y hệt lúc còn sống và lừa ta đọc bừa, còn weak_ptr
có cách trả lời trung thực câu hỏi "đối tượng còn đó không".
Node* raw = new Node(1);
shared_ptr<Node> a(raw);
shared_ptr<Node> b(raw); // BUG: a separate control block, unaware of a
Mỗi lần bạn viết shared_ptr<Node>(raw), thư viện dựng một khối điều khiển mới với bộ
đếm riêng. Ở đây a và b có hai bộ đếm tách rời, không hề biết nhau, mỗi bộ đếm đều tưởng
mình là chủ duy nhất. Khi cả hai về 0, vùng nhớ bị delete hai lần và chương trình sập. Gốc
rễ là bộ đếm phải dùng chung thì mới đếm đúng, mà nó chỉ dùng chung khi các shared_ptr
được sao chép từ nhau, không phải khi cùng dựng lại từ một con trỏ thô. Quy tắc: gọi
make_shared đúng một lần, sau đó chỉ nhân bản chính cái shared_ptr ấy, đừng bao giờ cầm
con trỏ thô đi dựng lần hai.
Bài tập thực hành
Bài tập 1: cấp phát và giải phóng mảng động
Viết hàm int main() cấp phát một mảng động gồm n số nguyên bằng new[], gán mỗi phần tử bằng bình phương chỉ số của nó, in mảng ra màn hình, rồi giải phóng đúng cách bằng delete[]. Hãy giải thích vì sao phải dùng delete[] chứ không phải delete.
Lời giải chi tiết
Với mảng cấp phát bằng new T[n], ta nhận về con trỏ tới phần tử đầu. Khi giải phóng, phải dùng delete[] để trình biên dịch biết đây là một mảng và gọi destructor cho từng phần tử (với kiểu có destructor), đồng thời trả lại đúng vùng nhớ. Nếu dùng delete thường cho con trỏ mảng, hành vi là không xác định (undefined behavior).
Các bước: (1) đọc n, (2) cấp phát int* a = new int[n], (3) duyệt vòng lặp gán a[i] = i * i, (4) in ra, (5) gọi delete[] a đúng một lần.
#include <iostream>
using namespace std;
int main() {
int n = 5;
int* a = new int[n]; // allocate a dynamic array on the heap
for (int i = 0; i < n; ++i) {
a[i] = i * i; // fill each element with the square of its index
}
for (int i = 0; i < n; ++i) {
cout << a[i] << " ";
}
cout << "\n";
delete[] a; // MUST use delete[] for arrays, not delete
return 0;
}
Ý chính: cặp new[] luôn đi với delete[]; dùng sai dạng giải phóng sẽ gây lỗi không xác định.
Bài tập 2: nhìn thấy rò rỉ bộ nhớ rồi sửa bằng unique_ptr
Cho đoạn mã cấp phát một Node bằng new trong hàm taoVaQuen() nhưng không bao giờ gọi delete, vì vậy đối tượng bị rò rỉ (destructor không bao giờ chạy). Hãy chỉ ra dòng gây rò rỉ, rồi viết lại bằng unique_ptr để vùng nhớ tự được giải phóng.
Lời giải chi tiết
Trong bản gốc, hàm tạo Node trên heap và trả về sau khi mất con trỏ, nên không còn cách nào gọi delete. Ta nhận ra rò rỉ vì thấy dòng "tao" được in mà không có dòng "huy" tương ứng. Cách sửa là để một unique_ptr<Node> sở hữu đối tượng; khi unique_ptr ra khỏi phạm vi, destructor chạy tự động theo RAII.
Các bước: (1) thay new Node(...) bằng make_unique<Node>(...), (2) bỏ nhu cầu delete thủ công, (3) chạy lại và kiểm tra đã có dòng "huy".
#include <iostream>
#include <memory>
using namespace std;
class Node {
public:
int v;
Node(int v) : v(v) { cout << "tao " << v << "\n"; }
~Node() { cout << "huy " << v << "\n"; }
};
// Leaky version (do NOT do this): the object is never deleted.
// void taoVaQuen() { Node* p = new Node(1); } // p is lost, "huy 1" never prints
void taoAnToan() {
auto p = make_unique<Node>(1); // unique_ptr owns the Node
} // p goes out of scope here -> Node destroyed
int main() {
taoAnToan(); // prints both "tao 1" and "huy 1"
return 0;
}
Ý chính: thiếu dòng "huy" trong kết quả là dấu hiệu rò rỉ; giao quyền sở hữu cho unique_ptr giúp destructor luôn được gọi.
Bài tập 3: chia sẻ sở hữu bằng shared_ptr
Tạo một Node trên heap và để hai con trỏ a và b cùng sở hữu nó bằng shared_ptr. In số đếm tham chiếu (use_count()) sau khi tạo a, sau khi gán b = a, và sau khi b được đặt lại bằng reset(). Quan sát thời điểm destructor chạy.
Lời giải chi tiết
shared_ptr giữ một bộ đếm tham chiếu dùng chung. Mỗi lần một shared_ptr mới cùng trỏ tới đối tượng, bộ đếm tăng; mỗi lần một con trỏ bị hủy hoặc reset(), bộ đếm giảm. Khi bộ đếm về 0, đối tượng mới bị xóa. Vì vậy destructor "huy" chỉ chạy khi chủ sở hữu cuối cùng buông ra.
Các bước: (1) a = make_shared<Node>(1) cho use_count() == 1, (2) b = a cho use_count() == 2, (3) b.reset() đưa về 1 (chưa hủy), (4) khi a ra khỏi main thì bộ đếm về 0 và destructor chạy.
#include <iostream>
#include <memory>
using namespace std;
class Node {
public:
int v;
Node(int v) : v(v) { cout << "tao " << v << "\n"; }
~Node() { cout << "huy " << v << "\n"; }
};
int main() {
auto a = make_shared<Node>(1); // reference count becomes 1
cout << a.use_count() << "\n"; // 1
auto b = a; // shared ownership, count becomes 2
cout << a.use_count() << "\n"; // 2
b.reset(); // b releases, count drops to 1 (not destroyed yet)
cout << a.use_count() << "\n"; // 1
return 0; // a is last owner -> count 0 -> "huy 1"
}
Ý chính: shared_ptr chỉ giải phóng đối tượng khi chủ sở hữu cuối cùng biến mất, nên destructor chạy đúng một lần dù có nhiều con trỏ cùng trỏ tới.
Bài tập 4: chuyển quyền sở hữu ra khỏi hàm
Hàm unique_ptr<Node> taoNode(int v) { return make_unique<Node>(v); } biên dịch được, dù
unique_ptr không sao chép được. Vì sao? Và nếu một hàm khác chỉ cần đọc đối tượng
thì nên nhận tham số kiểu gì?
Lời giải chi tiết
Hợp lệ vì trình biên dịch di chuyển (move) con trỏ ra ngoài thay vì sao chép nó. Đây là
cách chuẩn để một hàm tạo đối tượng trên heap rồi giao quyền sở hữu cho người gọi, thay
cho kiểu cũ là trả về con trỏ thô (kiểu cũ không nói được rằng người gọi có nghĩa vụ delete).
Hàm chỉ đọc thì nên nhận const Node&: nó mượn để xem, không đụng tới vòng đời. Đây là
nguyên tắc quan trọng: chỉ nơi nào thật sự sở hữu mới cần con trỏ thông minh; nơi chỉ đọc
thì tham chiếu là đủ và rẻ nhất. Nếu ta cố truyền unique_ptr theo giá trị, ta đã vô tình
trao luôn quyền sở hữu và đối tượng sẽ bị hủy khi hàm kết thúc.
Tự kiểm tra
Vì sao unique_ptr an toàn hơn con trỏ thô?
Vì nó áp dụng RAII: gắn vòng đời vùng nhớ động vào vòng đời một đối tượng trên
stack. Khi unique_ptr ra khỏi phạm vi (kể cả khi có ngoại lệ ném ra giữa chừng), nó
chắc chắn gọi delete. Con trỏ thô không có gì đảm bảo điều đó, lập trình viên
phải nhớ, và rất hay quên.
Câu hỏi tự kiểm
- 1Cấp phát bằng new int[n] thì giải phóng đúng bằng gì?
- 2unique_ptr an toàn hơn con trỏ thô chủ yếu nhờ điều gì?
- 3A giữ shared_ptr tới B và B giữ shared_ptr ngược lại tới A. Hậu quả và cách sửa là gì?
- 4Vì sao không được dựng hai shared_ptr riêng biệt từ cùng một con trỏ thô raw?
Tóm tắt
- Ưu tiên stack. Chỉ dùng heap khi cần vòng đời dài hơn khối lệnh hoặc kích thước động.
newđi với đúng mộtdelete,new[]đi vớidelete[].- Ba lỗi kinh điển: rò rỉ, hủy hai lần, con trỏ treo. Gốc rễ chung là con trỏ thô không nói được ai sở hữu.
- RAII buộc việc giải phóng vào hủy tử, mà hủy tử thì luôn chạy khi ra khỏi khối, kể cả khi
returnsớm hay có ngoại lệ. unique_ptrcho sở hữu độc quyền (mặc định),shared_ptrcho sở hữu chung,weak_ptrđể cắt chu trình. Mã hiện đại gần như không cónewtrần.
new/delete cho phép đối tượng sống trên heap nhưng buộc ta quản lý hủy bằng tay
(dễ rò rỉ / double free). Hãy ưu tiên unique_ptr + make_unique: tự hủy đúng
lúc theo RAII. Đây là cách viết C++ hiện đại an toàn.