【C++】特殊类设计

文章目录

在某些特殊的场景下,我们需要设计一些特殊的类,以下是一些场景的特殊的类

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

我们知道,拷贝只会发生在两个场景中:拷贝构造函数以及赋值运算符重载,因此想要让一个类禁止拷贝,只需让该类不能调用拷贝构造函数以及赋值运算符重载即可

C++98方式

将拷贝构造函数与赋值运算符重载只声明不定义,并且将其访问权限设置为私有即可。设置为私有,这样类外面就不能调用拷贝构造函数来构造对象了,但是还是可以在类中调用拷贝构造函数来构造对象,因为在类中不受访问限定符的限制,此时可能会导致需要进行深拷贝的类在拷贝构造时只完成了浅拷贝从而导致运行时崩溃,因为这样会导致同一块空间析构两次,但是我们只声明不定义,这样即使爱类中调用了拷贝构造函数,编译器就会在链接时检查出来,此时符号表的合并与重定位失败。

cpp 复制代码
//设计一个类,不能被拷贝
class CopyBan
{
	// ...

private:
	CopyBan(const CopyBan&);
	CopyBan& operator=(const CopyBan&);
	//...
};

设置成私有:如果只声明没有设置成private,用户自己如果在类外定义了,就可以不能禁止拷贝了

只声明不定义:不定义是因为该函数根本不会调用,定义了其实也没有什么意义,不写反而还简单,而且如果定义了就不会防止成员函数内部拷贝了。

C++11方式

C++11扩展了delete的用法,delete除了释放new申请的资源外,如果在默认成员函数后跟上=delete,表示让编译器删除掉该默认成员函数。此外,这个时候我们也不需要把拷贝构造函数和赋值重载函数定义为私有了

cpp 复制代码
class CopyBan
{
	// ...
	CopyBan(const CopyBan&) = delete;
	CopyBan& operator=(const CopyBan&) = delete;
	//...
};

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

C++98

将父类的构造函数设置为私有,这样子类就无法调用父类的构造函数来完成父类的成员变量的初始化,从而达到父类无法被继承的效果

cpp 复制代码
// C++98中构造函数私有化,派生类中调不到基类的构造函数。则无法继承
class NonInherit
{
public:
	static NonInherit GetInstance()
	{
		return NonInherit();
	}
private:
	NonInherit()
	{}
};

C++11

C++11提供了final关键字,被修饰的类不能被继承

cpp 复制代码
class A  final
{
	// ....
};

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

一般来说,C++中的对象可以在三个位置进行创建:

1.在栈上创建对象,对象出了局部作用域自动销毁

2.通过new关键字在堆上创建对象,对象出了局部作用域不会自动销毁,需要我们手动进行 释放(delete),如果不进行手动释放,则该资源就会在进程退出的时候即main函数结束之后自动销毁

3.通过该=static关键字在静态区(已初始化全局数据区)创建对象,对象的作用域为定义时所在的作用域,而对象的声明周期为整个进程,直到进程结束即在main函数调用结束之后由操作系统进行回收

设计一个类,只能在栈上创建对象,我们有两种方式:

1.在类中禁用operator new 和operator delete函数

new和delete是C++的关键字,其底层是调用operator new 混合operator delete函数来开辟和释放空间,如果类中没有重载operator new和operator delete函数,那么new和delete就会去调用全局的operator new和operator delete函数,需要注意的是,这两个函数是普通的全局函数,而不是运算符重载,只不过他们的函数名是这样

所以,我们可以在类中重载operator new和operator delete函数,然后将它们声明为删除函数,这样就不能通过new和delete在堆上创建和销毁对象了,但是这样有一个缺点,我们只是禁止了在堆上创建对象,但是我们仍然可以在静态区创建对象。

然后我们再提供一个CreateObj()成员函数,通过这个函数来创建一个在栈上的

cpp 复制代码
class StackOnly
{
public:
	void* operator new(size_t size) = delete;
	void operator delete(void* p) = delete;
};

2.将构造函数设置为私有,提供一个在栈上创建对象的静态成员函数

我们将构造函数设置为私有,这样在类的外面就不能随便创建对象了,但是我们可以提供一个CreateObj()成员函数,由于在类中不受访问限定符的限制,所以我们可以调用构造函数来创建一个在栈上的对象并返回

