Day 1:单例模式(Singleton Pattern)

从"两个 Logger 抢同一个日志文件"的坏味道出发,讲透单例模式的本质——全局唯一 + 全局访问点,以及它如何在 C++17 与 Qt 6 中正确落地,又为什么它是一把需要小心使用的刀。

目录

01

今日主题

Day 1:单例模式(Singleton Pattern)

一句话定义:单例模式保证一个类在整个进程生命周期内只有一个实例,并提供一个全局访问点。

它解决的是这样一类问题:有些对象——日志器、配置文件、连接池、独占硬件——天生只能有一份,却被多个模块共同需要。如果每个模块都自己 new 一个,就会得到多个"互相不认识"的副本:各自持有自己的状态、各自打开自己的资源,最终导致状态分裂、资源冲突、配置不一致。单例模式把"全局唯一"变成类的内置约束,而不是靠程序员自觉。

今天你将看到:无模式的坏代码长什么样 → 单例的三个核心动作(构造私有、静态持有、静态访问)→ C++17 最优雅的线程安全写法(Magic Static)→ Qt 中 QSettings 配置中心的实战封装 → 以及为什么说单例是"最简单也最容易用错"的模式。

02

为什么需要这个模式

假设你正在开发一个桌面客户端,需要一个日志模块。最直觉的写法是:谁要记日志,谁就 new 一个 Logger。看下面的坏味道代码:

// 坏味道示例:每个模块各自 new 一个 Logger,互相不认识
#include <fstream>
#include <string>

class Logger {
public:
    explicit Logger(const std::string& path)
        : m_ofs(path, std::ios::app) {}
    void log(const std::string& msg) { m_ofs << msg << std::endl; }

private:
    std::ofstream m_ofs;
};

// 模块 A:自己造一个日志器
void moduleA() {
    Logger logger("app.log");
    logger.log("moduleA: start");
}

// 模块 B:又造一个日志器,写同一个文件
void moduleB() {
    Logger logger("app.log");
    logger.log("moduleB: start");
}

这段代码有三个问题,而且会随着需求增加越来越严重:

  • 资源冲突:两个 std::ofstream 同时以追加模式打开同一个 app.log,多线程下写入会交错、丢失,甚至因为缓冲区竞争产生难以排查的错乱。日志文件句柄天然应该全局唯一。
  • 参数到处传:一旦需求升级——加日志级别、加按天轮转、加远程上报——构造函数参数就会膨胀,每个 new Logger 的地方都要跟着改。今天日志路径是 "app.log",明天要改成 "logs/app-20260813.log",你就得全工程搜索替换。
  • 无法统一策略:模块 A 想关掉 DEBUG 日志,模块 B 想保留——它们各持一份 Logger,全局日志级别无从谈起。日志策略应该是进程级统一决策,而不是每个模块各自的局部决定。

从 SOLID 的角度看:

  • 违反 DIP(依赖倒置原则):高层模块(moduleA/moduleB)直接依赖 Logger 的具体类并亲自负责创建,而不是依赖一个抽象的日志接口、由外部统一提供。
  • 违反 SRP 的延伸:每个调用方被迫承担"日志文件的打开位置、路径参数、生命周期"这些本该由日志系统自己管理的问题。
  • 全局状态的一致性被破坏:"只有一个日志器"是业务约束,但当前代码把这个约束寄托在"大家自觉用同一个路径"上,毫无保障。

重构的方向很清晰:把"创建"收进类内部,对外只暴露一个入口,保证全进程只有一个实例——这就是单例模式。

03

核心思想

单例模式的核心思想只有三句话:

  • 构造私有:构造函数设为 private,外部永远无法 new Singleton(),从语法层面杜绝第二个实例。
  • 静态持有:类内部用静态成员保存唯一实例(C++ 里最常用的是函数内静态局部变量)。
  • 静态访问:提供一个公开的静态方法 instance(),所有调用方通过它获取对象。

生活类比:一个公司只有一个法人代表,一个小区只有一个总配电房,一栋写字楼只有一部消防总闸。你要用电,不需要自己去发电,去总配电房就行——而且全楼共用的就是那一间。单例就是这个"总配电房":位置唯一、入口唯一、所有人都去那里取。

