Menu

Smart pointer trong C++: unique_ptr và shared_ptr

Smart pointer sở hữu vùng nhớ heap và tự giải phóng nó. Tìm hiểu unique_ptr, shared_ptr, make_unique và make_shared, và vì sao bạn gần như không bao giờ phải viết new/delete nữa.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Vấn đề mà smart pointer giải quyết

Ở trang trước bạn đã cấp phát bộ nhớ bằng new và giải phóng bằng delete. Cách đó chạy được, nhưng mọi gánh nặng đổ lên bạn: mỗi new cần một delete tương ứng, trên mọi nhánh code, kể cả những nhánh có ngoại lệ được ném ra giữa chừng. Bỏ sót một lần là rò rỉ bộ nhớ; chạy delete hai lần là làm hỏng heap.

Smart pointer khắc phục điều này bằng cách gắn vòng đời của vùng nhớ heap với một đối tượng bình thường trên stack. Khi đối tượng đó ra khỏi phạm vi, hàm hủy của nó sẽ chạy delete cho bạn, chắc chắn như vậy, kể cả khi một ngoại lệ làm stack bị tháo ra. Ý tưởng này gọi là RAII (Resource Acquisition Is Initialization), và smart pointer nằm trong header <memory>.

Bạn dùng *p và p->member y như với con trỏ thô. Khác biệt là bạn không bao giờ gọi delete: smart pointer làm việc đó.

unique_ptr: một chủ sở hữu, không chia sẻ

unique_ptr là smart pointer bạn nên dùng mặc định. Nó thể hiện quyền sở hữu độc quyền: tại mỗi thời điểm chỉ đúng một unique_ptr sở hữu đối tượng, và khi con trỏ đó bị hủy, đối tượng cũng bị hủy theo. So với con trỏ thô, nó không tốn thêm chi phí lúc chạy.

Tạo nó bằng make_unique (C++14). Hàm này nhận các đối số của hàm khởi tạo và trả về cho bạn một con trỏ sẵn sàng sử dụng:

Vì chỉ có thể có một chủ sở hữu, unique_ptr không thể sao chép. Cố sao chép nó sẽ gây lỗi biên dịch, và lỗi đó chính là ngôn ngữ đang bảo vệ bạn khỏi việc hai chủ sở hữu cùng cố delete một đối tượng:

auto a = make_unique<int>(10);
auto b = a;   // error: call to deleted copy constructor of unique_ptr

Để trao quyền sở hữu cho nơi khác, bạn di chuyển nó bằng std::move. Sau khi di chuyển, con trỏ ban đầu trở nên rỗng (nó chứa nullptr):

Đây là mô hình bạn cần trong hầu hết trường hợp: luôn có đúng một chủ sở hữu rõ ràng, và trình biên dịch bảo đảm điều đó.

shared_ptr: sở hữu chung bằng cách đếm tham chiếu

Đôi khi nhiều phần của chương trình thật sự cần dùng chung một đối tượng, và không phần nào biết phần nào sẽ xong sau cùng. Đó là lúc dùng shared_ptr. Nó giữ một bộ đếm tham chiếu: mỗi bản sao làm tăng bộ đếm, mỗi lần hủy làm giảm nó, và đối tượng chỉ được giải phóng khi bộ đếm về 0.

Tạo chúng bằng make_shared:

Khác với unique_ptr, sao chép shared_ptr là hoàn toàn được, vì đó chính là mục đích của nó. Cái giá phải trả là chi phí: bộ đếm tham chiếu được lưu trên heap và cập nhật nguyên tử (an toàn với đa luồng), nên shared_ptr nặng hơn unique_ptr. Chỉ dùng nó khi quyền sở hữu thật sự được chia sẻ, đừng dùng chỉ để khỏi phải nghĩ xem ai sở hữu cái gì.

make_shared cũng hiệu quả hơn shared_ptr<T>(new T(...)): nó cấp phát đối tượng và khối điều khiển trong một lần cấp phát thay vì hai.

weak_ptr và việc phá vòng tham chiếu

shared_ptr có một cái bẫy kinh điển: nếu hai đối tượng giữ shared_ptr trỏ tới nhau, bộ đếm tham chiếu của chúng không bao giờ về 0, nên không đối tượng nào được giải phóng. Đó là rò rỉ bộ nhớ dù bạn đã dùng smart pointer.

struct Node {
    shared_ptr<Node> next;   // if two nodes point at each other,
};                           // they keep each other alive forever

