设计模式学习——单例模式

这篇笔记详细介绍了单例模式的概念及其在项目中的应用。单例模式确保一个类在全局范围内只有一个实例,并通过这个唯一实例向其他模块提供数据访问。文章通过 C++ 代码示例展示了如何实现单例模式,包括饿汉模式和懒汉模式,并讨论了懒汉模式下的线程安全问题。最后,文章通过模拟宝可梦探险队中的任务板,展示了单例模式在实际场景中的应用。总结部分列出了单例模式的优点和缺点。

设计模式学习——单例模式

引入

单例模式,顾名思义是一种创建单一对象的方式。现实情景中,我们会在某些场景下使用单一对象。比如:

  • 一个班级只有一个班主任。
  • Windows 在多进程多线程环境下操作文件时,避免多个进程或线程同时操作一个文件,需要通过唯一实例进行处理。
  • 设备管理器设计为单例模式,例如电脑有两台打印机,避免同时打印同一个文件。

此次我们将以宝可梦探险队中的任务板为例,尝试搭建基于单例模式的消息队列。

单一性的保证

要确保在整个程序运行过程中只能存在一个单一变量,就要在定义类时删除或隐藏类的构造函数。在C++中,我们需要删除类的移动/复制构造函数,隐藏类的普通构造函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class TaskBoard {
public:
    TaskBoard(const TaskBoard& obj) = delete;
    TaskBoard& operator=(const TaskBoard& obj) = delete;
    ~TaskBoard() = default;
    static TaskBoard* getInstance();
private:
    TaskBoard() = default;
    static TaskBoard* _instance;
};

同时,我们声明公开静态的getInstance()方法和私有的_instance指针。这样,其它对象就能使用该方法获取TaskBoard的单一实例。

1
2
3
4
TaskBoard* TaskBoard::getInstance() {
    return TaskBoard::_instance;
}
TaskBoard* TaskBoard::_instance = new TaskBoard;

需要注意的是,由于C++中静态成员变量必须在类外部定义和初始化,所以TaskBoard* TaskBoard::_instance = new TaskBoard;这行代码仍然可以在作用域外部访问到该变量。

饿汉/懒汉模式

我们将刚才的getInstance()方法所代表的模式成为单例模式的饿汉模式。

1
2
3
4
5
6
7
8
9
/* 饿汉模式
 * 类加载时先创建好实例,再对外提供应用
 * 避免出现资源竞争
 */

TaskBoard* TaskBoard::getInstance() {
    return TaskBoard::_instance;
}
TaskBoard* TaskBoard::_instance = new TaskBoard;

与饿汉模式相对的是懒汉模式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/* 懒汉模式
 * 在使用getInstance()时创建唯一实例
 * 减少内存占用
 * 非线程安全,需要特殊处理
 */

TaskBoard* TaskBoard::getInstance() {
    if(_instance == nullptr)
        _instance = new TaskBoard;
    return TaskBoard::_instance;
}

不得不提的是,在现代应用程序中懒汉模式减少的内存占用并不显著。因此大多数情况下可考虑直接使用饿汉模式。

懒汉模式的线程安全

容易发现,当多个线程调用懒汉模式下的getInstance()方法时存在线程隐患。C++中有以下几种方法可以提供懒汉模式下的线程安全保障:

双重检查锁定法(不完全的线程安全)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/* 懒汉模式下的双重检查锁定法
 * 通过两个嵌套的if来判断对象是否为空
 * 不完全的线程安全(可能会被指令重排)
 */

TaskBoard* TaskBoard::getInstance() {
    if(_instance == nullptr) {
        _mutex_.lock();
        if(_instance == nullptr)
            _instance = new TaskBoard;
        _mutex.unlock();
    }
    return TaskBoard::_instance;
}

双重检查锁定法 + 原子变量(完全线程安全,但性能开销大)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/* 懒汉模式原子变量实现
 * 在双重检查锁定的基础上使用原子变量
 * 保证处理指令的时候使用默认原子顺序进行顺序原子操作
 * 避免指令重排
 * 多线程安全但效率低
 */

TaskBoard* TaskBoard::getInstance() {
    TaskBoard* board = _atomic_instance.load();
    if(board == nullptr) {
        std::lock_guard<std::mutex> locker(_mutex); // 使用lock_guard管理锁,无需手动解锁
        if(board == nullptr) {
            board = new TaskBoard;
            _atomic_instance.store(board);
        }
    }
    return board;
}

局部静态模式(完全线程安全,需要标准>=C++11)

1
2
3
4
5
6
7
8
9
/* 懒汉方法使用静态局部变量实现
 * 在getInstance()方法中定义了一个局部公告板对象,并且将这个对象作为唯一的单例实例。
 * C++11规定如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待该变量完成初始化。
 */

