【C++】特殊类设计

樊梓慕 2024-08-26 13:35:11 阅读 92

👀樊梓慕:个人主页

 🎥个人专栏:《C语言》《数据结构》《蓝桥杯试题》《LeetCode刷题笔记》《实训项目》《C++》《Linux》《算法》

🌝每一个不曾起舞的日子,都是对生命的辜负


目录

前言

1.设计一个只能在堆上创建对象的类

2.设计一个只能在栈上创建对象的类

2.1提供静态成员函数创建对象

2.2屏蔽operator new和operator delete

3.设计一个不能被拷贝的类

4.设计一个不能被继承的类

5.设计一个只能创建一个对象的类(单例模式)

5.1饿汉模式

5.2懒汉模式

5.3单例对象的释放

5.4饿汉与懒汉的对比


前言

本篇文章目的在于让读者掌握常见特殊类的设计方式,以便未来开发。


欢迎大家📂收藏📂以便未来做题时可以快速找到思路,巧妙的方法可以事半功倍。 

=========================================================================

GITEE相关代码:🌟樊飞 (fanfei_c) - Gitee.com🌟

=========================================================================


设计特殊类的方法可以总结为:『 关大门,开小门』。 

1.设计一个只能在堆上创建对象的类

只能在堆上创建对象,具象化就是只能通过new操作符创建对象,方式如下:

将构造函数设置为私有,防止外部直接创建对象调用构造函数初始化。将拷贝构造函数设置为私有,并且只声明不实现,防止外部调用拷贝构造函数在栈上创建对象。提供一个静态的成员函数,在该静态成员函数中完成对象的创建。

代码如下:

<code>class HeapOnly

{

public:

//3、提供静态的成员函数用来在堆上创建对象

static HeapOnly* CreateObj()

{

return new HeapOnly;

}

private:

//1、将构造函数设置为私有

HeapOnly()

{}

//2、将拷贝构造函数设置为私有,并且只声明不实现

//C++98

HeapOnly(const HeapOnly&);

//C++11

//HeapOnly(const HeapOnly&) = delete;

};

为什么创建对象的函数必须是静态成员函数?

向外部提供的CreateObj函数必须设置为静态成员函数,因为外部调用该接口就是为了创建对象的,而非静态成员函数必须通过对象才能调用,所以必须是静态成员函数。

注意:C++98通过将拷贝构造函数声明为私有以达到防拷贝的目的,C++11可以在拷贝构造函数后面加上=delete,表示让编译器将拷贝构造函数删除,此时也能达到防拷贝的目的。


2.设计一个只能在栈上创建对象的类

2.1提供静态成员函数创建对象

同样的,我们可以将构造函数屏蔽掉,然后提供一个创建对象的静态成员函数用来专门创建对象。

代码如下:

class StackOnly

{

public:

//2、提供一个创建对象的静态成员函数

static StackOnly CreateObj()

{

return StackOnly();

}

private:

//1、将构造函数私有化

StackOnly()

{}

};

但这样解决不了下面的场景:

无法防止外部调用拷贝构造函数创建对象。

StackOnly obj1 = StackOnly::CreateObj();

static StackOnly obj2(obj1); //在静态区拷贝构造对象

StackOnly* ptr = new StackOnly(obj1); //在堆上拷贝构造对象

那可不可以将拷贝构造函数屏蔽掉呢?

不能,不能将拷贝构造函数设置为私有,也不能用=delete的方式将拷贝构造函数删除,因为当前是在栈上创建的对象,即CreateObj函数当中创建的是局部对象,返回局部对象的过程中势必需要调用拷贝构造函数。


2.2屏蔽operator new和operator delete

如果为了使对象创建在栈上,即避免对象在堆上创建(不让new)。 

那么我们可以从『 不让new』这一角度出发来实现特殊类设计。

回顾下new和delete的原理:

new在堆上申请空间实际分为两步,第一步是调用operator new函数申请空间,第二步是在申请的空间上执行构造函数,完成对象的初始化工作。delete在释放堆空间也分为两步,第一步是在该空间上执行析构函数,完成对象中资源的清理工作,第二步是调用operator delete函数释放对象的空间。

new和delete默认调用的是全局的operator new函数和operator delete函数,但如果一个类重载了专属的operator new函数和operator delete函数,那么new和delete就会调用这个专属的函数。

所以只要把operator new函数和operator delete函数屏蔽掉,那么就无法再使用new在堆上创建对象了。

代码如下:

class StackOnly

{

public:

StackOnly()

{}

private:

//C++98

void* operator new(size_t size);

void operator delete(void* p);

//C++11

void* operator new(size_t size) = delete;

void operator delete(void* p) = delete;

};

但是这种方法仍然避免不了在静态区创建对象:

static StackOnly obj; //在静态区创建对象

所以将以上两种方式结合,才能达到真正的只能在栈上创建对象的目的。


3.设计一个不能被拷贝的类

要让一个类不能被拷贝,就要让该类不能调用拷贝构造函数和赋值运算符重载函数,因此直接将该类的拷贝构造函数和赋值运算符重载函数设置为私有,或者用C++11的方式将这两个函数删除即可。

代码如下:

class CopyBan

{

public:

CopyBan()

{}

private:

//C++98

CopyBan(const CopyBan&);

CopyBan& operator=(const CopyBan&);

//C++11

CopyBan(const CopyBan&) = delete;

CopyBan& operator=(const CopyBan&) = delete;

};


4.设计一个不能被继承的类

将该类的构造函数设置为私有即可。

因为子类的构造函数被调用时,必须调用父类的构造函数初始化父类的那一部分成员,但父类的私有成员在子类当中是不可见的,所以在创建子类对象时子类无法调用父类的构造函数对父类的成员进行初始化,因此该类被继承后子类无法创建出对象。

代码如下:

class NonInherit

{

public:

static NonInherit CreateObj()

{

return NonInherit();

}

private:

//将构造函数设置为私有

NonInherit()

{}

};

这种方式其实该类可以被继承,只不过是无法实例化而已,所以在C++11后,引入了final关键字来修饰该类,表明该类是『 最终类』,不能被继承。

代码如下:

class NonInherit final

{

//...

};


5.设计一个只能创建一个对象的类(单例模式)

单例模式:一个类只能创建一个对象,即单例模式,该模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点, 该实例被所有程序模块共享。

本质就是将数据用一个类管理起来,控制数据只有一份。

单例模式有两种实现模式:

5.1饿汉模式

不管你将来用不用,在程序启动时就创建一个唯一的实例对象。

即进入main函数前就创建了实例对象(静态区相当于全局)。

class Singleton

{

public:

//提供静态成员函数获取对象地址

static Singleton* GetInstance()

{

return &_sint;

}

//防止拷贝

Singleton(Singleton const&) = delete;

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

private:

//构造私有

Singleton(int x = 0, int y = 0, const vector<string>& vstr = { "yyyyy","xxxx" })

:_x(x)

, _y(y)

, _vstr(vstr)

{}

// 想让一些数据,当前程序只有一份,那就可以把这些数据放到这个类里面

// 再把这个类设计成单例,这个数据就只有一份了

int _x;

int _y;

vector<string> _vstr;

// 静态成员对象,不存在对象中,存在静态区,相当于全局的,定义在类中,受类域限制

static Singleton _sint;

};

Singleton Singleton::_sint(1, 1, { "西瓜","桃子" });//实例化

饿汉模式的问题:

如果单例对象数据较多,构造初始化成本较高,会影响程序启动速度,迟迟进不了main函数;多个单例类有初始化启动依赖关系,饿汉模式无法控制。  

线程安全问题:

饿汉模式在程序运行主函数之前就完成了单例对象的创建,由于main函数之前是不存在多线程的,因此饿汉模式下单例对象的创建过程是线程安全的。后续所有多线程要访问这个单例对象,都需要通过调用GetInstance函数来获取,这个获取过程是不需要加锁的,因为这是一个读操作。当然,如果线程通过GetInstance获取到单例对象后,要用这个单例对象进行一些线程不安全的操作,那么这时就需要加锁了。


5.2懒汉模式

懒汉模式完美解决了饿汉的问题。

懒汉模式即延迟加载。

class Singleton

{

public:

static Singleton* GetInstance()

{

// 第一次调用时,创建单例对象

// 线程安全问题,需要加锁

if (_psint == nullptr)

{

_psint = new Singleton;

}

return _psint;

}

static void DelInstance()

{

if (_psint)

{

delete _psint;

_psint = nullptr;

}

}

Singleton(Singleton const&) = delete;

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

private:

Singleton(int x = 0, int y = 0, const vector<string>& vstr = { "yyyyy","xxxx" })

:_x(x)

, _y(y)

, _vstr(vstr)

{}

// 想让一些数据,当前程序只有一份,那就可以把这些数据放到这个类里面

// 再把这个类设计成单例,这个数据就只有一份了

int _x;

int _y;

vector<string> _vstr;

// 静态成员对象,不存在对象中,存在静态区,相当于全局的,定义在类中,受类域限制

// 声明

static Singleton* _psint;

// 内部类,为了防止忘记释放单例

class GC

{

public:

~GC()

{

Singleton::DelInstance();

}

};

//声明

static GC gc;

};

//定义

Singleton* Singleton::_psint = nullptr;

Singleton::GC Singleton::gc;

需要注意的是:GetInstance函数第一次调用时需要对static指针进行写入操作,这个过程不是线程安全的,因为多个线程可能同时调用GetInstance函数,如果不对这个过程进行保护,此时这多个线程就会各自创建出一个对象,即线程安全问题。 

所以我们需要对其加锁,毋庸置疑我们肯定是想要在if前面加锁的,但是这里我们可以设计一个双检查加锁的方案,即:

class Singleton

{

public:

static Singleton* GetInstance()

{

// 第一次调用时,创建单例对象

// 线程安全问题,需要加锁

//双检查

if(_psint == nullptr)

{

unique_lock<mutex> lock(_mtx);

if (_psint == nullptr)

{

_psint = new Singleton;

}

}

return _psint;

}

static void DelInstance()

{

if (_psint)

{

delete _psint;

_psint = nullptr;

}

}

Singleton(Singleton const&) = delete;

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

private:

Singleton(int x = 0, int y = 0, const vector<string>& vstr = { "yyyyy","xxxx" })

:_x(x)

, _y(y)

, _vstr(vstr)

{}

// 想让一些数据,当前程序只有一份,那就可以把这些数据放到这个类里面

// 再把这个类设计成单例,这个数据就只有一份了

int _x;

int _y;

vector<string> _vstr;

// 静态成员对象,不存在对象中,存在静态区,相当于全局的,定义在类中,受类域限制

// 声明

static Singleton* _psint;

static mutex _mtx; //互斥锁

// 内部类,为了防止忘记释放单例

class GC

{

public:

~GC()

{

Singleton::DelInstance();

}

};

//声明

static GC gc;

};

//定义

Singleton* Singleton::_psint = nullptr;

Singleton::GC Singleton::gc;

mutex Singleton::_mtx; //初始化互斥锁

