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

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óngTrình biên dịch, khi ra khỏi khốiLập trình viên, bằng delete
Kích thướcPhải biết lúc biên dịchQuyết định lúc chạy
Vòng đờiGắn chặt với khối lệnhDài ngắn tùy ta
Rủi roGần như khôngRò 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ế.

new/delete thủ công vs unique_ptr tự hủy C++
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}
Ngăn xếp stack
main()
p→ #H1
Bộ nhớ động heap
#H1Node
v1
new Node(1): tạo đối tượng trên HEAP (#H1), constructor in "tao 1". Con trỏ p (trên stack) giữ địa chỉ #H1.
Kết quảtao 1
1/5

Ý 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ỏ p chỉ là một biến trên stack giữ địa chỉ. Nó không phải là đối tượng.
  • delete p gọi hủy tử rồi trả vùng nhớ về hệ thống. Sau dòng đó p thành con trỏ treo.
  • Quên delete thì rò rỉ, gọi delete hai 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ự delete khi q ra 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 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 ifreturn, 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 pq 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.

Ba lỗi hay gặp nhất khi mới học
  1. Cấp phát bằng new[] nhưng giải phóng bằng delete (thiếu ngoặc vuông).
  2. Gọi delete rồi vẫn dùng lại con trỏ đó. Sau delete, hãy gán p = 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.
  3. 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 delete nó, 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ằng std::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ó.

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.

Quy tắc chọn nhanh

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.

std::move chuyển sở hữu, con trỏ nguồn thành rỗng C++
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 ownership
11 // a is now empty (nullptr)
12}
Ngăn xếp stack
main()
Bộ nhớ động heap
(trống)
Bắt đầu main(). Chưa có gì trên heap.
1/6

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 vectorstring cũng là lớp RAII: chúng new bên trong và delete trong hủy tử của mình.

Cái bẫy còn lại của shared_ptr: chu trình tham chiếu

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".

Đừng dựng hai shared_ptr độc lập từ cùng một con trỏ thô
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 ab 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ỏ ab 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

Kiểm tra nhanh: cấp phát động và con trỏ thông minh0/4 đúngchưa trả lời
  1. 1Cấp phát bằng new int[n] thì giải phóng đúng bằng gì?
  2. 2unique_ptr an toàn hơn con trỏ thô chủ yếu nhờ điều gì?
  3. 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ì?
  4. 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ột delete, new[] đi với delete[].
  • 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 return sớm hay có ngoại lệ.
  • unique_ptr cho sở hữu độc quyền (mặc định), shared_ptr cho sở hữu chung, weak_ptr để cắt chu trình. Mã hiện đại gần như không có new trần.
Ghi nhớ

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.