终于学习到面向对象的设计思想了吗?不容易啊!
Item32: Make sure public inheritance models “is-a”
public inheritance 的实际含义就是“is-a”。
“If you write that class D (“Derived”) publicly inherits from class B (“Base”), you are telling C++ compilers (as well as human readers of your code) that every object of type D is also an object of type B, but not vice versa.”
子类公共继承了父类,实际上就是在说,子类的某个对象,在逻辑上也是被包含在父类中的。子类是父类的特化,父类是子类的泛化。
在一切需要使用父类对象的时候,使用子类对象也一定是合法的——因为子类对象在逻辑上同时也是父类的对象。
父类有的特征(成员),子类一定有(但不能直接访问父类的 private 成员);子类有的特征(成员),父类通常没有。
不要被直觉误导,自然语言的表述是不准确的:
- “鸟会飞”指的是“大部分鸟会飞”,而不是“所有鸟都会飞”。
- 如果我们要写一个
Bird类,那么我们不能将fly()定义为Bird类中的纯虚函数,因为fly()不是所有鸟类共有的特征。
继承其实远比我们想的复杂:有些事情,可以对基类做,但是不能对派生类做,但 public inheritance 的语义是“所有对基类能做的操作,一定能对派生类做”。这就很麻烦了。
所以,我们以前建立的语义直觉不一定是正确的,需要重新学习。
Things to Remember:
- Public inheritance means “is-a”. Everything that applies to base classes must also apply to derived classes, because every derived class object is a base class object.
Item33: Avoid hiding inherited names
这一节我们探讨类继承中 hiding inherited names 的问题。
我们知道,C++ 中每个变量/函数有各自的作用域 (scope)。对于同名变量/函数而言,调用它时,会由内而外一步步搜索各个作用域中的该变量/函数的名字,直到找到第一个名字符合要求的变量/函数。
在类继承时,作用域的关系是:派生类的作用域被包在基类的作用域之内。所以,当我们在类中调用某个变量/函数时,同样符合上面的规则。
但有一点需要注意:基类中具有相同函数名的全部函数(包括参数列表不同的重载函数),都会被派生类中的同名函数 hiding names。
具体可以看下面的例子:
| |
基类中所有名为 mf1 的函数,不论其有无参数,都会被派生类中的同名函数“隐藏”。这是我们在 public 继承中不希望看到的,因为在前一节中我们提到,public 表示“is-a”的语义,即派生类在逻辑上应当就是基类,派生类需要有基类的所有基础功能。
那么怎么办呢?使用 using 关键字,让基类中被隐藏的成员函数在派生类中也可见:
| |
但有时候,我们不想用 public 继承(也就是不需要“is-a”语义的时候),也不想让派生类拥有基类所有的函数实现,只是希望可以调用部分函数。这时候,可以直接定义一个 inline forwarding function,直接调用基类的对应函数:
| |
注:如果 Base::mf1() 被声明为 virtual void mf1() = 0;,那么必须在某处提供 Base::mf1() 的定义(例如 void Base::mf1() {}),否则链接时会报“未定义引用”。
Things to Remember:
- Names in derived classes hide names in base classes. Under public inheritance, this is never desirable.
- To make hidden names visible again, employ
usingdeclarations or forwarding functions.
Item34: Differentiate between inheritance of interface and inheritance of implementation
这一节教我们,需要根据继承的目的,来选择是否将一个类成员函数声明为纯虚函数、一般的虚函数,还是非虚函数。
继承通常有三种不同的需要:
- 只继承接口
- 同时继承接口和实现,但需要派生类 override。
- 同时继承接口和实现,不允许派生类 override。
这三种继承,各自需要不同的函数类型来实现。简单来说:
- 只继承接口,需要使用纯虚函数。
- 同时继承接口和实现且需要 override,需要使用非纯虚的虚函数。
- 同时继承接口和实现且不允许 override,需要使用非虚函数。
补充一点:非虚函数实际上代表着基类的不变约束 (invariant over specialization),即它对应的行为在所有的派生类中应该保持一致,不应该被改变。
需要注意的是,有时候我们希望基类提供一个默认实现,同时派生类能选择是否继承这个默认实现。这时候,有两种做法:
- 使用纯虚函数,同时在基类在
protected中提供一个默认实现的函数,让需要继承默认实现的派生类直接调用这个函数,而需要 override 的派生类则自己实现。 - 使用纯虚函数,同时给纯虚函数提供一个默认实现(是的,我们知道纯虚函数可以有默认实现,只是调用时需要加上基类的前缀),让需要继承默认实现的派生类直接调用纯虚函数的默认实现,而需要 override 的函数则自己实现。
这样,使用了纯虚函数,就可以避免“在未确认的情况下,自动继承默认实现”的事情出现。
注意,不论是将所有函数都声明为非虚函数,还是将所有函数都声明为虚函数,都是不太好的做法。我们需要根据实际情况,自己做出最合适的决策。
Things to Remember:
- Inheritance of interface is different from inheritance of implementation. Under public inheritance, derived classes always inherit base class interfaces.
- Pure virtual functions specify inheritance of interface only.
- Simple (impure) virtual functions specify inheritance of interface plus inheritance of a default implementation.
- Non-virtual functions specify inheritance of interface plus inheritance of a mandatory implementaion.
Item35: Consider alternatives to virtual functions
当你设计一个类层次结构,需要让不同的派生类提供各自的行为实现时,最容易想到的方案就是把函数声明为 virtual。作者用游戏角色类的设计来展开讨论:不同类型的角色可能有不同的血量计算方式,把 healthValue 设计成 virtual function 似乎顺理成章:
| |
但正因为这个方案太 obvious,我们往往不会停下来想想有没有其他可能性。这个 item 的目的就是帮你跳出这个思维惯性。作者介绍了四种替代方案。
NVI (Non-Virtual Interface) Idiom
有一种思路认为,virtual functions 几乎总是应该声明为 private。具体做法是:把 healthValue 保留为 public non-virtual 成员函数,由它去调用一个 private virtual 函数(比如叫 doHealthValue),让后者完成实际工作:
| |
这就是 NVI idiom,它是 Template Method 设计模式的一种具体体现。我把那个 public non-virtual 函数(healthValue)称为 virtual function 的 wrapper。
这么做最大的好处是:wrapper 可以在调用前后插入固定逻辑。”before” 部分可以做加锁、写日志、检查类不变量和函数前置条件之类的事情;”after” 部分可以做解锁、检查后置条件、重新验证类不变量之类的事情。如果让 client 直接调用 virtual function,这些操作根本无法可靠地实施。
有一个常见的疑惑:NVI idiom 要求派生类去重定义一个 private virtual 函数——派生类自己都调用不了它!但这并不是设计上的矛盾。重定义一个 virtual function,指定的是”怎么做”;调用一个 virtual function,指定的是”什么时候做”。这两个关注点是独立的。基类保留对执行时机的控制权,派生类只负责提供具体的实现。
不过 NVI 不强制要求 virtual function 一定是 private。如果派生类的实现需要调用基类的对应版本,那 virtual function 应该声明为 protected。有些场景下(比如多态基类的析构函数),virtual function 甚至必须是 public,这时 NVI 就不适用了。
Strategy via Function Pointers
NVI idiom 本质上还是在用 virtual function。一个更彻底的思路是:把血量计算完全移出角色类的继承体系。具体做法是让每个角色的构造函数接收一个函数指针,指向实际的健康值计算函数:
| |
这是 Strategy 设计模式的一个简单应用。对比 virtual function 方案,它的灵活性体现在几个方面。
同一角色类型的不同实例可以使用不同的计算函数:
| |
你甚至可以在运行时替换计算函数——只要给 GameCharacter 加一个 setHealthCalculator 成员函数就行。
代价是计算函数不再是一个成员函数,因此无法访问角色的非公开成员。如果精确计算血量需要用到 private 信息,你就得权衡了:要么削弱封装性(把计算函数设为 friend,或者暴露公开的 accessor),要么换一种方案。
Strategy via std::function
函数指针的做法虽然灵活,但限制也比较多:计算函数必须是一个普通函数(不能是 function object 或 member function),返回类型也必须是 int。用 std::function 替换函数指针,可以把这些限制全部解除:
| |
std::function<int(const GameCharacter&)> 是一种”目标签名”——它描述的是”接受一个 const GameCharacter&,返回一个 int”的可调用实体。只要签名兼容(参数类型可以隐式转换,返回类型可以隐式转换成 int),任何 callable entity 都可以往里塞:
| |
最后一个例子值得展开说一下。GameLevel::health 表面上只接受一个 GameCharacter 参数,但作为 member function,它实际上有一个隐式的 this 参数,所以总共有两个参数。std::bind 把 currentLevel 绑定为每次调用时的 GameLevel 对象,从而生成一个只接受 GameCharacter 的可调用实体。这种灵活性是原始函数指针完全做不到的。
原书使用的是
std::tr1::function和std::tr1::bind,这是 C++03 时代 TR1 库中的名字。C++11 把它们并入了标准库,去掉了tr1::前缀,所以现在直接写std::function和std::bind即可。在现代 C++ 中,更推荐用 lambda 表达式替代std::bind,代码会更简洁直观。
Classic Strategy Pattern
如果你更喜欢传统的面向对象风格,可以把血量计算逻辑单独抽取成一个独立的类层次结构:
| |
对应的类图是:GameCharacter 是角色层次结构的根(EvilBadGuy、EyeCandyCharacter 等派生自它);HealthCalcFunc 是计算算法层次结构的根(SlowHealthLoser、FastHealthLoser 等派生自它);每个 GameCharacter 对象持有一个 HealthCalcFunc 指针。
熟悉 Strategy 设计模式的开发者一眼就能认出这种写法。它的一个额外好处是容易扩展:当你需要调整某个计算算法时,只需在 HealthCalcFunc 的继承体系中新增一个派生类即可。
Things to Remember
- Virtual function 的替代方案包括 NVI idiom 以及多种形式的 Strategy 设计模式。NVI idiom 本身就是 Template Method 模式的一种体现。
- 把功能从成员函数移到类外部,意味着失去了对该类非公开成员的访问权。这可能会迫使你削弱封装性。
std::function对象可以扮演广义函数指针的角色,能持有任何与目标签名兼容的可调用实体。
Item 36: Never Redefine an Inherited Non-Virtual Function
问题的提出
假设 D 以 public 继承自 B,B 有一个 non-virtual 函数 mf。给定一个 D 类型的对象 x,以下两种调用方式:
| |
直觉上,它们应该表现一致,因为调用的是同一个函数、同一个对象。但如果 D 重新定义了 mf,且 mf 是 non-virtual,结果就会出人意料:
| |
静态绑定与动态绑定
这种两面行为的根源在于绑定方式的不同。
non-virtual 函数是 statically bound。pB 的类型是 B*,所以通过 pB 调用的 non-virtual 函数永远是 B 中定义的版本,即使 pB 实际指向一个 D 对象。Virtual 函数则不同,它是 dynamically bound,调用哪个版本取决于指针实际指向的对象类型,而非指针的声明类型。
用表格对比:
| 特性 | non-virtual | virtual |
|---|---|---|
| 绑定时机 | 编译期 | 运行期 |
| 依据 | 指针/引用的声明类型 | 对象的实际类型 |
pB->mf() vs pD->mf() | 可能调用不同函数 | 一定调用同一函数 |
实用层面的问题
从工程角度看,一个 D 对象在 mf 被调用时,有时表现得像 B,有时像 D,决定因素居然是指针的声明类型,而非对象本身。这种不一致极易导致难以追踪的 bug。References 和 pointers 在此表现完全相同,因此问题不会因为使用引用而消失。
理论层面的论证
Item 32 指出 public inheritance 意味着 is-a,即凡适用于 B 对象的也适用于 D 对象。Item 34 则说明,在类中声明 non-virtual 函数,意味着为该类的所有特化版本建立了一个 invariant——这个函数的行为在继承体系中应当保持不变。
将这两点应用于 B::mf:
- 每个
D都是B,所以D对象在mf上的行为应与B对象一致。 mf是 non-virtual,所以B已经规定了mf的实现方式,派生类必须同时继承接口和实现。
若 D 重新定义 mf,设计就出现矛盾。假设 D 确实需要不同的实现,那么只有两种可能:
D不是真正的B——此时不应使用 public inheritance。mf并非 invariant——此时mf应当是 virtual。
无论哪种情况,重新定义 inherited non-virtual 函数都是错误的。
与 Item 7 的关系
Item 7 要求多态基类的析构函数必须是 virtual。这其实是 Item 36 的特例:若基类析构函数为 non-virtual,派生类不可避免地会重新定义它(即使不显式声明,编译器也会自动生成)。因此,Item 7 本质上是从 Item 36 推导出的必然结论,只是因其重要性而单独成条。
Things to Remember
Never redefine an inherited non-virtual function.
这条规则没有例外。如果派生类需要不同的行为,要么将基类函数改为 virtual,要么重新审视继承关系本身是否合理。
Item37: Never redefine a function’s inherited default parameter value
讨论范围的限定
Item 36 已经论证过,重新定义 inherited non-virtual 函数永远是错误的。因此 Item 37 的讨论范围可以安全地限定在一种情形:继承一个带有默认参数值的 virtual 函数,并在派生类中修改这个默认值。
核心矛盾
Virtual 函数是 dynamically bound 的,但 default parameter values 是 statically bound 的。这个不对称是问题的根源。
考虑如下类层次:
| |
定义三个指针:
| |
ps、pc、pr 的 static type 都是 Shape*,这与它们实际指向的对象无关。pc 的 dynamic type 是 Circle*,pr 的 dynamic type 是 Rectangle*。virtual 函数的调用由 dynamic type 决定:
| |
但当调用使用默认参数时,情况变得反常:
| |
pr 的 dynamic type 是 Rectangle*,因此调用的是 Rectangle::draw。但 pr 的 static type 是 Shape*,而 default parameter values 由 static type 决定,所以实际传入的参数是 Shape::Red 而非 Rectangle::Green。最终得到一个由两个类的声明拼凑而成的调用,这几乎一定不是程序员的本意。
这个行为与指针还是引用无关,关键在于 draw 是 virtual 函数,且其默认参数值在派生类中被重新定义了。
为什么 C++ 这样设计
如果 default parameter values 也采用 dynamic binding,编译器必须在运行时确定 virtual 函数的适当默认值,这比编译期确定更慢、更复杂。C++ 选择在效率和实现简洁性上妥协,代价就是程序员若忽视本条规则,会得到令人困惑的行为。
重复默认参数的陷阱
遵守规则的一个直接做法是在基类和派生类中写相同的默认值:
| |
但这引入了 code duplication,更糟的是带有依赖关系的重复:一旦 Shape 中的默认值改变,所有重复该值的派生类也必须同步修改,否则就再次违反了本条规则。
NVI Idiom 作为解决方案
Item 35 中讨论的 non-virtual interface idiom 可以解决这个问题。让 public non-virtual 函数负责指定默认参数,同时调用一个 private virtual 函数完成实际工作:
| |
由于 non-virtual 函数不应被派生类重新定义(Item 36),这个设计明确保证了 draw 的默认参数值永远是 Red。派生类只负责实现 doDraw,无需关心默认参数。
静态绑定与动态绑定的对比
| 特性 | 函数调用 | 默认参数值 |
|---|---|---|
| 绑定机制 | dynamic binding | static binding |
| 决定依据 | 对象的 dynamic type | 指针/引用的 static type |
| 修改风险 | 多态行为符合预期 | 参数来源与函数来源不一致 |
需要记住的
Never redefine an inherited default parameter value. Default parameter values are statically bound, while virtual functions are dynamically bound.
Item 38: Model “has-a” or “is-implemented-in-terms-of” through Composition
什么是 Composition
Composition 是指一个类型的对象包含另一个类型的对象。例如:
| |
Person 对象由 string、Address 和 PhoneNumber 对象组合而成。在程序员圈子里,composition 还有很多别名:layering、containment、aggregation、embedding,它们指的都是同一个基本机制。
Composition 的两种含义
Composition 实际上有两层含义,取决于你所处的领域。
在 application domain 中,composition 表达的是 has-a 关系。Person has a name,Person has an address。你不会说一个人是一个名字,或者一个人是一个地址。这种区分大多数人都能直观理解,所以 is-a 和 has-a 的混淆相对少见。
在 implementation domain 中,composition 表达的是 is-implemented-in-terms-of 关系。这时的对象不是现实世界的映射,而是软件实现的产物,比如缓冲区、互斥锁、搜索树等。
一个典型的错误
假设你需要一个表示小集合的模板,集合中不能有重复元素。你首先想到复用标准库的 set,但 set 通常用平衡搜索树实现,每个元素有三个指针的开销。你的应用更看重空间而非时间,所以标准库的 set 不适合你。
你决定自己实现,而底层用链表。标准库正好有 list 模板,于是你写了:
| |
这看起来合理,因为一个 Set 对象确实底层是一个 list 对象。但这里有问题。list 允许重复,插入两次 3051,list<int> 里就有两个 3051。但 Set 不允许重复,插入两次 3051,集合里只能有一个。
所以 Set is not a list。有些对 list 成立的事情,对 Set 不成立。Public inheritance 用错了。
正确的做法
关系不是 is-a,而是 is-implemented-in-terms-of。应该用 composition:
| |
实现可以充分利用 list 和标准库已有的功能:
| |
这些函数足够简单,可以考虑 inline,但 Item 30 的讨论应该在做决定之前回顾。
关于接口设计的说明
有人可能认为 Set 的接口应该遵循 STL 容器的约定,这样更符合 Item 18 的原则。但遵循那些约定需要添加大量东西,会掩盖 Set 和 list 之间的关系。既然这个关系正是本条要说明的重点,这里选择教学清晰性而非 STL 兼容性。而且,Set 的接口细节不应掩盖一个无可争议的事实:Set 和 list 的关系不是 is-a,而是 is-implemented-in-terms-of。
需要记住的
Composition 的含义与 public inheritance 完全不同。在 application domain 中,composition 意味着 has-a。在 implementation domain 中,它意味着 is-implemented-in-terms-of。
Item 39: Use Private Inheritance Judiciously
语义与行为
C++ 把 public inheritance 视为 is-a 关系。Item 32 展示了编译器会隐式将 Student 转换为 Person 以让函数调用成功。如果换成 private inheritance,这个转换就不会发生:
| |
Private inheritance 有两条基本规则。第一,编译器不会将派生类对象转换为基类对象。第二,从 private base 继承来的成员在派生类中变成 private,无论它们在基类中是 protected 还是 public。
含义
private inheritance 意味着 is-implemented-in-terms-of。选择让 D privately inherit from B,是因为想利用 B 的某些特性,而非因为 B 和 D 的对象之间存在概念上的关系。它是一种纯粹的实现技术,继承来的所有内容都变成 private,因为这些都只是实现细节。用 Item 34 的术语来说,private inheritance 只应继承实现,接口应当被忽略。
与 Composition 的对比
Item 38 指出 composition 也可以表达 is-implemented-in-terms-of。那么该如何选择?答案很简单:能用 composition 时就用 composition,必须用 private inheritance 时才用。
什么时候必须?主要是当 protected 成员和 virtual 函数进入视野时。还有一个边缘情况涉及空间优化,但那是边缘情况,可以稍后考虑。
典型用例:重写 Virtual 函数
假设我们在开发一个涉及 Widget 的应用,需要追踪 Widget 成员函数的调用频率,以及这些比例随时间的变化。为此需要设置一个定时器来收集统计信息。
工具箱里正好有这样一个类:
| |
Widget 需要重新定义 Timer 的 virtual 函数 onTick,但 Widget is not a Timer。让客户端能在 Widget 上调用 onTick 会误导他们,违反 Item 18 的原则。因此选择 private inheritance:
| |
Timer 的 public onTick 在 Widget 中变成 private,重新声明时保持 private。如果把它放到 public 接口,客户端会误以为可以调用。
替代方案:Nested Class + Composition
private inheritance 并非唯一选择。可以在 Widget 内部声明一个 private nested class,让它 public 继承 Timer,在那里重写 onTick,然后在 Widget 中放一个该类型的对象:
| |
这个设计更复杂,涉及 public inheritance、composition 和新类 WidgetTimer。展示它主要是为了提醒:解决问题通常有多种途径,值得训练自己考虑多种方案。
尽管如此,有两个理由可能让你倾向于这种 public inheritance + composition 的设计。
第一,你可能希望允许 Widget 有派生类,但阻止它们重写 onTick。如果 Widget 继承自 Timer,即使继承是 private,派生类仍然可以重写 virtual 函数。但 WidgetTimer 是 Widget 的 private 成员,Widget 的派生类无法访问它,自然无法继承或重写。如果你在 Java 或 C# 中用过 final 或 sealed,这就是在 C++ 中近似实现相同行为的方法。
第二,你可能希望最小化 Widget 的编译依赖。如果 Widget 继承自 Timer,编译 Widget 时必须能看到 Timer 的定义,Widget 的头文件很可能需要 #include Timer.h。但如果 WidgetTimer 被移出 Widget,Widget 只持有一个指向 WidgetTimer 的指针,那么 Widget 只需要 WidgetTimer 的前向声明,无需包含任何与 Timer 相关的内容。对大型系统而言,这种解耦可能很重要。
边缘情况:Empty Base Optimization
Private inheritance 的另一个用途是空间优化,但这只适用于没有数据的类。这样的类没有 non-static 数据成员,没有 virtual 函数,也没有 virtual base classes。从概念上说,这种空类的对象不应占用空间,但 C++ 出于技术原因要求独立对象必须有非零大小。
| |
sizeof(HoldsAnInt) > sizeof(int)。大多数编译器中 sizeof(Empty) 是 1,因为编译器会向空对象中静默插入一个 char。而对齐要求可能导致 HoldsAnInt 的对象大小增加到一个完整的 int,实测结果通常如此。
但基类部分不是独立对象,不受此约束。如果用继承代替包含:
| |
sizeof(HoldsAnInt) == sizeof(int)。这就是 empty base optimization(EBO),所有测试过的编译器都实现了它。EBO 通常只在单继承下可行,C++ 的对象布局规则一般不允许将其应用于多继承的派生类。
实践中,空类并不真正为空。它们通常包含 typedef、enum、static 数据成员或非 virtual 函数。STL 中有许多技术上为空但包含有用成员的类,比如 unary_function 和 binary_function,用户定义的函数对象通常继承自它们。由于 EBO 的广泛实现,这种继承很少增加继承类的大小。
回到基本原则
大多数类不是空的,所以 EBO 很少能成为使用 private inheritance 的正当理由。大多数继承对应的是 is-a,那是 public inheritance 的职责。Composition 和 private inheritance 都表示 is-implemented-in-terms-of,但 composition 更容易理解,应优先使用。
Private inheritance 最可能是合理设计策略的场景,是两个非 is-a 关系的类之间,一个需要访问另一个的 protected 成员,或需要重写其 virtual 函数。即使在这种情况下,public inheritance 加 composition 的组合往往也能达到目的,只是设计更复杂。审慎地使用 private inheritance,意味着在考虑了所有替代方案之后,它仍是表达两个类之间关系的最佳方式。
需要记住的
Private inheritance 意味着 is-implemented-in-terms-of。它通常不如 composition,但在派生类需要访问基类的 protected 成员或需要重写继承的 virtual 函数时,它是合理的选择。与 composition 不同,private inheritance 可以实现 empty base optimization,这对致力于最小化对象大小的库开发者可能很重要。
Item 40: Use Multiple Inheritance Judiciously
两种立场
关于 multiple inheritance(MI),C++ 社区大体分为两派。一派认为如果 single inheritance(SI)是好的,MI 必然更好。另一派认为 SI 是好的,但 MI 不值得麻烦。本条的目标是理解这两种视角。
名字歧义
MI 引入了一个 SI 没有的问题:可能从多个基类继承同名成员。这会产生新的歧义机会:
| |
注意,即使只有一个函数是可访问的(BorrowableItem::checkOut 是 public,而 ElectronicGadget::checkOut 是 private),调用仍然是 ambiguous 的。这是因为 C++ 解析重载函数调用的规则:编译器先找出最佳匹配的函数,然后才检查可访问性。在这个例子中,checkOut 这个名字在名字查找阶段就是 ambiguous 的,所以根本不会进入函数重载解析或最佳匹配判定,ElectronicGadget::checkOut 的可访问性因此从未被检查。
解决歧义必须显式指定:
| |
显式调用 ElectronicGadget::checkOut 也可以,但歧义错误会被替换为"试图调用 private 成员函数"的错误。
菱形继承与 Virtual Inheritance
MI 常常出现在带有更高层基类的层次结构中,这可能导致所谓的"致命菱形":
| |
只要继承层次中存在多条路径连接同一个基类和派生类(如 File 到 IOFile 通过 InputFile 和 OutputFile 两条路径),就必须面对一个问题:基类的数据成员是否应该在每条路径上复制一份。假设 File 有数据成员 fileName,IOFile 应该有两个 fileName 还是只有一个?
C++ 不站队,两种方案都支持,默认行为是复制。如果不想要复制,必须让有数据的类成为 virtual base class。所有直接继承它的类都要使用 virtual inheritance:
| |
标准库中有一个类似的 MI 层次,只不过类是类模板,名字分别是 basic_ios、basic_istream、basic_ostream 和 basic_iostream。
从正确行为的角度看,public inheritance 应该总是 virtual 的。如果这是唯一的视角,规则就很简单:任何时候用 public inheritance,都用 virtual public inheritance。但正确性不是唯一的视角。避免继承字段的复制需要编译器在幕后做一些技巧,结果是使用 virtual inheritance 的类创建的对象通常比不使用时要大。访问 virtual base class 中的数据成员也比访问非 virtual base class 中的要慢。编译器之间细节不同,但基本趋势是明确的:virtual inheritance 有代价。
代价还体现在其他方面。Virtual base class 的初始化规则比非 virtual base 的更复杂、更不符合直觉。初始化 virtual base 的责任由层次结构中最派生的类承担。这条规则的含义包括:从 virtual base 派生出来的类必须意识到它们的 virtual base,无论距离多远;当层次结构中加入新的派生类时,它必须承担其 virtual base(包括直接和间接的)的初始化责任。
关于 virtual base class 的建议很简单。第一,除非需要,否则不要用。默认使用非 virtual inheritance。第二,如果必须用 virtual base class,尽量不要在里面放数据。这样就不必担心这类类的初始化(以及赋值)规则中的怪异之处。值得注意的是,Java 和 .NET 中的 Interface 在很多方面与 C++ 的 virtual base class 类似,它们不允许包含任何数据。
MI 的正当用法:接口与实现的分离
考虑下面这个建模 Person 的 Interface class(见 Item 31):
| |
IPerson 的客户端必须按 IPerson 指针和引用来编程,因为抽象类不能实例化。为了创建可操作的对象,客户端使用工厂函数来实例化 IPerson 的派生类:
| |
makePerson 如何创建返回指针指向的对象?显然必须有一个 IPerson 的 concrete 类可供实例化。假设这个类叫 CPerson。作为 concrete 类,CPerson 必须提供 IPerson 纯虚函数的实现。它可以从头写,但更好的是利用已有组件。
假设一个旧的数据库相关类 PersonInfo 提供了 CPerson 需要的大部分功能:
| |
PersonInfo 的设计目的是以各种格式打印数据库字段,每个字段值的开始和结束有特殊字符串作为分隔符。默认情况下,分隔符是方括号,所以字段值 “Ring-tailed Lemur” 会格式化为 [Ring-tailed Lemur]。PersonInfo 的虚函数 valueDelimOpen 和 valueDelimClose 允许派生类指定自己的分隔符。PersonInfo::theName 的实现大致如下:
| |
theName 调用 valueDelimOpen 生成开头分隔符,然后生成名字值本身,再调用 valueDelimClose。因为这两个函数是 virtual 的,theName 返回的结果不仅取决于 PersonInfo,还取决于 PersonInfo 的派生类。
作为 CPerson 的实现者,你发现 IPerson 的文档要求 name 和 birthDate 返回未修饰的值,不允许有分隔符。也就是说,如果某人叫 Homer,name 应该返回 "Homer" 而不是 "[Homer]"。
CPerson 和 PersonInfo 的关系是 PersonInfo 碰巧有一些函数能让 CPerson 更容易实现。仅此而已。它们的关系是 is-implemented-in-terms-of,可以用 composition(Item 38)或 private inheritance(Item 39)表示。Item 39 指出 composition 通常是首选,但如果要重新定义 virtual 函数,就必须用 inheritance。这里 CPerson 需要重新定义 valueDelimOpen 和 valueDelimClose,所以简单的 composition 不行。最直接的方案是让 CPerson private 继承 PersonInfo。同时 CPerson 必须实现 IPerson 接口,这需要 public inheritance。于是得到一个 MI 的合理应用:public 继承 Interface,private 继承 Implementation:
| |
这个例子展示了 MI 可以既有用又可理解。
总结
Multiple inheritance 只是面向对象工具箱中的另一个工具。与 single inheritance 相比,它通常更复杂、更难理解。如果有一个与 MI 设计大致等价的 SI 设计,SI 设计几乎肯定更可取。如果唯一想出的设计涉及 MI,应该再想想,几乎肯定有办法让 SI 工作。同时,MI 有时是最清晰、最可维护、最合理的方式。当那种情况出现时,不要害怕使用它,只是要确保审慎地使用。
需要记住的
Multiple inheritance 比 single inheritance 更复杂。它可能导致新的歧义问题和 virtual inheritance 的需求。Virtual inheritance 在大小、速度以及初始化和赋值的复杂性方面都有代价。当 virtual base class 没有数据时,它最实用。Multiple inheritance 有正当的用途,一种场景是结合 public 继承 Interface class 和 private 继承一个帮助实现的类。