1. 什么是单例模式?

单例模式是一种创建型设计模式,其核心思想是确保一个类在任何情况下都只有一个实例,并提供一个全局访问点来获取这个唯一的实例

可以把它想象成一个国家的总统或者一个学校的校长,在整个系统运行期间,这个角色只能有一个人担任。无论你从哪个部门、哪个流程去“找校长”,最终找到的都是同一个人。

为了在 C++ 中实现这一点,通常需要满足三个关键条件:

  1. 私有的构造函数:为了防止外部代码通过 new 操作符随意创建类的实例。
  2. 一个私有的、静态的、指向本类实例的指针或对象:这是存放那个唯一实例的地方。
  3. 一个公有的、静态的、用于获取实例的方法:这是全局唯一的访问点,负责创建并返回那个唯一的实例。

2. 为什么需要单例模式?

单例模式主要用于解决那些“全局唯一”且需要被频繁共享访问的资源或服务。常见的应用场景包括:

  • 日志记录器(Logger):整个应用程序通常只需要一个日志记录器,所有模块都通过它来写入日志文件。
  • 配置管理器(Configuration Manager):读取和管理应用的配置信息(如数据库连接字符串、API 密钥等),这些信息在整个应用中是共享的。
  • 数据库连接池(Database Connection Pool):管理数据库连接,避免频繁地创建和销毁连接,整个应用共享一个连接池。
  • 硬件接口访问:比如访问打印机、串口等,通常需要一个统一的管理器来避免冲突。

使用单例模式可以带来以下好处:

  • 资源节约:避免了重复创建重量级对象带来的开销。
  • 数据一致性:确保所有部分访问的是同一份数据或状态。
  • 全局访问:提供了一个方便的全局访问点,简化了代码。

3. 如何实现单例模式?(C++ 实现的演进)

在 C++ 中,单例的实现有多种方式,并且随着 C++ 标准的演进,最佳实践也在发生变化。下面我们来看一下这个演进过程。

版本一:懒汉式(Lazy Singleton)- 基础但线程不安全

“懒汉”指的是只有在第一次被请求时,实例才会被创建,而不是在程序启动时就创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// version_1_lazy.cpp
#include <iostream>

class Singleton {
private:
// 1. 私有构造函数
Singleton() {
std::cout << "Singleton instance created." << std::endl;
}

// 2. 私有静态实例指针
static Singleton* instance_;

public:
// 3. 公有静态获取实例的方法
static Singleton* getInstance() {
// 只有当 instance_ 为空时才创建
if (instance_ == nullptr) {
instance_ = new Singleton();
}
return instance_;
}

// 为了防止拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;

void showMessage() {
std::cout << "Hello from Singleton!" << std::endl;
}
};

// 在类外初始化静态成员
Singleton* Singleton::instance_ = nullptr;

int main() {
Singleton* s1 = Singleton::getInstance();
Singleton* s2 = Singleton::getInstance();

if (s1 == s2) {
std::cout << "s1 and s2 are the same instance." << std::endl;
}

s1->showMessage();
// 注意:这个版本需要手动释放内存,非常不推荐
// delete s1; // 谁来释放?何时释放?这是一个问题
return 0;
}
  • 问题
    1. 线程不安全:在多线程环境下,如果两个线程同时进入 if (instance_ == nullptr) 判断,并且都判断为 true,那么它们都会创建一个实例,这就破坏了单例的原则。这被称为竞态条件(Race Condition)
    2. 内存泄漏new 出来的实例没有被 delete,会导致内存泄漏。虽然可以设计一个 destroy 方法或者使用智能指针,但这会增加复杂性。

版本二:懒汉式 + 锁(Thread-Safe Lazy Singleton)

为了解决线程安全问题,最直接的方法就是加锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// version_2_lazy_with_lock.cpp
#include <iostream>
#include <mutex> // 引入互斥锁

class Singleton {
private:
Singleton() { std::cout << "Singleton instance created." << std::endl; }
static Singleton* instance_;
static std::mutex mutex_; // 增加一个互斥锁

public:
static Singleton* getInstance() {
std::lock_guard<std::mutex> lock(mutex_); // 进入前加锁
if (instance_ == nullptr) {
instance_ = new Singleton();
}
return instance_;
// lock_guard 会在作用域结束时自动解锁
}

Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};