TaskBoard* TaskBoard::getInstance() {
    static TaskBoard board;
    return &board;
}

实现任务板信息队列

在宝可梦不可思议的迷宫:空之探险队中,玩家所处的宝藏镇中有一块公告板。需要帮助的宝可梦可以在公告板上刊登委托,而探险队可以接下委托并进入迷宫完成任务。在这个情境中,我们可以认为这块公告任务板是一个唯一的消息队列。我们可以利用单例模式对其进行建模,模拟两只宝可梦发布任务,三只探险队接收任务并且完成任务的场景。

 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
//TaskBoard.cpp
std::mutex TaskBoard::_mutex;

TaskBoard* TaskBoard::getInstance() {
    return TaskBoard::_instance;
}
TaskBoard* TaskBoard::_instance = new TaskBoard;
// 使用饿汉模式
// 即使如此涉及到除了单一实例以外的变量也要注意多线程安全性

bool TaskBoard::isEmpty() const{
    std::lock_guard<std::mutex> locker(_mutex);
    if(_taskQueue.empty())
        return true;
    return false;
}

void TaskBoard::popTask() {
    std::lock_guard<std::mutex> locker(_mutex);
    if(!_taskQueue.empty()) _taskQueue.pop();
}

void TaskBoard::addTask(const int id) {
    std::lock_guard<std::mutex> locker(_mutex);
    _taskQueue.push(id);
}

int TaskBoard::getTask() const{
    std::lock_guard<std::mutex> locker(_mutex);
    if(!_taskQueue.empty()) return _taskQueue.front();
    return -1;
}
 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
//ExpeditionTeam.cpp
ExpeditionTeam::ExpeditionTeam(const std::string& name, int solveTime) : _name(name), _solveTime(solveTime) {
    _taskBoard = TaskBoard::getInstance();
};

void ExpeditionTeam::startWorking() {
    while(!_taskBoard->isEmpty()) {
        getTask();
        solveTask();
    }
}

void ExpeditionTeam::getTask() {
    if(!_taskBoard->isEmpty()) {
        std::cout << std::endl << "Task ID: " << _taskBoard->getTask() << " taken by " << "Team " << _name << std::endl;
        _taskBoard->popTask();
    }
}

void ExpeditionTeam::solveTask() {
    std::this_thread::sleep_for(std::chrono::seconds(_solveTime));
    std::cout << std::endl << "Task solved by Team " << _name << " with " << _solveTime << "sec." <<std::endl;
}

//HelpSeeker.cpp
HelpSeeker::HelpSeeker(const std::string &name) : _name(name){
    _taskBoard = TaskBoard::getInstance();
}

void HelpSeeker::sendTask(int id) const {
    _taskBoard->addTask(id);
    std::cout << std::endl << "Pokemon " << _name << " added a request! ID: " << id << std::endl;
}
 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
//TreasureTown.cpp
int main() {
    ExpeditionTeam team1("LoveFromEevees", 4);
    ExpeditionTeam team2("RazorWind", 3);
    ExpeditionTeam team3("Skull", 5);

    HelpSeeker pm1("Mawile");
    HelpSeeker pm2("Poochyena");

    std::thread producer_th1([&] {
        for (int i = 0; i < 50; i++) {
            pm1.sendTask(randid());
            std::this_thread::sleep_for(std::chrono::seconds(1));
        }
    });
    std::thread producer_th2([&] {
        for (int i = 0; i < 50; i++) {
            pm2.sendTask(randid());
            std::this_thread::sleep_for(std::chrono::seconds(1));
        }
    });
    std::this_thread::sleep_for(std::chrono::seconds(3));

    std::thread consumer_th1(&ExpeditionTeam::startWorking, &team1);
    std::thread consumer_th2(&ExpeditionTeam::startWorking, &team2);
    std::thread consumer_th3(&ExpeditionTeam::startWorking, &team3);

    producer_th1.join();
    producer_th2.join();
    consumer_th1.join();
    consumer_th2.join();
    consumer_th3.join();

    return 0;
}

可以在我的Github仓库中找到完整代码。

总结

在一个项目中,全局范围内,某个类的实例有且仅有一个,通过这个唯一实例向其他模块提供数据的全局访问,这种模式就叫单例模式。单例模式的典型应用就是任务队列。

  • 优点:内存中只有一个实例,减少内存开销,尤其是频繁创建和销毁实例时(如管理学院首页页面缓存);避免资源的多重占用(如写文件操作)。
  • 缺点:没有接口,不能继承;与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心实例化方式。