但是CreateObj()函数必须是静态的,因为如果是普通的成员函数,则其中第一个参数是隐藏的this指针,所以想要调用这个函数就必须先创建一个对象,然而在构造函数私有的情况下,我们是不能在类的外面通过其他的 方式创建对象的,这个就会出现先有鸡还是先有蛋的问题,调用这个函数需要创建对象,而调用这个函数的目的就是为了创建对象。而静态成员函数没有this指针,所以可以通过类名+域作用限定符的方式进行调用,而不需要通过对象来进行调用

最后,我们还需要删除拷贝构造函数,防止通过拷贝的方式来创建在栈上的对象和在静态区的对象

cpp 复制代码
class StackOnly
{
public:
	static StackOnly CreateObj()
	{
		return StackOnly();
	}
private:
	StackOnly()
	{}
	StackOnly(const StackOnly&) = delete;
};

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

设计一个类,只能在堆上创建对象,有以下两种方式:

1.将类的构造函数私有,拷贝构造声明成私有,或者直接将拷贝构造函数删除,防止别人调用拷贝在栈上生成对象。提供一个静态的成员函数,在该静态成员函数中完成堆对象的创建

cpp 复制代码
// 只能在堆上创建对象的类
class HeapOnly
{
public:
	static HeapOnly* CreateObj()
	{
		return new HeapOnly;
	}
private:
	HeapOnly()
	{}

	HeapOnly(const HeapOnly&) = delete;
};

2.将析构函数设置为私有,提供一个专门的成员函数,在该成员函数中完成对对象的析构

对于在栈上创建的对象来说,对象出了局部作用域就会自动调用析构函数完成对对象资源的清理工作,对于静态区的对象来说,它也会在main函数调用完毕之后自动调用析构函数进行析构,如果我们将析构函数设置为私有,那么在定义此类对象的时候编译器会自动报错

而对于在堆上创建的对象来说,编译器不会主动调用析构函数来回收其资源,而是由用户手动进行delete或进程退出后由操作系统来进行回收,所以编译器不会报错,但是需要注意的是,对于自定义类型的对象,delete会首先调用其析构函数完成对象的资源清理工作,然后再调用operator delete释放对象的空间,所以我们这里不能使用delete关键字来手动释放new处理的对象,因为析构函数已经被设置成了私有,在类的外面无法访问

所以我们需要一个Destory成员函数,通过它来调用析构函数来完成对资源的清理工作,此外,Destory不需要定义为静态成员函数,因为只有类的对象才需要调用它。最后,我们也不需要将拷贝构造函数进行删除,因为拷贝构造出来的栈对象或静态对象仍然无法调用析构函数,但是删除了也没有影响

cpp 复制代码
class HeapOnly
{
public:
	HeapOnly()
	{}

	void Destory()
	{
		this->~HeapOnly();
	}

private:
	~HeapOnly()
	{}

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

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

设计模式

设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类的、代码设计经验的总结 。为什么会产生设计模式这样的东西呢?就像人类历史发展会产生兵法。最开始部落之间打仗时都是人拼人的对砍。后来春秋战国时期,七国之间经常打仗,就发现打仗也是有套路的,后来孙子就总结出了《孙子兵法》。孙子兵法也是类似。

使用设计模式的目的:为了代码可重用性、让代码更容易被他人理解、保证代码可靠性。 设计模式使代码编写真正工程化;设计模式是软件工程的基石脉络,如同大厦的结构一样。

单例模式

之前我们已经接触过一些模式,比如迭代器模式,配接器/适配器模式

一个类只能创建一个对象,即单例模式,该模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,这种方式简化了在复杂环境下的配置管理。

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

饿汉模式

饿汉模式就是说不管你将来用不用,程序启动时就创建一个唯一的实例对象。

饿汉模式就是将构造函数私有,然后删除拷贝构造函数和赋值重载函数,由于单例模式全局只能允许有一个唯一的对象,所以我们可以定义一个静态类的对象作为类的成员,然后提供一个GetInstance函数来获取这个静态类的对象,需要注意的是,类的静态成员是属于整个类的,并且静态成员函数只能在类中声明,在类外定义,定义时需要指定类域,同时我们是通过GetInstance接口来获取这个唯一的对象,所以GetInstance也必须是静态函数

饿汉模式的特点就是在类加载的时候就创建单例对象,因此其实例化在程序运行之前(main函数调用之前)就已经完成,饿汉模式的实现如下:

cpp 复制代码
class Singleton
{
public:
	static Singleton& GetInstance()
	{
		return _sins;
	}

