C++常用设计模式1——单例模式

最近在学习各种设计模式,感觉收获非常多。

关于设计模式的学习,这里我想推荐一个视频:https://www.bilibili.com/video/av22292899。这个课的老师是个高手,对面向对象编程和设计模式的理解非常透彻,听完这门课后有种恍然大悟的感觉,对面向对象编程本身的理解有了很大的提高。

我们知道,C++是一种支持面向对象编程的语言。所谓“面向对象”的三大特性,相信大家都已经滚瓜烂熟:封装、继承、多态。但很多人在实际编程过程中,只是知道皮毛而没有领会“面向对象”编程方法的精髓(包括我),只是把“对象”理解成“封装了一系列成员函数和变量的一个类”,并没有真正地把面向对象编程的思维方式与结构化的思维方式区分开。而“设计模式”就是帮助我们彻底地理解面向对象编程的一个必学课程。

归根到底,所谓“设计模式”,就是一系列“在某种具体的应用情境下,彻底地遵循面向对象编程原则,以达到这个情境下软件的高度可复用性和扩展性”的软件设计方法。强烈推荐所有具备一定C++基础的同学们抽出时间看一下这门课,相信我,你们一定会有所收获的。

废话不多说,先看看单例模式:

1、模式定义

单例模式(Singleton Pattern)是最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

注意:

1、单例类只能有一个实例。

2、单例类必须自己创建自己的唯一实例。

3、单例类必须给所有其他对象提供这一实例。

2、为什么要用单例模式?

学习过设计模式的话,我们可以发现,设计模式在很多情况下是依靠运行时多态,也就是虚函数来实现的,而虚函数的性能开销在有些情况下是不能忽略的。

有两种场景可以考虑单例模式:

1、逻辑上来说类就应该只有一个实例;

2、如果类的实例过多,对性能造成拖累,而任务可以只通过一个类的实例来完成。

3、单例模式的实现

单例模式应该如何实现呢?考虑两个关键点:

关键点1:将构造函数设为protected或private成员函数。当然,如果觉得有必要,某些构造函数可以加上=delete关键字,明确表示禁止使用。

关键点2:与类绑定的,只有一个的东西是什么?我们应该想到静态变量!单例模式的实现跟静态变量是紧密联系的。单例模式的实现虽然简单,但有一点务必不能忽略:线程安全性。

(1)Meyers’Singleton:利用local static 对象的特性

Meyers’Singleton是一种被称为“饿汉模式”的单例模式实现方法。饿汉模式有以下几点要求:保证类实例是唯一的;提供全局可访问点;延迟构造,直到第一次使用该实例。下面的实现利用了函数中的静态变量(local static)的特性:只有在第一次调用的时候初始化。需要明确的是,这种实现在C++98中不是线程安全的!这种实现也被称为Meyers’Singleton,在C++11中推荐使用这种实现方法(实际上gcc4.0后这个方法就是线程安全的,所以这个也是实现单例模式的首选方法)。
class Singleton {
private:
    Singleton();
    Singleton(const Singleton& other);
public:
    static Singleton* getInstance() {
        static Singleton* m_instance = new Singleton();
        return m_instance;
    }
};

(2)非线程安全的实现

永远不要使用下面的方法来实现单例模式(因为在任何情况下下面的方法都是非线程安全的),这里介绍这种方法只是为了说明线程安全在单例模式构造中的重要性。

class Singleton {
private:
	Singleton();
	Singleton(const Singleton& other);
public:
	static Singleton* getInstance();//返回一个指向类对象的指针
	static Singleton* m_instance;//静态变量:一个指向类对象的指针
};

Singleton* Singleton::m_instance = nullptr;

//线程非安全版本
Singleton* Singleton::getInstance() {
	//只有在第一次调用的时候才赋值
	if (m_instance == nullptr) {
		m_instance = new Singleton();
	}
	return m_instance;
}

假设有两个线程A和B。让我们考虑这种情况:当线程A进入到if (m_instance == nullptr)时,m_instance为空。此时如果线程A的时间片用完,线程B进入到if (m_instance == nullptr) ,m_instance还是为空,于是还是会执行判断语句里的语句。这样就生成了两个类的对象。如果线程更多,甚至可能是多个。

