Effective C++ 2e Item39

原创 2001年07月31日 22:13:00

条款39: 避免 "向下转换" 继承层次

在当今喧嚣的经济时代,关注一下我们的金融机构是个不错的主意。所以,看看下面这个有关银行帐户的协议类(Protocol class )(参见条款34):

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;

  ...

};

很多银行现在提供了多种令人眼花缭乱的帐户类型,但为简化起见,我们假设只有一种银行帐户,称为存款帐户:

class SavingsAccount: public BankAccount {
public:
  SavingsAccount(const Person *primaryOwner,
                 const Person *jointOwner);
  ~SavingsAccount();

  void creditInterest();                // 给帐户增加利息

  ...

};

这远远称不上是一个真正的存款帐户,但还是那句话,现在什么年代?至少,它满足我们现在的需要。

银行想为它所有的帐户维持一个列表,这可能是通过标准库(参见条款49)中的list类模板实现的。假设列表被叫做allAccounts:

list<BankAccount*> allAccounts;         // 银行中所有帐户

和所有的标准容器一样,list存储的是对象的拷贝,所以,为避免每个BankAccount存储多个拷贝,银行决定让allAccounts保存BankAccount的指针,而不是BankAccount本身。

假设现在准备写一段代码来遍历所有的帐户,为每个帐户计算利息。你会这么写:

// 不能通过编译的循环(如果你以前从没
// 见过使用 "迭代子" 的代码,参见下文)
for (list<BankAccount*>::iterator p = allAccounts.begin();
     p != allAccounts.end();
     ++p) {

  (*p)->creditInterest();      // 错误!

}

但是,编译器很快就会让你认识到:allAccounts包含的指针指向的是BankAccount对象,而非SavingsAccount对象,所以每次循环,p指向的是一个BankAccount。这使得对creditInterest的调用无效,因为creditInterest只是为SavingsAccount对象声明的,而不是BankAccount。

如果"list<BankAccount*>::iterator p = allAccounts.begin()" 在你看来更象电话线中的噪音,而不是C++,那很显然,你以前无缘见识过C++标准库中的容器类模板。标准库中的这一部分通常被称为标准模板库(STL),你可以在条款49和M35初窥其概貌。但现在你只用知道,变量p工作起来就象一个指针,它将allAccounts中的元素从头到尾循环一遍。也就是说,p工作起来就好象它的类型是BankAccount**而列表中的元素都存储在一个数组中。

上面的循环不能通过编译很令人泄气。的确,allAccounts是被定义为保存BankAccount*,但要知道,上面的循环中它事实上保存的是SavingsAccount*,因为SavingsAccount是仅有的可以被实例话的类。愚蠢的编译器!对我们来说这么显然的事情它竟然笨得一无所知。所以你决定告诉它:allAccounts真的包含的是SavingsAccount*:

// 可以通过编译的循环,但很糟糕
for (list<BankAccount*>::iterator p = allAccounts.begin();
     p != allAccounts.end();
     ++p) {

  static_cast<SavingsAccount*>(*p)->creditInterest();

}

一切问题迎刃而解!解决得很清晰,很漂亮,很简明,所做的仅仅是一个简单的转换而已。你知道allAccounts指针保存的是什么类型的指针,迟钝的编译器不知道,所以你通过一个转换来告诉它,还有比这更合理的事吗?

在此,我要拿圣经的故事做比喻。转换之于C++程序员,就象苹果之于夏娃。

这种类型的转换 ---- 从一个基类指针到一个派生类指针 ---- 被称为 "向下转换",因为它向下转换了继承的层次结构。在刚看到的例子中,向下转换碰巧可以工作;但正如下面即将看到的,它将给今后的维护人员带来恶梦。

还是回到银行的话题上来。受到存款帐户业务大获成功的激励,银行决定再推出支票帐户业务。另外,假设支票帐户和存款帐户一样,也要负担利息:

class CheckingAccount: public BankAccount {
public:
  void creditInterest();    // 给帐户增加利息

  ...

};

不用说,allAccounts现在是一个包含存款和支票两种帐户指针的列表。于是,上面所写的计算利息的循环转瞬间有了大麻烦。

第一个问题是,虽然新增了一个CheckingAccount,但即使不去修改循环代码,编译还是可以继续通过。因为编译器只是简单地听信于你所告诉它们(通过static_cast)的一切:*p指向的是SavingsAccount*。谁叫你是它的主人呢?这会给今后维护带来第一个恶梦。维护期第二个恶梦在于,你一定想去解决这个问题,所以你会写出这样的代码:

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,做某事;但如果属于类型T2,做另外某事" 之类的代码,就要扇自己一个耳光。这不是C++的做法。是的,在C,Pascal,甚至Smalltalk中,它是很合理的做法,但在C++中不是。在C++中,要使用虚函数。

记得吗?对于一个虚函数,编译器可以根据所使用对象的类型来保证正确的函数调用。所以不要在代码中随处乱扔条件语句或开关语句;让编译器来为你效劳。如下所示:

class BankAccount { ... };      // 同上

// 一个新类,表示要支付利息的帐户
class InterestBearingAccount: public BankAccount {
public:
  virtual void creditInterest() = 0;

  ...

};

class SavingsAccount: public InterestBearingAccount {

  ...                           // 同上

};

class CheckingAccount: public InterestBearingAccount {

  ...                           // as above

};

用图形表示如下:

                         BankAccount
                                  ^
                                  |
                 InterestBearingAccount
                                 //
                                /  /
                               /    /
     CheckingAccount   SavingsAccount

因为存款和支票账户都要支付利息,所以很自然地想到把这一共同行为转移到一个公共的基类中。但是,如果假设不是所有的银行帐户都需要支付利息(以我的经验,这当然是个合理的假设),就不能把它转移到BankAccount类中。所以,要为BankAccount引入一个新的子类InterestBearingAccount,并使SavingsAccoun和CheckingAccount从它继承。

存款和支票账户都要支付利息的事实是通过InterestBearingAccount的纯虚函数creditInterest来体现的,它要在子类SavingsAccount和CheckingAccount中重新定义。

有了新的类层次结构,就可以这样来重写循环代码:

// 好一些,但还不完美
for (list<BankAccount*>::iterator p = allAccounts.begin();
     p != allAccounts.end();
     ++p) {

  static_cast<InterestBearingAccount*>(*p)->creditInterest();

}

尽管这个循环还是包含一个讨厌的转换,但代码已经比过去健壮多了,因为即使又增加InterestBearingAccount新的子类到程序中,它还是可以继续工作。

为了完全消除转换,就必须对设计做一些改变。一种方法是限制帐户列表的类型。如果能得到一列InterestBearingAccount对象而不是BankAccount对象,那就太好了:

// 银行中所有要支付利息的帐户
list<InterestBearingAccount*> allIBAccounts;

// 可以通过编译且现在将来都可以工作的循环
for (list<InterestBearingAccount*>::iterator p =
        allIBAccounts.begin();
     p != allIBAccounts.end();
     ++p) {

  (*p)->creditInterest();

}

如果不想用上面这种 "采用更特定的列表" 的方法,那就让creditInterest操作使用于所有的银行帐户,但对于不用支付利息的帐户来说,它只是一个空操作。这个方法可以这样来表示:

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();

}

要注意的是,虚函数BankAccount::creditInterest提供一个了空的缺省实现。这可以很方便地表示,它的行为在缺省情况下是一个空操作;但这也会给它本身带来难以预见的问题。想知道内幕,以及如何消除这一危险,请参考条款36。还要注意的是,creditInterest是一个(隐式的)内联函数,这本身没什么问题;但因为它同时又是一个虚函数,内联指令就有可能被忽略。条款33解释了为什么。

正如上面已经看到的,"向下转换" 可以通过几种方法来消除。最好的方法是将这种转换用虚函数调用来代替,同时,它可能对有些类不适用,所以要使这些类的每个虚函数成为一个空操作。第二个方法是加强类型约束,使得指针的声明类型和你所知道的真的指针类型之间没有出入。为了消除向下转换,无论费多大工夫都是值得的,因为向下转换难看、容易导致错误,而且使得代码难于理解、升级和维护(参见条款M32)。

至此,我所说的都是事实;但,不是全部事实。有些情况下,真的不得不执行向下转换。

例如,假设还是面临本条款开始的那种情况,即,allAccounts保存BankAccount指针,creditInterest只是为SavingsAccount对象定义,要写一个循环来为每个帐户计算利息。进一步假设,你不能改动这些类;你不能改变BankAccount,SavingsAccount或allAccounts的定义。(如果它们在某个只读的库中定义,就会出现这种情况)如果是这样的话,你就只有使用向下转换了,无论你认为这个办法有多丑陋。

尽管如此,还是有比上面那种原始转换更好的办法。这种方法称为 "安全的向下转换",它通过C++的dynamic_cast运算符(参见条款M2)来实现。当对一个指针使用dynamic_cast时,先尝试转换,如果成功(即,指针的动态类型(见条款38)和正被转换的类型一致),就返回新类型的合法指针;如果dynamic_cast失败,返回空指针。

下面就是加上了 "安全向下转换" 的例子:

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!");
  }
}

这种方法远不够理想,但至少可以检测到转换失败,而用dynamic_cast是无法做到的。但要注意,对所有转换都失败的情况也要检查。这正是上面代码中最后一个else语句的用意所在。采用虚函数,就不必进行这样的检查,因为每个虚函数调用必然都会被解析为某个函数。然而,一旦打算进行转换,这一切好处都化为乌有。例如,如果某个人在类层次结构中增加了一种新类型的帐户,但又忘了更新上面的代码,所有对它的转换就会失败。所以,处理这种可能发生的情况十分重要。大部分情况下,并非所有的转换都会失败;但是,一旦允许转换,再好的程序员也会碰上麻烦。