	Singleton(const Singleton& sin) = delete;
	Singleton& operator=(const Singleton& sin) = delete;
private:
	Singleton()
	{}
	// 类静态成员的声明
	static Singleton _sins;
};
// 类静态成员的定义
Singleton Singleton::_sins;

由于饿汉模式的对象在main函数前就被创建,所以它不存在线程安全的问题,但是它也存在以下的一些问题:

1.有的单例对象构造十分耗时或者需要占用很多资源,比如加载插件,连接数据库,初始化网络,读取文件等等,所以就会导致程序启动的时间过长

2.饿汉模式在程序启动时就创建了单例模式对象,并且是全局的,所以它会一直存在中,在没有用到时,就会有一定的内存消耗

3.当多个单例对象在初始化存在依赖关系的时候,饿汉模式就无法控制,比如A,B两个单例模式存在于不同的文件中,我们要求先初始化A,再初始化B,但是A和B谁先初始化是由操作系统自动进行调度控制的,我们无法进行控制

懒汉模式

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

cpp 复制代码
class Singleton
{
public:
	static Singleton& GetInstance()
	{
        if(_psins == nullptr)
            _psins = new Singleton;
        return *_psins;
	}

	Singleton(const Singleton& sin) = delete;
	Singleton& operator=(const Singleton& sin) = delete;
private:
	Singleton()
	{}
	// 类静态成员的声明
	static Singleton* _psins;
};
// 类静态成员的定义
Singleton Singleton::_psins = nullptr;

由于懒汉模式是在第一次使用单例对象时才去创建单例对象,所以就不存在程序启动加载慢以及不使用对象浪费系统资源的问题,同时,我们也可以通过在程序中先使用A对象再使用B对象的方式来控制有初始化依赖关系的单例对象初始化顺序问题

懒汉模式的线程安全问题与双检查加锁

但是懒汉模式也引入了一下新的问题--单例对象的创建是线程不安全的。对于饿汉模式来说,由于其单例对象是在程序运行之前就已经创建好了,所以程序运行过程中我们直接获取该对象即可,不用再去创建对象,所以不存在对象创建的线程安全问题。但是对于懒汉模式来说,其单例对象是在第一次使用时才创建的,那么在多线程模式下,就有可能存在多个线程并行/并发的去执行_psin = new Singleton语句,从而导致前面创建出来的单例对象指针被后面覆盖,最终发生内存泄漏

所以我们需要对判断单例模式是否创建以及创建单例模式的过程进行加锁,这个时候我们就需要增加一个锁的成员,因为只需要一个并且是对同一个进行加锁,所以也应该是静态的,代码如下:

cpp 复制代码
class Singleton
{
public:
	// 第一次获取单例对象的时候创建对象
	static Singleton& GetInstance()
	{
		_smtx.lock();
		if (_psins == nullptr)
		{
			_psins = new Singleton;
		}
		_smtx.unlock();

		return *_psins;
	}

	Singleton(const Singleton& sin) = delete;
	Singleton& operator=(const Singleton& sin) = delete;
private:
	Singleton()
	{}
	// 类静态成员的声明
	static Singleton* _psins;
	static std::mutex _smtx;
};
// 类静态成员的定义
Singleton* Singleton::_psins = nullptr;
mutex Singleton::_smtx;

虽然上面的代码已经可以解决懒汉单例模式对象创建时的线程安全问题了,但是还可以优化一下,因为每次创建对象的时候都需要进行加锁和解锁,从而造成性能上的消耗,由于只有第一次调用单例对象时_psins才为空,所以其实_smtx真正有意义的只有一次,即第一次创建对象时,但是我们却每次都要先加锁才能对_psins的状态进行判断。为了避免这种性能消耗,我们可以使用双检查加锁机制,通过双检查,我们可以在兼顾效率的同时,保证懒汉模式的线程安全,代码如下:

cpp 复制代码
static Singleton& GetInstance()
{
	if (_psins == nullptr)
	{
		_smtx.lock();
		if (_psins == nullptr)
		{
			_psins = new Singleton;
		}
		_smtx.unlock();
	}

	return *_psins;
}

注意:

1.懒汉模式相当于共享资源,它被当前进程下的所有线程共享,所有不仅仅创建单例对象的过程是不安全的,访问单例对象数据的过程也是不安全的

2.只是单例模式对象创建的线程安全问题我们可以通过加锁来保证,而单例对象数据的线程安全则只能由用户手动加锁进行保护,此时我们可以使用只能指针来解决问题

3.linux提供了线程同步与互斥机制来保证共享资源的安全,具体来说,我们可以通过对共享资源访问过程进行加锁来保证该资源只能多个线程串性访问,同时,为了避免某一线程竞争的能力过强或持续的申请加锁,linux又提供了条件变量,最后,为了能够在不访问共享资源的前提下就能掌握共享资源的使用情况,从而高效的对共享资源进行管理和分配,linux又提供了信号量

封装RAII实现对加锁解锁的自动管理

对于上面的代码,当第一次创建单例对象失败,即new失败抛异常时,程序会因为互斥锁lock之后没有unlock而崩溃,这个问题我们可以通过try-catch的方式来进行解决,但是更好的办法还是通过智能指针的防止,即专门封装一个用于管理锁的类,代码如下:

cpp 复制代码
// 专门对加锁解锁进行管理的类
template<class Lock>
class LockGuard
{
public:
	LockGuard(Lock& lk)
		:_lk(lk)
	{
		_lk.lock();
	}