Cách khắc phục là weak_ptr: một kẻ quan sát shared_ptr mà không sở hữu. Nó không làm tăng bộ đếm tham chiếu, nên không bao giờ giữ một đối tượng sống. Để dùng đối tượng, bạn gọi .lock(), hàm này cho bạn một shared_ptr nếu đối tượng vẫn còn, hoặc một shared_ptr rỗng nếu nó đã mất.

Dùng weak_ptr cho "con trỏ ngược" và bộ đệm: bất cứ chỗ nào bạn muốn tham chiếu tới một đối tượng mà không giành quyền sở hữu nó.

Lỗi thường gặp và những cái bẫy

Smart pointer loại bỏ phần lớn bug về bộ nhớ, nhưng vẫn còn vài cái bẫy:

Đừng trộn quyền sở hữu thông minh và thô trên cùng vùng nhớ. Không bao giờ tạo hai smart pointer từ cùng một con trỏ thô, vì mỗi cái sẽ cố delete nó:

int* raw = new int(5);
unique_ptr<int> a(raw);
unique_ptr<int> b(raw);   // disaster: both will delete the same int (double free)

Đây chính là lý do nên ưu tiên make_unique/make_shared: không có con trỏ thô nào trôi nổi để bị dùng sai.

unique_ptr chỉ di chuyển được, nên hãy truyền theo giá trị để trao quyền sở hữu. Nếu một hàm chỉ cần dùng chứ không sở hữu đối tượng, hãy nhận một tham chiếu thông thường hoặc một T* thô: con trỏ thô chỉ để quan sát là hoàn toàn ổn:

void consume(unique_ptr<int> p);   // takes ownership (move into it)
void observe(int* p);              // just looks, owns nothing

Đừng mặc định dùng shared_ptr. Nó hấp dẫn vì sao chép thoải mái, nhưng việc đếm tham chiếu nguyên tử tốn hiệu năng thật, và sở hữu chung khiến vòng đời khó suy luận hơn. Mặc định dùng unique_ptr; chỉ chuyển sang shared_ptr khi bạn thật sự cần nhiều chủ sở hữu.

unique_ptr cho mảng cần dạng mảng. make_unique<int[]>(n) cho bạn một unique_ptr<int[]> gọi đúng delete[]. Trong thực tế, hãy ưu tiên std::vector cho mảng động: nó quản lý bộ nhớ cho bạn và còn theo dõi kích thước nữa.

Tiếp theo: chuỗi

Giờ bạn đã kiểm soát được việc quản lý bộ nhớ: smart pointer cho bạn cấp phát trên heap mà không rò rỉ. Một trong những thứ phổ biến nhất bạn sẽ cấp phát và truyền đi là văn bản, và C++ có một công cụ an toàn hơn nhiều so với bộ đệm char* thô. Trang tiếp theo nói về std::string: cách nó tự mở rộng, các thao tác bạn sẽ dùng hằng ngày, và vì sao nó giải phóng bạn hoàn toàn khỏi việc quản lý bộ nhớ thủ công.

Câu hỏi thường gặp

Smart pointer trong C++ là gì?

Smart pointer là các đối tượng trong <memory> (unique_ptr, shared_ptr, weak_ptr) bọc một con trỏ thô và tự động delete vùng nhớ khi chúng ra khỏi phạm vi. Chúng cho bạn cấp phát trên heap mà không phải tự delete, và không bị rò rỉ bộ nhớ khi lỡ quên.

unique_ptr và shared_ptr khác nhau thế nào?

unique_ptr là chủ sở hữu duy nhất của đối tượng: nó không thể sao chép, chỉ có thể di chuyển, và giải phóng vùng nhớ ngay khi nó bị hủy. shared_ptr cho phép sở hữu chung bằng cách đếm tham chiếu: nhiều shared_ptr có thể trỏ tới cùng một đối tượng, và đối tượng chỉ được giải phóng khi cái cuối cùng bị hủy. Hãy ưu tiên unique_ptr trừ khi bạn thật sự cần sở hữu chung.

Nên dùng make_unique hay new trong C++ hiện đại?

Dùng make_unique và make_shared. Chúng cấp phát và bọc đối tượng trong một bước, nên không có lệnh new thô nào mà kết quả có thể bị rò rỉ trước khi tới được smart pointer. Theo kinh nghiệm chung, một codebase C++ hiện đại gần như không nên có new hay delete trần.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu