C++进阶--特殊类设计

一、前言

   在C++类与对象中,C++98中,一个空类中编译器会默认生成六个成员函数,分别是构造函数、析构函数、拷贝构造函数、赋值运算符重载函数、普通对象和const对象取地址重载函数;在C++11中增加了移动构造和移动赋值。而对于一些特殊的类,当用户未显示生成相应的方法时,我们也不希望编译器生成该方法。所以每当设计一个类的时候都得根据用户的需求来进行相应的设计。

二、设计一个类,不能被拷贝

   一个类中会发生拷贝构造的情景有:拷贝构造函数和赋值运算符重载。要使一个类中不可发生拷贝,则只需让该类中的拷贝构造函数和赋值运算符重载不可被调用即可。

2.1 C++98方式

  • 将拷贝构造函数与赋值运算符重载函数只声明而不进行定义,且将其访问权限设置为私有。
  • 设置私有的原因:如果只声明没有设置成private,用户自己如果在类外定义了,就不能禁止拷贝了。
  • 只声明不定义:不定义是因为该函数根本不会调用,定义了其实也没有什么意义,不写反而还简单,而且如果定义了就不会防止成员函数内部拷贝了。
class CopyBan
{
	// ...
private:
	CopyBan(const CopyBan&);
	CopyBan& operator=(const CopyBan&);
	//...
};

2.2 C++11方式

  • C++扩展delete的用法,delete除了释放new申请的资源外,如果在默认成员函数后跟上=delete,表示让编译器删除掉该默认成员函数。
class CopyBan
{
	// ...
	CopyBan(const CopyBan&)=delete;
	CopyBan& operator=(const CopyBan&)=delete;
	//...
};

三、设计一个类,只能在堆上创建对象

1、将类的构造函数私有,拷贝构造声明成私有,防止别人调用拷贝在栈上生成对象
2、提供一个静态的成员函数,在该静态成员函数中完成堆对象的创建(new一个对象返回)

为什么要是静态的成员函数?

  • 类的普通成员函数需要对象去调用,而静态的成员函数可以通过类域访问,不需要通过对象访问。
//思路1:C++98
class HeapOnly
{
public:
	void Destroy()
	{
		delete this;
	}

private:
	~HeapOnly()
	{
		cout << "~HeapOnly()" << endl;
	}

	int _x;
};

int main()
{
	/*HeapOnly ho1;
	static HeapOnly ho2;*/

	HeapOnly* pho3 = new HeapOnly;
	pho3->Destroy();

	return 0;
}
//思路2:C++11
class HeapOnly
{
public:
	static HeapOnly* CreateObj(int x = 0)
	{
		HeapOnly* p = new HeapOnly(x);
		return p;
	}

private:
	HeapOnly(int x=0)
		:_x(x)
	{}

	HeapOnly(const HeapOnly& hp) = delete;
	HeapOnly& operator=(const HeapOnly& hp) = delete;

	int _x;
};


int main()
{
	/*HeapOnly ho1;
	static HeapOnly ho2;

	HeapOnly* pho3 = new HeapOnly;*/

	HeapOnly* p1=HeapOnly::CreateObj(1);
	//HeapOnly p2(*p1);

	return 0;
}

注意:上面存在一个小小的细节需要我们注意的

HeapOnly* p1=HeapOnly::CreateObj(1);
HeapOnly p2(*p1);

   使用p1指针的内容拷贝构造对象,此时拷贝的对象是在栈上的,所以我们也要把拷贝构造函数搞成私有或者delete掉
说明

  • 将拷贝构造函数设置为私有,并且只声明不实现,防止外部调用拷贝构造函数在栈上创建对象。
  • 向外部提供的CreateObj函数必须设置为静态成员函数,因为外部调用该接口就是为了获取对象的,而非静态成员函数必须通过对象才能调用,这就变成鸡生蛋蛋生鸡的问题了。
  • C++98通过将拷贝构造函数声明为私有以达到防拷贝的目的,C++11可以在拷贝构造函数后面加上**=delete**,表示让编译器将拷贝构造函数删除,此时也能达到防拷贝的目的。

四、设计一个类,只能在栈上创建对象

   将构造函数私有化,然后设计静态方法创建对象返回即可(返回匿名对象)

1.将构造函数设置为私有,防止外部直接调用构造函数在堆上创建对象。
2.向外部提供一个获取对象的static接口,该接口在栈上创建一个对象并返回。

class StackOnly
{
public:
    //提供一个获取对象的接口,并且该接口必须设置为静态成员函数
	static StackOnly CreatorObj()
	{
		return StackOnly();//返回匿名对象
	}
private:
	StackOnly()
		:_a(0)
	{}

	int _a;
};

   上面的代码同样有Bug,我们仍然可以创建不在栈上的对象:无法防止外部调用拷贝构造函数创建对象。

class StackOnly
{
public:
	static StackOnly CreatorObj()
	{
		return StackOnly();//返回匿名对象
	}
private:
	StackOnly()
		:_a(0)
	{}

	int _a;
};
int main()
{
	StackOnly obj = StackOnly::CreatorObj();//在静态区拷贝构造对象
	StackOnly* ptr = new StackOnly(obj); //此时调用的是拷贝构造去new一个对象, ptr指向的对象在堆上创建, -在堆上拷贝构造对象
	return 0;
}

所以该如何解决呢?能不能按照上面的方法,把拷贝构造函数写成私有或者delete呢?

   但是我们不能将构造函数设置为私有,也不能用=delete的方式将拷贝构造函数删除,因为CreatorObj函数返回的时候是传值返回,要拷贝构造一个临时对象返回。
   既然拷贝构造函数不能封死,那就封死new吧,让用户无法使用new

方法:屏蔽new

   因为new在底层调用void* operator new(size_t size)函数,一个类可以重载它专属的operator new,如果没有专属的operator new,new一个对象时就去调用全局的。如果一个类重载了operator new,通过new创建该类对象时,就去调用专属的operator 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:
	static StackOnly CreatorObj()
	{
		return StackOnly();//返回匿名对象
	}
	//C++11的方式
	void* operator new(size_t size) = delete;
	void operator delete(void* p) = delete;
private:
	//C++98的方式:私有化
	//void* operator new(size_t size){}
	//void operator delete(void* p) {}
	StackOnly()
		:_a(0)
	{}

	int _a;
};

五、设计一个类,不能被继承

5.1 C++98方式

   将父类的构造函数私有化,子类对象创建的时候,需要先调用父类的构造函数初始化父类的部分,然后再调用子类的构造函数初始化,但父类的私有成员在子类当中是不可见的,所以在创建子类对象时子类无法调用父类的构造函数对父类的成员进行初始化,因此该类被继承后子类无法创建出对象,这样做了之后派生类中调不到基类的构造函数,则无法实现继承。

class Base
{
private:
	Base()
		:_a(0)
	{}

	int _a;
};

5.2 C++11方式

   C++98的这种方式其实不够彻底,因为这个类仍然可以被继承(编译器不会报错),只不过被继承后无法实例化出对象而已。于是C++11中提供了final关键字,被final修饰的类叫做最终类,最终类无法被继承,此时就算继承后没有创建对象也会编译出错。
   final关键字:final修饰类,表示该类不能被继承

class Base final	//子类继承的时候就会报错了:不能将final类类型用作基类
{
private:
	Base()
		:_a(0)
	{}

	int _a;
};

六、设计一个类,只能创建一个对象(单例模式)

设计模式
设计模式是一套被反复使用、多数人知晓的、经过分类的、代码设计经验的总结。目的是为了代码重用性、让代码更容易被他人理解、保证代码可靠性。 其中单例模式、工厂模式、观察者模式使用得比较多

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

单例模式的两种实现模式:饿汉模式和懒汉模式

6.1 饿汉模式

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

单例模式的饿汉实现方式如下

1.将构造函数设置为私有,并将拷贝构造函数和赋值运算符重载函数设置为私有或删除,防止外部创建或拷贝对象。
2.提供一个全局的static单例对象,并在程序入口之前完成单例对象的初始化。
3.提供一个静态函数用于获取单例对象。

//饿汉模式:一开始(main函数之前)就创建对象
class Singleton
{
public:
	static Singleton* GetInstance()
	{
		return _ins;
	}

	void Add(const string& str)
	{
		_mtx.lock();

		_v.push_back(str);
		/*++_n;*/

		_mtx.unlock();
	}

	void Print()
	{
		_mtx.lock();

		for (auto& e : _v)
		{
			cout << e << endl;
		}
		cout << endl;

		_mtx.unlock();
	}

private:
	//限制类外面随意创建对象
	Singleton()
	{}
 
 //防拷贝
	Singleton(const Singleton& s) = delete;
	Singleton& operator=(const Singleton& s) = delete;

private:
	mutex _mtx;
	/*int _n = 0;*/
	vector<string> _v;

	static Singleton* _ins;
};

Singleton* Singleton::_ins = new Singleton;

饿汉模式的优缺点:
优点:简单
缺点:可能会导致进程启动慢,且如果有多个单例类对象实例启动顺序不确定

  • 如果这个单例对象在多线程高并发环境下频繁使用,性能要求较高,那么显然使用饿汉模式来避免资源竞争,提高响应速度更好。

6.2 懒汉模式

   如果单例对象构造十分耗时或者占用很多资源,比如加载插件,初始化网络连接,读取文件,而有可能该对象程序运行时不会用到,那么也要在程序一开始就进行初始化,就会导致程序启动时非常的缓慢,所以这种情况使用懒汉模式(延迟加载)更好。

单例模式的懒汉实现方式如下

1.将构造函数设置为私有,并将拷贝构造函数和赋值运算符重载函数设置为私有或删除,防止外部创建或拷贝对象。
2.提供一个指向单例对象的static指针,并在程序入口之前先将其初始化为空。
3.提供一个全局访问点获取单例对象。

class Singleton
{
public:
	static Singleton* GetInstance()
	{
		//双检查加锁
		if (_ins == nullptr)        //提高效率,不需要每次获取单例都加锁解锁
		{
			_imtx.lock();

			if (_ins == nullptr)     //保证线程安全和只new一次
			{
				_ins = new Singleton;
			}
			_imtx.unlock();
		}
		return _ins;
	}

	//一般全局都要使用单例对象,所以单例对象一般不需要显示释放
	//有些特殊场景,想显示释放一下
	static void DelInstance()
	{
		_imtx.lock();
		if (_ins)
		{
			delete _ins;
			_ins = nullptr;
		}
		_imtx.unlock();
	}

	//内部类:单例对象回收
	class GC
	{
	public:
		~GC()
		{
			DelInstance();
		}
	};

	static GC _gc;


	void Add(const string& str)
	{
		_vmtx.lock();

		_v.push_back(str);
		/*++_n;*/

		_vmtx.unlock();
	}

	void Print()
	{
		_vmtx.lock();

		for (auto& e : _v)
		{
			cout << e << endl;
		}
		cout << endl;

		_vmtx.unlock();
	}

	~Singleton()
	{
		//持久化
		//比如要求程序结束时,将数据写到文件,单例对象析构时持久化就比较好

	}

private:
	//限制类外面随意创建对象
	Singleton()
	{}

	//防拷贝
	Singleton(const Singleton& s) = delete;
	Singleton& operator=(const Singleton& s) = delete;

private:
	mutex _vmtx;
	/*int _n = 0;*/
	vector<string> _v;

	static Singleton* _ins;
	static mutex _imtx;
};

Singleton* Singleton::_ins = nullptr;
mutex Singleton::_imtx;

Singleton::GC Singleton::_gc;

int main()
{
	///*Singleton s1;
	//static Singleton s2;*/

	//Singleton::GetInstance()->Add("张三");
	//Singleton::GetInstance()->Add("李四");
	//Singleton::GetInstance()->Add("王五");

	//Singleton::GetInstance()->Print();

	srand(time(0));

	int n = 30;
	thread t1([n]() {
		for (size_t i = 0; i < n; ++i)
		{
			Singleton::GetInstance()->Add("t1线程:" + to_string(rand()));
		}
		});

	thread t2([n]() {
		for (size_t i = 0; i < n; ++i)
		{
			Singleton::GetInstance()->Add("t2线程:" + to_string(rand()));
		}
		});

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

	Singleton::GetInstance()->Print();

	Singleton::GetInstance();

	//Singleton s(*Singleton::GetInstance());

	return 0;
}

双检查枷锁:

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

优点:第一次使用实例对象时,创建对象,进程启动无负载,多个单例实例启动顺序自由控制。
缺点:复杂。

6.3 饿汉模式和懒汉模式对比

饿汉模式
优点:简单,提前准备好单例对象
缺点1:无法控制单例创建初始化顺序

  • 如果有多个单例类需要创建单例对象,并且它们之间的初始化存在某种依赖关系,比如单例对象A的创建必须在单例对象B之后,此时饿汉模式也会存在问题,因为我们无法保证这多个单例对象中的哪个对象先创建

缺点2:因为饿汉模式在程序运行主函数之前就会创建对象,如果单例类的构造函数中所做的工作比较多,就会导致程序迟迟无法进入主函数,在外部看来就好像是程序卡住了。

懒汉模式
优点:解决上述饿汉模式的缺点

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

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

  • 26
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值