Effective C++ 第二版 37)不要重定义非虚函数 38)不要重定义缺省参数值 39)避免向下转换

来源:互联网 发布:淘宝商城工艺品 编辑:程序博客网 时间:2024/06/04 19:57

条款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::mf
pD->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---