C++编程规范:类的继承与设计1
第32条:弄清所要编写的是哪种类
- 一个值类应该:
- 有一个公用析构函数,复制构造函数和带有值语义的赋值
- 没有虚拟函数(包括析构函数)
- 总是用作具体类,而不是基类
- 总是在栈中实例化,或者是作为另一个类直接包含的成员实例化
- 一个基类应该:
- 有一个公用而且虚拟,或者保护而且非虚拟的析构函数,和一个非公有复制构造函数和赋值运算符
- 通过虚拟函数建立接口
- 总是动态地在堆中实例化为具体派生类对象,并通过一个(智能)指针使用
- traits类是携带有关类型信息的模板,一个traits应该:
- 只包含typedef和静态函数,没有可修改的状态或者虚拟函数
- 通常不实例化(其构造一般是被禁止的)
- 策略类(通常是模板)是可插拔行为的片段,一个策略类应该:
- 可能有也可能没有状态或者虚拟函数
- 通常不独立实例化,只能作为基类或者成员
- 异常类通过值抛出,但应该通过引用捕获,一个异常类应该:
- 有一个公有析构函数和不会失败的构造函数(特别是一个不会失败的复制构造函数,从异常的复制构造函数抛出将使程序中止)
- 有虚拟函数,经常实现克隆和访问
- 从std::exception虚拟派生更好
第33条:用小类代替巨类
- 小类更易于编写,更易于保证正确、测试和使用,更容易理解和部署
- 巨类会削弱封装性,更难保证正确和错误安全。
第34条:用组合代替继承
- 继承是C++中第二紧密的耦合关系,仅次于友元关系
- 组合在不影调用代码的情况下具有更大的灵活性
- 例外情况:使用公有继承模仿可替换性
- 使用到非公有继承的几种情况:
- 需要改写虚拟函数
- 需要访问保护成员
- 需要在基类之前构造已经使用过的对象,或者在基类之后销毁对象
- 能够确定空基类优化能带来好处,包括这种情况下优化的确很重要,以及这种情况下目标编译器确实能实施这种优化
- 如果需要控制多态,或者说需要可替换性关系,但是关系应该只对某些代码可见(通过友元)
第35条:避免从并非要设计成基类的类中继承
- 将独立类用作基类是一种严重的设计错误,应该避免。 要添加行为,应该添加非成员函数而非成员函数 要添加状态,应该使用组合而不是继承 要避免从具体的基类中继承
- 删除指向派生类对象的基类指针会产生未定义行为
第36条:优先提供抽象接口
- 抽象接口是完全由虚函数构成的抽象类,没有状态(成员数据),通常也没有成员函数实现 抽象基类应该定义功能,而不是实现功能
- 依赖性倒置原则(Dependency Inversion Principle,DIP):
- 高层模块不应该应该低层模块,相反,两者都应该依赖抽象
- 抽象不应该依赖于细节,相反,细节应该依赖于抽象
- DIP的三个设计优点:
- 更强的健壮性。系统中较不稳定的部分(即实现)依赖于更稳定的部分(即抽象)
- 更大的灵活性。基于抽象接口的设计通常更加灵活,如果能正确地建模抽象,那么就能很容易地对新的需求设计新的实现
- 更好的模块性
- 二次机会定律(Law Of Second Chances):需要保证正确的最重要的东西是接口,其他所有东西以后都可以更改,如果接口弄错了,可能再也不允许修改了。
- 例外情况:空基类优化(EBO)是一个纯粹为了优化而使用继承(最好是非公有的)实例
第37条:公有继承即可替换性。继承不是为了重用,而是为了被重用
- 不要通过公用继承重用(基类中已有的)代码,公用继承是为了被重用的。
- Liskov替换原则(Liskov Substitution Principle):公用继承所建模的必须总是“是一个(is-a)”关系:所有基类约定必须满足这一点,因此如果要成功地满足基类的约定,所有虚拟成员函数的改写版本就必须不多于其基类版本,其承诺也必须不少于其基类版本。
- 公用继承的目的是实现可替换性,而不是为了派生类重用基类的代码,从而用基类代码实现自己
- 例外情况:策略类和混入类(为虚拟基类提供部分而不是全部实现的类)通过公用继承添加行为,但是这并不是误用公用继承来建模“用…来实现”关系。
第38条:实施安全的覆盖
- 要保持基类中函数的前后置条件,不要改变虚拟函数的默认参数,应该显式地将覆盖函数重新声明为virtual,谨防在虚拟类中隐藏重载函数
- 覆盖函数可以要求更少而提供更多,但是不能要求更多而承诺更少
- 在覆盖的时候,永远不要修改默认参数,它们不是函数签名的一部分,客户代码将因为不知情而将不同参数传递给函数,但是具体要传递哪一个参数,将取决于它们具有层次结构中哪个节点的访问权限。
- 如果基类的重载函数应该课件,则应该写一条using声明语句,在派生类中重新声明。
第39条:考虑将虚拟函数声明为非公用的,将公用函数声明为非虚拟的
- 应该将虚拟函数设为私有的,或者如果派生类需要调用基类版本,则设为保护的。
- 将公用函数设为非虚拟的,将虚拟函数设为私有的,这就是所谓的非虚拟接口(Non Virtual Interface, NVI)模式
- 公用虚拟函数本质上有两种不同而且相互竞争的职责:
- 它指定了接口。作为公用函数,它是类向外界提供的接口的一部分。
- 它指定了实现细节。作为虚拟函数,它为派生类替换函数的基类实现提供了一个自定义点。
- 例外情况:NVI对析构函数不适用,因为他们的执行顺序很特殊
- NVI不支持调用者的协变返回类型,如果需要协变量对调用代码可见,而又不适用dynamic_cast向下强制,则将虚拟函数设为公有的会更容易。
C++中,只要原来的返回类型是指向类的指针或引用,新的返回类型是指向派生类的指针或引用,覆盖的方法就可以改变返回类型。
第40条:避免提供隐式转换
- 在C++中,一个转换序列最多只能包含一个用户定义的转换
- 避免隐式转换的方式:
- 默认时,为单参数构造函数加上explicit
class Widget{ explicit Widget(unsigned int widgetizationFactor); explicit Widget(const char* name, const Widget* other = 0); }; - 使用提供转换的命名函数代替转换操作符
class String{ const char* as_char_pointer() const; };
- 默认时,为单参数构造函数加上explicit
- 标准std::string定义了一个参数类型为const char*的隐式构造函数,设计者为其采取了预防措施:
- 没有目标为const char*的自动转换,这种转换是通过两个命名函数c_str()和data()提供的
- 对所有为std::string定义的比较操作符都进行了重载,从而能够以任意顺序比较const char*和std::String,这样就避免了创建隐藏的临时变量。