回到软件工程,我们要分清什么在变、什么不变:

  • 不变的:"全局唯一 + 全局访问"这个契约。调用方永远写 Logger::instance().log(...),这一行代码不需要变。
  • 可以变的:实例的创建时机(懒汉式首次使用时创建 / 饿汉式程序启动即创建)、线程安全策略(锁 / Magic Static)、底层存储介质(文件 / 网络 / 内存)。这些都被封装在类内部,对调用方完全透明。

为什么能降低耦合?因为调用方与"对象的创建过程、生命周期、唯一性保证"彻底解耦了。调用方只依赖一个稳定的访问点,不依赖任何构造细节——这本质上是对"创建"这一变化的封装,也是为什么单例常被看作一种简化版的工厂(明天讲 Factory Method 时你会看到这个联系)。

04

UML / 角色关系

单例的类图是所有 GoF 模式里最简单的,但每个细节都有讲究:

┌──────────────────────────────────┐
│             Singleton             │
├──────────────────────────────────┤
│ - instance_ : Singleton*          │  // 私有静态:唯一的实例
│ - Singleton()                     │  // 私有构造:禁止外部 new
│ - Singleton(const Singleton&)     │  // 私有/删除:禁止拷贝
├──────────────────────────────────┤
│ + instance() : Singleton&         │  // 公有静态:全局访问点
│ + businessMethod() : void         │  // 业务方法
└──────────────────────────────────┘
        ▲              ▲
        │              │
   ┌────┴────┐    ┌────┴────┐
   │ 模块 A   │    │ 模块 B   │
   │(调用方)  │    │(调用方)  │
   └─────────┘    └─────────┘
   都调用 Singleton::instance(),拿到同一个对象

角色与职责:

  • Singleton(单例类):自己既是类,又是唯一实例的保管者。它同时扮演"产品"和"工厂"两个角色——这也是单例和普通类的根本区别。
  • instance()(静态访问点):唯一合法的获取途径。第一次调用时负责创建(懒汉式),之后直接返回既有实例。它是整个模式的"门面"。
  • 私有构造函数:唯一性的语法保障。没有它,单例就是一句空话。
  • 调用方(Client):模块 A、B 等所有使用方。它们不创建、不销毁、不持有单例,只通过 instance() 借用。

谁依赖谁?所有调用方依赖 Singleton::instance(),而 Singleton 不依赖任何调用方——依赖方向是单向的、稳定的。谁创建谁?instance() 首次被调用时创建(C++17 主流做法),或程序启动时创建(饿汉式)。

需要注意的扩展性真相:单例几乎没有子类扩展点——构造函数私有,子类无法安全派生;即便用模板技巧强行扩展,也违背了"全局唯一"的本意。所以单例适合封装"稳定不变的能力",不适合封装"需要多态变化的能力"(后者请期待 Day 2 的工厂方法)。

05

最小 C++ 示例

下面用 C++17 实现一个线程安全的单例日志器。这里采用 C++11 起的标准写法——函数内静态局部变量(Magic Static):编译器自动保证初始化只发生一次且线程安全,同时懒加载、自动析构,是现代 C++ 中最优雅、最不可能写错的单例实现。

// logger.h —— 单例日志器:整个程序共享同一个实例
#include <fstream>
#include <iostream>
#include <mutex>
#include <string>

class Logger {
public:
    // ① 删除拷贝与赋值:单例对象不允许被复制
    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;

    // ② 全局访问点:首次调用时创建,之后永远返回同一个对象
    static Logger& instance() {
        // C++11 起,函数内 static 局部变量的初始化是线程安全的
        static Logger s_logger;
        return s_logger;
    }

    void log(const std::string& msg) {
        std::lock_guard<std::mutex> lock(m_mutex);  // 多线程写文件时加锁
        m_ofs << msg << std::endl;
    }

private:
    // ③ 构造私有:外部无法 new Logger(...)
    Logger() { m_ofs.open("app.log", std::ios::app); }  // 追加模式打开
    ~Logger() = default;

    std::ofstream m_ofs;  // 全进程唯一的文件句柄
    std::mutex    m_mutex; // 保护文件写入
};