Singleton* Singleton::instance_ = nullptr;
std::mutex Singleton::mutex_;
  • 优点:通过 std::mutex 保证了线程安全。
  • 问题
    1. 性能开销:每次调用 getInstance() 都需要加锁和解锁,即使实例已经被创建。当实例创建后,这个锁其实是多余的,会影响高并发场景下的性能。
    2. 内存泄漏问题依然存在。

版本三:双重检查锁定(Double-Checked Locking Pattern, DCLP)

为了解决版本二的性能问题,人们想出了 DCLP。其思想是:先检查一次实例是否存在,如果不存在再加锁,加锁后再检查一次(防止其他线程在等待锁期间已经创建了实例)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// version_3_dclp.cpp
// !! 注意:这是一个有历史问题的模式,现代 C++ 中不推荐 !!
#include <iostream>
#include <mutex>
#include <atomic> // 需要原子操作来保证可见性

class Singleton {
private:
Singleton() { std::cout << "Singleton instance created." << std::endl; }
static std::atomic<Singleton*> instance_; // 使用 atomic
static std::mutex mutex_;

public:
static Singleton* getInstance() {
Singleton* tmp = instance_.load(std::memory_order_relaxed);
if (tmp == nullptr) { // 第一次检查
std::lock_guard<std::mutex> lock(mutex_);
tmp = instance_.load(std::memory_order_relaxed);
if (tmp == nullptr) { // 第二次检查
tmp = new Singleton();
instance_.store(tmp, std::memory_order_release);
}
}
return tmp;
}

// ...
};

std::atomic<Singleton*> Singleton::instance_{nullptr};
std::mutex Singleton::mutex_;
  • 问题:DCLP 的实现在 C++11 之前是不可靠的,因为存在指令重排(Memory Reordering)问题,可能导致一个线程拿到一个尚未完全构造好的对象。在 C++11 及以后,通过 std::atomic 和正确的内存序(memory order)可以正确实现,但它非常复杂、难以理解且容易出错。我们有更好的选择。

版本四:Meyers’ Singleton(C++11 最佳实践)

这是 C++ 之父 Scott Meyers 提出的方法,利用了 C++11 标准中对 静态局部变量(Static Local Variable) 初始化的保证。

C++11 标准规定:在一个函数或块内部的 static 变量的初始化是线程安全的。它只会被初始化一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// version_4_meyers_singleton.cpp (推荐!)
#include <iostream>

class Singleton {
private:
Singleton() {
std::cout << "Singleton instance created." << std::endl;
}

public:
// 删除拷贝构造和赋值操作
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;

static Singleton& getInstance() {
// C++11 保证了静态局部变量的初始化是线程安全的
static Singleton instance;
return instance;
}

void showMessage() {
std::cout << "Hello from Singleton! Address: " << this << std::endl;
}
};

int main() {
Singleton& s1 = Singleton::getInstance();
Singleton& s2 = Singleton::getInstance();

s1.showMessage();
s2.showMessage();

if (&s1 == &s2) {
std::cout << "s1 and s2 are the same instance." << std::endl;
}

return 0; // 无需手动释放,程序结束时会自动销毁
}
  • 优点
    1. 线程安全:C++11 标准保证了 static Singleton instance; 这行代码的执行是线程安全的。
    2. 简洁优雅:代码量最少,逻辑最清晰。
    3. 无内存泄漏instance 是一个栈上的静态对象,其生命周期由程序管理,程序结束时会自动析构,无需手动 delete
    4. 懒汉式加载:只有在第一次调用 getInstance() 时才会创建实例。

这是现代 C++ 中实现单例模式的首选方案。

版本五:饿汉式(Eager Singleton)

“饿汉”指的是在程序启动时(main 函数执行前),实例就已经被创建好了,不管你用不用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// version_5_eager.cpp
#include <iostream>

class Singleton {
private:
Singleton() { std::cout << "Singleton instance created at startup." << std::endl; }

// 在程序启动时就初始化
static Singleton instance_;

public:
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;

static Singleton& getInstance() {
return instance_;
}
};

// 在类外定义并初始化静态成员
Singleton Singleton::instance_;