(3)互斥锁实现(线程安全)

为了解决线程安全的问题,最容易想到的方法是使用互斥锁。

std::mutex m;//全局的互斥量
class Singleton {
private:
	Singleton();
	Singleton(const Singleton& other);
public:
	static Singleton* getInstance();
	static Singleton* m_instance;
};

Singleton* Singleton::m_instance = nullptr;

//线程安全版本,但锁的代价过高
Singleton* Singleton::getInstance() {
	std::unique_lock lock(m);//析构时自动解锁
	if (m_instance == nullptr) {
		m_instance = new Singleton();
	}
	return m_instance;
}

但是,线程安全问题只是出现在实例第一次初始化过程。然而,以上加锁的代码却使得每一次获取Singleton都要首先获取锁资源,Singleton的访问变成了串行,在高并发情况下性能损失很大。

(4)双锁检查DoubleCheck Lock:reordering问题

为了对加锁的线程安全写法进行性能上的优化,有一种经典的解决方法叫双锁检查(Double Check Lock):
//双检查锁,但由于内存读写reorder不安全
std::mutex m;//全局的互斥量
class Singleton {
private:
	Singleton();
	Singleton(const Singleton& other);
public:
	static Singleton* getInstance();
	static Singleton* m_instance;
};

Singleton* Singleton::m_instance = nullptr;

Singleton* Singleton::getInstance() {
	if (m_instance == nullptr) {
		std::unique_lock lock(m);
		if (m_instance == nullptr) {
			m_instance = new Singleton();
		}
	}
	return m_instance;
}

双锁检查长期以来是个非常经典的解决方案,解决了前面加锁的性能损失。直到后来才有人发现了这种写法的漏洞:内存读写reordering会导致双检查锁失效。

什么是reordering?它指的是由于编译器的优化举措,代码在CPU的指令层的执行顺序可能会跟我们预想的不一样。

比如,对于上面代码的m_instance = new Singleton(),我们一般认为的,也是常见的执行顺序为:

1.分配内存空间;2.调用构造器在内存空间中初始化一些成员变量;3.将这块内存空间的地址赋值给m_instance;

Reordering后,2跟3的执行顺序可能会反过来:

1.分配内存空间;2. 将这块内存空间的地址赋值给m_instance;3.调用构造器在内存空间中初始化一些成员变量。

假设有两个线程A和B。如果线程A通过进入到m_instance = newSingleton()。如果按照reordering后的顺序,可能会出现一种情况:m_instance只是被赋值了内存的地址,但这块内存还没有调用构造器构造完成。此时线程B如果检查m_instance,发现它非空,可能就会马上使用这块空间(但实际上这个对象还没完成构造),这就会导致程序出现难以预知的错误。

(5)C++11下线程安全的双检查锁实现

C++11提供了Atomic实现内存的同步访问,即不同线程总是获取对象修改前或修改后的值,无法在对象修改期间获得该对象。用这种方法可以克服双检查锁下的reordering问题。

std::mutex m;//全局的互斥量
class Singleton {
private:
	Singleton();
	Singleton(const Singleton& other);
public:
	static Singleton* getInstance();
	//atomic实现内存的同步访问
	static std::atomic<Singleton *> m_instance(nullptr);
};

Singleton* Singleton::getInstance() {
	if (m_instance == nullptr) {
		std::unique_lock lock(m);
		if (m_instance == nullptr) {
			m_instance = new Singleton();
		}
	}
	return m_instance;
}

(6)饿汉模式

饿汉模式比较复杂(由于C++中并未规定多个unlocal static对象的构造顺序,因此需要添加一大堆同步代码来确保安全),这里就不介绍了,感兴趣的可以参考如下链接:

https://blog.csdn.net/qq_35280514/article/details/70211845。

4、优缺点

优点: 1、在内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例。 2、避免对资源的多重占用(比如写文件操作)。

缺点:没有接口,不能继承,与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化。

5、单例对象的自动析构