// main.cpp —— 使用单例日志器
#include "logger.h"

int main() {
    // 不管谁调用、调用多少次,拿到的都是同一个对象
    Logger::instance().log("application start");
    Logger::instance().log("user login: alice");

    Logger& a = Logger::instance();
    Logger& b = Logger::instance();
    std::cout << "same instance: " << (&a == &b) << std::endl;  // 输出 1
    return 0;
}

代码解读:

  • instance() 里那句 static Logger s_logger; 是全部精华。它由编译器生成的 guard 变量保护,多线程同时首次调用时,只有一个线程执行构造,其余线程等待——不需要你写锁,也不需要双重检查。
  • 构造函数私有 + 拷贝删除,双保险:既不能 new,也不能复制。
  • 析构是 default 的,但仍是 private 的——这会让"栈上声明 Logger l;"也编译失败,进一步保证唯一入口。
  • 文件句柄、互斥锁都是成员,随静态对象一起创建、一起销毁,无需手动 delete,天然无泄漏(RAII)。

程序输出:

same instance: 1

同时 app.log 中追加了两行(无论调用多少次 instance(),都写进同一个文件、同一个句柄):

application start
user login: alice

对比"饿汉式"(类内静态成员 static Logger s_instance;):饿汉在程序启动、任何代码运行前就构造,简单但启动即付出代价,且构造顺序跨编译单元不可控;懒汉式(Magic Static)首次使用才创建,顺序可控、开销延迟,是现代 C++ 的首选。

06

Qt 实战示例

Qt 场景里最典型的单例需求是全局配置中心:一个桌面应用的界面主题、窗口位置、网络超时等设置,任何模块都要能读、能写,而且必须读写同一份。我们用 Qt 6 + C++17 实现一个 AppConfig 单例,内部用 QSettings 持久化。

appconfig.h

// appconfig.h —— 全局配置管理器(单例):全程序只有一份配置
#ifndef APPCONFIG_H
#define APPCONFIG_H

#include <QObject>
#include <QSettings>
#include <QVariant>
#include <QString>

class AppConfig : public QObject {
    Q_OBJECT
public:
    // 全局访问点:qApp 同款思路,返回进程内唯一实例
    static AppConfig& instance();

    QVariant value(const QString& key, const QVariant& def = {}) const;
    void setValue(const QString& key, const QVariant& val);
    QString theme() const { return value("ui/theme", "light").toString(); }

private:
    explicit AppConfig(QObject* parent = nullptr);  // 构造私有:禁止外部 new
    ~AppConfig() override = default;
    AppConfig(const AppConfig&) = delete;            // 禁止拷贝
    AppConfig& operator=(const AppConfig&) = delete;

    QSettings m_settings;  // 负责把配置持久化到磁盘
};

#endif // APPCONFIG_H

appconfig.cpp

// appconfig.cpp
#include "appconfig.h"

AppConfig& AppConfig::instance() {
    // C++11 magic static:懒加载 + 线程安全 + 自动析构
    static AppConfig s_config;
    return s_config;
}

AppConfig::AppConfig(QObject* parent)
    : QObject(parent),
      m_settings(QSettings::IniFormat, QSettings::UserScope,
                 "JankerLi", "DailyPattern") {
    // Linux 下配置落在 ~/.config/JankerLi/DailyPattern.ini
}

QVariant AppConfig::value(const QString& key, const QVariant& def) const {
    return m_settings.value(key, def);
}

void AppConfig::setValue(const QString& key, const QVariant& val) {
    m_settings.setValue(key, val);
}

main.cpp

// main.cpp
#include <QCoreApplication>
#include <QDebug>
#include "appconfig.h"

int main(int argc, char* argv[]) {
    QCoreApplication app(argc, argv);  // 进程级单例,qApp 指向它

    // 不同"模块"通过 instance() 读写同一份配置
    AppConfig::instance().setValue("ui/theme", "dark");
    AppConfig::instance().setValue("network/timeout", 5000);

    qInfo() << "theme  =" << AppConfig::instance().theme();
    qInfo() << "timeout=" << AppConfig::instance()
                                  .value("network/timeout").toInt();
    qInfo() << "两次 instance() 是同一个对象:"
             << (&AppConfig::instance() == &AppConfig::instance());

    // qApp:Qt 自带的进程唯一应用对象(单例思想的官方实践)
    qInfo() << "qApp 有效:" << (qApp != nullptr);
    return 0;
}

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(daily_pattern_day1 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)   # 处理 Q_OBJECT 宏

