Smart pointer trong C++: unique_ptr, shared_ptr và weak_ptr

Smart pointer trong C++: unique_ptr, shared_ptr và weak_ptr - Khuê Nguyễn Creator

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>:

Sơ đồ ba loại smart pointer trong C++: unique_ptr một chủ, shared_ptr nhiều chủ có bộ đếm, weak_ptr chỉ quan sát

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;
}

unique_ptr tự hủy đối tượng khi hàm kết thúc, kể cả khi return sớm

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 đương new nhưng an toàn hơn.
  • Dùng -> và * như con trỏ thường: s->ten(), *s.
  • unique_ptr có 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;
}

g++ báo lỗi use of deleted function khi sao chép unique_ptr

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;
}

std::move chuyển quyền sở hữu unique_ptr vào vector, biến cũ thành nullptr

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_ptr với use_count bằng 3, giảm dần và hủy đối tượng khi về 0

  • shared_ptr sao 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ả s và noiBat bị 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;
}

Vòng tham chiếu giữa hai shared_ptr làm rò rỉ 128 byte, không đối tượng nào bị hủy

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;
}

Dùng weak_ptr phá vòng tham chiếu, lock lấy người mượn và cả hai đối tượng được hủy

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

  1. 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ần delete[] không?
  2. 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ằng std::move. In 2 vector sau khi chuyển.
  3. Trong ví dụ shared_ptr, in use_count() sau từng lệnh push_back để thấy bộ đếm tăng dần.
  4. Trong ví dụ weak_ptr, gọi an.reset() trước khi lock(). 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é.

Rate this post

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *