目录
一、特殊类设计
1. 设计一个对象不能被拷贝的类
2. 设计一个只能在堆上创建对象的类
2.1 禁止构造函数
2.2 禁止析构函数
3. 设计一个只能在栈上创建对象的类
4. 设计一个不能被继承的类
5. 设计一个只能创建出一个对象的类(单例模式)
5.1 单例模式的含义和作用
5.2 饿汉模式
5.3 懒汉模式
5.4 懒汉模式的线程安全问题
5.5 单例对象的释放问题
5.6 另一种懒汉模式的写法
二、类型转换
1.C中的类型转换
2. C++中的类型转换
2.1 static_cast
2.2 reinterpret_cast
2.3 const_cast
2.4 dynamic_cast
一、特殊类设计
特殊类设计,简而言之就是设计出一些具有特殊功能的类,例如禁止拷贝构造等。.
1. 设计一个对象不能被拷贝的类
要实现这个类很简单,在C++11之前,是采用“只声明不实现”并将其声明为私有的方式实现这个类。而在C++11之后就可以使用delete关键字删除类中的默认成员函数:
2. 设计一个只能在堆上创建对象的类
2.1 禁止构造函数
在一般情况下,一个类如果没有任何限制,其实是可以在栈区、堆区和静态区三个区域创建对象的:
那如果想让一个类只能在堆上创建对象,首先就要让这个类的对象不能在外部被随意使用。所以,第一步就是要封掉外部对构造函数的调用,即将构造函数声明为私有,然后单独提供一个函数提供对象创建。
但是要注意,因为要调用类的普通成员函数,就必须要有一个类。而这里不允许在外部创建类,只能通过类的成员函数创建,所以这个成员函数必须要为静态成员函数。类中静态成员函数的调用无需创建类。
尽管此时已经禁止了直接创建一个在栈上的对象,但是还是有方法间接在栈上创建对象的,那就是拷贝构造:
可以看到,虽然构造函数已经被封死,无法直接调用构造函数在栈上创建对象,但是却可以通过拷贝构造的方式实现在栈上创建对象。
要禁止通过拷贝构造的方式在栈上创建对象有几个方法。
(1)delete关键字
第一个方法就是用delete关键字删除拷贝构造函数:
2.2 禁止析构函数
如果不想类外部随意创建对象,将类的析构函数设置为私有也是一个可行的方法:
因为这是一个局部对象,出了作用域就要销毁。但是此时编译器无法找到该对象的析构函数,所以禁止了该对象的实例化。
在这种情况下,要创建对象就可以用new的方式创建。因为new的对象的生命周期是全局域。但是,因为析构函数是私有,所以虽然可以创建对象,却无法显式销毁:
要解决这种情况也很简单,直接在类中提供一个调用析构函数的函数即可:
注意,在显式调用析构时,一定要加this,否则可能无法编译通过。
当然,为了防止拷贝构造生成在栈上的对象,也可以将拷贝构造函数删除:
3. 设计一个只能在栈上创建对象的类
要实现这一目标,第一步就需要禁止在类外部随意调用构造函数,然后用类内部提供的函数创建对象:
但是这里并不能完全限制类的对象在栈上创建,这个类依然可以在静态区被创建:
这个问题是无法完全解决的。因为这里在栈上创建和在静态区创建都是利用了拷贝构造,如果禁掉拷贝构造,就无法创建对象;但如果不禁,就会出现上图的情况。
如果确实想让这个类无法在在静态区创建,就需要完全封死外部的创建,然后让使用者完全通过调用类的函数来进行操作:
当然,这种方式也是有缺陷的,那就是每次调用CreateObj()都会创建一个新的对象,所以每次调用的对象都是不同的对象:
当然,可以用引用的方式接收对象,这样就可以使用同一对象了。但是要注意,引用接收时必须要加const:
4. 设计一个不能被继承的类
要设计一个不能被继承的类很简单,在C++98中,是将父类的构造函数私有,这样子类就无法调用父类的构造函数,就无法继承:
而在C++11中,新提供了一个关键字“final”,使用了该关键字的类将无法被继承:
5. 设计一个只能创建出一个对象的类(单例模式)
只能创建一个对象的类,叫做“单例模式”。单例模式是类的设计模式中的一种。设计模式就好比是一本兵法,是一套被反复使用、多数人知晓、经过分类的代码设计经验的总结。
设计模式一共有23种,而单例模式,就是其中之一。在C++中,其实并不是那么喜欢谈设计模式,在实际使用的设计模式可能也只有几种。但是Java和C++不同,java非常喜欢谈设计模式,如果是学习java,可能就需要对绝大部分乃至全部设计模式都有所了解。
在以前学习C++时,大家都接触过“迭代器模式”和“配接器模式”。如果大家是学习C++,又想对其他设计模式有所涉猎,可以去看看“工厂模式”和“观察者模式”。这两个设计模式在一些C++的大型项目中可能有所使用。至于其他的设计模式,C++中就几乎很少使用了。
5.1 单例模式的含义和作用
一个类在全局只有一个对象,即单例模式。该模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。
在实际中,单例模式是有许多应用场景的。例如服务器的配置信息,该信息需要在整个服务器的开启周期内存在,但是又只需要一份。因此,这些配置数据就可以交由一个单例对象统一存储读取,当有对象需要这些信息时,直接从这个单例对象中读取即可。
要实现单例模式,一般有两种方式。
5.2 饿汉模式
饿汉模式,简单来讲,就是在程序完全启动进入main函数之前就创建一个单例对象,无论你是否要使用。
在饿汉模式中只创建一个对象的方式,首先就是禁止在类外随意创建对象。所以先将构造函数设置为私有。然后再在这个类中声明一个类对象,通过声明的这个类对象实现创建对象。因为这个类对象为私有,所以要将其设置static静态成员,以实现在类外实例化该对象。
为了方便外部使用这个单例对象,所以可以在类中提供一个函数,返回这个单例对象的引用:
这就是一个简单的饿汉模式。
为了方便测试,写一个插入函数和一个打印函数:
再写出如下程序并运行:
可以看到,此时这里所使用的就是一个实例化对象。
但是,由于此处并未禁止拷贝构造,所以是可以通过拷贝构造实例化出不同的对象的:
此时就不再是同一个对象了。因此,在单例模式的饿汉模式中,要禁止拷贝构造:
饿汉模式的优点:
饿汉模式的优点就是简单易懂。
饿汉模式的缺点:
(1)饿汉模式的单例对象因为需要在进入main函数之前创建并初始化,而进入main函数就是程序启动成功。所以就可能导致程序的启动速度比较慢。并且有些类可能在初始化时还需要调用网络或数据库,这就会进一步拖慢程序的启动速度。
(2)当存在多个单例对象时,难以控制单例对象的初始化顺序。如果是在同一个文件里面还好,先定义的先初始化,还比较好控制;但如果在不同文件中,就难以控制初始化顺序。并且如果在不同文件中的两个类有初始化的依赖关系,一个类的初始化依赖与另一个类,此时就可能出现问题。
5.3 懒汉模式
懒汉模式与饿汉模式相反,懒汉模式的单例对象并不是在main函数之前创建,而是什么时候需要就什么时候创建。
懒汉模式和饿汉模式一样,都需要禁止在类外随意创建对象。所以要将构造函数声明为私有。但是与饿汉模式不同的是,懒汉模式不再是在类中创建一个对象,而是创建一个类指针。这个指针初始化为空,当其为空时,就表示没有创建对象,于是在类中new一个对象,让这个指针指向它;如果不为空,表示已经创建对象,直接返回即可:
懒汉模式的优点对比饿汉模式就很明显。首先,懒汉模式的对象是在main函数之中创建的,不会影响程序的启动。其次,懒汉模式可以通过调用函数的顺序来控制对象创建的顺序。
但是,懒汉模式存在一个问题,那就是“线程安全”问题。
5.4 懒汉模式的线程安全问题
懒汉模式在单线程情况下并没有什么问题,但一旦是多线程,就可能出现各种问题。如果不知道线程是什么,可以翻阅同一专栏的线程库或linux中的线程栏中的内容进行了解,这里就不再过多赘述。
由于这里是要通过new创建对象,如果有多个线程并发访问这里函数,就可能导致同时有多个线程通过判断进入创建对象的语句。例如线程A刚通过判断进入准备new对象,此时CPU将线程A调走,换线程B进来,由于线程A并没有创建对象,所以此时_pins还是空,于是线程B通过循环new了一个对象。当线程B创建完对象被切走后,线程A被切回来,它从创建对象的位置开始运行。于是线程A也创建了一个对象,并将这个对象返回。这时就会出现不同的线程new了不同的对象的情况,但是仅返回了一个对象的地址,造成内存泄漏。
要解决很简单,可以直接使用“锁”。通过锁来让线程并发访问临界资源。
这个锁要定义为静态,因为返回对象的函数是静态函数。静态函数没有this指针,无法调用类中的普通成员变量。
修改获取对象的函数如下:
在C++中的锁其实是用类实现的,所以可以直接用类的方式调用上锁和解锁函数。但是上面的写法有一定的缺陷。在懒汉模式中,因为只需要创建一个对象,所以只会进入判断语句一次。但是上面的写法使得每次调用GetInstance函数时都要上锁解锁,其中不仅会有锁资源的消耗,还会使线程由并发变为串行,拖慢运行效率。
因此,可以通过再外面再套一层检查,即“双检查”的方式,使该程序仅在第一次创建对象时使用锁,其他情况下无需使用锁:
如果在未来遇到这种只需要保护第一次进入的情况,就可以使用“双检查”。
但是这里还有一个问题,那就是new是可能抛异常的。一旦new抛异常,它就可能直接跳到外部的catch中。此时就会导致程序运行不到解锁的地方,使得线程持有的锁未释放而形成死锁。
为了避免这种情况,我们可以采取异常捕获的方式处理:
但是这种方式写出来的代码并不好看。所以可以采取另一种方式,即“RAII”,使锁的生命周期与作用域相绑定:
此时,这个锁就是和它所属的作用域的生命周期一致,一旦离开该作用域,将自动调用析构函数进行解锁。
在实际中,是不需要我们自己写这个LockGuard的,库中自带:
5.5 单例对象的释放问题
一般来讲,单例模式的单例对象的生命周期都是整个程序,所以不释放也没有问题,因为在程序结束时会自动回收。当然,如果单例对象在程序运行时就不需要了,就需要手动释放。
假设一个单例对象在程序结束后,需要将它的数据写入到文件中,就可以提供一个释放函数:
但是如果不想每次结束时时都自己去调用这个函数来写入数据和释放空间,而是向自动实现,就可以采用“内部类”的RAII:
由于GC是内部类,所以可以不受限制直接访问外部类的static成员。这里直接在析构函数中调用DelInstance()函数,然后在定义一个静态成员。该成员的生命周期属于全局域。当程序结束时会调用它的析构函数释放资源,而它的析构函数中又调用了DelInstance函数,进而实现自动调用。
5.6 另一种懒汉模式的写法
在C++11之后,又有了一种懒汉模式的写法,这种写法不再使用指针而是创建静态对象:
静态变量只会在第一次调用的时候初始化。所以,这个函数只有在第一次调用时才会创建并初始化一个_sins对象。当再次调用时,不会再调用构造函数进行初始化。
我们在构造函数中打印一句话并写下如下代码进行测试:
运行程序:
只出现了一次打印,说明此处仅调用了一次构造函数来创建一个静态成员。且这个成员是在调用GetInstance函数后才创建,满足懒汉模式的条件。
但是,使用这种方式要小心,因为在C++11之前,这种方式是不能保证局部静态变量的初始化是线程安全的。在C++11之后才能保证。如果是某些对C++11支持的不太好的编译器,可能就会出现线程安全问题。所以,这种写法并不是通用的。
二、类型转换
1.C中的类型转换
在C语言中,如果赋值运算符左右两侧类型不同,或者形参与实参类型不匹配或者返回值类型与接收返回值类型不一致时,就需要发生类型转化。在C中,一共有两种形式的类型转换:隐式类型转换和显式类型转换。
隐式类型转换:编译器在编译阶段自动进行,能转换则转换,不能转换就编译失败
显式类型转换:需要用户自行处理
2. C++中的类型转换
虽然C中的类型转换方式简单,但是还存在不少缺点:
(1)隐式类型转换有些情况会出现如数据精度丢失等问题。并且这些问题有时候用户难以察觉。
(2)显示类型转换会将所有的情况融合在一起,代码不够清晰。
为了解决这些问题,在C++中,又提出了四种类型转换。分别是static_cast、reinterpret_cast、const_cast、dynamic_cast。在以后遇到需要强制类型转换的场景下,最后都针对性的使用这四种类型转换。这几个类型转换的运用方式都是“类型转换<要转换的类型>(变量名)”
但是,这四种类型转换的方式并不是强制要求的,因为C++需要兼容C,所以在C++中依然可以使用C的转换方式。
2.1 static_cast
这种类型转换的方式主要用于相近类型的转换。例如char、int、double这类具有一定相似性,只是在精度、范围大小之类的地方有区别的类型。
例如下面的int转double,就可以使用static_cast:
但是如果是指针转int,就无法使用:
因为指针是地址,和double这种整型并没有什么关联性可言,所以无法转换。
2.2 reinterpret_cast
reinterpret_cast就是用于关联性很弱的类型的强制转换的。比如static_cast无法将指针转为整型,但是reinterpret_cast就可以:
但是,reinterpret_cast也不是所有类型都可以任意转换,例如int*转double:
2.3 const_cast
const_cast可以用于删除变量的常性,便于赋值。其实函数中const修饰的变量并不是存在常量区,而是存在栈中。因为常量区的数据是绝对不允许修改的。而const变量虽然不允许直接修改,但可以间接修改。更准确来讲,const修饰的变量不应该叫做“常量”,而应该叫做“常变量”。
例如下图中的程序,就通过const_cast的强制转换修改const变量a的值:
但是,我们分别将a和*bp的值打印出来:
这就很奇怪了,程序能够正常运行,说明代码没有问题,bp确实修改了a的值。但是为什么打印出来a和*bp的值不一样呢?再打开调试面板对比:
此时可以看到更奇怪的现象,从调试面板来看,a确确实实被修改为了2,但是打印结果中的a却是1。造成这个现象的原因就是编译器优化。因为a是const修饰的,所以在这个程序中,编译器默认为变量a不会被修改。既然a不会被修改,为了提高效率,就没必要每次使用a时都从内存中读取。于是在运行这个程序时,a会被自动加载到CPU的寄存器中,当需要使用a时,就从寄存器中读取,而不是从内存找那个读取。当然,在vs中就是采用“压栈”的方式,而不是放入寄存器。但导致的结果都是一样的,不同的只是从寄存器还是从压栈中获取数据。
但是,a在后面被我们通过间接的方式修改了值,此时CPU并不知道内存中的a已经被修改,继续从寄存器中读取a,而不是从内存中读取。这就导致了数据不一致的问题。
要解决这一问题很简单,加上“volatile”关键字即可。这个关键字是用于“总是从内存中获取数据”的。即带有这个关键字的变量,无论它是什么状态,当需要使用时都需要从内存中读取。
将程序修改如下并运行:
此时的打印结果就是正常的。
C++单独将这个强制类型转换分出来就是为了警示使用者,使用这个类型转换是有风险的。因为C++是兼容C的,所以,C其实也是可以用显式类型转换去掉常性的:
2.4 dynamic_cast
dynamic_cast是C++单独拥有的类型转换方式。主要是为了处理父子类的指针/引用问题。
dynamic_cast用于父类对象的指针/引用与子类对象的指针或引用的相互转换。这里涉及两种转换。
向上转换:子类对象指针/引用——>父类对象指针/引用。这种情况不需要转换。子类指针给父类指针赋值时,会发生切片,将只属于父类的部分拷贝给父类指针。
向下转换:父类对象指针/引用——>子类对象指针/引用(使用dynamic_cast转换是安全的)。因为父类中缺少子类的成员变量,所以当父类指针转为子类子类指针时,就可能导致越界访问出现错误。
例如如下的程序:
在这个程序里面,func函数的参数是一个父类指针,此时既可以传父类指针进去,也可以传子类指针进去。但是,在这里是将传进来的指针转为了子类指针。如果这个父类指针本身就指向一个子类对象,那还没有什么问题。但如果这个父类指针指向的是一个父类对象,就可能出问题。
将func函数修改如下:
在传入一个指向父类对象的父类指针的同时,修改子类和父类的成员变量。运行该程序:
此时就出现了报错。原因是“越界访问”。因为这里用一个子类指针接收了一个强转后的指向父类对象的指针,这个指针指向的数据中并没有子类的成员变量。只是通过强转让bptr误以为它有。所以当bpt去指定的地址处访问时,该地址并不属与它的访问范围,发生越界访问。
使用dynamic_cast进行转化:
此时程序依然会崩溃,但是可以发现,bptr的地址变为了0。这是因为dynamic_cast并不是直接进行转换,而是会先进行检查。如果检查结果是可以转换,就转换;如果不能转换,就返回0。
由此,我们就可以通过转换后的指针的地址来判断是否转换成功:
程序正常运行,筛选掉了转换失败的情况。
但是要注意,dynamic_cast只能适用于父类中存在虚函数的父子类转换,如果不存在虚函数,dynamic_cast失效:
这个条件可以看成是一种规定,不必深究。只需要记住dynamic_cast的使用有这个条件即可。