【C++】C++特殊类设计
一.设计一个类不能被拷贝在C重我们知道拷贝只会发生在两个场景中拷贝构造函数和赋值运算符重载。如果我们想要让一个类禁止拷贝就要让该类不能调用拷贝构造函数以及赋值运算符重载就行。1.C98在C98中要想达到我们想要的效果将拷贝构造函数与赋值运算符重载只声明不定义同时将其访问权限设置为私有就行了。来看代码class CopyBan { public: CopyBan() { } private: CopyBan(const CopyBan cb); // 拷贝构造函数声明 CopyBan operator(const CopyBan cb); // 赋值运算符重载声明 }问题来了问题1.为什么设为 private如果放在 public外部就可以调用拷贝 如果只声明不私有化用户可以在类外部自己实现这两个函数拷贝依旧可以发生达不到禁止拷贝的目的。 放到 private类外部无法调用拷贝构造、赋值重载。问题2.为什么只声明不写函数定义不实现函数体正常业务代码不会去调用这两个拷贝函数如果外部不小心调用拷贝编译阶段就报错访问权限不够如果类内部成员函数不小心写了拷贝代码链接阶段报错只有声明没有函数实现。如上面的代码当我们尝试拷贝或者赋值这个类对象时拷贝构造、赋值运算符重载是编译器自动触发调用的。 这类函数是在类的外部域外完成调用外部访问不了私有成员因此直接编译报错以此实现禁止拷贝。给大家一个简单的记法编译器帮你调用拷贝相关函数这个调用发生在类外面私有函数外面访问不到直接拦截拷贝行为。2.C11C11 拓展了delete的用法。以前delete只用来释放new开辟出来的堆内存现在把 delete写在默认成员函数后面代表告诉编译器直接删掉这个默认成员函数。写上delete之后编译器就不会自动生成对应的默认函数。只要代码尝试调用这个被删除的函数直接编译报错。来看代码#includeiostream using namespace std; class CopyBan { public: CopyBan() { } private: CopyBan(const CopyBan cb) delete; // 拷贝构造函数声明 CopyBan operator(const CopyBan cb) delete; // 赋值运算符重载声明 };二.设计一个类只能在堆上创建对象我们在写代码的时候正常创建类对象时是在栈上创建并且自动调用构造函数来初始化。来看下面我们设计一个类就只能在堆上创建对象。1.【实现方式】只允许在堆上创建对象需求这个类的对象只能 new 出来放在堆上不能在栈上创建也不能拷贝。实现思路把构造函数设为私有外部就不能直接HeapOnly obj;在栈上创建对象对外暴露一个静态成员函数在这个静态函数内部调用new创建堆对象把拷贝构造禁用防止拿到堆对象指针后拷贝出栈上对象。class HeapOnly { public: //静态接口专门用来创建堆对象 static HeapOnly* CreateObject() { return new HeapOnly; } private: //构造私有外部无法直接实例化 HeapOnly() {} //禁止拷贝避免用堆对象拷贝出栈对象 HeapOnly(const HeapOnly hp) delete; };简洁说明一下就是构造函数私有类外面就不能直接定义栈对象。只能调用静态CreateObject()在函数内部用 new 在堆上生成对象。 再把拷贝构造删掉防止有人拿堆上的对象拷贝构造出一个栈上对象。2.【实现原因】a.为什么要用静态成员函数创建堆对象普通非静态成员函数调用需要./-要传this指针前提是得先有类对象。 但构造函数已经私有外部没法直接创建对象普通成员函数用不了。静态成员函数不依附对象调用不需要this直接类名::就能调用。 同时静态函数属于类域内部可以访问私有构造函数所以在静态函数里面可以new调用私有构造生成堆对象。b.为什么还要禁用拷贝构造只私有化构造函数还不够。 拿到堆对象指针后可以解引用通过拷贝构造在栈上复制出对象这样栈上就出现该类对象打破 “只能堆上创建” 的限制。把拷贝构造函数禁用private / delete上面这种拷贝写法直接编译报错杜绝栈上拷贝对象保证对象只能存在堆上。三.设计一个类只能在栈上创建对象要实现不能在堆上创建类对象。使用new创建对象时会调用类的operator new(size_t size)申请内存delete释放对象时会调用类的operator delete(void* p)释放内存。我们把operator new私有化外部就无法调用从而阻止在堆上创建对象。1.方法一想要禁止类对象在堆上分配核心思路就是禁用 new 和 delete 运算符。因为 new 表达式会调用 operator new 来分配内存delete 表达式会调用 operator delete 来释放内存——把这两个入口堵死堆上创建就无从谈起了。实现方式很简单在类中将它们声明为 delete 即可class StackOnly { public: StackOnly() {} void* operator new(size_t size) delete; void operator delete(void* p) delete; };这样一来如果尝试在堆上创建对象int main() { StackOnly* pso new StackOnly; // 编译错误尝试引用已删除的函数 return 0; }2.方法二另一种阻止堆上创建对象的方式是将构造函数私有化然后提供一个静态成员函数来负责对象的创建。因为 new 表达式需要调用构造函数一旦构造函数变成私有的外部就无法通过 new 来构造对象了。实现方式如下class StackOnly { public: static StackOnly CreateObject() { return StackOnly(); // 在栈上构造并返回 } private: StackOnly() {} // 私有构造函数外部无法调用 };这样一来如果尝试在堆上创建对象int main() { StackOnly* pso new StackOnly; // 编译错误无法访问私有构造函数 StackOnly so StackOnly::CreateObject(); // 通过静态方法在栈上创建 return 0; }此时 new 一个对象的时候由于默认构造函数私有而无法调用所以报错。但是其实这里有一个漏洞静态区创建上面两种方法虽然阻止了栈上和堆上的创建但都忽略了一个问题——静态区数据段。无论是方法一的 delete operator new还是方法二的私有构造函数都无法阻止下面这种写法static StackOnly so StackOnly::CreateObject(); // 在静态区上创建因为静态对象的创建不涉及 new 运算符而且 CreateObject() 本身就是公有静态方法调用它构造对象在语法上是完全合法的。最终这个对象会被存放在全局数据区或静态存储区生命周期贯穿整个程序运行期间。类的核心设计原则想彻底控制一个类的对象创建位置思路其实很明确只暴露静态成员函数作为创建入口封堵其他所有可能的创建途径。具体来说就是要做到以下几点构造函数私有化 —— 阻止外部直接构造拷贝构造和赋值操作禁用 —— 防止通过拷贝绕开限制operator new / operator delete 禁用 —— 阻止堆分配静态方法作为唯一创建接口 —— 统一管控对象的创建逻辑只要有一条路没堵死设计就不算严谨四.设计一个类不能被继承1.C98实现禁止继承把基类的构造函数设为private。 派生类在实例化的时候编译器会自动调用基类的构造函数。但基类构造是私有派生类访问不到编译直接报错就实现不能被继承。//基类 class NonInherit { private: //基类构造函数私有 NonInherit() {} }; //派生类 class B : public NonInherit {};2.C11直接简单粗暴使用final 关键字来修饰类表示该类不能被继承。class A final {};五.设计一个类只能创建一个对象单例模式1.设计模式Design Pattern就是前人反复实践、总结出来的一套代码设计经验流传广、已经分好类别可以拿来复用。为什么会出现设计模式可以类比兵法。最早打仗就是硬拼打的多了人们慢慢摸索出打仗的套路就总结出《孙子兵法》这类兵法。写代码也是一样大量项目写多之后很多场景会反复遇到大家就把靠谱的解决思路提炼出来就成了设计模式。使用设计模式的目的提高代码复用率代码可读性更好方便别人看懂维护同时保障代码稳定可靠。 设计模式把代码开发往工程化方向推进属于软件工程里很基础的一套思路就好比盖房子的建筑框架。2.单例模式一种设计模式单例模式一个类只能生成一个对象实例。 保证整个系统里这个类仅有一份实例同时对外提供一个全局的访问入口程序各个模块都共用这同一个对象。举个实际场景服务器程序配置信息存放在配置文件。可以用一个单例对象专门读取这份配置程序里其他所有模块都通过这个单例拿配置数据复杂场景下配置管理会简单很多。实现思路全局只保留一份实例一般把实例放在静态区或者堆上保证只会创建一次。把构造函数私有化外部就不能随便 new 或者定义对象避免额外创建实例。拷贝构造、赋值重载用delete删掉禁止拷贝防止复制出新的对象破坏单例。3.【实现模式】单例模式有两种实现模式1饿汉模式咋说呢就是程序刚启动就把对象创建好了不管后面代码会不会用到这个单例提前就准备就绪就像饿急了上来就先把饭吃了。// 饿汉模式 class Singleton { public: // 获取单例对象接口 static Singleton* GetInstance() { return m_instance; } private: // 构造函数私有外部不能随便创建对象 Singleton() {}; // C98老写法只声明不定义禁止拷贝 // Singleton(Singleton const s); // Singleton operator(Singleton const s); // C11推荐直接删除拷贝、赋值编译就报错 Singleton(const Singleton s) delete; Singleton operator(const Singleton s) delete; // 静态成员存单例实例 static Singleton m_instance; }; // 全局域初始化程序跑main函数之前就把对象创建好了 Singleton Singleton::m_instance; int main() { cout Singleton::GetInstance() endl; cout Singleton::GetInstance() endl; cout Singleton::GetInstance() endl; return 0; }构造私有外部不能直接写Singleton s;也不能new Singleton去创建对象。静态成员m_instance归属于类本身存放在静态区。在程序启动后main 函数还没跑之前这个对象就已经创建完毕。GetInstance()静态接口直接返回唯一实例的地址外部只能调用这个接口获取对象。删掉拷贝构造和赋值重载避免拷贝生成新对象保证单例不会被破坏。运行结果静态成员变量只能在类的外部定义和初始化。所以饿汉模式把单例对象放在类域外完成初始化main 函数执行之前对象就已经建好对象存放在静态区全局只有这一份实例后续无法再新建别的对象。外部想要拿到这个单例只能调用静态成员函数GetInstance()。 静态成员函数可以直接访问同类的静态成员变量m_instance。int main() { Singleton* s Singleton::GetInstance(); // 获取单例对象 return 0; }简单的注意事项 类里面写的static Singleton m_instance;只是声明并不是定义必须在类外面补充定义初始化静态成员才会真正开辟内存。为什么称之为饿汉模式呢不管将来会不会使用到这个单例对象但是在程序一启动还没有进入 main 函数之前就创建一个唯一的实例对象。就像一个饿汉一样一上来就先吃创建单例对象。【总结】如果单例在多线程、高并发场景下会被频繁调用对性能要求高选饿汉模式更合适。它不存在多线程资源竞争的问题响应速度更快。饿汉模式优点整个进程内保证只有一份实例。写法简单程序启动就把对象创建完成。饿汉模式缺点如果有 A、B、C 多个单例互相之间存在依赖没法控制它们的初始化顺序容易出问题。程序一启动就全部创建单例数量多的时候会拖慢进程启动速度。补充饿汉是 main 执行前就完成初始化由编译器保证所以天生没有线程竞争不需要加锁。2懒汉模式// 懒汉模式单例线程安全 #include iostream #include mutex #include thread using namespace std; class Singleton { public: static Singleton* GetInstance() { // 第一层检查避免每次调用都加锁 if (nullptr m_pInstance) { // 只有实例为空时才加锁 lock_guardmutex lock(m_mtx); // 第二层检查防止多个线程同时通过第一层检查 if (nullptr m_pInstance) { m_pInstance new Singleton(); } } return m_pInstance; // 返回单例指针 } // 内嵌垃圾回收类程序结束时自动释放单例对象 class CGarbo { public: ~CGarbo() { if (Singleton::m_pInstance) delete Singleton::m_pInstance; } }; // 静态垃圾回收对象程序退出时自动析构 static CGarbo Garbo; private: // 构造函数私有化 Singleton() {} // 防拷贝 Singleton(const Singleton s) delete; Singleton operator(const Singleton s) delete; static Singleton* m_pInstance; // 单例对象指针 static mutex m_mtx; // 互斥锁 }; // 静态成员初始化 Singleton* Singleton::m_pInstance nullptr; mutex Singleton::m_mtx; Singleton::CGarbo Singleton::Garbo; void func() { cout Singleton::GetInstance() endl; } int main() { // 多线程测试 thread t1(func); thread t2(func); t1.join(); t2.join(); cout Singleton::GetInstance() endl; cout Singleton::GetInstance() endl; return 0; }不过这种写法存在线程安全问题当多线程环境下一个线程判断 m_instance nullptr 通过后还没执行 new时间片切走了另一个线程进来同样判断通过也 new 了一个对象等第一个线程恢复执行又 new 了一个把之前那个给覆盖掉了导致内存泄漏单例也就失效了。因此这里我们就使用了一个东西叫互斥锁#include iostream #include mutex using namespace std; class Singleton { public: static Singleton* GetInstance() { _mtx.lock(); if (m_instance nullptr) { m_instance new Singleton; } _mtx.unlock(); return m_instance; } private: Singleton() {} Singleton(Singleton const) delete; Singleton operator(Singleton const) delete; static Singleton* m_instance; static mutex _mtx; }; Singleton* Singleton::m_instance nullptr; mutex Singleton::_mtx; int main() { cout Singleton::GetInstance() endl; cout Singleton::GetInstance() endl; cout Singleton::GetInstance() endl; return 0; }和之前的版本相比这里加了一把互斥锁_mtx.lock(); if (m_instance nullptr) { m_instance new Singleton; } _mtx.unlock();每次调用 GetInstance 都会先加锁保证同一时刻只有一个线程能进入临界区做判断和创建这样就不会出现之前那种判断完还没 new 就被切走的问题了。不过这写法性能不咋地因为每次调用都得加锁解锁单例对象创建好之后其实就没必要再锁了。后面可以优化成双重检查锁定把锁的粒度再缩小一点。因为加锁需要加在一个锁上才有用所以我们也要将锁设为静态的然后在类外进行初始化这样就保证了只实例化出一个对象来但实际上这样还是有问题的。当我们 new 出一个对象之后我们在进行调用 GetInstance 的时候还会不停的加锁解锁。加锁解锁是有性能消耗的所以不断的加锁解锁是很不好的所以这里我们要使用双检测加锁#include iostream #include mutex using namespace std; class Singleton { public: static Singleton* GetInstance() { if (m_instance nullptr) { _mtx.lock(); if (m_instance nullptr) { m_instance new Singleton; } _mtx.unlock(); } return m_instance; } private: Singleton() {} Singleton(Singleton const) delete; Singleton operator(Singleton const) delete; static Singleton* m_instance; static mutex _mtx; }; Singleton* Singleton::m_instance nullptr; mutex Singleton::_mtx; int main() { cout Singleton::GetInstance() endl; cout Singleton::GetInstance() endl; cout Singleton::GetInstance() endl; return 0; }这样减少了加锁提高了效率但还是会有一点问题单例对象什么时候释放呢其实单例对象是不需要释放的。因为在一般情况下单例在进程启动之后随时会被使用而且这个对象也不大所以就可以不去释放它。那么会有内存泄漏吗如果进程是正常结束的因为 new 出来得到的进程地址空间是虚拟内存当结束之后就会将地址通过页表取消映射那么 new 出来的资源就会还给 OS。当然如果想要释放也是没有问题的如上我们实现一个 DelInstance 手动调用的去释放。当然也有可能考虑到程序结束时需要处理一下持久化保存一些数据比方说要将数据写到硬盘上。那么我们就可以写一个析构函数在析构函数中去处理。如果担心会忘记手动调用 DelInstance 那么可以通过设计一个类在类中实现一个析构函数。如上就是会创建一个_gc对象。等 main 函数执行结束_gc生命周期走到尽头就会调用它的析构函数在里面完成单例对象的释放。同样要把构造函数私有化禁用拷贝构造和赋值重载以此保证单例的唯一性。 另外还要增加一个静态的单例对象指针成员变量。在类外实例化静态指针变量的时候并没有创建单例对象而是将其初始化为空。int main() { Singleton* ps Singleton::GetInstance();//获取单例对象 return 0; }我们在写代码时候在获取单例对象时如果是第一次使用那么就会在堆区上 new 一个单例对象并且将所在地址赋值给静态的成员指针变量。为什么叫懒汉模式懒汉模式也叫延时加载模式。就是说这个单例对象一开始不创建等到第一次真正要用的时候才去创建。假如单例对象构造起来特别费劲比如要加载一堆插件、初始化网络连接、读取配置文件什么的如果程序一启动就把这些都做了那启动速度肯定快不了。而且万一程序跑起来压根没用上这个对象呢那之前的初始化工作不就白干了吗。懒汉模式正好避免了这个问题用的时候再创建不用就不创建。优点第一次使用时才创建对象进程启动没负担。多个单例之间的初始化顺序可以通过代码调用顺序自由控制。缺点实现起来比饿汉模式复杂。另外在多线程环境下不加锁会有安全问题但加了锁又会影响性能所以才搞出双重检查这种写法来平衡线程安全和效率。
