新谈设计模式 · Chapter 01 — 单例模式 Singleton
Chapter 01 — 单例模式 Singleton灵魂速记皇帝只有一个谁来问都是同一个。秒懂类比一个国家只有一个皇帝。不管哪个大臣上朝见到的都是同一位。你不能new一个新皇帝——那叫造反。问题引入假设你有一个全局配置管理器Config config1;// 线程A创建了一个Config config2;// 线程B又创建了一个// config1 改了设置config2 完全不知道——数据不一致你需要的是全局只有一份谁来拿都是同一个对象。模式结构┌─────────────┐ │ Singleton │ ├─────────────┤ │ -instance │ ← 静态私有唯一的那一个 ├─────────────┤ │ -Singleton()│ ← 构造函数私有不让外部 new │ getInstance│ ← 唯一的公开入口 └─────────────┘C 实现方式一Meyers’ Singleton推荐#includeiostream#includestringclassConfig{public:// —— 禁用所有拷贝和移动操作后面有详解——Config(constConfig)delete;Configoperator(constConfig)delete;Config(Config)delete;Configoperator(Config)delete;staticConfiggetInstance(){staticConfig instance;// 关键函数局部静态变量returninstance;}voidset(conststd::stringkey,conststd::stringvalue){// 简化示例实际用 mapif(keytheme)theme_value;}std::stringget(conststd::stringkey)const{if(keytheme)returntheme_;return;}private:Config():theme_(dark){std::coutConfig 初始化只会打印一次\n;}// 析构函数放在 private// 防止外部拿到引用后手动 delete getInstance()// 让生命周期完全由 static 存储期管理。~Config()default;std::string theme_;};intmain(){Configc1Config::getInstance();Configc2Config::getInstance();c1.set(theme,light);std::coutc2.theme c2.get(theme)\n;// lightstd::cout同一个(c1c2?是:否)\n;// 是}输出Config 初始化只会打印一次 c2.theme light 同一个是为什么 Meyers’ Singleton 是线程安全的C11 标准§6.7 / [stmt.dcl]明确规定函数局部static变量的初始化如果多个线程同时进入只有一个线程执行初始化其余线程阻塞等待初始化完成。编译器通常的实现方式是为每个局部static变量生成一个隐藏的 flag类似std::once_flag首次进入时加锁初始化、翻转 flag后续进入直接跳过。所以static Config instance;这一行天然就是线程安全的——一次初始化后续直接返回没有任何额外开销。C11 之前没有这个保证才需要自己加锁下面的 DCLP现在可以放心用。为什么必须禁用拷贝和移动单例的语义是全局仅此一份如果允许拷贝或移动就打破了这个约束ConfigcfgConfig::getInstance();Config copycfg;// 如果允许拷贝就有了第二个 ConfigConfig movedstd::move(cfg);// 如果允许移动原来的单例就被掏空了所以我们需要把拷贝构造、拷贝赋值、移动构造、移动赋值全部 delete。一个细节C 的规则是——如果你显式声明了拷贝构造/赋值编译器就不会隐式生成移动构造/赋值。所以只删拷贝在技术上够用代码也能编译。但问题是这属于碰巧安全靠的是你记住了语言规则的隐式行为。读你代码的人不一定能立刻反应过来移动也被禁了。哪天有人注释掉了你的 delete别笑真有人干这事移动操作就自动生成了。**显式写出四个 delete是最清晰、最防呆的做法。**这也是 C Core Guidelines 推荐的要么全写要么全不写Rule of Five / Rule of Zero。为什么析构函数也放在 private你可能注意到析构函数是private的。原因很朴素getInstance()返回的是引用如果析构函数是public的那技术上你可以这样搞ConfigcfgConfig::getInstance();cfg.~Config();// 手动调用析构——合法但致命或者更隐蔽的deleteConfig::getInstance();// 编译会过如果析构是 public把析构放进private编译器直接帮你拦住这些误操作。static局部变量的销毁由运行时在程序结束时自动处理调用std::atexit注册的清理函数不需要你手动析构所以private析构不影响正常使用。方式二双重检查锁 DCLP了解即可#includemutex#includeatomicclassConfig{public:staticConfig*getInstance(){// 第一次检查无锁快路径// 用 memory_order_acquire 确保读到 instance_ 非空时// 对象的构造一定已经完成不会读到半初始化的对象Config*tmpinstance_.load(std::memory_order_acquire);if(!tmp){std::lock_guardstd::mutexlock(mutex_);tmpinstance_.load(std::memory_order_relaxed);if(!tmp){tmpnewConfig();// memory_order_release 保证上面 new 的所有写操作// 在 store 之前对其他线程可见instance_.store(tmp,std::memory_order_release);}}returntmp;}private:Config()default;staticstd::atomicConfig*instance_;staticstd::mutex mutex_;};std::atomicConfig*Config::instance_{nullptr};std::mutex Config::mutex_;为什么说 DCLP 在 C11 之前是个坑因为 C11 之前没有标准内存模型编译器和 CPU 可能对指令重排序new Config()可能先分配内存、把地址写给instance_然后才调用构造函数。这时第二个线程看到instance_非空拿到了一个还没构造完的对象——程序炸了。C11 引入了std::atomic和内存序memory order才让 DCLP 能正确实现。但既然同一个标准也给了我们 magic staticsMeyers’ SingletonDCLP 基本上就没有存在的必要了。结论直接用 Meyers’ Singleton把 DCLP 当历史知识了解就行。什么时候用✅ 适合❌ 别用全局唯一资源配置、日志、线程池只是为了方便当全局变量需要延迟初始化第一次用到才创建对象间没有强制唯一性要求需要控制资源的访问入口多实例场景如多数据库连接单例的坑说实话设计模式里单例是争议最大的一个甚至有人叫它反模式。以下是常见的坑1. 隐藏依赖关系函数签名看不出来它依赖了哪个单例。你以为processOrder()只依赖参数里传进来的东西结果它内部偷偷调了Config::getInstance()和Logger::getInstance()——单元测试的时候你没法换假实现mock因为依赖关系是藏在函数体里的。如果改用依赖注入构造函数传参依赖一目了然测试时传 mock 对象就行classOrderService{public:// 依赖注入依赖关系写进签名一目了然OrderService(Configconfig,Loggerlogger):config_(config),logger_(logger){}private:Configconfig_;Loggerlogger_;};2. 析构顺序问题Static Destruction Order FiascoC 对不同编译单元.cpp 文件中全局/静态对象的构造顺序不做保证标准没规定析构则是构造的逆序。如果单例 A 的析构函数引用了单例 B而 B 恰好先被析构了——未定义行为。Meyers’ Singleton函数局部static的析构顺序遵循 LIFO后构造的先析构。一般来说比全局static可控但跨单例的互相引用仍然需要小心。3. 本质是全局状态多线程环境下单例就是一个所有线程都能摸到的全局变量。如果你需要对它做读写不只是只读访问就得加锁——加了锁就可能有竞争。这类问题往往在上线高并发时才暴露出来。经验如果你的项目里单例超过了 3 个该停下来想想是不是架构有问题。防混淆对比单例 Singleton全局变量初始化时机延迟第一次调用时程序启动时main 之前访问控制通过接口可加校验/日志裸露谁都能改多态支持支持可返回子类实例不支持对比单例 Singleton静态类全静态方法有无实例有一个真实对象没有实例只是函数的命名空间能否多态能虚函数不能能否延迟初始化天然延迟做不到或很别扭状态管理成员变量持有状态全靠静态变量散乱现代 C 小贴士C17 引入了inline变量可以在头文件里定义全局变量而不违反 ODROne Definition Rule// logger.hclassLogger{public:Logger(constLogger)delete;Loggeroperator(constLogger)delete;Logger(Logger)delete;Loggeroperator(Logger)delete;staticLoggergetInstance(){staticLogger instance;returninstance;}voidlog(conststd::stringmsg){/* ... */}private:Logger()default;~Logger()default;};// 头文件里的全局快捷引用inlineLoggerloggerLogger::getInstance();为什么这里要加inline没有inline的话Logger logger是一个普通全局变量的定义。如果这个头文件被a.cpp和b.cpp同时#include链接器会看到两个同名定义——违反 ODR报 “multiple definition” 错误。inline告诉编译器和链接器这个变量可能在多个编译单元中出现定义但它们是同一个变量合并成一份就行。这和inline函数的道理一样——允许多处定义链接器保留一份。这样你就可以在代码里直接写logger.log(blah)而不用每次Logger::getInstance().log(blah)用起来更顺手。 上一章 | 目录 | 下一章 工厂方法