int main() {
std::cout << "Main function started." << std::endl;
Singleton& s1 = Singleton::getInstance();
Singleton& s2 = Singleton::getInstance();

if (&s1 == &s2) {
std::cout << "s1 and s2 are the same instance." << std::endl;
}
return 0;
}
  • 优点
    1. 天然线程安全:因为实例在多线程代码开始执行之前就已经创建好了,不存在竞态条件。
    2. 实现简单
  • 缺点
    1. 启动耗时:如果单例的构造函数非常耗时,会拖慢程序的启动速度。
    2. 资源浪费:如果程序从头到尾都没有使用这个单例,那么它的创建就成了一种资源浪费。

4. 单例模式的优缺点

优点

  • 保证唯一实例:核心价值,确保了某些类只有一个对象。
  • 全局访问点:提供了一个方便的全局访问点,简化了对象间的调用。
  • 延迟初始化:懒汉式实现可以做到只在需要时才创建实例,节约资源。

缺点

  • 违反单一职责原则(SRP):一个类既要负责其核心业务逻辑,又要负责管理自己的生命周期(创建、销毁),职责过重。
  • 引入全局状态:单例本质上是全局变量的变种,会引入全局状态,使得代码的依赖关系变得不清晰。一个函数依赖了某个单例,但从函数签名上看不出来。
  • 难以进行单元测试:由于全局状态和紧耦合,对依赖单例的模块进行单元测试变得非常困难。很难用一个模拟的(Mock)对象来替换单例。
  • 扩展性差:如果未来需要这个类有多个实例,修改会非常困难。

5. 一个完整的现代 C++ 示例 (Logger)

下面我们使用 Meyers’ Singleton 实现一个简单的日志记录器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// complete_logger_example.cpp
#include <iostream>
#include <fstream>
#include <string>
#include <mutex>
#include <chrono>
#include <iomanip>

class Logger {
public:
// 获取单例实例的公共静态方法
static Logger& getInstance() {
static Logger instance;
return instance;
}

// 禁止拷贝和赋值
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;

// 日志记录方法
void log(const std::string& message) {
// 使用锁来保证多线程写入文件的安全
std::lock_guard<std::mutex> lock(mutex_);

// 获取当前时间
auto now = std::chrono::system_clock::now();
auto in_time_t = std::chrono::system_clock::to_time_t(now);

// 写入日志文件
log_file_ << std::put_time(std::localtime(&in_time_t), "%Y-%m-%d %X")
<< " - " << message << std::endl;
}

private:
// 私有构造函数,在其中打开日志文件
Logger() {
log_file_.open("application.log", std::ios::out | std::ios::app);
if (!log_file_.is_open()) {
std::cerr << "Error: Could not open log file!" << std::endl;
} else {
log("Logger instance created.");
}
}

// 私有析构函数,在其中关闭日志文件
~Logger() {
if (log_file_.is_open()) {
log("Logger instance destroyed.");
log_file_.close();
}
}

std::ofstream log_file_;
std::mutex mutex_; // 用于保护文件写入操作
};

// 模拟多线程环境
#include <thread>
void worker_thread(int id) {
Logger::getInstance().log("Thread " + std::to_string(id) + " started.");
// ... do some work ...
Logger::getInstance().log("Thread " + std::to_string(id) + " finished.");
}

int main() {
Logger::getInstance().log("Main function started.");

std::thread t1(worker_thread, 1);
std::thread t2(worker_thread, 2);

t1.join();
t2.join();

Logger::getInstance().log("Main function finished.");

return 0;
}

运行这段代码后,你会看到一个 application.log 文件,里面的日志记录了主函数和两个线程的活动,并且所有日志都是通过同一个 Logger 实例写入的。


6. 总结与建议

  • 单例模式是一个强大但需要谨慎使用的工具。 它能有效解决全局唯一资源的管理问题。
  • 在现代 C++ (C++11及以后) 中,强烈推荐使用 Meyers’ Singleton(基于静态局部变量的实现),因为它最简洁、安全、高效。
  • 在使用单例模式前,请三思:是否真的需要一个全局唯一的实例?有没有其他方式可以替代,比如依赖注入(Dependency Injection)?依赖注入通常能提供更好的可测试性和更松的耦合。
  • 如果决定使用,请确保你的单例类是线程安全的,尤其是在其成员函数会修改内部状态时(如上面的 log 方法)。