条款37 决不要重新定义继承而来的非虚函数
实践依据
假设类D公有继承于类B, B中定义了公有成员函数mf; mf的参数和返回类型假设为void;
|
1
2
3
4
5
6
7
8
|
class B
{public: void mf();...};class D: public B
{ ... };//D
x; //
x 是类型D 的一个对象 |
那么, 如果这么做: ]
|
1
2
|
B
*pB = &x; //
得到x 的指针pB->mf(); //
通过指针调用mf |
和下面这么做的执行行为不同, 这一定会出乎意料;
|
1
2
|
D
*pD = &x; //
得到x 的指针pD->mf(); //
通过指针调用 mf |
因为两种情况下调用的都是对象x的成员函数mf; 但是如果mf是非虚函数而D又定义了自己的mf, 行为就会不同;
|
1
2
3
4
5
6
7
|
class D: public B
{public: void mf(); //
隐藏了B::mf; 参见条款50...};pB->mf(); //
调用B::mfpD->mf(); //
调用 D::mf |
原因:
B::mf和D::mf作为非虚函数是静态绑定的(条款38); 因为pB被声明为指向B的指针类型, 通过pB调用非虚函数将调用B中的函数, 即使pB指向的是派生类对象;
相反, 虚函数是动态绑定的, 所以不会有这种问题; 如果mf是虚函数, 通过pB或pD调用mf都将调用D::mf, 因为pB和pD实际上指向的都是类型D的对象;
结论:
如果写D时重新定义了从B继承的非虚函数mf, D的对象就可能表现出分裂的行为; D的对象在mf被调用时, 行为有可能像B, 也有可能像D, 决定因素和对象本身没有关系, 而是取决于指向它的指针所声明的类型; 引用和指针情况一样;
理论依据
条款35说明公有继承的含义是Is-a, 条款36说明类中声明非虚函数实际上是建立了特殊的不变性; 那么: 适用于B对象的一切也适用于D对象, 因为D对象Is-a B对象; B的子类必须同时继承mf的接口和实现, 因为mf是非虚;
如果D重新定义了mf, 设计中就产生矛盾: 如果D是需要实现和B不同的mf, 而且每个B的对象是需要使用B实现的mf, 那么D对象将不是Is-a B对象; 这种情况D不能从B公有继承; 反之, 如果D必须从B公有继承, 而且D需要和B不同的mf的实现, 那么mf应该作为虚函数; 最后: 如果每个D Is-a B, 并且mf真的为B建立了特殊的不变性, 那么D就不能重新定义mf;
Note 任何条件下都要禁止重新定义继承而来的非虚函数;
条款38 决不要重新定义继承而来的缺省参数值
缺省参数只能作为函数的一部分而存在; 只有2种函数可以继承: 虚函数和非虚函数; 重定义缺省参数值的唯一方法是重定义一个继承而来的函数, 重定义非虚函数是一种错误(条款37); 所以, 范围缩小为"继承一个有确实参数值的虚函数";
理由: 虚函数是动态绑定的, 而缺少参数值是静态绑定的;
对象的静态类型指声明的存在于程序代码文本中的类型;
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
enum ShapeColor
{ RED, GREEN, BLUE };//
一个表示几何形状的类class Shape
{public: //
所有的形状都要提供一个函数绘制它们本身 virtual void draw(ShapeColor
color = RED) const =
0;...};class Rectangle: public Shape
{public://
注意:定义了不同的缺省参数值 ---- 不好! virtual void draw(ShapeColor
color = GREEN) const;...};class Circle: public Shape
{public: virtual void draw(ShapeColor
color) const;...}; |
使用指针:
|
1
2
3
|
Shape
*ps; //
静态类型 = Shape*Shape
*pc = new Circle; //
静态类型 = Shape*Shape
*pr = new Rectangle; //
静态类型 = Shape* |
>ps, pc, pr都声明为Shape*类型, 他们都以此作为自己的静态类型;
Note 这和他们真正所指向的对象的类型没有关系, 他们的静态类型总是Shape*;
对象的动态类型是由他当前所指的对象的类型决定的; 对象的动态类型表示他将执行何种行为;
>pc的动态类型是Circle*, pr是Rectangle*, ps没有动态类型, 因为他没有指向任何对象;
Note 动态类型可以在程序运行时改变, 典型的方式是赋值:
|
1
2
|
ps
= pc; //
ps 的动态类型// 现在是 Circle*ps
= pr; //
ps 的动态类型// 现在是 Rectangle* |
虚函数是动态绑定的, 虚函数通过哪个对象被调用, 具体被调用的函数由那个对象的动态类型决定:
|
1
2
|
pc->draw(RED); //
调用Circle::draw(RED)pr->draw(RED); //
调用Rectangle::draw(RED) |
将虚函数和确实参数值结合起来分析, 可能出现的情况:调用的是一个定义在派生类, 但使用了基类中的缺省参数值的虚函数;
|
1
|
pr->draw(); //
调用 Rectangle::draw(RED)! |
>pr的动态类型是Rectangle*, 在Rectangle::draw中缺省参数值是GREEN; 但是由于pr的静态类型是Shape*, 函数调用的参数值是从Shape类中取得的, 而不从Rectangle; 结果非常不符合直觉!
无论ps, pc, pr是指针还是引用都会出现问题, 问题酒出在: draw是一个虚函数, 并且他的缺省参数在子类被重新定义了;
C++坚持这种有违常规的做法是出于运行效率的考虑; 如果缺省参数值被动态绑定, 编译器就必须想办法为虚函数在运行时确定合适的缺省值, 这将比现在采用的在编译阶段确定缺省值的机制更慢更复杂; 这个选择是为了速度的提高和实现的简便;
[Java完全就不支持缺省参数, 所以没这类问题 - -!, 但是Java可以用重载的方式实现缺省值, 虽然麻烦, 反而更安全些]
条款39 避免"向下转换"继承层次
e.g. 有关银行账户的协议类 Protocol class (条款34)
|
1
2
3
4
5
6
7
8
9
10
11
|
class Person
{ ... };class BankAccount
{public: BankAccount(const Person
*primaryOwner, const Person
*jointOwner); virtual ~BankAccount(); virtual void makeDeposit(double amount)
= 0; virtual void makeWithdrawal(double amount)
= 0; virtual double balance() const =
0;...}; |
假设只有一种银行账户, 存款账户:
|
1
2
3
4
5
6
7
|
class SavingsAccount: public BankAccount
{public: SavingsAccount(const Person
*primaryOwner, const Person
*jointOwner); ~SavingsAccount(); void creditInterest(); //
给帐户增加利息...}; |
银行向为所有的账户维持一个列表, 可能通过标准库list类模板实现, 假设列表叫做allAccounts:
|
1
|
list<BankAccount*>
allAccounts; //
银行中所有帐户 |
和所有标准容器一样, list存储的是对象的拷贝; 为避免每个BankAccount存储多个拷贝, 让allAccounts保存BankAccount的指针, 而不是自身;
遍历所有账户, 为每个账户计算利息:
|
1
2
3
4
5
|
//
不能通过编译的循环(如果你以前从没 // 见过使用 "迭代子" 的代码,参见下文)for (list<BankAccount*>::iterator
p = allAccounts.begin(); p != allAccounts.end(); ++p){ (*p)->creditInterest(); //
错误!} |
>allAccounts包含的指针指向BankAccount对象, 而不是SavingsAccount对象, p指向一个BankAccount, 对creditInterest调用无效;
list<BankAccount*>::iterator p = allAccounts.begin()是标准模板库STL的使用; p工作起来就像一个指针, 将allAccounts中的元素从头到尾循环一遍; p工作起来就好像他的类型是BankAccount**, 列表中的元素都好像存储在一个数组中;
>上例的循环中, allAccounts定义为保存BankAccount*, 但实际上保存的是SavingsAccount*, 因为BankAccount是抽象类, 只有SavingsAccount可以实例化;
显示地告诉编译器, 列表存储的是SavingsAccount*:
|
1
2
3
4
5
|
//
可以通过编译的循环,但很糟糕for (list<BankAccount*>::iterator
p = allAccounts.begin(); p != allAccounts.end(); ++p){ static_cast<SavingsAccount*>(*p)->creditInterest();} |
这种类型的转换--从一个基类指针到一个派生类指针--被称为"向下转换", 因为它向下转换了继承的层次结构; 上例中, 向下转换碰巧可以工作, 但它将给之后的维护带来恶果;
支票账户业务, 假设支票账户和存款账户一样, 也负担利息:
|
1
2
3
4
5
|
class CheckingAccount: public BankAccount
{public: void creditInterest(); //
给帐户增加利息...}; |
>allAccounts现在是一个包含存款和支票两种账户指针的列表;
于是上面的利息计算循环就出了问题:
虽然增加了CheckingAccount, 不修改循环代码, 编译还是可以通过; 编译器只是简单地语法和有效性检查, 接受 *p指向的是SavingAccount*的指示;
于是代码维护时, 有可能出现这样的代码:
|
1
2
3
4
5
6
|
for (list
<BankAccount*>::iterator p = allAccounts.begin(); p != allAccounts.end(); ++p) { if (*p
指向一个 SavingsAccount) static_cast<SavingsAccount*>(*p)->creditInterest(); else static_cast<CheckingAccount*>(*p)->creditInterest();} |
任何时候发现代码写出"如果对象属于类型T1, doSomeThingA; 但如果属于类型T2, doSomeThingB"这样的代码, 就不属于C++的风格; 在C, Pascal, Smalltalk中这样做合理, 但在C++中有虚函数;
对于虚函数, 编译器可以根据对象的类型来保证正确的函数调用, 所以不用写条件或开关语句, 让编译器来决定:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
|
class BankAccount
{ ... }; //
同上//
一个新类,表示要支付利息的帐户class InterestBearingAccount: public BankAccount
{public: virtual void creditInterest()
= 0;...};class SavingsAccount: public InterestBearingAccount
{... //
同上};class CheckingAccount: public InterestBearingAccount
{... //
as above}; |
存款和支票账户都支付利息, 所以把这个共同行为转移到一个公共基类中; 如果不是所有的银行账户都需要支付利息, 就不能把这行为转移到BankAccount类中哦; 所以引入InterestBearingAccount继承自BankAccount, Saving和Checking的从他继承;
存款和支付账户需要支付信息的行为是通过InterestBearingAccount的纯虚函数creditInterest体现的, 必须在子类中重新定义;
|
1
2
3
4
|
//
好一些,但还不完美for (list<BankAccount*>::iterator
p = allAccounts.begin(); p != allAccounts.end(); ++p) { static_cast<InterestBearingAccount*>(*p)->creditInterest();} |
>尽管依旧包含一个转换, 但代码比之前的健壮, 即使增加InterestBearingAccount的子类, 它还是可以继续正确工作;
为了消除转换, 必须对设计做改变, 1) 可以限制账户列表的类型:
|
1
2
3
4
5
6
7
8
|
//
银行中所有要支付利息的帐户list<InterestBearingAccount*>
allIBAccounts;//
可以通过编译且现在将来都可以工作的循环for (list<InterestBearingAccount*>::iterator
p = allIBAccounts.begin(); p
!= allIBAccounts.end(); ++p){ (*p)->creditInterest();} |
>使用InterestBearingAccount代替BankAccount限制类型;
如果不想用"采用特定列表"的方法, 2) 就让creditInterest操作适用于所有的银行账户, 对于不用支付利息的账户它只是一个空操作; [常用方法]
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class BankAccount
{public: virtual void creditInterest()
{}...};class SavingsAccount: public BankAccount
{ ... };class CheckingAccount: public BankAccount
{ ... };list<BankAccount*>
allAccounts;//
看啊,没有转换!for (list<BankAccount*>::iterator
p = allAccounts.begin(); p
!= allAccounts.end(); ++p){ (*p)->creditInterest();} |
Note 虚函数BankAccount::creditInterest提供了空的缺省实现; 它的缺省行为是空操作, 这样也可能会带来难以预见的问题(条款36);
Note creditInterest是一个(隐式的)内联函数, 但同时又是一个虚函数, 内联指令可能会被忽略;(条款33)
"向下转换"可以通过几种方法来消除, 最好的方法是将这种转换用虚函数调用来代替; 它可能对某些类不适用, 所以使这些类的虚函数成为空操作; 第二种方法是加强类型约束, 使得指针的声明类型和你需要的指针类型一致;
Note 值得花费精力去消除向下转换, 因为向下转换代码丑, 容易错, 并且代码难以理解, 升级和维护;
但有些情况下, 还是会不得不执行向下转换;
假如还是开始的情况, allAccounts保存BankAccount指针, creditInterest只是为SavingAccount对象定义, 写一个循环为每个账户计算利息; 假设你不能改动这些类, 不能改变BankAccount,
SavingsAccount或allAccounts的定义(如果他们都在某个只读的库中定义) [3rdParty], 这样就只能被迫使用向下转换;
尽管如此, 还是有比上面的原始转换更好的方法: "安全向下转换"; 通过C++的dynamic_cast运算符来实现(M2); 对指针使用dynamic_cast时, 转换成功(指针类型和被转类型一致), 返回新类型的合法指针;
如果失败, 返回空指针;
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
class BankAccount
{ ... }; //
和本条款开始时一样class SavingsAccount: //
同上public BankAccount
{ ... };class CheckingAccount: //
同上public BankAccount
{ ... };list<BankAccount*>
allAccounts; //
看起来应该熟悉些了吧...void error(const string&
msg); //
出错处理函数;//
见下文 // 嗯,至少转换很安全for (list<BankAccount*>::iterator
p = allAccounts.begin(); p
!= allAccounts.end(); ++p) {//
尝试将*p 安全转换为SavingsAccount*;// psa 的定义信息见下文 if (SavingsAccount
*psa = dynamic_cast<SavingsAccount*>(*p))
{ psa->creditInterest(); }//
尝试将它安全转换为CheckingAccount else if (CheckingAccount
*pca = dynamic_cast<CheckingAccount*>(*p))
{ pca->creditInterest(); }//
未知的帐户类型 else { error("Unknown
account type!"); }} |
>这种方法不够理想, 但至少可以检测转换失败, 用static_cast无法做到;[基类到子类强转, 调用函数时可能出现类似slicing的越界错误]
Note 对所有转换都失败的情况也要检查; 如上例中的else块;
采用虚函数可以避免这样的检查, 每个虚函数调用必然会被解析为某个函数; 然而一旦进行转换, 虚函数就无法使用; 如果类层次结构中增加了新类型的账户, 但忘记更新上面的代码, 所有的转换就会失败, 所以处理这种可能性很重要;
上例if语句部分, 是dynamic_cast的定义变量方法, 使得代码更简洁; 对psa或pca来说, 只有初始化成功的情况下才会真正被使用; 这样就不需要在条件语句外定义变量(条款32);
老的编译器可能不支持, 那就分开写:
|
1
2
3
4
5
6
7
8
9
|
//...SavingsAccount
*psa; //
传统定义CheckingAccount
*pca; //
传统定义if (psa
= dynamic_cast<SavingsAccount*>(*p))
{ psa->creditInterest();}else if (pca
= dynamic_cast<CheckingAccount*>(*p))
{ pca->creditInterest();} |
psa, pca变量在哪定义并不是很重要; 重点在: 用if-then-else风格的编程来进行向下转换比用虚函数逊色很多, 应该将这种方法保留作为万不得已的方案;
---YC---

本文探讨了 C++ 中继承与多态的实践应用,包括如何避免重定义继承而来的非虚函数和缺省参数值,以及如何正确处理继承层次结构中的向下转换等问题。

被折叠的 条评论
为什么被折叠?



