在所有设计模式里,如果要评个「又常见、又容易被骂」的前三名,单例肯定占一个坑。
写过几年 C++ 的人,多半都见过类似的吐槽:
所谓单例,就是给全局变量穿了件西装。
这话听着有点阴阳怪气,但也不算冤。要把单例这回事讲清楚,大概得按这样的顺序来聊:
- 为什么会有「只要一份」这种需求?
- 早年我们是怎么用全局变量和教科书单例糊弄过去的?
- C++11 之后,Meyers Singleton 为啥突然「香」了一截?
- 更重要的:什么时候该克制自己,别动不动就整一个单例?
如果要先剧透一个结论,那就是:这些年看下来,所谓单例,其实是把三件原本可以分开的设计决定硬捆在了一起:
- 这个东西在概念上只需要一份(唯一性);
- 这份东西放在一个全局入口上,谁都能拿到(访问方式);
- 这份东西从进程启动活到进程结束(生命周期)。
真正的「恍然大悟」往往发生在这里:
- 当你能把这三件事拆开思考时,
- 你就会知道什么时候真该用单例,
- 什么时候只需要保留「唯一性」,别顺手把全局访问和生命周期也一起打包进去。
下面就按这个脉络来慢慢拆。你可以边看边对照自己项目里的那些「上古单例」,看看踩过多少坑。
1. 为什么总有人想要「全局只有一份」?
先别急着骂单例,先承认一个现实:
在一套稍微像样点的 C++ 系统里,确实会有一些「怎么看都该全局一份」的东西,比如:
- 日志子系统:所有模块最好往同一个后端打 log;
- 配置中心:所有人都要读同一份配置快照;
- 线程池 / 连接池:资源昂贵,多来几份纯属浪费。
你当然可以直接丢一个全局对象出去:
Logger g_logger;
Config g_config;
ThreadPool g_pool;
刚上手的时候,这么写爽得很:
- 哪里想打 log 就
g_logger.info(...); - 哪里想查配置就
g_config.get(...); - 完全不用管「谁把谁传给谁」。
问题是,几年之后你回头看,会发现这堆全局对象像是在代码里埋了一圈「地雷」:
- 初始化顺序:
- 不同翻译单元里的全局对象,谁先构造谁后构造,是不受你控制的;
- 有时候
g_logger还没准备好,别的全局对象的构造函数就想用它,直接未定义行为;
- 析构顺序:
main退出之后,全局对象按某个顺序开始析构;- 这时候如果还有线程在跑、还有人碰那些资源,崩不崩全看运气;
- 可测试性:
- 单元测试之间全靠同一套全局状态,互相污染,谁也别想「从干净环境开始」。
单例模式想要解决的,就是:
在**确实需要「全局一份」**的前提下, 让这份东西的构造、销毁、访问, 比「乱飞的全局变量」有一点点秩序,可控一点。
换句话说,当年大家已经隐约意识到:
- 完全禁止全局状态,在工业界不现实;
- 什么都丢成裸
global,迟早要付出代价;
单例想做的,就是给这类「躲不过去的全局东西」一个明确的名字和门面:
- 谁负责创建它;
- 谁负责提供统一入口;
- 谁对它失控的时候背锅。
这背后其实很符合那一代面向对象的基本信念:
把「乱七八糟的过程式代码」收拢到几个承担明确责任的对象里, 至少先把锅划清楚。
2. 教科书里的单例长什么样?
设计模式那本经典的《GoF》,对单例给出过一套非常「教科书」的写法:
- 把构造函数设成
private; - 提供一个
getInstance()静态函数; - 里面维护一个指向「唯一实例」的静态指针或引用。
很多老代码是这样写的:
class Singleton {
public:
static Singleton& instance() {
static Singleton s; // 早期也常写成 static 成员
return s;
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
这段代码背后的想法很直白:
- 构造函数是私有的,外面没法自己
new; - 只能通过
Singleton::instance()拿到那个「唯一实例」; - 拷贝 / 赋值都被
delete掉,所以你也复制不出第二份来。
乍一看,这就是个「给全局对象加了一层门禁」的壳子。
有意思的是,这段代码也是后来大家挂在嘴边的「Meyers Singleton」的雏形——区别只在于:
- 早期很多人把那个
static Singleton s;写成静态成员; - C++11 之后,大家更推荐像上面这样,写成函数内部的局部
static。
为什么要这么讲究?这里就扯到 C 时代就留下来的几个「老坑」。
3. C++11 之前:线程安全和初始化顺序的噩梦
在单线程的世界里,static Singleton s; 看着一片祥和;
但只要你认真想一想「它是什么时候建出来的」「谁先谁后」,问题就来了。
那时候其实有两类噩梦,写库的人和用库的人谁都睡不踏实:
-
一个是初始化顺序:
-
不同翻译单元里的全局/静态对象,标准只保证“各自内部有顺序”, 至于 A.o 里的全局先构,还是 B.o 里的全局先构,谁也说不准;
-
于是代码里常常出现这种关系:
// logger.cpp Logger g_logger; // config.cpp Config g_config(g_logger); // 构造里想用 logger结果运行时哪天
g_config先于g_logger构造, 你就在构造函数里用到了一个还没准备好的对象——典型的初始化顺序灾难。
-
-
另一个是多线程下的懒加载:
- 大家为了绕开“谁先谁后”的问题,
想出一个招数:把全局对象藏进函数里,
只在第一次调用
instance()的时候才static一份出来; - 也就是后来被称为 construct-on-first-use 的写法。
- 大家为了绕开“谁先谁后”的问题,
想出一个招数:把全局对象藏进函数里,
只在第一次调用
在只有一个线程的时候,这招很好用,既规避了跨翻译单元的初始化顺序,又让构造时机变得可预期。 但一旦上多线程,你就会发现:
- “第一次调用”的判定不是那么好做的;
- 多个线程同时跑进来时,谁先 new、谁后 new,更是麻烦。
很长一段时间里,很多人是这么写「线程安全单例」的:
class Singleton {
public:
static Singleton& instance() {
if (!s_instance) { // 1. 先检查一次
std::lock_guard<std::mutex> lock(mutex_);
if (!s_instance) { // 2. 再检查一次
s_instance = new Singleton();
}
}
return *s_instance;
}
private:
static Singleton* s_instance;
static std::mutex mutex_;
};
这就是经典的 Double-Checked Locking(双重检查锁定):
- 先不加锁看一眼是不是已经初始化了;
- 如果没有,再加锁,进去再看一眼;
- 还没有,就
new一份。
听起来挺聪明,既想要「只初始化一次」,又想减少加锁开销。
问题是:在 C++11 之前,
- 内存模型含糊;
- 编译器、CPU 可能重排读写顺序;
- 你以为的「只 new 一次」在某些平台上一样能玩坏。
具体会坏在哪?可以脑补这样一个时间线:
- 线程 A 进来,看到
s_instance == nullptr; - 按你写的顺序,应该是:先构造对象,再把指针写回
s_instance; - 但在当时的内存模型下,编译器/CPU 完全可能「先把指针写出去,再慢慢把对象构完」。
这时候,如果线程 B 刚好也进来,看了一眼 s_instance:
- 发现不为 null,就直接拿去用了;
- 可是它看到的那块内存,可能还在半构造状态, 有的成员已经初始化了,有的还没有——属于纯未定义行为的范畴。
再加上 CPU 缓存、乱序执行这些因素搅在一起, 你光靠「if + 双重检查 + 一把普通 mutex」是说不清楚自己到底规避了哪些问题、又留下了哪些洞的。
于是大家开始各种祭出 pthread_once、平台 API,或者干脆接受「懒得纠结,先全局 static 吧」这种折衷。
总之,那时候的单例,在多线程上下文里多少有点心惊胆战:
- 一边是「锁很贵、样板很多」的时代背景;
- 一边是「内存模型说不清、重排规则很模糊」的语言现实;
你能感受到设计者的挣扎:既想表现出「我关心并发安全」,又缺乏一套可靠的、被标准兜底的做法。
4. C++11 之后:Meyers Singleton 真香?
C++11 做了一件很重要的小事:
规定函数内部的局部
static对象, 在第一次执行到声明时, 以线程安全的方式初始化一次,以后只复用同一份。
这句话翻译成工程师语言就是:
- 你可以安心地在
instance()里写static Singleton s;; - 不用自己加锁;
- 不用再折腾 Double-Checked Locking 那套玄学写法。
于是我们终于可以心安理得地写出这样一个单例:
class Singleton {
public:
static Singleton& instance() {
static Singleton s; // C++11 之后,线程安全的局部 static
return s;
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
相比裸奔的全局变量,这里我们至少得到了几件好事:
- 懒加载:第一次有人调用
instance(),才真正构造; - 避开了「跨翻译单元的全局对象初始化顺序」这个老坑;
- 语义上很清楚:
- 这个类型就声明「我就是想全局一份」;
- 调用方一看
Singleton::instance(),心理预期也一致。
当然,代价也要心里有数:
- 这个局部
static的销毁时机,依然由运行时决定, 通常是在main退出之后; - 如果你在别的全局/静态对象的析构函数里,还去访问这个单例, 一样可能踩到「静态析构顺序」的地雷;
- 很多团队干脆约定俗成:
- 单例只负责「构」,不依赖它的析构干善后;
- 把资源清理交给进程结束时的操作系统, 用更简单的心智模型换一点点资源浪费。
从设计理念的角度看,这一段小历史其实挺有意思:
- 早年间,库设计者要靠各种「模式」来弥补语言在并发、初始化顺序上的不足;
- C++11 之后,标准把一些约定俗成的「写法」直接升级成了语言保证;
这也是现代 C++ 的一条主线:
能让编译器和标准库帮你兜底的事情,就别再自己手搓那些危险的样板代码。
5. 那些年我们被单例坑过的场景
单例招骂,根子不在于「代码不好写」, 恰恰相反——它太好写了。
好写到什么地步?
- 新人一看:
- “哇,这东西到处都要用,那就做成单例吧”;
- 老项目一看:
- “这块依赖太多,不想一层层传参数,那就塞进单例吧”。
时间一长,整个系统里就会冒出一个又一个「上帝单例」:
- 配置 + 日志 + 上报 + 统计,全塞一个
GlobalContext里; - 所有模块都直接
GlobalContext::instance()进去乱拽; - 你稍微改一下里面的东西,全世界一起编译、一起崩。
几个典型的后果是:
- 依赖关系全被「藏」在单例后面:
- 你在某个类里看不到它到底依赖了什么,
只看到到处在
SomeSingleton::instance();
- 你在某个类里看不到它到底依赖了什么,
只看到到处在
- 单元测试难上加难:
- 单例里塞了一堆可变状态;
- 测试 A 改了一点,测试 B 接着用;
- 想 reset 还得专门开后门接口;
- 重构成本高到离谱:
- 一旦你想把单例拆开、分层, 会发现整个项目几百处都直接拿它当全局垃圾桶在用。
回头看,这时候单例已经不再是「给全局变量穿西装」, 而是「给上帝对象披上了一层合法外衣」。从架构的视角看,它已经从一个「管理全局资源的小帮手」,演变成了一个简易版的 Service Locator(服务定位器):
- 真正的依赖关系,被藏在一堆
Singleton::instance()背后; - 模块边界在调用栈上看起来很干净,实际上谁都能跨层拿任何服务。
这种「看上去很解耦,实际上处处互相勾连」的感觉,大概也是不少架构师后来对单例逐渐失去耐心的原因。
6. 什么时候该克制自己别用单例?
这里有一个我自己常用的小 heuristics,分享给你:
- 你是不是只是懒得传参数,才想到用单例?
- 如果把这个依赖显式写进构造函数 / 接口, 代码会不会反而更清爽?
很多时候,我们真正需要的并不是「全局唯一」, 而是「在这一段逻辑里,所有人共用同一个实例」。
一旦把视角从「全局」缩回到「这一段逻辑」, 你会发现很多单例都可以改写成更健康的样子:
- 用依赖注入:
- 把
Logger&、Config&当成构造参数往下传; - 测试时换成假实现,互不干扰;
- 把
- 用普通对象 + 生存期管理:
- 在
main或上层模块里 new 一次,传给需要的地方; - 生存期一目了然,不靠魔法静态对象。
- 在
换一个更抽象一点的说法,其实就是:
- 单例一次性做了三件事:
- 决定「这个类型在概念上是唯一的」;
- 决定「所有人都通过一个全局入口拿它」;
- 决定「它从进程启动活到进程结束」。
而在很多成熟项目里,后两件事往往会被拆开:
- 保留「概念上唯一」,但把实例挂在某个
AppContext/ 根对象里, 由上层负责创建和回收,再通过构造函数一层层往下传; - 或者只在一条请求链路里保证「这一条链路大家共用同一个实例」, 请求结束就把它一起回收,不让它无限期地活在全局空间里。
这样一来,你既保住了「该唯一的东西仍然唯一」, 又避免了「到处都是看不见的全局依赖」。
所以在不少成熟项目里,大家慢慢形成了这样一个共识:
能不用单例就不用单例, 真要用,就只用在极少数「像操作系统服务」一样的基础设施上。
比如:日志后端、指标上报管道、异常捕获框架…… 这些东西确实更像「进程级服务」,做成单例也情有可原。
7. 小结:写单例简单,用单例难
从语法角度看,C++11 之后实现一个像样的单例, 其实就那几行:
- 函数内部的局部
static; - 删除拷贝 / 赋值;
- 必要时再把移动也删了。
真正考验功力的地方,在于你敢不敢少用它:
- 把该传的依赖老老实实通过构造函数 / 接口传下去;
- 把该分层的逻辑分清楚,不要图省事全塞进一个「上帝单例」;
- 在那些确实需要「进程唯一实例」的地方, 把单例当成一件重要的基础设施来设计,而不是临时救火的工具。
当你习惯从「唯一性 / 访问方式 / 生命周期」这三根轴去想单例, 上面这几条就不再是抽象的口号,而会直接映射到你正在写的那段代码上:
- 这东西是不是非要全局唯一?
- 它是不是非得谁都能随手拿到?
- 它是不是非要从进程启动活到结束?
写过几年 C++ 再回头看单例,你大概会发现:
真正高级的用法,不是到处用单例, 而是你清楚知道在这三件事里,哪一件该做,哪一件该拆开。