上面if语句的条件部分,有些看上去象变量定义的东西,看到它你是不是慌张地擦了擦眼镜?如果真这样,别担心,你没看错。这种定义变量的方法是和dynamic_cast同时增加到C++语言中的。这一特性使得写出的代码更简洁,因为对psa或pca来说,它们只有在被dynamic_cast成功初始化的情况下,才会真正被用到;使用新的语法,就不必在(包含转换的)条件语句外定义这些变量。(条款32解释了为什么通常要避免多余的变量定义)如果编译器尚不支持这种定义变量的新方法,可以按老方法来做:

for (list<BankAccount*>::iterator p = allAccounts.begin();
     p != allAccounts.end();
     ++p) {

  SavingsAccount *psa;        // 传统定义
  CheckingAccount *pca;       // 传统定义

  if (psa = dynamic_cast<SavingsAccount*>(*p)) {
    psa->creditInterest();
  }

  else if (pca = dynamic_cast<CheckingAccount*>(*p)) {
    pca->creditInterest();
  }

  else {
    error("Unknown account type!");
  }
}

当然,从处理事情的重要性来说,把psa和pca这样的变量放在哪儿定义并不十分重要。重要之处在于:用if-then-else风格的编程来进行向下转换比用虚函数要逊色得多,应该将这种方法保留到万不得已的情况下使用。运气好的话,你的程序世界里将永远看不到这样悲惨荒凉的景象。

Effective C++ 目录

改变旧有的C习惯(Shifting from C to C++) 013 条款1:尽量以 const 和 inline 取代 #define 013 Prefer const and inline...
  • sunrise918
  • sunrise918
  • 2011年08月19日 17:34
  • 492

Effective C++ 2e Item3

条款3:尽量用new和delete而不用malloc和freemalloc和free(及其变体)会产生问题的原因在于它们太简单:他们不知道构造函数和析构函数。假设用两种方法给一个包含10个string...
  • lostmouse
  • lostmouse
  • 2001年06月29日 19:39
  • 873

Effective C++ 2e Item25

条款25: 避免对指针和数字类型重载快速抢答:什么是“零”?更明确地说,下面的代码会发生什么?void f(int x);void f(string *ps);f(0);               ...
  • lostmouse
  • lostmouse
  • 2001年07月15日 18:27
  • 697

Effective C++ 2e Item42

条款42: 明智地使用私有继承条款35说明,C++将公有继承视为 "是一个" 的关系。它是通过这个例子来证实的:假如某个类层次结构中,Student类从Person类公有继承,为了使某个函数成功调用,...
  • lostmouse
  • lostmouse
  • 2001年08月02日 19:18
  • 649

Effective C++ 2e Item26

条款26: 当心潜在的二义性每个人都有思想。有些人相信自由经济学,有些人相信来生。有些人甚至相信COBOL是一种真正的程序设计语言。C++也有一种思想:它认为潜在的二义性不是一种错误。这是潜在二义性的...
  • lostmouse
  • lostmouse
  • 2001年07月15日 18:28
  • 627

Effective C++ 2e Item31

条款31: 千万不要返回局部对象的引用,也不要返回函数内部用new初始化的指针的引用本条款听起来很复杂,其实不然。它只是一个很简单的道理,真的,相信我。先看第一种情况:返回一个局部对象的引用。它的问题...
  • lostmouse
  • lostmouse
  • 2001年07月23日 20:24
  • 669

Effective C++ 2e Item4

条款4:尽量使用C++风格的注释旧的C注释语法在C++里还可以用,C++新发明的行尾注释语法也有其过人之处。例如下面这种情形:    if ( a > b ) {      // int temp =...
  • lostmouse
  • lostmouse
  • 2001年06月29日 20:41
  • 752

Effective C++ 2e Item17

 条款17: 在operator=中检查给自己赋值的情况做类似下面的事时,就会发生自己给自己赋值的情况:class X { ... };X a;a = a;                     /...
  • lostmouse
  • lostmouse
  • 2001年07月09日 20:14
  • 634

Effective C++ 2e Item35

继承和面向对象设计很多人认为,继承是面向对象程序设计的全部。这个观点是否正确还有待争论,但本书其它章节的条款数量足以证明,在进行高效的C++程序设计时,还有更多的工具听你调遣,而不仅仅是简单地让一个类...
  • lostmouse
  • lostmouse
  • 2001年07月29日 18:38
  • 585

Effective C++ 2e Item16

条款16: 在operator=中对所有数据成员赋值条款45说明了如果没写赋值运算符的话,编译器就会为你生成一个,条款11则说明了为什么你会经常不喜欢编译器为你生成的这个赋值运算符,所以你会想能否有个...
  • lostmouse
  • lostmouse
  • 2001年07月08日 19:36
  • 632
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:Effective C++ 2e Item39
举报原因:
原因补充:

(最多只允许输入30个字)