当前位置: 首页 > news >正文

精读C++20设计模式——创造型设计模式:单例模式

精读C++20设计模式——创造型设计模式:单例模式

​ 我当时读到这里的时候更多的是惊讶,因为作者并不喜欢单例模式。当然单例模式的确存在它的意义。在很多场景下,如果我们期待全局程序总是访问唯一对象的情况下,我们才会去使用单例模式。比如说——全局唯一的数据库,全局唯一的日志对象。这个时候,单例模式就会显得非常的有用。老样子,我们不立马上代码,而是一步一步从头开始,思考着如何编写更好的符合单例模式范式的代码。

所以,从最基本的开始

​ 单例模式比较奇怪的是——最开始的时候,我相信很多朋友第一次出现遇到单例构造的场景的解决方案,下意识的是写注释(笔者没有略去书中提到的,是因为我真的看到过有人是这样写注释的,还特别的打了很多感叹号)

struct GlobalOLED {/*You should only invoke the creation for once*/GlobalOLED();
};

​ 但是问题在于,我们假设人人在开发的时候都会阅读文档,但是麻烦的点在于C++是一个很喜欢隐式操作的语言,我们人可以看懂,但是在传播的时候,我们很容易触发拷贝构造/赋值,或者是利用RAII的时候很容易自动的触发。这是我们不希望的。

​ 有一种办法,**我想我们的读者朋友显然已经想到:那就是将我们的任何破坏了单例特性(或者说全局唯一特性)的拷贝或者移动的构造/赋值禁用掉。**很是合理!

struct GlobalOLED {
public:// ...
private:/*You should only invoke the creation for once*/GlobalOLED();GlobalOLED& operator=(const GlobalOLED&) = delete;GlobalOLED(const GlobalOLED&) = delete;GlobalOLED& operator=(GlobalOLED&&) = delete;GlobalOLED(GlobalOLED&&) = delete;
};

​ 这个时候,我们自然会注意到,这好像也不太对,因为这个时候,我们没办法创建这个对象了:构造函数被放到了私有域中。那我们就必须要求存在一个接口访问到这个对象,这样,问题就被缩小到这个场景:在构造上下文中,保证我们的对象总是唯一的。其他所有外置使用咱们要走请求的接口。

​ 构造上下文的唯一性,一个在C++11之后的方式就是直接static修饰变量,他就会保证咱们的初始化只有一次。非常的安全而且简单。

struct GlobalOLED {
public:static GlobalOLED&	get_instance() {static GlobalOLED oled;	// static construct the oledreturn oled;}
private:/*You should only invoke the creation for once*/GlobalOLED();GlobalOLED& operator=(const GlobalOLED&) = delete;GlobalOLED(const GlobalOLED&) = delete;GlobalOLED& operator=(GlobalOLED&&) = delete;GlobalOLED(GlobalOLED&&) = delete;
};

​ 当然还有类似std::call_once啊,双重检查的办法。这个笔者不打算深入聊。这里只是给出代码:

	static GlobalOLED&	get_instance() {GlobalOLED* _oled = oled.load(std::memory_order_consume);if(_oled) return _oled; // already inited!std::lock_guard<std::mutex> _(instance_lock);_oled = oled.load(std::memory_order_consume); // load againif(!_oled) // still request init{_oled = new GlobalOLED; // process init sessionsoled.store(_oled, std::memory_order_release);}return _oled;}std::atomic<GlobalOLED*> oled;

单例模式为什么不讨喜?

压倒了单一职责

​ 做过软件工程的朋友也许能理解作者严肃的说“单例模式是最糟糕的模式”:本质上就体现在了最经典的单例模式实际上就是一个全局唯一对象的封装。举个例子:

void doWork() {// ...GlobalOLED::get_instance().processBufferUpdate();
}

​ 我们编写doWork的时候,实际上就让GlobalOLED直接穿透了咱们的模块,本来,我们的模块需要依赖到接口上,但是现在,这种书写方式却让我们依赖到了实现上去了,穿透了我们的接口。

​ 这种穿透同样的也造成了单元测试的麻烦。笔者编写单元测试的时候,就发现这样的问题:

void testDoWork() {// 想替换 Logger?抱歉,它是全局唯一的doWork();
}

​ 我们没办法替换Logger了!这下我们测试的时候,不得不依赖实际上正在使用的模块,而我们没办法保证**单元测试中每个单元是独立的!单例模式强迫每一个涉及到单例的模块都必须连同单例模块一起被测试,而且完全没法解耦。**更好的做法是使用 依赖注入(Dependency Injection):把 Logger 当作参数传进去,而不是写死成单例。

Tips:static单例的问题

​ 下面的是针对static实现单例的问题:如果单例持有一些重量级资源(比如大缓存、文件句柄),而实际只在很短时间用到,却始终常驻内存,就会浪费资源。C++ 中静态对象的销毁顺序不可控。如果单例比它依赖的对象更早析构,就会引发“野指针”问题。

难以扩展

假如说我们后面想要控制多个OLED,我们就发现,一旦 GlobalOLED 被设计为单例,就会很难扩展成多实例,往往需要重构大面积代码。单例在本质上 违背了“对扩展开放、对修改关闭”的开闭原则(OCP)

倒不如说——真正的单例模式实际上极端的少见。我们完全可以按照这些方式,重新评估我们单例模式的使用。


改进!翻转过来使用DI方式

​ 上面的问题发现没有?实际上,我们很多场景下,更多涉及到的是——对于一个子模块是全局唯一的。那么,我们就没有必要让他们在程序运行期间的时候就出现。而是提供一个针对封闭子系统下,注入一个对象作为单例进行使用。这样我们就能改善问题所在。这就是依赖注入

class OLEDUpdater {
public:Worker(GlobalOLED& oled) : oled(oled) {}void doWork() {oled.processBufferUpdate();}GlobalOLED& getWorkInstance() {return oled;}private:GlobalOLED& oled;
};

这样就能在测试中轻松替换 Logger。因为现在,我们可以缩小单例到一个更小的子系统!


总结

我们在解决什么问题?

单例模式要解决的问题很明确:在程序的运行过程中,确保某个对象始终只有一个实例,并且提供一个全局访问点

  • 在某些场景下(如日志、配置、数据库连接池、设备驱动接口等),全局唯一性是合理且必要的。
  • 我们希望避免程序中出现多个对象实例而导致数据不一致、资源浪费或状态混乱。

但是,C++ 语言的隐式拷贝构造、赋值以及对象生命周期问题,让“保证唯一性”并不天然成立,需要明确的范式来约束。


我们是怎么解决的?

解决方式经历了一个演进的过程:

  1. 注释约束(错误示范)
    最原始的做法是靠注释提醒“只允许创建一次”,但这种约定显然不可靠,编译器不会帮你检查。

  2. 禁用拷贝/赋值构造
    通过 = delete 显式禁用复制、移动构造和赋值运算符,从语义上防止对象被意外复制:

    struct GlobalOLED {
    private:GlobalOLED();GlobalOLED(const GlobalOLED&) = delete;GlobalOLED& operator=(const GlobalOLED&) = delete;
    };
    
  3. 私有构造 + 公有访问接口
    通过将构造函数私有化,并提供一个静态方法来访问唯一实例:

    static GlobalOLED& get_instance() {static GlobalOLED oled; // static 保证只初始化一次return oled;
    }
    
  4. 多线程安全实现
    在复杂场景下,使用 std::call_once 或“双重检查锁”来确保多线程初始化时的安全性。

整体思路就是:封死一切可能产生多实例的路径,只保留唯一的访问入口


这样做的优缺点如何?
✅ 优点
  • 保证唯一性:能确保全局只有一个实例,适合日志、配置、硬件驱动等场景。
  • 全局访问点:在任何地方都能轻松调用,不需要显式传递对象。
  • 懒加载:结合 staticstd::call_once,能实现按需初始化,节省启动开销。
❌ 缺点
  • 全局状态污染:引入隐式依赖,破坏模块解耦,本质上就是“伪装的全局变量”。
  • 难以测试:强制依赖全局实例,导致单元测试无法 Mock 或替换实现。
  • 生命周期不可控:静态对象的销毁顺序不确定,可能引发“野指针”问题;重量级资源常驻内存也可能浪费资源。
  • 扩展性差:一旦设计为单例,就很难扩展成多实例,违背开闭原则(OCP)。
  • 容易掩盖设计问题:单例常常被滥用来快速解决问题,反而隐藏了架构上的不合理。
方面优点缺点
唯一性确保全局对象唯一存在,避免状态冲突无法灵活扩展为多实例,违背 OCP
访问方式全局访问点,调用方便引入隐式依赖,模块耦合度增加
资源管理可用于全局缓存、配置管理等场景生命周期难以控制,可能导致资源浪费或析构顺序问题
实现复杂度C++11 之后可用 staticstd::call_once 实现线程安全多线程安全实现稍有不慎会引发竞态,调试困难
工程实践适用于日志、配置、驱动接口等全局唯一模块不利于单元测试,难以 Mock,掩盖架构问题
进一步的做法:DI

单例模式的最大问题在于它“穿透”了接口,把实现强制塞进全局调用中。一个更好的方式是——翻转依赖关系

  • 在封闭的子系统中,将需要全局唯一的对象,通过 依赖注入(Dependency Injection, DI) 传递给使用方。
  • 这样既能保证在子系统内部对象的唯一性,又不会让调用方依赖全局状态。
class OLEDUpdater {
public:OLEDUpdater(GlobalOLED& oled) : oled(oled) {}void doWork() {oled.processBufferUpdate();}private:GlobalOLED& oled;
};// 使用时手动注入
int main() {auto& oled = GlobalOLED::get_instance();OLEDUpdater updater(oled);updater.doWork();
}

这样一来:

  • 单例被约束在更小的作用域(比如子系统内部)
  • 调用方只依赖接口,不依赖具体实现
  • 测试时可以轻松替换 Mock 对象,提高可测试性
http://www.dtcms.com/a/414059.html

相关文章:

  • 网络实践——基于epoll_ET工作、Reactor设计模式的HTTP服务
  • 设计模式-行为型设计模式(针对对象之间的交互)
  • 选手机网站彩票网站开发制作模版
  • qq钓鱼网站在线生成器北京网站设计公司地址
  • SQL流程控制函数完全指南
  • 做电商网站前端的技术选型是移动商城积分和积分区别
  • 弄一个关于作文的网站怎么做微信分销网站建设官网
  • 怎么做站旅游网站上泡到妞平面设计师服务平台
  • 温室大棚建设 网站及排名转卖类似淘宝网站建设有哪些模板
  • 广西网站建设-好发信息网阿里邮箱 wordpress
  • 便捷网站建设费用搜关键词网站
  • 网站添加百度地图导航wordpress安装 centos
  • 如何自己建一个网站企业简介宣传片视频
  • 成都美誉网站设计建设优惠券网站
  • 整形网站源码一个网站如何做盈利
  • 机械设备东莞网站建设石家庄开发区网站建设
  • 代制作网站公司网站建设包括
  • 怎么手动安装网站程序搭建微信小程序
  • 郑州建网站371怎么把东西发布到网上卖
  • wordpress 点图片链接拼多多seo怎么优化
  • 石家庄做网站wordpress 文章摘要
  • 网站建设服务类型现状做兼职上哪个网站
  • 重庆网站seo排名用dw制作一个网站
  • 太原模板建站定制深圳网站建设及推广
  • vps 网站 需要绑定域名吗建设部网站拆除资质
  • 六安网站自然排名优化价格遵义网站建设网帮你
  • 网站版面设计流程包括哪些盐城手机网站建设
  • 重庆网站搭建昆明网站建设报价
  • 设计制作网站的公司深圳全网整合营销
  • 辽宁建设厅查询网站首页怎么给自己的网站做优化