find_package(Qt6 REQUIRED COMPONENTS Core)

add_executable(daily_pattern_day1
    main.cpp
    appconfig.h
    appconfig.cpp
)

target_link_libraries(daily_pattern_day1 PRIVATE Qt6::Core)

程序输出:

theme   = "dark"
timeout = 5000
两次 instance() 是同一个对象: true
qApp 有效: true

几点说明:

  • QSettings 本身不是单例——你完全可以创建多个 QSettings 对象读同一个配置文件。但"配置在业务语义上全局唯一"这个约束,由我们这层单例封装来保证:所有模块都走 AppConfig::instance(),就不会出现"模块 A 改了主题、模块 B 还读着旧值"的分裂。
  • 继承 QObject 是为了将来可以发信号(比如主题变化时通知所有界面刷新),这是单例 + 观察者(Day 15)的经典组合;当前示例不依赖信号,去掉 Q_OBJECT 也能编译。
  • Qt 5 / Qt 6 差异:qApp 宏在 Qt 6 中随 <QCoreApplication> 即可使用;而在 Qt 5 中它定义在 <QApplication>(QtWidgets 模块)里,纯 Core 程序需要改用 QCoreApplication::instance()。本示例面向 Qt 6。
  • 关于"Qt 自己如何使用单例":QApplication 是进程唯一的,Qt 源码中第二个 QApplication 的构造会直接 qFatal("instance already exists") 终止程序——这是框架层面的强制单例,我们将在第 12 章深入。
07

代码执行流程

以上面 Qt 示例为对象,程序从启动到退出的完整协作流程:

① 程序入口,构造应用对象:main() 首先构造 QCoreApplication app——这是 Qt 自带的"进程唯一应用对象"(qApp),事件循环、全局状态(焦点、剪贴板)都以它为根。它本身就是单例思想的官方实践。

② 首次调用 instance(),触发创建:执行到 AppConfig::instance().setValue("ui/theme", "dark") 时,函数内静态局部变量 s_config 首次被初始化:编译器检查 guard → 调用私有构造函数 → 成员 m_settings 被构造(QSettings 定位到 ~/.config/JankerLi/DailyPattern.ini,文件不存在则稍后自动创建)→ setValue 把 "dark" 写入内存缓存。若此时另有线程同时调用 instance(),它会在 guard 上等待,不会重复构造。

③ 后续调用,直接复用:再执行 AppConfig::instance().theme() 时,guard 已通过,直接返回既有对象的引用,零构造开销。模块 A 写入的配置,模块 B 立刻能读到——因为大家拿到的是同一个对象、同一份 QSettings 缓存。qApp != nullptr 验证应用对象确实存在且唯一。

④ 进程退出,自动析构:main() 返回后进入静态对象析构阶段,s_config 被析构(构造逆序),m_settings 把尚未落盘的变更写回 INI 文件,文件句柄由 RAII 自动关闭。全程没有手动 new/delete,没有泄漏,也没有悬空指针。

08

为什么这样设计

从架构视角拆解今天的代码,看单例到底买到了什么、代价又是什么:

  • 解耦点:调用方不再各自持有 Logger/Config 的副本,也不再负责创建与生命周期,只依赖 Logger::instance() 这一个最小入口。代码从"多处 new + 多处传参"收敛为"一处创建、处处使用",模块之间的共享方式变得显式且单一。
  • 扩展点(OCP):实例的创建策略(懒汉/饿汉)、线程安全策略、底层介质(ofstream / spdlog / 远程日志;QSettings / 数据库)全部封装在类内部。把文件日志换成网络日志,只需改 Logger 内部实现,所有调用方零改动——对扩展开放、对修改封闭。注意:这里的"扩展"是内部实现替换,而不是子类扩展(单例与继承天生冲突)。
  • DIP 的进一步要求:严格讲,更彻底的做法是让调用方依赖抽象接口 ILogger,单例只是它的一个实现。今天示例直接用具体类,是为了聚焦单例本身;真实工程中建议"接口 + 单例实现"两层——接口保测试性,单例保唯一性。
  • 组合优于继承:单例用"静态成员组合"实现唯一性,而非继承;恰恰相反,构造私有让继承变得危险(子类无法正确构造基类单例)。这提醒我们:不是所有模式都拥抱继承。
  • 对测试的影响(最重要的代价):单例的全局状态会跨测试用例残留——用例 1 改了 theme,用例 2 读到的就是脏值。缓解三板斧:① 单例保持"薄",只做访问与转发,业务逻辑放进可注入的普通类;② 提供 resetForTest() 之类的测试钩子;③ 在依赖注入容器中管理对象生命周期,让"唯一"由容器保证而非类自己。
