Observer pattern trong C++: tự động báo tin khi dữ liệu thay đổi

Observer pattern trong C++: tự động báo tin khi dữ liệu thay đổi - Khuê Nguyễn Creator

Trong bài này chúng ta sẽ học Observer pattern trong C++: cách để một đối tượng tự động báo tin cho nhiều nơi khi nó thay đổi, mà không cần biết những nơi đó là ai. Đây là pattern nằm sau nút “Đăng ký nhận thông báo”, sự kiện bấm nút trên giao diện, hay hàm callback khi có dữ liệu mới từ cảm biến.

Bài 32 trong Serie Lập trình C++ từ A tới Z

Bài toán: báo cho độc giả khi sách được trả

Cuốn “Clean Code” đang có người mượn, An và Bình đều muốn mượn nên đăng ký chờ. Khi sách được trả, thư viện phải báo cho cả hai, đồng thời ghi lại nhật ký.

Cách ngây thơ là trong hàm trả sách gọi thẳng an.nhanTin(...), binh.nhanTin(...), nhatKy.ghi(...). Như vậy class Sach phải biết mọi loại đối tượng quan tâm tới nó. Mai thư viện thêm gửi email, gửi SMS, cập nhật màn hình ở quầy, lại phải sửa class Sach.

Observer pattern là gì?

Observer (người quan sát) chia làm 2 vai:

  • Subject (đối tượng được theo dõi), ở đây là cuốn sách: giữ một danh sách những ai đăng ký, và khi có thay đổi thì gọi lần lượt từng người.
  • Observer (người theo dõi): đăng ký với subject một lần, sau đó tự động được báo mỗi khi có tin.

Giống bấm “Đăng ký” một kênh YouTube: kênh không cần biết từng người xem là ai, cứ có video mới là thông báo tự tới mọi người đã đăng ký.

Sơ đồ Observer pattern trong C++: cuốn sách báo tin cho các độc giả và nhật ký đã đăng ký khi được trả

Cài đặt Observer pattern trong C++ bằng std::function

Sách GoF cài đặt Observer bằng một interface có hàm ảo capNhat(). Trong C++ hiện đại, cách gọn hơn là lưu danh sách các hàm std::function, nhờ vậy observer có thể là hàm thường, lambda hay phương thức của bất kỳ class nào:

#include <functional>
#include <iostream>
#include <string>
#include <vector>

class Sach
{
public:
    using HamBaoTin = std::function<void(const std::string&)>;

    explicit Sach(const std::string& ten) : ten_(ten) {}

    void dangKyCho(HamBaoTin ham) { nguoiCho_.push_back(ham); }   // dang ky nhan thong bao

    void duocTra()
    {
        std::cout << "'" << ten_ << "' vua duoc tra lai\n";
        for (const auto& baoTin : nguoiCho_)                        // bao cho tat ca nguoi cho
            baoTin(ten_);
        nguoiCho_.clear();
    }

private:
    std::string ten_;
    std::vector<HamBaoTin> nguoiCho_;
};

class DocGia
{
public:
    explicit DocGia(const std::string& ten) : ten_(ten) {}
    void nhanTin(const std::string& tenSach) const
    {
        std::cout << "  -> " << ten_ << ": sach '" << tenSach << "' da co, moi den muon\n";
    }

private:
    std::string ten_;
};

int main()
{
    Sach sach("Clean Code");
    DocGia an("An"), binh("Binh");

    sach.dangKyCho([&an](const std::string& t) { an.nhanTin(t); });
    sach.dangKyCho([&binh](const std::string& t) { binh.nhanTin(t); });
    sach.dangKyCho([](const std::string& t) { std::cout << "  -> [Nhat ky] cap nhat trang thai '" << t << "'\n"; });

    sach.duocTra();
    return 0;
}

Sách được trả, Observer báo tin cho An, Bình và ghi nhật ký

  • std::function<void(const std::string&)> là kiểu “bất kỳ thứ gì gọi được với một chuỗi và không trả về gì”. Lambda ở bài STL, hàm thường, đều gán được vào kiểu này.
  • dangKyCho thêm hàm vào danh sách, duocTra gọi lần lượt từng hàm. Class Sach không biết DocGia là gì.
  • Observer thứ ba không phải độc giả mà là một lambda ghi nhật ký. Thêm loại người nhận mới chỉ cần đăng ký thêm, Sach không đổi một dòng.
  • Lambda [&an] bắt an theo tham chiếu, để gọi đúng đối tượng an trong main.