	~LockGuard()
	{
		_lk.unlock();
	}
private:
	Lock& _lk;;//这里只能使用引用,因为锁不允许进行拷贝
};
class Singleton
{
public:
	// 多个线程一起调用GetInstance,存在线程安全的风险,
	//static Singleton& GetInstance()
	//{
	//	// 第一次获取单例对象的时候创建对象
	//	// 双检查加锁
	//	if (_psins == nullptr)  // 对象new出来以后,避免每次都加锁的检查,提高性能
	//	{
	//		// t1  t2
	//		_smtx.lock();

	//		try
	//		{
	//			if (_psins == nullptr)  // 保证线程安全且只new一次
	//			{
	//				_psins = new InfoSingleton;
	//			}
	//		}
	//		catch (...)
	//		{
	//			_smtx.unlock();
	//			throw;
	//		}

	//		_smtx.unlock();
	//	}

	//	return *_psins;
	//}

	static Singleton& GetInstance()
	{
		// 第一次获取单例对象的时候创建对象
		// 双检查加锁
		if (_psins == nullptr)  // 对象new出来以后,避免每次都加锁的检查,提高性能
		{
			// t1  t2
			//LockGuard<mutex> lock(_smtx);
			std::lock_guard<mutex> lock(_smtx);

			if (_psins == nullptr)  // 保证线程安全且只new一次
			{
				_psins = new Singleton;
			}	
		}

		return *_psins;
	}

	Singleton(const Singleton& sin) = delete;
	Singleton& operator=(const Singleton& sin) = delete;
private:
	Singleton()
	{}
	// 类静态成员的声明
	static Singleton* _psins;
	static std::mutex _smtx;
};
// 类静态成员的定义
Singleton* Singleton::_psins = nullptr;
mutex Singleton::_smtx;

这里需要注意的是,在LockGuard类中,_lk成员变量的类型必须是引用类型,因为锁是不允许拷贝的:

同时,mutex库中其实提供了LockGuard类,我们可以直接使用库中的lock_guard类来对加锁解锁来进行自动管理

单例对象的资源释放与保存问题

一般来说,单例对象都是不需要考虑释放的,因为不管是饿汉模式还是懒汉模式,单例对象都是全局的,全局资源在进程结束后会被自动回收(进程退出后操作系统会解除进程地址空间与物理空间的映射),但是我们也可以对其进行手动的回收,需要注意的是,有时我们需要在回收资源之前将资源的相关数据保存在文件中,这种情况下我们就必须手动回收了。

我们可以在类中定义一个静态的DelInstance接口来回收与保存资源(该函数不会被频繁的调用,所以不比加锁),代码如下:

cpp 复制代码
// 一般单例对象不需要考虑释放
// 单例对象不用时,必须手动处理,一些资源需要保存
// 可以手动调用主动回收
// 也可以让他自己在程序结束时,自动回收
static void DelInstance()
{
	// 保存数据到文件
	// ...

	std::lock_guard<mutex> lock(_smtx);
	if (_psins)
	{
		delete _psins;
		_psins = nullptr;
	}
}

其次,我们也可以定义一个内部类GC,然后通过在Singleton类中定义一个静态的GC类对象,使得在程序结束回收该GC对象时自动调用GC类的析构函数从而完成资源回收与数据保存工作,这样就可以避免忘记调用DelInstance接口从而导致数据丢失的情况,代码如下:

cpp 复制代码
// 专门对加锁解锁进行管理的类
template<class Lock>
class LockGuard
{
public:
	LockGuard(Lock& lk)
		:_lk(lk)
	{
		_lk.lock();
	}

