【C++】特殊类设计 | 单例模式
慕雪年华

本篇博客让我们来康康一些特殊类的实现方式!

1.不支持拷贝的类

在一些场景下,比如智能指针、多线程操作、IO流等是不支持拷贝的。因为它们的拷贝会导致一些问题,秉着解决不了问题,就解决提出问题的人的思路,禁止了这些类的拷贝

C++98中,可以将拷贝构造和=重载只声明不定义,并将其访问权限设置为私有

  • 设置为私有可以防止其他人在类外定义

C++11中,提供了一个特殊的关键字delete来禁止实现拷贝构造和 =重载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 禁止拷贝的类
class BanCopy
{
public:
//构造
BanCopy()
{
_a = _b = 0;
}

//C++11
BanCopy(const BanCopy& c) = delete;
BanCopy& operator=(const BanCopy& c) = delete;

private:
//C++98的办法,声明为私有且不定义
//BanCopy(const BanCopy& c);
//BanCopy& operator=(const BanCopy& c);

int _a;
int _b;
};

image

2.只能在堆上创建的类

操作方法和上面的思路类似,只需要把构造函数私有化就可以了

  • 同时还需要取消拷贝构造,否则可以用拷贝构造在栈上开一个新的对象
  • 赋值重载不一定需要取消,因为赋值重载无法创建新对象
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
// 只能在堆上开辟
class HeapOnly
{
public:
static HeapOnly* CreatObj(int a,int b)
{
return new HeapOnly(a, b);
}

private:
// 构造函数私有
HeapOnly()
:_a(0),
_b(0)
{}
HeapOnly(int a,int b)
:_a(a),
_b(b)
{}
// 同时拷贝构造也需要私有,禁止拷贝创建对象
HeapOnly(const HeapOnly& h) = delete;
// 赋值不一定需要delete,因为赋值不能创建新对象
// HeapOnly& operator=(const HeapOnly& h) = delete;

int _a;
int _b;
};

这样写了之后,想创建对象就可以调用static函数来操作

image

而且因为我们并没有私有化析构函数,所以析构是可以正常调用的!

2.1 另类操作

还可以使用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
39
// 只能在堆上开辟
class HeapOnly
{
public:
static HeapOnly* CreatObj(int a,int b)
{
return new HeapOnly(a, b);
}
static void DelObj(HeapOnly* ptr)
{
delete ptr;
}

// 因为析构私有了,所以可以把构造公有
HeapOnly()
:_a(0),
_b(0)
{}
HeapOnly(int a, int b)
:_a(a),
_b(b)
{}
private:
// 构造函数私有
// ....

// 同时拷贝构造也需要私有,禁止拷贝创建对象
HeapOnly(const HeapOnly& h) = delete;
// 赋值不一定需要delete,因为赋值不能创建新对象
// HeapOnly& operator=(const HeapOnly& h) = delete;

~HeapOnly()
{
_a = _b = 0;
}

int _a;
int _b;
};

这样设计了之后,直接在栈上/全局区开辟空间会报错,但是new不受影响。

因为析构私有了,所以delete不能正确调用析构函数,我们需要使用static函数指定指针进行析构

image

除了这种办法,还有另外一个法子可以不传入指针

1
2
3
4
5
//删除自己
void DelObj()
{
delete this;
}

直接用对象调用此函数即可

1
2
HeapOnly* h6 = new HeapOnly();
h6->DelObj();

只不过这样可能有些不太好理解,视具体情况而定喽!

3.只能在栈上创建的类

相同的思路,设计一个static的创建对象函数,来创建一个栈上的对象return

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 只能在栈上开辟
class StackOnly
{
public:
static StackOnly CreatObj()
{
return StackOnly();//创建匿名对象返回,编译器直接优化为一个构造

//这么写的话,就不能禁止拷贝构造
//StackOnly st;
//return st;
}
// 不能禁用拷贝构造,因为return的时候可能会调用(编译器优化是取决于平台的)
private:
StackOnly() {
_a = _b = 0;
}

int _a;
int _b;
};

