C++使用智能指针实现模板形式的单例类

发布时间:2026/10/20 3:54:58
C++使用智能指针实现模板形式的单例类
前言单例模式要解决的问题是某个类型在整个进程中只允许存在一个实例并提供一个全局访问点。把这件事写成模板是为了让Config、Logger、ConnectionPool这类类型复用同一套“唯一实例 线程安全初始化 禁止拷贝”的逻辑而不是每个类各抄一遍。围绕单例有不少误解。有人说“单例必须用双重检查锁定double-checked locking”但在 C11 之后函数内静态变量的初始化本身由标准保证线程安全绝大多数场景不需要手写双重检查而手写版本若忘了把指针声明为std::atomic反而制造数据竞争属于未定义行为undefined behavior, UB。有人以为“用了std::unique_ptr就自动线程安全”智能指针只负责所有权与释放不负责首次创建的同步。还有人把std::make_uniqueT()直接写进单例模板结果因为构造函数是私有的而编译失败——std::make_unique不是这个类的友元。本文讲三件事单例在 C11 之后的线程安全初始化依据如何用std::unique_ptr/std::shared_ptr写出模板形式以及析构顺序、私有构造访问这些容易翻车的地方。代码以 C17 为基准涉及 C17 的inline static会注明本机没有编译环境未做编译验证。一、单例的三条硬要求第一构造入口必须受控。构造函数应为私有或受保护拷贝构造与拷贝赋值应删除避免出现第二个实例或意外复制。第二首次访问必须线程安全。多个线程同时第一次调用instance()时只能构造一次且其他线程要能看到完整构造后的对象。第三生命周期要明确。单例什么时候销毁、销毁时能否安全访问其他全局对象必须有约定。很多“单例崩溃”不是创建的问题而是程序退出时静态对象析构顺序的问题。C11 标准规定函数内的静态局部变量在控制流第一次经过其声明时初始化如果初始化正在被另一个线程执行其他线程会阻塞等待初始化完成后才继续。这条保证通常被称为“magic statics”。它让下面这种写法成为最简单的线程安全懒汉式// 适用于 C17static T instance() {static T obj;return obj;}前提是编译器实现了 C11 的这条规则。极老的工具链例如早期的 MSVC可能提供开关或行为不一致使用前应以工具链文档为准。对现代 GCC、Clang、MSVC 而言这是标准要求。二、用 unique_ptr 写模板单例把上面的局部静态对象换成std::unique_ptrT就得到“智能指针持有”的版本。它的好处是所有权表达明确单例对象由模板内部持有外部只能拿到引用同时可以替换删除器便于测试时注入自定义销毁逻辑。// 适用于 C17#include memorytemplate typename Tclass Singleton {public:Singleton(const Singleton) delete;Singleton operator(const Singleton) delete;static T instance() {// C11 起局部静态的初始化是线程安全的static std::unique_ptrT holder(new T());return *holder;}protected:Singleton() default;~Singleton() default;};使用方这样写// 适用于 C17class Config : public SingletonConfig {friend class SingletonConfig; // 允许模板访问私有构造public:int port() const { return port_; }private:Config() : port_(8080) {}int port_ 0;};instance()里的new T()出现在SingletonT的成员函数中而SingletonT是T的友元所以能调用私有构造函数。这里不能用std::make_uniqueT()它内部执行new T(...)的函数不是友元访问检查会失败。要么像上面这样在友元上下文里直接new要么在T里提供一个公有的静态工厂要么把构造函数改为 public 并靠约定约束。析构方面std::unique_ptrT的默认删除器会执行delete ptr需要T的析构函数可访问。上面的Config没有显式写析构隐式析构是公有的所以没问题。如果你把~Config()设为私有就必须让std::default_deleteConfig成为友元或者给unique_ptr换一个在友元上下文里定义的自定义删除器。如果希望单例可以被重置测试里常用可以把持有者改成类的静态成员并用std::call_once保证只创建一次。C17 的inline static可以让静态成员的定义留在头文件里避免为每个实例化类型写一遍类外定义// 适用于 C17#include memory#include mutextemplate typename Tclass ResettableSingleton {public:static T instance() {std::call_once(flag_, ResettableSingleton::create);return *holder_;}static void reset() {std::lock_guardstd::mutex lock(mutex_);holder_.reset();flag_ std::once_flag{}; // 允许下次重新创建}ResettableSingleton(const ResettableSingleton) delete;ResettableSingleton operator(const ResettableSingleton) delete;protected:ResettableSingleton() default;~ResettableSingleton() default;private:static void create() { holder_.reset(new T()); }inline static std::unique_ptrT holder_;inline static std::once_flag flag_;inline static std::mutex mutex_;};reset()只应在没有其他线程使用单例时调用否则正在使用对象的线程会持有悬垂引用。这类接口适合测试不适合运行期随意调用。三、shared_ptr 版本与观察者有些设计希望单例的“所有权”可以被共享例如插件在初始化阶段拿到shared_ptr并在自己的生命周期内持有。写法是把持有者换成std::shared_ptrT// 适用于 C17#include memorytemplate typename Tclass SharedSingleton {public:static std::shared_ptrT instance() {static std::shared_ptrT holder(new T());return holder;}SharedSingleton(const SharedSingleton) delete;SharedSingleton operator(const SharedSingleton) delete;protected:SharedSingleton() default;~SharedSingleton() default;};每次调用返回一个新的shared_ptr副本引用计数增加静态holder在程序退出时释放自己那一份若外部还持有对象会活得更久。这时的“单例”语义已经变成“进程内唯一的共享对象”而不是“严格与进程同生命周期”。std::weak_ptr在这里的用途是观察而非持有外部保存weak_ptr需要时lock()可以知道单例是否已经销毁。但只要有静态holder存在它就不会提前销毁所以weak_ptr更多用于测试或可重置的设计。使用shared_ptr要留意循环引用如果单例对象内部又持有指向自己的shared_ptr引用计数永远不归零。需要打破环时把其中一条边改成weak_ptr。四、析构顺序与“单例依赖单例”函数内静态对象在程序退出时按构造的逆序销毁。如果Logger的析构函数里调用Config::instance()而Config的单例已经先被销毁那么访问的就是一个生命周期已经结束的对象属于 UB。这类问题在单进程测试时不一定暴露在关闭路径复杂时很难排查。规避方式有三类。其一让单例的析构函数不做任何依赖其他单例的事只释放自身资源。其二对确实需要“退出时按顺序清理”的对象不要依赖静态析构改为显式提供shutdown()在main返回前按确定顺序调用。其三把单例改为由main或应用容器持有通过引用传递彻底避开全局析构顺序。单例还常被滥用为“全局变量容器”。它隐藏了依赖关系让单元测试难以替换实现也让初始化顺序变得隐式。如果一个类型并非在语义上必须唯一优先通过构造函数参数传入只有日志器、进程级配置这类确实唯一的设施才值得单例。常见坑点❌ 手写双重检查锁定却用普通指针static T* p; if (!p) { lock; if (!p) p new T; }。✅ 普通指针的读写产生数据竞争是 UB用函数内静态对象或把指针改为std::atomicT*并配合正确的内存序。❌ 在模板单例里写std::make_uniqueT()而T的构造函数是私有的。✅make_unique不是友元在友元上下文里写new T()或提供公有工厂。❌ 只删除拷贝构造不删除拷贝赋值。✅ 两个都 delete否则仍可能赋值出第二个逻辑实例。❌ 把模板静态成员的定义放进.cpp。✅ 模板在每个实例化点都需要定义静态成员应放在头文件C17 起可用inline static。❌ 单例 A 的析构函数里调用单例 B。✅ 静态析构顺序不可控析构函数只做自身清理跨对象清理显式编排。❌instance()返回T*调用方顺手delete。✅ 返回T或返回shared_ptr明确所有权不要让外部拿到可删除的裸指针。❌ 继承SingletonT后忘记在T里声明friend class SingletonT;。✅ 私有构造无法被模板调用友元声明是模板单例成立的前提。❌ 把单例当全局变量用任何地方都能改状态。✅ 限定接口、把可变状态收进成员函数并加同步能用依赖注入就不要用单例。总结方案创建时机线程安全来源适用场景局部静态对象static T obj;首次调用C11 静态初始化规则首选简单且可靠局部静态unique_ptrT首次调用同上需要自定义删除器或显式所有权call_once 静态成员首次调用std::call_once需要reset或延迟参数局部静态shared_ptrT首次调用同上外部需要共享所有权饿汉式全局对象程序启动静态初始化启动期就要用、无依赖模板单例的骨架很短删除拷贝、私有构造、一个返回引用的静态函数、一个智能指针持有者。真正决定它是否可靠的是三个细节创建是否只发生一次、私有构造是否对模板开放、销毁时是否还在访问别的全局对象。把这三件事处理清楚比记住某种“标准写法”更有用。