Trong bài này chúng ta sẽ học Singleton pattern trong C++: đảm bảo một class chỉ có đúng một đối tượng, và mọi nơi trong chương trình đều truy cập được đối tượng đó. Đây là pattern đơn giản nhất, hay được hỏi khi phỏng vấn nhất, và cũng là pattern bị lạm dụng nhiều nhất, nên phần nhược điểm cuối bài quan trọng không kém phần cách viết.
Bài 30 trong Serie Lập trình C++ từ A tới Z
Bài toán: một nhật ký dùng chung
Chương trình thư viện cần ghi nhật ký (log) mọi thao tác mượn, trả sách, đánh số thứ tự từng dòng. Các hàm muonSach, traSach, báo cáo cuối ngày nằm rải rác ở nhiều file nhưng phải ghi vào cùng một nhật ký, dùng chung một bộ đếm số dòng.
Nếu mỗi hàm tự tạo một đối tượng Logger riêng thì mỗi đối tượng có bộ đếm riêng, số thứ tự bị lặp lại. Truyền một Logger qua tham số của mọi hàm thì rất phiền, vì gần như hàm nào cũng cần ghi log.
Ví dụ đời thường: một thư viện chỉ có một cuốn sổ nhật ký đặt ở quầy. Ai cần ghi thì tới quầy ghi vào chính cuốn đó, không ai được tự mua sổ mới.
Singleton pattern là gì?
Singleton đảm bảo 2 điều:
- Class chỉ có đúng một đối tượng, không ai tạo thêm được.
- Có một điểm truy cập chung để lấy đối tượng đó từ bất cứ đâu.
Cách viết Singleton pattern trong C++
Cách viết chuẩn trong C++ hiện đại gọi là Meyers singleton (theo tên Scott Meyers):
#include <iostream>
#include <string>
class Logger
{
public:
static Logger& instance()
{
static Logger duyNhat; // tao lan dau duoc goi, an toan da luong tu C++11
return duyNhat;
}
Logger(const Logger&) = delete; // cam sao chep
Logger& operator=(const Logger&) = delete;
void ghi(const std::string& noiDung)
{
soDong_++;
std::cout << "[LOG " << soDong_ << "] " << noiDung << '\n';
}
private:
Logger() { std::cout << "(Khoi tao Logger)\n"; } // constructor private
int soDong_ = 0;
};
void muonSach(const std::string& ten)
{
Logger::instance().ghi("Muon " + ten);
}
void traSach(const std::string& ten)
{
Logger::instance().ghi("Tra " + ten);
}
int main()
{
std::cout << "Bat dau main\n";
muonSach("Lap trinh C++");
traSach("Clean Code");
std::cout << "Cung mot doi tuong? " << (&Logger::instance() == &Logger::instance()) << '\n';
return 0;
}
Ba thành phần làm nên Singleton:
- Constructor
private: bên ngoài không tạo đượcLoggermới. - Hàm
static instance(): điểm truy cập chung. Hàmstaticgọi được mà không cần đối tượng, bằng cú phápLogger::instance(). - Biến
staticcục bộduyNhatbên tronginstance(): được tạo một lần duy nhất ở lần gọi đầu tiên, sống tới khi chương trình kết thúc. Để ý dòng “(Khoi tao Logger)” in ra sau “Bat dau main”, đúng lúc gọiinstance()lần đầu.
Từ C++11, chuẩn đảm bảo biến static cục bộ được khởi tạo an toàn khi nhiều luồng cùng gọi lần đầu: chỉ một luồng tạo, các luồng khác chờ. Vì vậy cách viết này vừa ngắn vừa an toàn, không cần tự khóa mutex.
Cấm sao chép
Hai dòng = delete xóa hàm sao chép và toán tử gán. Nếu thiếu chúng, viết Logger banSao = Logger::instance(); sẽ tạo ra đối tượng thứ hai và phá vỡ Singleton. Có = delete và constructor private thì mọi cách tạo thêm đều là lỗi biên dịch:
#include <iostream>
class Logger
{
public:
static Logger& instance()
{
static Logger duyNhat;
return duyNhat;
}
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
private:
Logger() = default;
};
int main()
{
Logger banSao = Logger::instance(); // co tinh sao chep
Logger khac; // co tinh tao moi
return 0;
}
Lưu ý: Luôn lấy Singleton bằng tham chiếu: Logger& log = Logger::instance();. Quên dấu & thì là sao chép, và nhờ = delete nên trình biên dịch báo lỗi ngay thay vì âm thầm tạo bản thứ hai.
Nhược điểm của Singleton
Singleton thực chất là một biến toàn cục được bọc lại cẩn thận, nên mang theo các nhược điểm của biến toàn cục mà bài hằng và biến C++ đã nhắc:
- Phụ thuộc ngầm: nhìn khai báo
void muonSach(const std::string&)không biết hàm này có ghi log. Phụ thuộc bị giấu trong thân hàm. - Khó kiểm thử: muốn chạy thử
muonSachmà không in log, hoặc kiểm tra log ghi đúng nội dung, thì không thay đượcLoggerthật bằng mộtLoggergiả. - Trạng thái dùng chung: mọi phần của chương trình cùng sửa một đối tượng, dễ ảnh hưởng lẫn nhau, và khi dùng đa luồng thì phải tự bảo vệ dữ liệu bên trong (ở đây là
soDong_).
Khi nào nên dùng Singleton?
Nên dùng khi thật sự chỉ có một thứ đó trong thế giới thực của chương trình, và gần như mọi nơi đều cần:
- Nhật ký (logger), cấu hình chương trình đọc một lần lúc khởi động.
- Trong lập trình nhúng: lớp quản lý một ngoại vi phần cứng chỉ có một, như một cổng UART dùng để in log.
Không nên dùng chỉ để “khỏi phải truyền tham số”. Nếu một đối tượng chỉ vài hàm cần, hãy truyền nó vào qua tham số hoặc constructor (const KenhThongBao& như bài trừu tượng). Code dài thêm một chút nhưng phụ thuộc rõ ràng và dễ kiểm thử hơn nhiều.
Bài tập
- Viết Singleton
CauHinhlưu tên thư viện và số ngày mượn tối đa, đọc từ hằng số khi khởi tạo. Dùng nó trong 2 hàm khác nhau. - Trong ví dụ
Logger, thêm 2 luồng cùng gọimuonSach1000 lần như bài đa luồng.soDong_có bị đếm sai không? Sửa bằngstd::mutex. - Bỏ 2 dòng
= deleterồi viếtLogger banSao = Logger::instance(); banSao.ghi("...");. Số dòng của bản sao và bản gốc khác nhau thế nào?
Lời giải sẽ có trong bài tập tổng hợp của chương nhé.
Kết
Singleton pattern trong C++ đảm bảo một class chỉ có một đối tượng với điểm truy cập chung. Cách viết chuẩn gồm constructor private, hàm static instance() trả về tham chiếu tới biến static cục bộ, và = delete để cấm sao chép. Hãy dùng Singleton cho những thứ thật sự chỉ có một như logger, cấu hình, và cân nhắc truyền tham số thay thế khi có thể.
Tham khảo thêm về pattern này ở Refactoring.Guru: Singleton. Bài trước là design pattern là gì, bài tiếp theo chúng ta học Factory pattern để tạo đúng loại đối tượng theo dữ liệu. Nếu thấy bài viết có ích, hãy chia sẻ cho bạn bè nhé.