Lỗi kinh điển: observer bị hủy mà chưa hủy đăng ký

Subject giữ tham chiếu hoặc con trỏ tới observer. Nếu observer bị hủy trước mà vẫn còn trong danh sách thì sao?

#include <functional>
#include <iostream>
#include <memory>
#include <string>
#include <vector>

class DocGia
{
public:
    explicit DocGia(int maThe) : maThe_(maThe) {}
    void nhanTin(const std::string& tenSach) const
    {
        std::cout << "The " << maThe_ << " nhan tin: " << tenSach << '\n';
    }

private:
    int maThe_;
};

int main()
{
    std::vector<std::function<void(const std::string&)>> nguoiCho;

    auto an = std::make_unique<DocGia>(1024);
    DocGia* p = an.get();
    nguoiCho.push_back([p](const std::string& t) { p->nhanTin(t); });

    an.reset();                         // doc gia huy tai khoan, quen huy dang ky

    for (const auto& baoTin : nguoiCho)
        baoTin("Clean Code");
    return 0;
}

Observer bị hủy mà chưa hủy đăng ký in mã thẻ rác, AddressSanitizer báo heap-use-after-free

Lần chạy thường in ra mã thẻ rác thay vì 1024, không báo lỗi gì. AddressSanitizer chỉ đúng lỗi: heap-use-after-free. Độc giả đã bị hủy nhưng lambda trong danh sách vẫn giữ con trỏ tới vùng nhớ cũ, đúng lỗi con trỏ treo ở bài cấp phát động.

Lưu ý: Observer có vòng đời ngắn hơn subject thì phải hủy đăng ký trước khi bị hủy. Cách làm phổ biến: hàm dangKy trả về một mã số, observer gọi huyDangKy(maSo) trong hàm hủy của nó. Hoặc subject giữ std::weak_ptr tới observer và bỏ qua những observer đã hết hạn như bài smart pointer.

Ví dụ đầu bài không dính lỗi này vì an, binh và sach cùng nằm trong main, và danh sách được clear() ngay sau khi báo tin.

Khi nào nên dùng Observer?

  • Một thay đổi cần báo cho nhiều nơi, và danh sách những nơi đó thay đổi lúc chạy.
  • Muốn tách bên phát sự kiện khỏi bên xử lý: thư viện giao diện như Qt dùng cơ chế signal/slot chính là Observer, nút bấm không cần biết ai xử lý cú bấm.
  • Trong lập trình nhúng: hàm callback khi có ngắt, khi nhận đủ gói UART, khi cảm biến có dữ liệu mới.

Không nên dùng khi chỉ có đúng một nơi nhận và nó không bao giờ đổi, gọi hàm trực tiếp là rõ ràng hơn. Observer cũng làm luồng chương trình khó theo dõi hơn: đọc duocTra() không thấy được những hàm nào sẽ chạy.

Bài tập

  1. Thêm hàm int dangKyCho(HamBaoTin) trả về mã đăng ký và void huyDangKy(int ma) cho class Sach. Gợi ý: đổi vector thành std::map<int, HamBaoTin>.
  2. Viết class CamBienNhietDo có hàm capNhat(double), báo cho các observer khi nhiệt độ thay đổi quá 0.5 độ. Đăng ký một observer in ra màn hình và một observer cảnh báo khi vượt 35 độ.
  3. Sửa ví dụ con trỏ treo bằng cách lưu std::weak_ptr<DocGia> trong lambda và dùng lock() trước khi gọi.

Lời giải sẽ có trong bài tập tổng hợp của chương nhé.

Kết

Observer pattern trong C++ cho phép một đối tượng báo tin cho nhiều nơi mà không cần biết họ là ai: các bên quan tâm tự đăng ký, subject chỉ việc gọi lần lượt. Với std::function và lambda, pattern này rất gọn. Điều cần nhớ nhất là vòng đời: observer bị hủy thì phải hủy đăng ký, không thì subject sẽ gọi vào vùng nhớ đã giải phóng.

Tham khảo thêm ở Refactoring.Guru: Observer. Bài tiếp theo chúng ta học Strategy pattern để thay đổi cách tính toán ngay lúc chương trình chạy. Nếu thấy bài viết có ích, hãy 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 *