架构判断:单例适合"能力天然唯一且无状态变化"的系统级服务(日志、配置、资源池)。一旦发现单例里开始堆积业务状态、出现 setXxx 满天飞,说明你正在把全局可变状态塞进单例——那是最常见的架构坏味道(Global State 反模式)。
09

不使用会怎样

不写单例,最常见的替代方案是全局变量,而它恰恰是单例要消灭的东西。看反例:

// 反例:全局配置对象,谁都能改
#include <string>

struct AppConfig {
    std::string theme = "light";
    int timeout = 3000;
};
extern AppConfig g_config;  // 全局变量

这个方案随需求膨胀会依次暴露四个问题:

  • 没有统一入口,改到一半出 bug 无从查起:任何一行代码都能执行 g_config.theme = "dark",想审计"谁改了主题"只能靠搜索全局。单例至少把读写收进 value()/setValue() 两个方法,可以在那里加日志、加校验、加信号。
  • 初始化顺序不受控:C++ 规定同一编译单元内静态对象按定义顺序初始化,但跨编译单元的顺序未定义。如果模块 A 的静态对象在 g_config 之前构造并读取它,就是未定义行为。Magic Static 在首次调用时才初始化,顺序永远可控。
  • 测试相互污染:用例之间共享同一份全局状态,跑完一个用例不"复位"就会影响下一个。单例同样有这个问题,但至少可以在 instance() 旁边提供测试钩子,全局变量连钩子都无处挂。
  • 唯一性毫无保障:当"只有一个"是硬性业务约束时——同一日志文件句柄、同一数据库连接池、同一串口——全局变量根本管不住"有人又 new 了一个 Logger"。约束必须由类型系统保证(构造私有),而不是靠自觉。

至于"每个模块自己 new 一个"的方案(第 2 章),膨胀路径是:每加一个功能就要给构造函数加参数、每个调用点都要跟着改,最终形成"改一个配置项要动全工程"的修改风暴。单例的价值不在于代码量变少,而在于把变化收敛到一个点。

10

何时使用

适合使用(3~5 个场景):

  • 日志系统:全进程共享同一个写入路径、同一份级别配置(今天第 5 章的 Logger)。
  • 配置中心:全局唯一配置 + 统一读写入口,内部用 QSettings / INI / JSON 持久化(今天第 6 章的 AppConfig)。
  • 连接池 / 线程池:数据库连接、网络连接、工作线程池必须复用且全局唯一,避免每个模块各建一套耗尽资源。
  • 独占硬件资源:串口、打印机、GPU 上下文、摄像头——物理上就一个,必须串行访问。
  • 进程级应用对象:Qt 的 QCoreApplication / qApp,全局事件循环与窗口系统状态的唯一根对象。

不适合使用(2~4 个场景):

  • 将来可能要多实例的对象:多窗口各自的主题配置、多租户数据源——一旦"唯一"不是业务约束,单例就是给自己埋雷。
  • 需要单元测试 mock 的领域服务:业务逻辑依赖的 collaborator 应该可替换、可注入,而不是写死全局唯一。
  • 依赖注入容器已经管理的对象:容器本身就保证了作用域与生命周期(单例作用域由容器声明),再叠加手写单例是双重实现。
  • 只是为了"少传几个参数":那是参数传递 / 上下文对象的问题,不是单例的问题——用单例解决"传参麻烦"是典型的动机错误。