这里我们必须要有拷贝构造,因为return的时候,编译器如果不优化,那就是构造+拷贝,优化了之后才能变成直接构造

这是取决于平台的,如果禁用了拷贝,万一有些平台编译器没有做这种优化,你的代码就跑不动了

  • 另外,还有一个方法便是禁用掉operator new(),以此禁止了在堆上创建空间。如果用这种办法,构造函数就不需要设计为私有了

但是这两个办法都有个缺陷,那就是用户可以用拷贝构造在静态区上创建一个对象。这只能算个小瑕疵,可以不用管它

image

4.单例模式

单例模式是设计模式的其中一种

设计模式是一套被反复使用且较为流行的代码设计经验总结。

设计模式有非常多,感兴趣的老哥可以去搜专门的博客了解一下

单例模式:一个类只能创建一个对象。该模式可以保证在一个进程中,某一个类只会有一个实例化的对象

举个例子,比如服务器的配置信息是一个类,这个类就可以设计成单例模式,保证所有人访问到的配置信息完全相同,修改的时候也能同步给所有人。

4.1 饿汉

饿汉模式采用static成员来实现单例,思路和上面也是一样的,让构造函数私有而无法创建其他对象

  • 那我们的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
// 单例模式(饿汉)
// 饿汉模式采用static对象,是在main函数之前创建的
// 会影响程序启动的速度
class Singleton
{
public:
static Singleton* GetInstance()
{
return _sgp;
}

void Print()
{
cout << "----- System Info -----" << endl;
cout << " CPU " << _cpu << endl;
cout << " GPU " << _gpu << endl;
cout << " MEM " << _mem << endl;
cout << "----- End -----" << endl;
}
private:
Singleton()
:_cpu("i9-12900ks"),
_gpu("RTX 4090"),
_mem("128GB")
{}
Singleton(const Singleton& s) = delete;

string _cpu;
string _mem;
string _gpu;

//static Singleton _sg;//声明
static Singleton* _sgp;//声明
};

//Singleton Singleton::_sg;//定义
Singleton* Singleton::_sgp = new Singleton();//定义
//因为这里的sg和sgp都是属于类里面的成员,不受访问限定符的限制,才可以正常调用构造函数

因为_sg/_sgp这两个成员都在类内部声明的,所以它们属于整个类域,可以成功访问到内部的构造函数。

而在其他地方的对象由于没有办法访问到构造函数,而无法创建

image

由于饿汉模式是static对象,其初始化是在main函数之前进行的。如果采用饿汉模式的单例过多,程序迟迟没有运行到main处,会导致一个程序启动很慢

4.2 懒汉

一开始不创建对象,第一调用GetInstance再创建对象

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
// 懒汉
// 一开始不创建对象,第一调用GetInstance再创建对象
class InfoMgr
{
public:
static InfoMgr* GetInstance()
{
if (_sp == nullptr)
{
_sp = new InfoMgr;
}

return _sp;
}

void SetAddress(const string& s)
{
_address = s;
}

string& GetAddress()
{
return _address;
}
private:
InfoMgr()
:_address("bilibili"),
_secretKey(1234)
{}
InfoMgr(const InfoMgr&) = delete;

string _address;
int _secretKey;

static InfoMgr* _sp; // 声明
};

InfoMgr* InfoMgr::_sp = nullptr; // 定义

这里我们将内部的_sp定义为了nullptr,如果谁第一个调用,做一个判断,如果是nullptr就创建实例


由于懒汉可能会出现多个线程同时第一次访问这个单例,就会导致在两个线程中都在初始化这个单例,而某一次初始化会失败。这是一个线程安全问题,需要我们对单例进行加锁操作

多线程加锁问题,参考linux下的操作:C++线程操作
C++的操作以这个思路,修改为使用C++的thread库即可

