前言
单例(singleton)要保证「一个类型在进程内只有一个实例,并提供全局访问点」。把这件事写成类模板,是为了让多个类复用同一套「禁止拷贝、延迟构造、线程安全初始化」的代码,而不是每个类各抄一遍。
先澄清两个常见误解。第一,「单例模板能保证整个进程唯一」是有前提的:如果模板定义在头文件里、又被多个动态库各自实例化,可能出现每个模块一份实例;跨 DLL 边界时尤其要小心。第二,「函数内静态变量天然线程安全」指的是初始化线程安全,不是之后的所有访问都线程安全;如果单例内部有可变状态,仍然要自己同步。
本文讲三种写法:最简单的函数模板、基于 CRTP 的基类模板、以及带生命周期取舍的堆分配版本;随后讨论析构顺序、动态库与可测试性。所有代码以 C++17 为基准,并逐行说明约束。
一、单例真正要解决的问题
一个典型的单例需求包含四件事:
| 需求 | 实现手段 |
|---|
| 全局唯一实例 | 静态存储期对象,或受控的堆对象 |
| 延迟初始化 | 首次访问时才构造 |
| 禁止复制/移动 | 删除拷贝构造、拷贝赋值、移动操作 |
| 线程安全的首次初始化 | C++11 起的静态局部变量保证,或std::call_once |
其中「延迟初始化」不是必须的。若对象构造很轻、也不依赖运行期参数,直接用一个命名空间作用域的变量更简单。单例真正的价值在于控制初始化时机与统一访问入口,而不是省一个全局变量。
还要明确它不解决的问题:单例不是「线程安全的代名词」,也不是「依赖注入的替代品」。它把依赖藏进了调用点,测试时难以替换;这一点在后面的「可测试性」一节展开。
二、最简形式:函数模板 + 函数内静态对象
如果不要求被管理的类继承什么基类,一个函数模板就够了:
// 适用于 C++17
template <typename T>
T& singleton() {
static T instance;
return instance;
}用法是singleton<Config>().timeout。它的优点是零侵入:Config不需要改任何代码,也不需要友元。C++11 起,标准保证函数内静态局部变量的初始化是线程安全的:多个线程同时首次调用,只有一个执行构造,其余等待;若构造抛异常,初始化视为未完成,下次调用会重试。
局限也同样明显:
- 要求
T可默认构造,且构造函数对调用方可见(通常是 public)。 - 无法阻止别人另外构造
T的实例,「唯一性」只是约定。 - 无法在构造时传入运行期参数。
因此它适合配置、工具类这类「默认构造即可」的类型,不适合需要严格封住构造函数的场景。
三、CRTP 基类模板:把唯一性写进类型
要真正禁止外部构造,就得让构造函数私有,并把基类模板设为友元。这是 CRTP( curiously recurring template pattern,奇异递归模板模式)的典型用法:
// 适用于 C++17
template <typename Derived>
class Singleton {
public:
static Derived& instance() {
static Derived object;
return object;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
Singleton(Singleton&&) = delete;
Singleton& operator=(Singleton&&) = delete;
protected:
Singleton() = default;
~Singleton() = default;
};
class Logger : public Singleton<Logger> {
public:
void write(const char* text) {
// 具体实现省略
(void)text;
}
private:
Logger() = default;
~Logger() = default;
friend class Singleton<Logger>;
};逐点解释:
instance()里的static Derived object;是延迟构造的:第一次调用Logger::instance()时才执行Logger的构造函数。Singleton的拷贝/移动操作全部删除。派生类的隐式拷贝构造需要调用基类拷贝构造,基类版本被删除,派生类的隐式拷贝构造也随之被删除,因此Logger自动不可拷贝。这比在每个派生类里重复写= delete更省事。Logger的构造函数私有,只有Singleton<Logger>是友元才能访问。注意友元声明要写具体的特化Singleton<Logger>,或者写template <typename> friend class Singleton;。- 基类析构声明为
protected且非虚:单例对象只以静态存储期存在,不会通过基类指针delete,所以不需要虚析构;声明为 protected 可以阻止外部delete Singleton<Logger>*。 Logger的构造与析构都是私有,只有Singleton<Logger>是友元。instance()是Singleton<Logger>的成员,因此它既能构造静态对象,也能在退出时销毁它。访问控制在这里是自洽的。
实践中的最小可靠组合是:构造函数私有 + 友元基类特化 + 析构函数与构造同级(private 或 protected)+ 四个拷贝/移动操作删除。少写友元会编译失败,多写 public 构造则会破坏唯一性。
四、完整示例:带初始化的日志单例
很多单例在构造时需要读取配置或打开文件。可以通过一个init()在首次访问前完成,但要注意「首次访问」的竞态。更稳的做法是把初始化放进构造函数,参数从环境或固定配置读取:
// 适用于 C++17
#include <fstream>
#include <mutex>
#include <string>
template <typename Derived>
class Singleton {
public:
static Derived& instance() {
static Derived object;
return object;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
protected:
Singleton() = default;
~Singleton() = default;
};
class Logger : public Singleton<Logger> {
public:
void log(const std::string& line) {
std::lock_guard<std::mutex> lock(mutex_);
if (stream_.is_open()) {
stream_ << line << '\n';
}
}
private:
Logger() : stream_("app.log", std::ios::app) {}
~Logger() = default;
friend class Singleton<Logger>;
std::mutex mutex_;
std::ofstream stream_;
};
int main() {
Logger::instance().log("started");
return 0;
}这里有两个线程安全层次,必须区分:
instance()的构造由语言保证只执行一次,多线程同时首次调用也安全。log()的写入由成员mutex_保护。静态初始化安全不代表成员访问安全,如果多个线程同时写同一个ofstream,没有锁就是数据竞争。
另外,std::ofstream的打开失败不会抛异常(默认不启用异常掩码),is_open()为 false 时静默丢弃日志。生产代码通常要检查并在构造时报告错误,但不要在构造函数里抛异常后指望「下次重试」——静态局部变量初始化抛异常确实会允许重试,但每次访问都会再次尝试并再次抛出,容易形成难以诊断的循环。
五、生命周期:静态析构、堆分配与 DLL
5.1 静态对象的析构顺序
函数内静态对象在程序退出时销毁,顺序与构造完成顺序相反。问题在于:如果单例 A 的析构函数调用了单例 B,而 B 已经被销毁,就是释放后使用。这类问题在程序退出阶段才出现,往往难以复现。
规避手段:
- 析构函数里不要调用其他单例;
- 或者让单例「永不析构」,即用堆分配且不 delete(见下);
- 或者把相互依赖的对象合并成一个,明确构造/析构顺序。
5.2 堆分配与「故意泄漏」
// 适用于 C++17
template <typename T>
T& leakySingleton() {
static T* instance = new T();
return *instance;
}指针本身是静态的,但指向的对象永不销毁,因此不存在析构顺序问题。代价是资源不释放:文件缓冲可能未 flush,操作系统句柄要到进程结束才回收。对日志、注册表这类「活到进程结束」的对象,这是可接受的取舍;对需要按顺序落盘的对象则不合适。
注意这里的new只执行一次,由静态指针的初始化保证线程安全(C++11 起)。不要写成if (!ptr) ptr = new T();,那是有竞态的惰性初始化。
5.3 动态库边界
如果单例模板的定义在头文件中,且被主程序和某个 DLL 分别包含、实例化,可能出现两份实例。是否合并取决于符号可见性、链接方式与平台:Windows 的 DLL 各有独立地址空间与静态数据,通常各有一份;ELF 平台在默认可见性下可能由动态链接器合并,但-fvisibility=hidden又会改变结果。跨模块共享单例的可靠做法是把实例的存储与访问函数只在一个模块中定义,其他模块通过导出接口调用,而不是各自实例化模板。
5.4 可测试性
单例把依赖硬编码在调用点,单元测试无法替换成假对象。若一个类内部直接写Logger::instance().log(...),测试时无法捕获日志。更好的设计是让业务类接受一个日志接口的引用:
// 适用于 C++17
struct ILogger {
virtual ~ILogger() = default;
virtual void log(const std::string& line) = 0;
};
class Service {
public:
explicit Service(ILogger& logger) : logger_(logger) {}
void run() { logger_.log("run"); }
private:
ILogger& logger_;
};单例只保留在「组合根」(程序入口附近)创建一次,然后注入。这样既保留全局唯一,又不把依赖藏起来。
常见坑点
- ❌ 用
if (instance_ == nullptr) instance_ = new T();做惰性初始化。
✅ 多线程下这是数据竞争,属于 UB;用函数内静态对象或std::call_once。
- ❌ 双检锁里用普通指针,第二次检查不加锁也不加内存序。
✅ 需要std::atomic<T*>配 acquire/release;更简单的是直接用 C++11 的静态局部变量。
- ❌ 单例模板的静态实例定义在头文件的命名空间作用域,被多个源文件包含。
✅ 用函数内静态对象避免多重定义;C++17 可用inline变量,但要清楚其初始化时机。
- ❌ 只删除了拷贝构造,忘了拷贝赋值或移动操作。
✅ 四个一起= delete;或删除基类的拷贝/移动,让派生类隐式删除。
- ❌ 派生类构造函数私有,却忘了把基类模板设为友元。
✅ 写friend class Singleton<Logger>;或template <typename> friend class Singleton;。
- ❌ 在单例构造函数里再次调用
instance()。
✅ 静态局部变量初始化期间递归进入是未定义行为(常见表现为死锁或崩溃);把初始化拆成独立函数。
- ❌ 认为
instance()线程安全就等于单例的所有成员函数线程安全。
✅ 语言只保证初始化一次;可变成员仍需互斥量或原子操作保护。
- ❌ 在动态库和主程序里各自包含单例模板头文件,期望共享同一实例。
✅ 把存储与访问函数收敛到一个模块并导出;跨模块行为依赖平台与可见性设置。
总结
| 写法 | 是否禁止外部构造 | 线程安全初始化 | 适用场景 |
|---|
template <typename T> T& singleton() | 否 | 是(C++11 起) | 工具类、配置,构造函数公开 |
| CRTP 基类模板 | 是(配合私有构造与友元) | 是 | 需要严格控制唯一性的类 |
| 堆分配 + 永不释放 | 取决于封装 | 是 | 析构顺序敏感、活到进程结束 |
std::call_once | 取决于封装 | 是 | 初始化可能失败并需要重试 |
单例模板本身只有十几行,难点全在边界:访问控制写到什么程度、初始化是否线程安全、析构顺序会不会互相踩、跨模块是否真的唯一。把「构造私有 + 友元 + 四个操作删除 + 函数内静态对象」作为默认组合,再根据生命周期需求决定是否改为堆分配,通常就够用了。若发现单例让测试变得困难,优先考虑依赖注入,而不是继续给模板加参数。