1. 不可拷贝类
我们知道,某些资源只能有一个对象持有,拷贝可能导致资源混乱。例如智能指针std::unique_ptr
独占管理动态分配对象,文件句柄、网络套接字、数据库连接等资源通常也是独占的,不允许拷贝。
在C++11之前,要创建一个不可拷贝的类,通常的做法是将拷贝构造函数和赋值运算符重载声明为private
,并且只进行声明而不提供定义。例如下面这个NonCopyable
类:
class NonCopyable
{
public:NonCopyable() {}
private:// C++11之前,只声明不实现NonCopyable (const NonCopyable&){}NonCopyable& operator=(const NonCopyable&){}
};
当代码中某个地方尝试对该类的对象进行拷贝构造时,由于拷贝构造函数和赋值运算符重载是私有的,在类外部无法访问这些私有成员函数,所以从访问权限层面就阻止了拷贝行为。而只声明不实现的原因在于,如果只是声明为私有但不小心提供了定义,那么在类内部或者友元函数中还是有可能进行拷贝操作的,从而彻底禁止拷贝行为。
C++11引入了delete
关键字,利用这个关键字可以更清晰、简洁地实现不可拷贝的类。像下面这样的NonCopyable
类定义:
class NonCopyable {
public:NonCopyable() = default;// 使用delete关键字禁用拷贝构造函数和赋值运算符NonCopyable(const NonCopyable&) = delete;NonCopyable& operator=(const NonCopyable&) = delete;void doSomething() {//...}
};
通过将拷贝构造函数和赋值运算符重载直接标记为= delete
,明确告知编译器要禁用这两个函数。当代码中出现对该类对象进行拷贝构造或者赋值的操作时,编译器会直接报错,指出对应的函数已被删除,不允许调用,从而实现禁止拷贝的功能。
我们还可以定义一个通用的不可拷贝的基类(如nocopy
类),然后让具体需要禁止拷贝的类去继承这个基类,示例代码如下:
class nocopy
{
public:nocopy() {}nocopy(const nocopy&) = delete;const nocopy& operator=(const nocopy&) = delete;
};// 继承
class NonCopyable:public nocopy
{
public:NonCopyable() {};
private:
};
当派生类(如NonCopyable
类)的对象进行拷贝构造或者赋值操作时,在派生类的拷贝构造函数和赋值运算符重载的实现过程中(即使没有显式定义,编译器也会默认生成相应的函数,尝试调用基类的对应函数来处理基类部分的拷贝和赋值逻辑),会先在初始化列表中去调用基类的拷贝构造函数和赋值运算符重载。但由于基类(nocopy
类)已经通过delete
关键字(或者类似在C++11之前将其设为私有等方式)禁止了这些操作,所以派生类也就无法顺利完成拷贝构造和赋值操作,从而实现了不可拷贝的效果。
2. 只能在堆上创建的类
对于一些对象大小在编译时未知或生命周期不确定时,动态内存分配更合适。并且多个模块或对象需共享同一实例,堆上对象可通过指针或引用共享。例如大型数据结构(大数组、链表、树等)、资源管理器(文件处理类、数据库连接类等)、单例模式(确保对象唯一性和生命周期管理)。所以我们有时需要设计一个只能在堆上创建的类,一般我们有两种方式实现:
以下是关于只能在堆上创建的类的详细介绍,包含其不同实现方式、原理、优缺点以及使用时的注意事项等内容:
首先第一种方式,我们可以通过私有构造函数结合静态创建方法实现
class HeapOnly {
public:// 静态成员函数用于在堆上创建对象并返回指针static HeapOnly* create() {return new HeapOnly();}
private:// 构造函数私有化HeapOnly() {}// 拷贝构造函数私有化,禁止拷贝构造HeapOnly(const HeapOnly&) = delete;// 拷贝赋值运算符私有化,禁止赋值操作HeapOnly& operator=(const HeapOnly&) = delete;
};
由于构造函数是私有的,像HeapOnly obj;
这样直接在栈上创建对象的语句就无法通过编译,因为编译器会检查到在类外部没有权限调用构造函数。而对于拷贝构造函数和拷贝赋值运算符也进行了私有化或者删除的处理,避免了通过拷贝操作来间接创建对象的可能性,保证了对象创建的唯一性和可控性,只能按照规定的静态create
函数在堆上创建。
第二种方式我们可以通过私有化析构函数实现
- 实现方式:
class HeapOnly {
public:HeapOnly() = default;void Destroy(){delete this;}
private:// 析构函数私有化~HeapOnly() {}// 拷贝构造函数私有化,禁止拷贝构造HeapOnly(const HeapOnly&) = delete;// 拷贝赋值运算符私有化,禁止赋值操作HeapOnly& operator=(const HeapOnly&) = delete;
};
对于栈上对象,编译器会自动在对象生命周期结束(比如离开作用域)时调用析构函数进行资源清理等操作,但因为析构函数是私有的,在类外部代码中没有访问权限去执行这个操作,所以直接在栈上创建对象的语句就无法编译通过。而在堆上创建对象时,内存分配由new
操作符完成,只是后续需要手动释放内存,通过提供Destroy
函数来解决手动释放的问题,从而强制使对象只能在堆上创建并按照规定的方式进行内存释放管理。
3. 只能在栈上创建的类
栈上创建对象的生命周期与包含函数的作用域相同,函数结束时自动销毁,无需显式调用析构函数。并且栈上内存分配比堆上快,无需额外内存管理开销。例如锁类、作用域守卫类常设计为只能在栈上创建。所以有时候我们也需要设计一个只能在栈上创建的类。
我们首先肯定想到的是将构造函数或析构函数声明为私有,提供静态方法创建对象并返回。
//错误示例
class StackOnly {
public:static StackOnly Create(){static StackOnly obj;return obj;}
private:// 构造函数私有化StackOnly() {}
};
int main()
{//StackOnly* obj = new StackOnly(); errorStackOnly obj = StackOnly::Create(); //间接在堆上创建对象StackOnly* n = new StackOnly(obj); //okreturn 0;
}
但此方法不能完全防止使用new
创建对象,因为静态工厂方法返回对象时可能会用到拷贝构造函数(编译器可能优化),所以不能禁用/私有拷贝构造函数。而一旦不能禁止拷贝构造我们可以使用类似 StackOnly* obj = new(栈上创建的对象)
,调用拷贝构造间接创建一个堆上的对象,所以这种方式其实是不可取的。
所以我们可以换种思路, 既然要禁止在堆上开辟空间来创建类对象,我们可以采取直接禁止使用new
和delete
操作符的思路。因为在C++中,new
与delete
默认会调用全局的operator new
与operator delete
函数来进行内存分配和释放操作。所以,我们只需在类内对operator new
进行重载,并通过显式删除的方式将其禁用,同时也把operator delete
删除掉,如此一来,就能确保无法通过new
和delete
这两个操作符去创建该类的对象了。
class StackOnly {
public:StackOnly() = default;~StackOnly() = default;private:// 显式删除operator new操作符,禁止在堆上通过new创建对象void* operator new(std::size_t) = delete;// 显式删除operator delete操作符,禁止在堆上释放对象void operator delete(void*) = delete;// 同样也可以删除数组形式的new和delete操作符,防止通过new[]和delete[]来操作对象void* operator new[](std::size_t) = delete;void operator delete[](void*) = delete;
};int main() {// 正确的创建方式,在栈上创建对象StackOnly obj;// 以下方式会报错,因为new和delete操作符被删除,不能在堆上创建和释放对象// StackOnly* ptr = new StackOnly();// delete ptr;// StackOnly* arr = new StackOnly[5];// delete[] arr;return 0;
}
4. 不可被继承的类
在C++98标准下,可以通过将类的构造函数声明为private
来阻止类被继承。因为当一个类想要继承另一个类(作为子类)时,子类的构造函数会默认(隐式或显式地)调用父类的构造函数来初始化从父类继承下来的那部分成员。如果父类的构造函数是private
的,子类就无法访问它,也就没办法完成这个初始化过程,从而导致编译错误,实现了禁止继承的效果。
以如下代码为例:
class NonInherit
{
public:static NonInherit GetInstance(){return NonInherit();}
private:NonInherit(){}
};
这里定义了NonInherit
类,它的构造函数是私有的,唯一对外提供了一个静态成员函数GetInstance
用于获取该类的实例(返回一个临时的NonInherit
对象)。
C++11引入了final
关键字,当在类定义时使用final
关键字修饰该类,就明确告知编译器这个类是不允许被继承的,例如:
class A final
{// 类的成员定义等内容
};
如果后续有其他类试图去继承这个被final
修饰的类,像下面这样:
class B : public A
{// 编译会报错,因为A类不能被继承
};
编译器会直接报错,提示类A
是final
的,不允许作为基类被继承,这种方式更加直观、明确地表达了类不可继承的意图,而且语法简洁明了。
5. 控制可创建对象的类
有时候我们也需要一个可以控制可创建对象的类:
首先我们将禁止构造函数,拷贝构造函数以及赋值重载。并单独提供一个静态方法来创建对象,方便我们管理。
class LimitedObjectCreator {
public:// 静态方法用于创建对象,返回指向创建对象的指针static LimitedObjectCreator* create() {if (count > 0) {--count;std::cout << "对象创建成功,还可创建的对象数量:" << count << std::endl;return new LimitedObjectCreator();}else {std::cerr << "已超出对象创建限制数量,无法创建对象。" << std::endl;return nullptr;}}// 析构函数,用于释放对象资源,这里可以添加具体的资源释放逻辑(如果有需要)~LimitedObjectCreator() {++count;std::cout << "对象已销毁,还可创建的对象数量:" << count << std::endl;}static void setObjectLimit(int num) {count = num;}private:// 将构造函数声明为私有,禁止外部直接调用构造函数创建对象LimitedObjectCreator() {}// 拷贝构造函数也声明为私有,禁止拷贝构造LimitedObjectCreator(const LimitedObjectCreator&) = delete;// 赋值运算符重载同样声明为私有,禁止赋值操作LimitedObjectCreator& operator=(const LimitedObjectCreator&) = delete;static int count;
};// 初始化静态成员变量
int LimitedObjectCreator::count = 0;int main() {LimitedObjectCreator::setObjectLimit(3);LimitedObjectCreator* obj1 = LimitedObjectCreator::create();LimitedObjectCreator* obj2 = LimitedObjectCreator::create();LimitedObjectCreator* obj3 = LimitedObjectCreator::create();LimitedObjectCreator* obj4 = LimitedObjectCreator::create();if (obj1 != nullptr) {delete obj1;}if (obj2 != nullptr) {delete obj2;}if (obj3 != nullptr) {delete obj3;}if (obj4 != nullptr) {delete obj4;}return 0;
}
我们可以通过一个静态成员变量 count
来控制可创建对象的个数,其可以通过专门的函数 setObjectLimit
设置,每次创建时可创建对象个数会减一--count
,释放资源时可创建对象个数会相应增一++count
。
6. 单例模式
单例模式是一种设计模式,其核心特点是一个类只能创建一个对象,该模式可以保证系统中该类仅有一个实例,并提供一个访问它的全局访问点,此实例能被所有程序模块共享。
例如在服务器程序中,服务器的配置信息存放在文件里,可由一个单例对象统一读取配置数据,服务进程中的其他对象再通过这个单例对象获取配置信息,这简化了复杂环境下的配置管理。
单例模式有饿汉模式和懒汉模式两种实现方式:
6.1 饿汉模式
饿汉模式的核心思想是在程序启动阶段(也就是 main
函数执行之前)就创建单例类的唯一实例对象,无论后续程序运行过程中是否会实际使用到这个实例。这种方式就好像一个人很饿,提前把食物都准备好,不管后面吃不吃。
- 优点:实现起来较为简单直观,代码结构清晰,不需要考虑复杂的线程同步问题,因为实例在一开始就创建好了,后续只是获取这个已存在的实例而已。
- 缺点:可能会导致进程启动变慢,尤其是当单例对象的构造过程比较复杂、耗时(例如需要加载大量配置文件、初始化很多资源等),在程序启动时就执行这些操作会拖慢启动速度。另外,当存在多个单例类对象实例时,它们的创建顺序是不确定的,这在一些对实例初始化顺序有严格要求的场景下可能会带来问题。
namespace Hungry_Man {// 饿汉模式--main函数之前就创建对象class Singleton {public:static Singleton* GetInstance() {return &_inst;}private:Singleton(const Singleton&) = delete;Singleton& operator=(const Singleton&) = delete;Singleton() {}static Singleton _inst;};Singleton Singleton::_inst;
}
在这个代码中,Singleton
类定义了一个私有的静态成员变量 _inst
,它就是单例类的唯一实例。在类外进行了 Singleton::_inst
的定义,这使得在程序启动阶段,这个实例就会被创建出来(在 main
函数执行之前就已经存在了)。
GetInstance
函数很简单,它只是返回这个已经创建好的实例的地址。由于构造函数被声明为私有,外部无法随意创建 Singleton
类的其他对象,保证了整个程序中只有这一个实例存在,符合单例模式的要求。而且通过 delete
关键字删除了拷贝构造函数和赋值运算符重载函数,防止了通过拷贝或赋值的方式产生额外的对象实例。
6.2 懒汉模式
懒汉模式采取的是延迟加载的策略,只有在第一次真正需要使用单例对象时才去创建它,就好比一个人很懒,等到要吃东西了才去准备食物。这种模式适用于单例对象构造比较耗时或者占用资源较多(比如加载插件、初始化网络连接、读取文件等情况),并且程序运行时有可能根本不会用到该对象的场景,这样可以避免在程序启动阶段就消耗不必要的资源。
- 优点:因为是在第一次使用时才创建对象,所以不会影响程序启动的速度,进程启动时没有额外的负载。同时,多个单例实例的启动顺序可以根据实际使用的先后情况自由控制,比较灵活。
- 缺点:实现相对复杂一些,需要充分考虑线程安全问题,在多线程环境下,如果多个线程同时尝试获取单例对象,要保证只有一个线程能够创建实例,避免重复创建。另外,还需要考虑对象的释放问题,要合理地进行内存管理,确保单例对象在合适的时候被正确释放,避免内存泄漏等问题。
namespace Lazy_MAN {// 懒汉模式class Singleton {public:static Singleton* GetInstance() {// 双检查加锁方式,if (_pinst == nullptr) { // 第一次判断是防止对象创建好以后,还要每次加锁,就浪费了unique_lock<mutex> lock(_mtx);if (_pinst == nullptr) { // 第二次判断是为了防止多个线程一起写不安全现象_pinst = new Singleton;}}return _pinst;}static void DelInstance() {delete _pinst;_pinst = nullptr;}class GC{public:~GC(){Singleton::DelInstance();}};private:Singleton() {}Singleton(const Singleton&) = delete;Singleton& operator=(const Singleton&) = delete;static Singleton* _pinst;static mutex _mtx;static GC gc;// 定义一个静态成员变量,程序结束时,系统会自动调用它的析构函数从而释放单例对象};Singleton* Singleton::_pinst = nullptr;mutex Singleton::_mtx;
}
值得强调的是在 GetInstance
函数中采用了双检查加锁机制:
- 第一次
if (_pinst == nullptr)
判断:从性能优化角度出发,如果单例对象已经被创建(即_pinst
不为nullptr
),那么直接返回已存在的实例即可,无需再进行加锁和后续创建实例的操作。因为加锁解锁本身是有一定开销的,如果每次调用GetInstance
都进行加锁,会影响程序性能,尤其是在单例对象已经创建好的情况下,这种开销是不必要的。- 第二次
if (_pinst == nullptr)
判断:主要是从线程安全角度考虑,在多线程环境下,可能会出现多个线程同时通过了第一次if
判断(因为此时_pinst
确实为nullptr
,单例对象还未创建),然后这些线程都尝试获取锁并进入到临界区(由unique_lock
保护的代码块)。如果没有第二次if
判断,那么每个线程都会执行_pinst = new Singleton;
这一语句,从而导致创建多个单例对象,违背了单例模式的初衷。而第二次判断确保只有一个线程能够真正执行创建单例对象的操作,其他线程在等待锁释放后,再次检查_pinst
时,会发现单例对象已经被创建,从而避免重复创建,保证了在多线程环境下单例对象的唯一性。
除此之外,我们还定义了一个内部类 GC
,其析构函数中调用了 DelInstance
函数,这样在程序结束时,系统会自动调用 GC
的析构函数,进而释放单例对象,解决了对象释放的问题,避免内存泄漏。同时,通过将拷贝构造函数和赋值运算符重载函数声明为 delete
,同样防止了外部创建多个对象实例的情况,保证单例模式的正确实现。
当然我们也可以采用如下方式实现一个简单的懒汉单例模式:
class Singleton
{
public:static Singleton& GetInstance(){//C++11之后局部静态变量是线程安全的static Singleton inst;return inst;}
private:Singleton() = default;Singleton(const Singleton&) = delete;Singleton&operator=(const Singleton&) = delete;
};
int main()
{Singleton &inst = Singleton::GetInstance();return 0;
}
最后懒汉模式和饿汉模式的区别总结如下:
- 懒汉模式需要考虑线程安全和释放问题,实现相对复杂;饿汉模式不存在这些问题,实现简单。
- 懒汉是懒加载模式,在需要时初始化创建对象,不影响程序启动;饿汉模式在程序启动阶段就创建初始化实例对象,可能导致程序启动慢,影响体验。
- 如果有多个单例类且存在依赖关系(如B依赖A,要求A单例先创建初始化,B单例再创建初始化),则不能用饿汉模式(无法保证创建初始化顺序),这时懒汉模式可手动控制。
而且在实际应用中,懒汉模式通常更实用。