条款 31 - 条款 40 | 《Effictive C++》笔记
Effictive C++ 条款 31 - 条款 40。
条款 31:将文件间的编译依存关系降至最低(接口与实现分离)
如果类 A 的成员为另一个类 B 而非 B 的指针,那么当这个 B 的代码发生变动的时候 A 也要重新编译,这是因为编译 A 的时候需要申请和分配其成员的内存,如果其成员的内容发生变化,A 的内存结构也会改变,需要重新编译。如果将 B 换成 B 的指针,则对于上述情况就不需要重新编译。
条款 32:确定你的 public 继承塑模出 is-a(是一个)关系
public 继承意味 is-a:适用于基类身上的每一件事情一定也适用于派生类身上,因为每一个派生类对象也都是一个基类对象。
如果基类是矩形,那么正方形能不能作为派生类 public 继承矩形?答案是不可以,因为长宽不一致这件事适用于矩形这个基类,但是却不适用于正方形这个派生类。
条款 33:避免隐藏继承而来的名称
这里需要区分下重载、重写(覆盖)和隐藏。其中重载和重写都是比较正常的行为,隐藏则需要避免。
- 重载比较常见,主要发生在同一个作用域的两个同名但是参数列表不同的函数之间,调用时编译器会根据调用时使用的参数列表选择具体使用哪个函数。
- 重写(覆盖)是派生类实现或者重新实现基类函数的方式,其函数名、参数列、返回值类型必须与基类的函数完全一致。
- 如果派生类声明了一个和基类中的某些函数名称相同但是参数列表不同的函数,不管这些基类函数中是不是虚函数,它们都会被隐藏,也就是说派生类无法继承这些函数
1 | class Base { |
使用 using 声明式可以解决隐藏问题:
1 | class Derived : public Base { |
条款 34:区分接囗继承和实现继承
- 声明一个纯虚函数的目的是为了让派生类之继承函数接口
- 声明非纯虚函数的目的是让派生类继承该函数的接口的默认实现
- 但是这样可能有风险:某个非纯虚函数不适用于某个派生类,但是该派生类忘记重写
- 解决方法:将接口和默认实现分离,接口使用纯虚函数,默认实现使用另外一个单独的函数实现
- 声明非出现怒函数的目的是为了令派生类继承函数的接口以及一份强制性实现
条款 35:考虑 virtual 函数以外的其他选择
virtual 函数在派生中经常用到,在遇到一些问题时用 virtual 函数没问题,但是有时候我们应该思考一下是否有替代方案,以此来拓宽我们的视野。
假如现在正在写一个游戏,游戏中人物的血量随着战斗而减少,用一个函数 healthValue 返回这个血量值。因为不同人物血量值计算方法不同,所以应该将 healthValue 声明为 virtual:
1 | class GameCharacter { |
healthValue 并未被声明为纯虚,这暗示将会有个计算血量指数的默认算法。这是个很明白清楚的设计,正是因为如此,我们可能没有考虑其他替代方案。为了跳岀常规,我们来考虑一些其他解法。
通过 Non-Virtual Interface 方法实现 Template Method 模式
很多人主张 virtual 函数应该几乎总是 private,较好的设计是保留 healthvalue 为 public 非虚成员函数,让它调用一个 private 虚函数来做实际工作:
1 | class GameCharacter { |
这个设计是让用户通过 public 非虚成员函数间接调用 private 虚函数,这便是 non-virtual interface(NVI) 方法。它是所谓 Template Method 设计模式(与 C++ templates 没关系)的一个独特表现形式。这个非虚函数叫做该虚函数的 wrapper。
通过函数指针实现 Strategy 模式
除了 NVI 以外,还有一个主张:人物血量指数的计算和 GameCharacter 类无关,只与具体对象有关。这样就可以在 GameCharacter 的构造函数接受一个指针,指向血量计算函数:
1 | int defaultHealthCalc(const GameCharacter& gc); // 血量计算默认算法 |
这种方法和使用虚函数相比,有更多弹性:
- 同一人物类型之不同实体可以有不同的血量计算函数。也就是说同一人物类型不同的对象可以有不同的血量计算方式,例如在射击游戏中,一些购买防弹衣的玩家使用的对象血量减少更慢
- 某已知人物血量计算函数可以在运行期间变更
通过 std::function 完成 Strategy 模式
可以不用函数指针,而是用 std::function 对象。这样的对象可以持有任何可调用物,包括函数指针、函数对象、成员函数指针等,只要其签名式兼容于需求端:
1 | int defaultHealthCalc(const GameCharacter& gc); |
std::function 类型产生的对象可以持有任何与此签名兼容的可调用物。兼容是指这个可调用物的参数可被隐式转换为 const Game Character&,且其返回类型可以被隐式转换为 int。这种方法提供了更大的弹性:
1 | short calcHealth(const GameCharacter&); // 血量计算函数,返回类型不是 int |
古典的 Strategy 模式
在上面的 UML 图中,GameCharacter 是某继承体系中的基类,EvilBadGuy 和 EyeCandyCharacter 是派生类。HealthCalcFunc 是另
一继承体系的基类,SlowHealthLoser 和 FastHealthLoser 是其派生类。每个 GameCharacter 对象都还有指针,指向来自 HealthCalcFunc 继承体系的对象。
1 | class HealthCalcFunc { |
如果需要添加新的血量计算方法,只要为 HealthCalcFunc 继承体系添加一个派生类即可。
条款 36:绝不重新定义继承而来的 non-virtual 函数
对于 public 继承于基类的 B 的派生类 D,如果 D 重新定义了非虚函数 func_c,那么就违背了条款 32 的“每个 D 对象都是一个 B 对象”原则。
条款 37:绝不重新定义继承而来的缺省参数值
虚函数是动态绑定,但是其默认参数值却是静态绑定。这就意味着可能会调用一个定义于派生类内的虚函数时会使用基类中定义的默认参数值。
解决方法是使用 NVI(non-virtual interface):令基类中的一个 public 非虚函数调用私有的虚函数,后者可以被派生类重写。
条款 38:通过复合建模出 has-a 或“根据某物实现出”
- 复合是类型间的一种关系,当某种类型的对象包含其他类型的对象,便是这种关系
- 复合意味着 has-a(有一个)和 is-implemented-terms-of(根据某物实现出)
- 区别于 public 继承的 is-a,复合可以表示 has-a,如 set 对象可以根据 list 对象实现出来,但是它们之间却不适用于 public 继承的关系
条款 39:明确而审慎地使用 private 继承
private 继承的两条规则:
- 如果类间的继承关系是 private,编译器不会自动将一个派生类对象转换为一个基类对象
- 通过 private 继承来的所有成员在派生类中都会变成 private 属性
private 继承意味着根据某物实现出,而上一条款中的复合的意义也是这样,那在实际使用时如何做出选择呢,答案很简单:尽量使用复合,必要时才使用 private 继承。何时才是必要?
- 当需要使用基类中的 protected、private 成员的时候
- 需要重新实现基类中的虚函数的时候
- 程序对空间的使用有极致的要求的时候
上述第三条可以通过下面这个例子说明,如果这样做:
1 | class Empty {}; |
会发现 sizeof(HoldsAnInt) > sizeof(int)。在大多数编译器中 sizeof(Empty) 为 1 是因为 C++ 规定独立(非附属)对象必须有非 0 大小,编译器会在上述的 Empty 中安插一个 char,而如果还有对齐需求,编译器还可能会为该对象进行补位,这就可能造成 HoldsAnInt 对象不只额外获得一个 char 大小甚至实际上被放大到足够再存放一个 int。
但是这个限制仅针对“独立(非附属)”对象,不适用于派生类对象中的基类成分,如果这样做:
1 | class HoldsAnInt : private Empty { |
几乎可以确定 sizeof(HoldsAnInt) > sizeof(int)。这是所谓的 EBO(empyt base optimization,空白基类最优化)。
条款 40:明确而审慎地使用多重继承
- 虚继承会增加大小、速度、初始化(及赋值)复杂度等等成本。如果必须使用虚基类,尽可能避免在其中放置数据
- 多重继承的确有正当用途。其中一个场景是“public 继承某个接口类”和“private 继承某个协助实现的类”的组合
条款 31 - 条款 40 | 《Effictive C++》笔记