Trong bài này chúng ta sẽ học smart pointer trong C++: std::unique_ptr, std::shared_ptr và std::weak_ptr. Ở bài cấp phát động, chúng ta đã thấy 4 lỗi kinh điển của new và delete: rò rỉ bộ nhớ, con trỏ treo, delete hai lần, nhầm delete với delete[]. Smart pointer là cách C++ hiện đại loại bỏ gần hết các lỗi đó.
Bài 13 trong Serie Lập trình C++ từ A tới Z
Smart pointer trong C++ là gì?
Smart pointer (con trỏ thông minh) là một đối tượng bọc ngoài con trỏ thô, và tự delete vùng nhớ khi nó không còn được dùng nữa. Smart pointer nằm trên stack, nên khi ra khỏi phạm vi thì hàm hủy của nó chạy và giải phóng vùng nhớ trên heap. Đây chính là kỹ thuật RAII đã nhắc ở bài xử lý ngoại lệ trong C++.
Ví dụ đời thường: con trỏ thô giống mượn sách mà không ghi sổ, nhớ thì trả, quên thì sách mất. Smart pointer giống thẻ mượn có hạn trả tự động: hết hạn là sách tự quay về kệ, không phụ thuộc vào trí nhớ của người mượn.
Thư viện chuẩn có 3 loại, nằm trong <memory>:
unique_ptr: một chủ sở hữu duy nhất
std::unique_ptr sở hữu đối tượng một mình. Tạo bằng std::make_unique, và không bao giờ phải gọi delete:
#include <iostream>
#include <memory>
#include <string>
class Sach
{
public:
Sach(const std::string& ten) : ten_(ten) { std::cout << " Tao sach '" << ten_ << "'\n"; }
~Sach() { std::cout << " Huy sach '" << ten_ << "'\n"; }
const std::string& ten() const { return ten_; }
private:
std::string ten_;
};
void xuLy(bool coLoi)
{
auto s = std::make_unique<Sach>("Lap trinh C++");
std::cout << " Dang xu ly " << s->ten() << '\n';
if (coLoi)
{
std::cout << " Gap loi, return som\n";
return; // khong can nho delete
}
std::cout << " Xu ly xong\n";
}
int main()
{
std::cout << "Lan 1:\n";
xuLy(false);
std::cout << "Lan 2:\n";
xuLy(true);
return 0;
}
Cả 2 lần, cuốn sách đều được hủy khi hàm kết thúc, kể cả lần return sớm giữa chừng. Với con trỏ thô, các bạn phải nhớ viết delete trước mọi lệnh return, và trước cả những chỗ có thể ném ngoại lệ.
std::make_unique<Sach>("...")tạo đối tượng trên heap và trả vềunique_ptr. Tương đươngnewnhưng an toàn hơn.- Dùng
->và*như con trỏ thường:s->ten(),*s. unique_ptrcó kích thước bằng đúng một con trỏ thô, không tốn thêm gì. Vì vậy đây là lựa chọn mặc định.
unique_ptr không sao chép được
“Duy nhất” nghĩa là không thể có 2 unique_ptr cùng giữ một đối tượng:
#include <memory>
#include <string>
int main()
{
auto s1 = std::make_unique<std::string>("Lap trinh C++");
auto s2 = s1; // loi: unique_ptr khong sao chep duoc
return 0;
}
Thông báo lỗi dài nhưng ý chính nằm ở chữ use of deleted function: hàm sao chép của unique_ptr đã bị xóa có chủ đích. Nếu cho sao chép thì 2 con trỏ cùng delete một đối tượng, đúng lỗi delete hai lần ở bài trước. Trình biên dịch chặn lỗi đó từ gốc.
Chuyển quyền sở hữu bằng std::move
Muốn trao đối tượng cho chỗ khác, ví dụ đưa sách vào kho, dùng std::move:
#include <iostream>
#include <memory>
#include <string>
#include <vector>
int main()
{
std::vector<std::unique_ptr<std::string>> kho;
auto s = std::make_unique<std::string>("Lap trinh C++");
kho.push_back(std::move(s)); // chuyen quyen so huu vao kho
std::cout << "s sau khi move: " << (s == nullptr ? "nullptr" : *s) << '\n';
std::cout << "Sach trong kho: " << *kho[0] << '\n';
return 0;
}
Sau std::move(s), quyền sở hữu chuyển sang phần tử trong vector, còn s trở thành nullptr. Lúc nào cũng chỉ có một chủ. Vector chứa unique_ptr như vậy chính là cách chương trình thư viện lưu sách, tạp chí ở bài tính đa hình trong C++.
Lưu ý: Sau khi std::move một unique_ptr, đừng dùng lại biến cũ. Nó đã là nullptr, gọi s->ten() sẽ crash. std::move không di chuyển gì ngay tại dòng đó, nó chỉ đánh dấu “biến này cho phép lấy đi”, và hàm nhận (push_back) mới thực sự lấy.
shared_ptr: nhiều chủ sở hữu
Có những đối tượng thật sự cần nhiều nơi cùng giữ. Ví dụ một cuốn sách vừa nằm trong kho, vừa nằm trong danh sách “sách nổi bật”. Cuốn sách chỉ nên bị hủy khi cả hai danh sách đều bỏ nó. std::shared_ptr giải quyết bằng một bộ đếm tham chiếu:
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Sach
{
Sach(const std::string& t) : ten(t) {}
std::string ten;
~Sach() { std::cout << " Huy sach '" << ten << "'\n"; }
};
int main()
{
std::vector<std::shared_ptr<Sach>> kho;
{
std::vector<std::shared_ptr<Sach>> noiBat; // danh sach sach noi bat
auto s = std::make_shared<Sach>("Clean Code");
kho.push_back(s);
noiBat.push_back(s);
std::cout << "So noi dang giu: " << s.use_count() << '\n';
}
std::cout << "Sau khi bo danh sach noi bat, con " << kho[0].use_count() << " noi giu\n";
kho.clear();
std::cout << "Sau khi xoa kho\n";
return 0;
}
shared_ptrsao chép được. Mỗi bản sao làm bộ đếm tăng 1,use_count()cho biết đang có bao nhiêu nơi giữ.- Khi một bản sao bị hủy, bộ đếm giảm 1. Ra khỏi khối
{ }, cảsvànoiBatbị hủy, bộ đếm từ 3 về 1. - Bộ đếm về 0 thì đối tượng mới bị hủy, đúng lúc
kho.clear().
Cái giá của shared_ptr là bộ đếm cần thêm bộ nhớ, và việc tăng giảm bộ đếm phải an toàn khi nhiều luồng chạy cùng lúc nên chậm hơn unique_ptr. Vì vậy chỉ dùng khi thật sự có nhiều chủ sở hữu.
weak_ptr: phá vòng tham chiếu
shared_ptr có một điểm yếu: 2 đối tượng giữ shared_ptr tới nhau thì bộ đếm không bao giờ về 0. Độc giả giữ cuốn sách đang mượn, và cuốn sách cũng giữ người đang mượn nó:
#include <iostream>
#include <memory>
#include <string>
struct Sach;
struct DocGia
{
std::string ten;
std::shared_ptr<Sach> dangMuon;
~DocGia() { std::cout << " Huy doc gia " << ten << '\n'; }
};
struct Sach
{
std::string ten;
std::shared_ptr<DocGia> nguoiMuon; // tro nguoc lai doc gia
~Sach() { std::cout << " Huy sach " << ten << '\n'; }
};
int main()
{
auto an = std::make_shared<DocGia>();
an->ten = "An";
auto sach = std::make_shared<Sach>();
sach->ten = "Lap trinh C++";
an->dangMuon = sach;
sach->nguoiMuon = an;
std::cout << "Ket thuc main\n";
return 0;
}
Không có dòng “Huy” nào được in: cả độc giả lẫn cuốn sách đều không bao giờ bị hủy. Khi main kết thúc, an và sach bị hủy làm mỗi bộ đếm giảm còn 1, nhưng 2 đối tượng vẫn giữ nhau nên bộ đếm không về 0. AddressSanitizer báo rò rỉ 128 byte. Smart pointer vẫn rò rỉ được nếu dùng sai.
Cách sửa: một chiều của vòng dùng std::weak_ptr. weak_ptr trỏ tới đối tượng nhưng không tăng bộ đếm:
#include <iostream>
#include <memory>
#include <string>
struct Sach;
struct DocGia
{
std::string ten;
std::shared_ptr<Sach> dangMuon;
~DocGia() { std::cout << " Huy doc gia " << ten << '\n'; }
};
struct Sach
{
std::string ten;
std::weak_ptr<DocGia> nguoiMuon; // weak_ptr: khong giu doi tuong song
~Sach() { std::cout << " Huy sach " << ten << '\n'; }
};
int main()
{
auto an = std::make_shared<DocGia>();
an->ten = "An";
auto sach = std::make_shared<Sach>();
sach->ten = "Lap trinh C++";
an->dangMuon = sach;
sach->nguoiMuon = an;
if (auto nguoi = sach->nguoiMuon.lock())
std::cout << sach->ten << " dang duoc " << nguoi->ten << " muon\n";
std::cout << "Ket thuc main\n";
return 0;
}
Cả 2 đối tượng giờ được hủy đúng. Vì weak_ptr không giữ đối tượng sống, đối tượng có thể đã bị hủy lúc ta muốn dùng. Muốn truy cập phải gọi lock(): nó trả về một shared_ptr tạm thời nếu đối tượng còn, hoặc nullptr nếu đã bị hủy.
Quy tắc chọn chiều nào dùng weak_ptr: chiều “sở hữu” dùng shared_ptr, chiều “tham khảo ngược lại” dùng weak_ptr. Độc giả sở hữu sách đang mượn, còn sách chỉ cần biết ai đang mượn.
Nên dùng loại smart pointer nào?
| Tình huống | Dùng |
|---|---|
| Một đối tượng trên heap, một chủ (trường hợp phổ biến nhất) | std::unique_ptr + std::make_unique |
| Danh sách các đối tượng đa hình | std::vector<std::unique_ptr<LopCha>> |
| Thật sự nhiều nơi cùng sở hữu, không biết ai bỏ cuối | std::shared_ptr + std::make_shared |
| Trỏ ngược lại, cache, quan sát mà không sở hữu | std::weak_ptr |
| Chỉ dùng tạm đối tượng, không sở hữu | Tham chiếu const T& hoặc con trỏ thô T* |
Dòng cuối quan trọng: smart pointer nói về quyền sở hữu. Hàm chỉ cần đọc đối tượng thì nhận const Sach& như bài về hàm, không nhận unique_ptr hay shared_ptr. Con trỏ thô vẫn được dùng, nhưng chỉ với nghĩa “trỏ tới, không sở hữu, không delete“.
Bài tập
- Viết lại bài tập 1 của bài cấp phát động: hàm tạo mảng trả về
std::unique_ptr<int[]>thay cho con trỏ thô. Có còn cầndelete[]không? - Tạo
std::vector<std::unique_ptr<Sach>>gồm 3 cuốn, rồi chuyển cuốn thứ 2 sang một vector khác bằngstd::move. In 2 vector sau khi chuyển. - Trong ví dụ
shared_ptr, inuse_count()sau từng lệnhpush_backđể thấy bộ đếm tăng dần. - Trong ví dụ
weak_ptr, gọian.reset()trước khilock(). Chương trình in gì, vì sao?
Lời giải sẽ có trong bài tập tổng hợp của chương nhé.
Kết
Smart pointer trong C++ biến việc quản lý bộ nhớ từ “tự nhớ mà trả” thành “tự động trả”: unique_ptr cho một chủ sở hữu và là lựa chọn mặc định, shared_ptr cho nhiều chủ với bộ đếm, weak_ptr để trỏ ngược mà không tạo vòng. Kết hợp với std::vector và std::string, gần như không còn lý do để viết new và delete trong code mới.
Tham khảo thêm ở cppreference: smart pointers. Tiếp theo, chúng ta bước vào chương lập trình hướng đối tượng với bài so sánh OOP và lập trình hướng cấu trúc. Thấy bài viết hữu ích, các bạn chia sẻ cho bạn bè nhé.