对GetInstance函数中创建单例对象的过程进行保护,本质就是需要引入互斥锁,最简单的加锁方式就是在进行if判断之前加锁,在整个if语句之后进行解锁。但实际只有GetInstance函数第一次被调用,创建单例对象时需要使用互斥锁进行保护,而后续调用GetInstance函数获取单例对象只是一个读操作,是不需要使用互斥锁进行保护的。如果简单的将加锁解锁操作放到if语句前后,那么在后续调用GetInstance函数获取已经创建好的单例对象时,就会进行大量无意义的加锁解锁操作,导致线程不断切入切出,进而影响程序运行效率。对于这种只有第一次需要加锁保护的场景可以使用双检查加锁,双检查就是在当前加锁和解锁的外面再进行一次if判断,判断static指针是否为空。这样一来,后续调用GetInstance函数获取已经创建好的单例对象时,外层新加的if判断就会起作用,这样就避免了后续无意义的加锁解锁操作。


在C++11之后,懒汉模式可以这样实现:

class Singleton

{

public:

static Singleton* GetInstance()

{

//局部静态对象

static Singleton _sinst;

return &_sinst;

}

Singleton(Singleton const&) = delete;

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

private:

Singleton(int x = 0, int y = 0, const vector<string>& vstr = { "yyyyy","xxxx" })

:_x(x)

, _y(y)

, _vstr(vstr)

{}

int _x;

int _y;

vector<string> _vstr;

};

在GetInstance方法中的局部静态对象会在第一次调用GetInstance函数时被创建出来,而又因为该对象是静态的,生命周期为全局,所以利用这种方式也就实现了懒汉模式。

并且这里单例对象的定义过程是线程安全的,因为现在的C++标准保证多线程初始化static变量不会发生数据竞争,可以视为原子操作

我们推荐懒汉按这样设计,但是在考虑到一些大的单例对象,并不适合。

原因有以下两点:

单例对象定义在静态区,太大的单例对象会占用较大静态区空间。单例对象创建在静态区后没办法主动释放。


5.3单例对象的释放

单例对象创建后一般在整个程序运行期间都可能会使用,所以我们可以不考虑单例对象的释放,程序正常结束时会自动将资源归还给操作系统。

如果要考虑单例对象的释放,可以参考以下两种方式:

第一种方式:在单例类中编写一个DelInstance函数,在该函数中进行单例对象的释放动作,当不再需要该单例对象时就可以主动调用DelInstance释放单例对象。

代码如下:

static void DelInstance()

{

if (_psint)

{

delete _psint;

_psint = nullptr;

}

}


第二种方式:在单例类中实现一个私有内嵌的垃圾回收类,在垃圾回收类的析构函数中完成单例对象的释放。在单例类中定义一个静态的垃圾回收类对象,当单例对象生命周期结束就会调用垃圾回收对象的析构函数,这时便对单例对象进行了释放,代码在懒汉模式中。

当然这种方式也有如下两个缺点:

单例对象定义在静态区,因此太大的单例对象不适合使用这种方式。单例对象创建在静态区后没办法主动释放。


5.4饿汉与懒汉的对比

饿汉模式的优点就是简单,但是它的缺点也比较明显。

饿汉模式在程序运行主函数之前就会创建单例对象,如果单例类的构造函数中所做的工作比较多,就会导致程序迟迟无法进入主函数,在外部看来就好像是程序卡住了。此外,如果有多个单例类需要创建单例对象,并且它们之间的初始化存在某种依赖关系,比如单例对象A的创建必须在单例对象B之后,此时饿汉模式也会存在问题,因为我们无法保证这多个单例对象中的哪个对象先创建。

懒汉模式的缺点就是,在编码上比饿汉模式复杂,在创建单例对象时需要考虑线程安全的问题。

但懒汉模式就能很好的解决上述饿汉模式的缺点,因为懒汉模式并不是一开始就完成单例对象的创建,因此不会导致程序迟迟无法进入主函数,并且懒汉模式中各个单例对象创建的顺序是由各个单例类中的GetInstance函数第一次被调用的顺序决定,因此是可控制的。


=========================================================================

如果你对该系列文章有兴趣的话,欢迎持续关注博主动态,博主会持续输出优质内容

🍎博主很需要大家的支持,你的支持是我创作的不竭动力🍎

🌟~ 点赞收藏+关注 ~🌟

=========================================================================



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。