过度设计提醒:一个类在程序里只被 new 了一次,不等于需要单例。判断标准只有一条——"全局唯一"是不是业务本身的硬约束。是,才用;只是碰巧,就不要用。还要警惕"单例 + 可变状态 + 多线程"三件套:它几乎是并发 bug 的温床,能用 const/无状态就绝不放可变状态进去。

11

与其他模式的区别

单例容易和三个概念混淆:

① 与"静态工具类"(如 std::filesystem、Qt 的 QFile::exists)的区别:静态工具类只有静态方法、无实例、无状态;单例有实例、有状态、可持有资源(文件句柄、连接)、可延迟初始化、还可以实现接口被注入。单例能做的静态类做不了(比如持有 ofstream 成员),静态类能做的单例没必要做。判断标准:需要跨调用保持状态吗?

② 与"工厂方法"(Day 2 预告)的区别:instance() 本质上是一个"只生产一个产品的工厂"。但工厂方法的职责是把"创建哪个对象"的决策推迟到子类,允许每次/按需返回不同对象;单例则把"永远返回同一个"作为铁律。二者是"创建的封装"这条路上的两个方向:单例封装的是一次性创建,工厂封装的是多态创建。

③ 与"享元模式"(Flyweight,Day 11)的区别:享元维护一组可共享对象,按 key 复用(如 QFont 缓存、QPixmap 缓存),对象之间可以不同;单例只有一个对象,没有 key 的概念。享元解决"大量重复对象的内存开销",单例解决"全局唯一性的约束"。

维度全局变量静态工具类单例
实例数量一个(无保障)零(无实例)一个(语法保障)
状态持有有,随处可改无有,入口收敛
初始化时机静态初始化(顺序不可控)—首次调用(可控、可懒加载)
可测试性极差差(难以替换)差,但可用接口+注入缓解
12

Qt 源码中的体现

单例在 Qt 框架里不是"一个类",而是一套贯穿始终的设计习惯。最典型的四处:

  • QCoreApplication::instance() 与 qApp:这是 Qt 对单例最直接的官方实践——静态方法返回进程唯一的应用对象。Qt 6 中 qApp 宏定义在 <QCoreApplication> 里;Qt 5 中则需要包含 <QApplication>。你写的 AppConfig::instance() 和它是同一套路。
  • QApplication 构造时的强制检查:Qt 源码里,当 QCoreApplication::instance() 已经存在时再构造新的 QApplication,会执行 qFatal("QApplication::QApplication: instance already exists") 直接终止程序。这是"唯一性靠类型系统与运行时双重强制"的教科书案例——比我们示例更狠,因为它连"再建一个"的行为都从源头掐死。
  • QApplication::style():全局唯一样式对象。整个进程的所有控件共享同一个 QStyle,任何模块都能通过 QApplication::style() 拿到它(默认由 QStyleFactory 创建)。主题切换就是替换这一个对象——单例 + 策略模式(Day 18)的组合。
  • QSettings 的"约定式唯一":QSettings 本身不是单例,但它用"组织名 + 应用名"决定唯一的配置文件路径。多个 QSettings 对象指向同一文件,语义上就是全局一份配置——所以业务层用单例封装它是顺理成章的(今天的 AppConfig 正是如此)。

设计意图:为什么 QCoreApplication 必须是单例?因为事件循环、焦点窗口、剪贴板、全局快捷键这些状态在物理上只能有一份——两个事件循环同时跑,鼠标事件该发给谁?这就是"全局唯一是业务约束"的活例子。读 Qt 源码时,看到 static QCoreApplication *instance() 这类签名,就能立刻意识到:这里在用单例思想守护进程级状态。

13

面试常见问题

先自己思考,再看答案。

Q1:C++ 中如何写出线程安全的单例?C++11 的 Magic Static 为什么线程安全?

答案:最推荐的是函数内静态局部变量写法:static Logger s_logger; return s_logger;。C++11 标准规定静态局部变量的初始化由编译器插入 guard 变量保护,多个线程同时首次进入时只有一个线程执行构造,其余线程阻塞等待,构造完成后统一放行;且构造是"恰好一次"的(若构造抛异常,guard 重置,下次调用会重试)。不需要手动加锁,也不会出现双重检查锁定的竞态。