让我们考虑一个问题:m_instance指向的空间什么时候释放呢?我们知道,如果单例的对象是被用new申请的堆对象,就无法被自动释放,因此使用单例模式时,必须考虑单例对象析构的问题,在某些地方将其释放掉。

这就涉及到了单例的一个铁律:单例应该由类的使用者来释放, 而不是类的提供者!我们看一个错误的代码:

class Singleton {
private:
	Singleton();
	Singleton(const Singleton& other);
	static Singleton* m_instance;
public:
	//返回一个指向类对象的指针
	static Singleton* getInstance() {
		//只有在第一次调用的时候才赋值
		if (m_instance == nullptr) {
			m_instance = new Singleton();
		}
		return m_instance;
	}
	~Singleton() {
		if (m_instance != nullptr) {
			delete m_instance;//递归调用析构
			//由于递归调用析构,下面释放资源的工作永远无法得到执行
			//...其它释放资源的工作
		}
	}
};

Singleton* Singleton::m_instance = nullptr;

运行后发现, 析构函数被多次调用了,为什么呢?因为当类的使用者调用delete的时候, 实际上就是调用析构函数来释放单例。但是,现在类的提供者在析构函数中delete这个单例,显然又会调用析构函数,所以形成了递归调用析构函数,后面释放资源的工作永远无法得到执行。因此,绝对不要在单例的析构函数中释放单例!

因此,在单例的析构函数中,应该把delete m_instance这句删掉,并且完成其它的资源释放工作。应该由外界类的使用者调用delete。那我们应该在什么时候删除该实例呢?下面给出两个例子:

方法1:

atexit注册一个释放单例的函数,在程序退出后进行资源的释放。下面是非线程安全的实现,不推荐这种写法。这里只是为了演示方便:

class Singleton {
private:
	Singleton();
	Singleton(const Singleton& other);
	static Singleton* m_instance;//静态变量:一个指向类对象的指针
public:
	//返回一个指向类对象的指针
	static Singleton* getInstance() {
		if (m_instance == nullptr) {
			m_instance = new Singleton();
		}
		return m_instance;
	}
	~Singleton() {
		//...其它释放资源的工作
	}
};

Singleton* Singleton::m_instance = nullptr;

void fun_release() {
	if (Singleton::m_instance) {
		delete Singleton::m_instance;
		Singleton::m_instance = nullptr;
	}
}

int main()
{
	//...
	atexit(fun_release);
	//...
}

但是这样的附加代码很容易被忘记,还是不方便。一个妥善的方法是让这个类自己知道在合适的时候把自己删除。或者说把删除自己的操作挂在系统中的某个合适的点上,使其在恰当的时候自动被执行。

方法2:

我们知道,程序在结束的时候,系统会自动析构所有的全局变量。事实上,系统也会析构所有的类的静态成员变量。利用这个特征,我们可以在单例类中定义一个这样的静态成员变量,而它的唯一工作就是在析构函数中删除单例类的实例,如下面的代码中的CGarbo类。下面的代码演示了如何在Meyer's Singleton的单例实现下利用一个local static的CGarbo类实现对单例的自动析构。

#include<iostream>

using namespace std;

class Singleton {
private:
    int num;
    Singleton();
    Singleton(const Singleton& other);
    Singleton(int x) {
        num = x;
        cout << "单例对象创建!成员变量num=" << x << endl;
    }
    class CGarbo//它唯一的工作就是在析构函数中删除Singleton的实例
    {
    private:
        Singleton* m_instance = nullptr;
    public:
        CGarbo(Singleton* instance) {
            m_instance = instance;
        }
        ~CGarbo() {
            if (m_instance) {
                delete m_instance;
                m_instance = nullptr;
            }
        }
    };
public:
    static Singleton* getInstance(int x) {
        static Singleton* m_instance = new Singleton(x);
        static CGarbo cgrbo= CGarbo(m_instance);
        return m_instance;
    }
    ~Singleton() {
        cout << "释放单例占有的资源!" << endl;
        system("pause");
    }
};

int main()
{
    Singleton* single= Singleton::getInstance(5);
    return 0;
}
程序运行结果如下:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值