一直以来,使用C++面向对象机制,主要是为了其封装和多态特性。往往设计类时,只是为了功能的堆砌,没有考虑的更加深入。
之前也阅读过《Effective C++》,只是那时是在学生时代。如今工作了,重新阅读,有不少新的感悟。最关键的是,能从更高的视角去设计程序,之前杂乱无序的点与点,逐渐连接成一条条线。希望后续能够成面、成立体。
回到正题:如何设计C类?当然,更合理的表述应该是:如何设计高效、优雅的C类?(菜鸟的思考)
注意:我们并不是要设计一个程序,或者一个大的模块,我们考虑的更加具体,具体到其中的一点。
我们需要实现哪些功能?
“先谋后动”,在设计具体类时,必须要清楚下面几点:
1、设计该类的目的
清晰的概念远比模棱两口的理解,更能帮助我们深入分析问题。有时候我们觉得我就是需要实现这个功能罢了,只要能用就好了。然而,多和产品经理或技术经理沟通,有可能出现意想不到的结果:
- 该功能没有我们想到那么复杂,并不需要自己设计;
- 该功能比我们想到的更加复杂,我们需要考虑更多正确性、高效性、扩展性、维护性等方面的额问题。
2、使用该类的场景
不同的使用场景,相同功能类的设计需求是不一样的。譬如,设计一个视频解码类,如果使用场景为视频播放器,那我们设计的类必须要考虑不同的编码格式;但如果使用场景为视频会议,我们设计的类就不需要考虑太多编码格式的问题,反而需要针对某种格式进行效率优化。
3、潜在的扩展方向
程序不是一成不变的,外界事物不停的变化,催生不同的需求。如果我们的程序不可扩展,那每次需求变更,之前的工作都白费了。类的设计一定要考虑到,未来潜在的扩展方向。如果我们无法确定潜在的扩展方向,至少留下可扩展的接口,不要把一切行为、属性都写死。
如何构造析构、如何拷贝赋值?
C类的构造析构和拷贝赋值是设计C类时最基本的要点,有不少细节部分需要考虑:
- 构造函数:合成的默认构造函数、默认构造函数、default关键词、explicit关键字、类型转换、延迟初始化、单例模式
- 析构函数:默认析构函数、虚析构函数
- 拷贝构造函数:深拷贝/浅拷贝、禁止拷贝
- 赋值构造函数:深拷贝/浅拷贝、禁止拷贝
除了上面的细节部分,我们需要明确几个准则:
- 除非默认操作非你所需,否则请用
=default
来定义构造析构函数; - 除非编译器合成为你所需,否则请用
=delete
来定义赋值拷贝函数; - 除非类不可能成为基类,否则请将析构函数定义为
virtual
; - 构造与析构过程中,不调用virtual函数、析构函数不能抛出异常。
- 拷贝构造复制需要处理
深拷贝
和浅拷贝
,赋值操作需要额外考虑自我赋值
。
显式初始化和类型转换
首先表明我的立场:建议单参数构造函数都声明为explicit,拒绝隐式类型转换,除非你有意要隐式转换。
显式初始化主要针对于单参数构造函数,用explicit
关键字来声明构造函数——拒绝隐式类型转换。在C++中,单参数的构造函数主要承担两个功能:对象构造、类型转换。
这儿强调单参数
,是由于多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数声明为explicit,但你若是非要声明也是可以的。同时,我们只能在类内声明explicit关键词,不能再类外部重复声明。
1 | class String{ |
使用隐式类型转换需要注意的是编译器只会自动执行一步类型转换,从《C++ Primer》中摘取一个例子来看看:
1 | class SalesData { |
很多时候,隐式的类型转换逻辑上是合理的,我们设计上需要实现一个隐式转换,是可以接受的。很多编译器会自动将类型转换操作转换成对象构造操作。譬如将整形转换成年龄:
1 | class Age { |
然而大多数隐式类型转换常常会带来逻辑上的错误,而且这种错误一旦发生很难查询,所以并不推荐使用隐式转换——除非你是有意为之,否则都使用显式构造。
explicit
之所以被导入到C++,是为了提供程序员一种方法,使他们能够制止”单一参数的constructor”被当作一个conversion运算符。
《C++ Primer》课后习题中有一题很好的阐述了——何时需要隐式转换、何时需要避免隐式转换:
std::vector将其单参数构造函数定义成explicit的,而std::string则不是,你觉得原因何在?
std::string接受的单参数是const char*类型,如果我们得到了一个常量指针,则把它看做std::string对象是自然而然的过程,编译器自动把参数类型转换成类类型也非常符合逻辑,因此我们无须指定为explicit。与std::string相反,std::vector接受的单参数是int类型,这个参数的原意是指定std::vector的容量。如果我们在本来需要std::vector的地方提供一个int值并且希望这个int值自动转换成std::vector,则这个过程显得比较牵强,因此把std::vector的单参数构造函数定义成explicit的更加合理。
C的标准模板库是学习C理论实践的好地方,总结下标准库中用explicit修饰的构造函数:
- 接受一个容量参数的std::vector构造函数是explicit的。
- 接受一个指针参数的std::shared_ptr/std::auto_ptr构造函数是explicit的。
- 接受一个std::string参数的std::sstream构造函数是explicit的
- 接受一个std::string或C-String参数的std::fstream构造函数是explicit的
虚函数表
虚函数表在构造函数中建立,在析构函数中销毁,因此构造函数不可能为虚函数,而析构函数可以为虚函数。同时,调用派生类构造函数,首先会出发基类的构造函数,如果基类的构造函数调用了一个虚函数,该虚函数此时并不具备多态特性。也就是——绝不要在构造和析构过程中调用虚函数。
虚析构函数
带有多态性质的基类应该声明一个virtual析构函数,如果class带有任何virtual函数,其就应该拥有一个virtual析构函数。默认情况下,类的析构函数是非虚函数,如果基类的析构函数是虚函数,则派生类的析构函数也都是虚函数。对于多态性质的基类,如果不声明虚析构函数,则会导致内存泄漏,看看下面代码:
1 | class Ball { |
当然,如果class一定不会用作基类使用,或者即使是作为基类使用,也不会定义virtual函数,那么是可以不用价格基类声明为virtual的。
单例模式
如果程序中某个类只允许有一个实例,这个时候就需要使用到单例模式。单例模式Wrapper有很多实现,譬如Boost中的singleton,Poco中的SingletonHold。我们来看看Poco中对单例Wrapper的实现:
1 | template <class S> |
看看Poco::SingletonHolder的代码,S类型的实例仅仅创建一次。如果我们需要将单例嵌入到自己的代码逻辑中,可以按照下面代码实现:
1 | class Singleton |
下文中延迟初始化那一节也是一种单例模式的实现。
深拷贝、浅拷贝
如果一个类拥有资源,当这个类的对象发生复制过程的时候,资源重新分配,这个过程就是深拷贝,反之,没有重新分配资源,就是浅拷贝。
1 | class Test { |
禁止拷贝
如果我们不需要编译器为我们实现默认拷贝构造函数,我们需要主动地驳回编译器这一默认的行为。通常的做法有三种:
- 将相应的成员函数(这里是拷贝构造函数/赋值构造函数)声明为private,并不予实现;
- 将函数声明为delete;
- 继承一个不允许该操作的基类。
1 | class UnCopyable { |
延迟初始化
延迟初始化是一个很有用的概念。一个对象的延迟初始化(也称延迟实例化)意味着该对象的创建将会延迟至第一次使用该对象时。延迟初始化主要用于提高性能,避免浪费计算,并减少程序内存要求。
尽量将对象的定义延迟到第一次使用,甚至是直到能够给它初值实参为止。
1 | class LazyInstance { |
上述代码中,instance实例对象直到getInstance()函数被第一次调用时,才会进行初始化。对比下列代码:
1 | class LazyInstance { |
instance实例对象是静态成员,会在程序一开始调用默认构造函数初始化。
延迟初始化同时也解决了跨编译单元中非局部静态成员的初始化次序不定
的问题——函数内局部静态对象会在第一次调用时被初始化。我们通过下列例子来说明该问题(摘自Effective C++):
1 | /* FileSystem.cpp */ |
由于FileSystem.cpp和Directory.cpp是两个编译单元,其初始化次序在C++标准中并没有被定义,我们无法确定tsf是否一定在dir初始化之前初始化。解决办法就是采用延迟初始化:
1 | class FileSystem {}; |
接口设计需要考虑哪些问题?
接口设计的原则就是易用
。为了达到易用
的目的,我们需要遵循下面准则,同时也要考虑下面问题:
接口一致准则
除非有好理由,否则应该尽量令你的类的行为与内置类型一致。
接口的一致性,主要体现在两点:
- 相同操作,自定义类型的行为应该与内置类型一致;
- 相同接口名,在同一自定义类型中,其功能、使用方式也应该一样;
误操作防御准则
考虑客户在使用该接口时,可能出现哪些错误,进行针对性防御设计。(如果防御编程消耗过大,就要仔细权衡了。)
- 参数限定,譬如:
- 如果参数存在上下限、固定值集合(月份值),最好进行限定(新建月份类型);
- 如果是值传递,最好是改成常引用传递(内置类型、STL迭代器、函数对象除外);
- 如果不想改变参数值,最好声明常参数。
- 返回值限定,譬如:
- const限定(防止试图修改返回值);
- 智能指针限定(防止内存泄漏)。
私有还是公有?
为什么接口可以是私有呢?因为从广义来说,类的设计者也是类中成员的客户,私有的接口是面向类的设计者的。
对于成员函数,主要分为三种:私有、继承、公有,其安排主要是根据这三者的区别来定,没有什么特殊的地方。
对于数据成员,建议将所有数据成员声明为私有(考虑封装)。不管是类对象访问,还是派生类对象访问,都可以通过对应的getter/setter函数来操作。需要注意的是:protected并不比public更具封装性。
成员函数还是非成员函数?
-
如果接口必须要操作类私有成员,需要将接口声明为成员函数或友元函数。
-
如果接口可以通过访问成员函数来进行所需操作,建议用
非成员、非友元函数
来替换成员函数。
因为这样做可以增加封装性、包括弹性和机能扩充性。这种非成员、非友元函数
被称为便利函数,往往一个类会存在一系列不同的便利函数,功能相关的便利函数会被定义在同一个头文件内。
- 如果接口的所有参数都需要进行类型转换,那么这个函数必须是非成员函数。
1 | class Rational { |
是否需要为inline函数?
需要明确的是:inline只是对编译器的一个申请,并不是强制命令。将函数定义于class定义式内是隐喻inline的,定义于class定义式外需要显式声明为inline。通常inline函数被放置在头文件内。
如果编译器无法将你要求的函数inline化,其通常会给你一个警告信息。下列函数往往是非inline的:
- 函数内部包含循环体;
- 构造函数/析构函数/虚函数;
- 存在被改变的可能:内联函数被改变,所有用到内联函数的程序都必须重新编译,而非内联函数只需要连接就行了。
对于是否需要为inline函数的建议是:将大多数inline函数限制在小型、被频繁调用的函数身上,同时不要因为函数模板出现在头文件,就将其声明为inline。
接口与实现分离
接口与实现分离能够将编译依存性最小化,采用Handles Classes和Interface Classes能够很好地实现——相依于声明,不要相依于定义式。程序库头文件应该以完全且仅有声明式的形式存在,这种做法不论是否涉及template都适用。
如果我们按照一般的C++教材上的方式现实代码,编译依存关系会导致很严重的重新编译和连接:
1 | /* Person.h*/ |
上面代码,如果Data.h中的内容或其依赖的内容有任何改变,所有包含Person.h头文件的源文件都需要重新编译和连接。
Handles Classes利用pimpl idiom(pointer to implementation)思想,利用该思想来重写上面代码:
1 | /* Person.h */ |
Interface Classes利用抽象基类和多态的特性,将接口定义在接口类中,而具体实现使用具体类,改写Person代码如下:
1 | /* PersonI.h */ |
两种改写方法都存在一定的代价,一般程序库头文件推荐使用Interface Classes。
接口实现需要考虑哪些问题?
返回值
- 绝不要返回指向一个函数局部自动对象的指针或引用。
- 绝不要返回指向一个函数内部堆对象的引用,除非你能完全杜绝内存泄漏问题。
- 绝不要返回指向一个函数局部静态对象的指针或引用,如果有可能同时需要多个这样的对象。
1 | int& getStatic(int param) { |
注意:第三种情况,并不是要我们完全不要返回,譬如我们需要某类型的唯一实例,返回函数局部静态变量是可行的:
1 | Person& getPerson() |
- 避免返回对象内部成分(私有成员:数据和函数)的句柄(指针或引用),例外:operator[]。
1 | class Rect { |
类型转换
优良的C++代码很少使用转型,但是也无法完全摆脱转型。考虑使用转型时,需要遵守下列准则:
- 尝试使用无需转型操作的设计替代转型动作,譬如:使用类的多态特性取代dynamic_cast转型;
- 将转型操作隐藏在某个函数背后,如果需要转型时,调用该函数,而不是直接将转型操作放进代码内;
- 使用C++新式转型操作取代旧式转型操作。
转型操作有时很难避免,因此我们需要清楚的掌握四种转型操作(static_cast、const_cast、reinterpret_cast、dynamic_cast)的差异与缺陷:
dynamic_cast
:影响程序效率,同时也是程序异常的潜在因素,尽量拒绝使用dynamic_cast转型操作;const_cast
:去除常量性,如果需要获取常量性,需要使用static_cast;reinterpret_cast
:不可移植,在指针转换的时候可以使用,但是要慎用;static_cast
:上面三种转型之外的转型操作都可以由static_cast来负责。
转型操作切记试图揣摩编译器中对象的布局,因为对象的布局是随着不同的编译器而不同的。
资源释放
通过良好的接口实现,接口使用者,无须考虑资源释放等问题。资源释放问题可以通过智能指针很好的解决。使用std::shared_ptr和删除器,我们能很好的解决资源释放问题。
1 | std::shared_ptr<Resource> res(getResource(), releaseResource); |
异常安全
当异常被抛出后,异常安全的函数不会泄露任何资源,也不允许数据败坏。异常安全函数能够提供下列三种保证之一:
- 基本承诺:如果异常被抛出,程序内的任何事物都保持在有效状态下(并不保证是原来的状态)。
- 强烈保证:如果异常被抛出,如果函数成功,就会完全成功,如果函数失败,就会恢复到调用函数之前的状态。
- 不抛保证:绝不抛出异常,程序总能完成它们原先承诺的功能。
我们推荐:对于C++函数,需要提供强烈保证,尽量不抛保证。
为了完成异常安全保证,我们需要知道哪些操作不抛异常,哪些操作会抛出异常,哪些操作有助于异常安全保证:
- 作用于内置类型身上的所有操作都是nothrow保证的;
- 动态内存如果无法找到足够内存以满足需求会抛出bad_alloc异常;
- swap函数提供不抛保证,利用copy-and-swap特性能够很轻松的完成强烈异常保证(当然也存在例外);
1 | struct PMImpl { |
有时候我们是做不到强烈异常保证的,譬如消耗过大、程序结构复杂、历史代码等,这个时候就需要退而取其次——基本保证。函数提供的异常安全性保证通常最高只等于其所调用的各个函数的异常安全保证中的最弱者。所以,即使我们努力的保证了自己写的代码是强烈异常安全的,我们还是可能调用非此保证的代码。
标准库版本的swap函数时不抛出异常的,如果我们需要自己实现swap函数,也务必要保证其不抛出异常——因为swap的一个最好的应用是帮助classes提供强烈的异常安全性保证。
我们还需要注意一定:构造函数和析构函数一定不能抛出异常,否则会导致资源泄漏。