Q2:懒汉式与饿汉式单例有什么区别?各有什么优缺点?

答案:饿汉式在程序启动时(类加载/静态初始化阶段)就创建实例,写法简单、天然线程安全,但启动即付出构造开销,且跨编译单元的静态初始化顺序不可控,可能访问未初始化的依赖。懒汉式在首次调用 instance() 时才创建,节省启动开销、初始化顺序可控,但 C++11 之前需要双重检查加锁(容易写错);C++11 之后直接用 Magic Static 即可,兼顾懒加载与线程安全,是现代 C++ 首选。

Q3:单例模式最大的缺点是什么?为什么说它难以单元测试?如何缓解?

答案:最大缺点是引入隐式的全局状态:任何代码都能拿到并修改它,测试用例之间会互相污染(用例 A 改的状态残留给用例 B);同时调用方直接依赖具体类,无法注入 mock。缓解方法:① 单例做薄,业务逻辑放进可注入的普通类;② 定义抽象接口,让调用方依赖接口而非单例类;③ 提供 resetForTest() 测试钩子;④ 用依赖注入容器管理单例作用域。

Q4:单例对象什么时候析构?析构顺序有什么坑?

答案:Magic Static 的局部静态对象在 main() 返回之后、静态析构阶段按构造逆序析构。坑在于:如果析构函数访问了另一个静态对象,而后者已经先被析构,就是未定义行为(典型的如析构时写日志,而 Logger 先没了)。因此析构函数应保持简单、不依赖其他静态对象;需要受控释放时,提供显式的 shutdown() 方法在 main 结束前调用。

Q5:单例和全局变量有什么区别?

答案:全局变量任何人都能直接读写,无法控制创建时机、无法保证唯一性、初始化顺序跨编译单元未定义;单例把"唯一实例"和"访问方式"都收进类内部:构造私有(语法级禁止第二个实例)、静态方法统一入口(可加校验/日志/惰性初始化)、生命周期由类管理。本质区别是:全局变量是无约束的共享,单例是有约束的共享。

14

今日练习

练习:实现一个支持级别过滤的日志单例(15~30 分钟)

需求:在今天的 Logger 基础上增强——支持日志级别 DEBUG / INFO / WARN / ERROR;提供 setLevel() 全局设置最低输出级别;日志行带时间戳,形如 [2026-08-13 09:30:00] [INFO] user login: alice;多线程调用安全;仍保持单例语义(instance() 全局唯一)。

设计目标:体会"唯一性 + 状态(当前级别)集中在单例内部",以及调用方代码如何保持一行不变(Logger::instance().info("..."))。

提示:

  • 提示 1:级别过滤可以用枚举 + std::atomic 或 std::mutex 保护 m_level;枚举比较用 level >= m_level 判断是否输出。
  • 提示 2:时间戳用 std::chrono::system_clock + std::put_time 格式化到 std::ostringstream,再统一拼行写入文件。

完成后再想一个问题:如果有一天产品要求"每个模块可以单独设置日志级别",单例还合适吗?——这就是第 10 章"不适合场景"的活体检验。

15

今日总结

一句话记忆:全局唯一 + 全局访问——构造私有、静态持有、静态访问,一个类、一个实例、一个入口。

看到以下代码特征时,可以考虑这个模式:

  • 多个模块各自 new 同一个"资源类"(日志、配置、连接),出现多实例状态不同步。
  • 同一个对象被当作参数在模块间传来传去,只是为了共享一份状态。
  • 配置文件、连接池、硬件资源需要"全进程统一入口"的约束。
  • 构造函数参数随需求不断膨胀,每个调用点都要跟着改。
  • 需要 qApp 式的进程级唯一对象(应用上下文、全局样式、全局设置)。

明日预告:Day 2 工厂方法模式(Factory Method)。今天的 instance() 其实就是一个"只产一个产品的工厂";明天我们将回答:当"创建哪个对象"需要推迟到子类决定、每次调用可能产生不同对象时,如何把 new 从调用方彻底剥离——这也是从单例迈向"创建型模式家族"的第一步。