	~LockGuard()
	{
		_lk.unlock();
	}
private:
	Lock& _lk;;//这里只能使用引用,因为锁不允许进行拷贝
};
class Singleton
{
public:
	// 多个线程一起调用GetInstance,存在线程安全的风险,
	//static Singleton& GetInstance()
	//{
	//	// 第一次获取单例对象的时候创建对象
	//	// 双检查加锁
	//	if (_psins == nullptr)  // 对象new出来以后,避免每次都加锁的检查,提高性能
	//	{
	//		// t1  t2
	//		_smtx.lock();

	//		try
	//		{
	//			if (_psins == nullptr)  // 保证线程安全且只new一次
	//			{
	//				_psins = new InfoSingleton;
	//			}
	//		}
	//		catch (...)
	//		{
	//			_smtx.unlock();
	//			throw;
	//		}

	//		_smtx.unlock();
	//	}

	//	return *_psins;
	//}

	static Singleton& GetInstance()
	{
		// 第一次获取单例对象的时候创建对象
		// 双检查加锁
		if (_psins == nullptr)  // 对象new出来以后,避免每次都加锁的检查,提高性能
		{
			// t1  t2
			//LockGuard<mutex> lock(_smtx);
			std::lock_guard<mutex> lock(_smtx);

			if (_psins == nullptr)  // 保证线程安全且只new一次
			{
				_psins = new Singleton;
			}	
		}

		return *_psins;
	}

	// 一般单例对象不需要考虑释放
	// 单例对象不用时,必须手动处理,一些资源需要保存
	// 可以手动调用主动回收
	// 也可以让他自己在程序结束时,自动回收
	static void DelInstance()
	{
		// 保存数据到文件
		// ...

		std::lock_guard<mutex> lock(_smtx);
		if (_psins)
		{
			delete _psins;
			_psins = nullptr;
		}
	}
	
	// 也可以让他自己在程序结束时,自动回收
	class GC
	{
	public:
		~GC()
		{
			if (_psins)
			{
				cout << "~GC()" << endl;
				DelInstance();
			}
		}
	};

	Singleton(const Singleton& sin) = delete;
	Singleton& operator=(const Singleton& sin) = delete;
private:
	Singleton()
	{}
	// 类静态成员的声明
	static Singleton* _psins;
	static std::mutex _smtx;
    static GC _gc;
};
// 类静态成员的定义
Singleton* Singleton::_psins = nullptr;
mutex Singleton::_smtx;
Singleton::GC Singleton::_gc;

懒汉模式的一种简便实现

有人给出了懒汉模式的一种简便实现,代码如下:

cpp 复制代码
class Singleton
{
public:
	static Singleton& GetInstance()
	{
		static Singleton sinst;
		return sinst;
	}

private:
	Singleton()
	{}

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

对于上面的实现方式:

1.它符合单例模式的特点--全局只有一个单例对象,由于sinst是静态的局部对象,所以当我们第二次及以后再执行

static Singleton& GetInstance()时,编译器并不会再去创建新的单例对象,同时,虽然sinst是局部静态对象,但是其生命周期是全局的,并不影响使用

2.它符合懒汉模式的特点--只有在第一次使用单例对象的时候在去创建对象

3.该方法不需要在堆上创建单例对象,并且C++11标准规定了局部静态对象的初始化是线程安全的,所以该方法避免了线程安全和new抛异常等问题。但是只有在C++11及其之后的标准中局部对象的初始化是线程安全的,而在C++11之前的版本并不能保证,所以需要保证编译器支持C++11的标准

在实际开发的过程中,单例模式的使用及其广泛,但是大多数情况下都是使用饿汉模式,只有在少数的情况下才使用懒汉模式。

相关推荐
Dream_Snowar41 分钟前
速通Python 第三节
开发语言·python
唐诺1 小时前
几种广泛使用的 C++ 编译器
c++·编译器
高山我梦口香糖2 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
冷眼看人间恩怨2 小时前
【Qt笔记】QDockWidget控件详解
c++·笔记·qt·qdockwidget
信号处理学渣2 小时前
matlab画图,选择性显示legend标签
开发语言·matlab
红龙创客2 小时前
某狐畅游24校招-C++开发岗笔试(单选题)
开发语言·c++
Lenyiin2 小时前
第146场双周赛:统计符合条件长度为3的子数组数目、统计异或值为给定值的路径数目、判断网格图能否被切割成块、唯一中间众数子序列 Ⅰ
c++·算法·leetcode·周赛·lenyiin
jasmine s2 小时前
Pandas
开发语言·python
biomooc2 小时前
R 语言 | 绘图的文字格式(绘制上标、下标、斜体、文字标注等)
开发语言·r语言
骇客野人3 小时前
【JAVA】JAVA接口公共返回体ResponseData封装
java·开发语言