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ý.
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;
}
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.dangKyChothêm hàm vào danh sách,duocTragọi lần lượt từng hàm. ClassSachkhông biếtDocGialà 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,
Sachkhông đổi một dòng. - Lambda
[&an]bắtantheo tham chiếu, để gọi đúng đối tượngantrongmain.
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;
}
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
- Thêm hàm
int dangKyCho(HamBaoTin)trả về mã đăng ký vàvoid huyDangKy(int ma)cho classSach. Gợi ý: đổivectorthànhstd::map<int, HamBaoTin>. - Viết class
CamBienNhietDocó hàmcapNhat(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 độ. - Sửa ví dụ con trỏ treo bằng cách lưu
std::weak_ptr<DocGia>trong lambda và dùnglock()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é.