4.3 二者优缺点

饿汉的优点

  • 简单易用
  • 因为是在main函数前初始化,处于单线程状态,没有线程安全问题

缺点:

  • 但是初始化顺序不确定,如果有其他类的依赖关系,可能会出现依赖项B在当前单例A后初始化,导致A无法完成初始化而程序boom
  • 饿汉单例是在main函数之前创建的,拖慢程序启动速度

懒汉的优点

  • 第一次调用的时候才初始化变量,提高程序启动速度
  • 可以控制初始化顺序,按顺序来初始化,避免依赖关系问题

缺点:

  • 第一次调用的时候,加载会慢一些

基于这两个的优缺点,让我想出来一个不算办法的办法

如果想控制饿汉的初始化顺序,可以在main一启动的时候,就调用一个初始化函数来初始化这些单例。这样依旧会拖慢进程启动的顺序,但解决了初始化顺序的问题!

实际上,一个单例究竟要不要在main之前就初始化需要看具体情况的!

4.4 单例释放资源

一般情况下,单例的类是不需要手动释放的,因为整个进程都需要使用这个单例

但如果我们的单例和一个文件挂钩,进程结束的时候,需要将单例里面的信息保存到文件里面,要怎么操作?

可以写一个垃圾回收类,在最后调用析构来回收资源

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
// 懒汉 -- 一开始不创建对象,第一调用GetInstance再创建对象
class InfoMgr
{
public:
static InfoMgr* GetInstance()
{
// 还需要加锁,留着后面填坑
if (_spInst == nullptr)
{
_spInst = new InfoMgr;
}

return _spInst;
}

void SetAddress(const string& s)
{
_address = s;
}

string& GetAddress()
{
return _address;
}

// 实现一个内嵌垃圾回收类
class CGarbo {
public:
~CGarbo() {
if (_spInst)
delete _spInst;
}
};

// 定义一个静态成员变量,程序结束时,系统会自动调用它的析构函数从而释放单例对象
static CGarbo Garbo;//声明

private:
InfoMgr()
:_address("bilibili"),
_secretKey(1234)
{}

~InfoMgr()
{
// 假设析构时需要信息写到文件持久化
}
InfoMgr(const InfoMgr&) = delete;

string _address;
int _secretKey;

static InfoMgr* _spInst; // 声明
};

InfoMgr* InfoMgr::_spInst = nullptr; // 定义
InfoMgr::CGarbo Garbo;//定义

4.5 static单例

有人会采用下面的方式来实现懒汉的单例,其采用static对象,让编译器自动帮我们实现单例!

  • 全局static变量会在main之前初始化
  • 局部static变量会在第一次调用的时候初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Singleton
{
public:
static Singleton* GetInstance()
{
// 局部的静态对象,第一次调用时初始化
static Singleton _s;
return &_s;
}

private:
// 构造函数私有
Singleton(){};
// 拷贝构造/赋值重载取消
Singleton(Singleton const&) = delete;
Singleton& operator=(Singleton const&) = delete;
};

但是!这个操作并不通用,其取决于编译器和平台的实现。特别是在C++11之前;

C++11之后,保证了局部静态变量初始化时的线程安全,我们便可以采用这种办法来实现单例。

C++11中局部static变量的线程安全问题

但是!一定要确认你的代码只在C++11的环境下运行!!

5.不能被继承的类

C++98中,只需要将构造函数私有,派生类无法调用基类构造函数,也就无法继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// c++98,构造私有
class A {
public:
static A GetInstance()
{
return A();
}

private:
A()
{
_a = 0;
}
int _a;
};

而C++11中提供了一个关键字final,用这个关键字修饰类,就无法被继承

1
2
3
4
5
//C++11直接用关键字final
class B final
{
//...
};

结语

几个特殊类到这里就讲解结束辣,其中懒汉多线程加锁还留了一个坑,待后续我会回来更新补上的!

感谢你看到最后!