第六章 继承和面向对象设计(37-42)

转载 2006年05月29日 22:10:00

条款37: 决不要重新定义继承而来的非虚函数

有两种方法来看待这个问题:理论的方法和实践的方法。让我们先从实践的方法开始。毕竟,理论家一般都很耐心。

假设类D公有继承于类B,并且类B中定义了一个公有成员函数mf。mf的参数和返回类型不重要,所以假设都为void。换句话说,我这么写:

class B {
public:
  void mf();
  ...
};

class D: public B { ... };

甚至对B,D或mf一无所知,也可以定义一个类型D的对象x,

D x;                          // x是类型D的一个对象

那么,如果发现这么做:

B *pB = &x;                   // 得到x的指针

pB->mf();                     // 通过指针调用mf

和下面这么做的执行行为不一样:

D *pD = &x;                   // 得到x的指针

pD->mf();                     // 通过指针调用mf

你一定就会感到很惊奇。

因为两种情况下调用的都是对象x的成员函数mf,因为两种情况下都是相同的函数和相同的对象,所以行为会相同,对吗?

对,会相同。但,也许不会相同。特别是,如果mf是非虚函数而D又定义了自己的mf版本,行为就不会相同:

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指向的是从B派生的类的对象,如上例所示。

相反,虚函数是动态绑定的(再次参见条款38),因而不会产生这类问题。如果mf是虚函数,通过pB或pD调用mf时都将导致调用D::mf,因为pB和pD实际上指向的都是类型D的对象。

所以,结论是,如果写类D时重新定义了从类B继承而来的非虚函数mf,D的对象就可能表现出精神分裂症般的异常行为。也就是说,D的对象在mf被调用时,行为有可能象B,也有可能象D,决定因素和对象本身没有一点关系,而是取决于指向它的指针所声明的类型。引用也会和指针一样表现出这样的异常行为。

实践方面的论据就说这么多。我知道你现在想知道的是,不能重新定义继承而来的非虚函数的理论依据是什么。我很高兴解答。

条款35解释了公有继承的含义是 "是一个",条款36说明了为什么 "在一个类中声明一个非虚函数实际上为这个类建立了一种特殊性上的不变性"。如果将这些分析套用到类B、类D和非虚成员函数B::mf,那么,

· 适用于B对象的一切也适用于D对象,因为每个D的对象 "是一个" B的对象。
· B的子类必须同时继承mf的接口和实现,因为mf在B中是非虚函数。

那么,如果D重新定义了mf,设计中就会产生矛盾。如果D真的需要实现和B不同的mf,而且每个B的对象 ---- 无论怎么特殊 ---- 也真的要使用B实现的mf,那么,每个D将不 "是一个" B。这种情况下,D不能从B公有继承。相反,如果D真的必须从B公有继承,而且D真的需要和B不同的mf的实现,那么,mf就没有为B反映出特殊性上的不变性。这种情况下,mf应该是虚函数。最后,如果每个D真的 "是一个" B,并且如果mf真的为B建立了特殊性上的不变性,那么,D实际上就不需要重新定义mf,也就决不能这样做。

不管采用上面的哪一种论据都可以得出这样的结论:任何条件下都要禁止重新定义继承而来的非虚函数。
条款38: 决不要重新定义继承而来的缺省参数值

让我们从一开始就把问题简化。缺省参数只能作为函数的一部分而存在;另外,只有两种函数可以继承:虚函数和非虚函数。因此,重定义缺省参数值的唯一方法是重定义一个继承而来的函数。然而,重定义继承而来的非虚函数是一种错误(参见条款37),所以,我们完全可以把讨论的范围缩小为 "继承一个有缺省参数值的虚函数" 的情况。

既然如此,本条款的理由就变得非常明显:虚函数是动态绑定而缺省参数值是静态绑定的。

什么意思?你可能会说你不懂这些最新的面向对象术语;或者,过度劳累的你一时想不起静态和动态绑定的区别。那么,让我们来复习一下。

对象的静态类型是指你声明的存在于程序代码文本中的类型。看下面这个类层次结构:

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;

  ...

};

用图形来表示是下面这样:

                Shape
                    //
                   /  /
                  /    /
   Rectangle    Circle

现在看看这些指针:

Shape *ps;                      // 静态类型 = Shape*

Shape *pc = new Circle;         // 静态类型 = Shape*

Shape *pr = new Rectangle;      // 静态类型 = Shape*

这个例子中, ps, pc,和pr都被声明为Shape指针类型,所以它们都以此作为自己的静态类型。注意,这和它们真的所指向的对象的类型绝对没有关系 ---- 它们的静态类型总是Shape*。

对象的动态类型是由它当前所指的对象的类型决定的。即,对象的动态类型表示它将执行何种行为。上面的例子中,pc的动态类型是Circle*,pr的动态类型是Rectangle*。至于ps,实际上没有动态类型,因为它(还)没有指向任何对象。

动态类型,顾名思义,可以在程序运行时改变,典型的方法是通过赋值:

ps = pc;                        // ps的动态类型
                                // 现在是Circle*

ps = pr;                        // ps的动态类型
                                // 现在是Rectangle*

虚函数是动态绑定的,意思是说,虚函数通过哪个对象被调用,具体被调用的函数就由那个对象的动态类型决定:

pc->draw(RED);                  // 调用Circle::draw(RED)

pr->draw(RED);                  // 调用Rectangle::draw(RED)

我知道这些都是老掉牙的知识了,你当然也了解虚函数。(如果想知道它们是怎么实现的,参见条款M24)但是,将虚函数和缺省参数值结合起来分析就会产生问题,因为,如上所述,虚函数是动态绑定的,但缺省参数是静态绑定的。这意味着你最终可能调用的是一个定义在派生类,但使用了基类中的缺省参数值的虚函数:

pr->draw();                     // 调用Rectangle::draw(RED)!

这种情况下,pr的动态类型是Rectangle*,所以Rectangle的虚函数被调用 ---- 正如我们所期望的那样。Rectangle::draw中,缺省参数值是GREEN。但是,由于pr的静态类型是Shape*,这个函数调用的参数值是从Shape类中取得的,而不是Rectangle类!所以结果将十分奇怪并且出人意料,因为这个调用包含了Shape和Rectangle类中Draw的声明的组合。你当然不希望自己的软件以这种方式运行啦;至少,用户不希望这样,相信我。

不用说,ps, pc,和pr都是指针的事实和产生问题的原因无关。如果它们是引用,问题也会继续存在。问题仅仅出在,draw是一个虚函数,并且它的一个缺省参数在子类中被重新定义了。

为什么C++坚持这种有违常规的做法呢?答案和运行效率有关。如果缺省参数值被动态绑定,编译器就必须想办法为虚函数在运行时确定合适的缺省值,这将比现在采用的在编译阶段确定缺省值的机制更慢更复杂。做出这种选择是想求得速度上的提高和实现上的简便,所以大家现在才能感受得到程序运行的高效;当然,如果忽视了本条款的建议,就会带来混乱。
条款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风格的编程来进行向下转换比用虚函数要逊色得多,应该将这种方法保留到万不得已的情况下使用。运气好的话,你的程序世界里将永远看不到这样悲惨荒凉的景象。
条款40: 通过分层来体现 "有一个" 或 "用...来实现"

使某个类的对象成为另一个类的数据成员,从而实现将一个类构筑在另一个类之上,这一过程称为 "分层"(Layering)。例如:

class Address { ... };           // 某人居住之处

class PhoneNumber { ... };

class Person {
public:
  ...

private:
  string name;                   // 下层对象
  Address address;               // 同上
  PhoneNumber voiceNumber;       // 同上
  PhoneNumber faxNumber;         // 同上
};

本例中,Person类被认为是置于string,Address和PhoneNumber类的上层,因为它包含那些类型的数据成员。"分层" 这一术语有很多同义词,它也常被称为:构成(composition),包含(containment)或嵌入(embedding)。

条款35解释了公有继承的含义是 "是一个"。对应地,分层的含义是 "有一个" 或 "用...来实现"。

上面的Person类展示了 "有一个" 的关系。一个Person对象 "有一个" 名字,地址,电话号码和传真号码。你不能说,一个人 "是一个" 名字或一个人 "是一个" 地址;你得说,一个人 "有一个" 名字, "有一个" 地址,等等。大多数人对区分这些没什么困难,所以混淆 "是一个" 和 "有一个" 的情况相对来说比较少见。

稍微有点麻烦的是区分 "是一个" 和 "用...来实现"。例如,假设需要一个类模板,用来表示任意对象的集合,并且集合中没有重复元素。程序设计中,重用(Reuse)是再好不过的一件事了,而且你也许已经读过条款49中关于C++标准库的总体介绍,那么,你的第一反应一定是想采用标准库中的set模板。是啊,既然可以使用别人所写的东西,为什么还要再去写一个新的模板呢?

但是,深入研究set的帮助文档后,你会发现,set的下述限制将不能满足你的程序要求:set要求包含在它内部的元素必须是完全有序的,即,对set中的任两个元素a和b来说,一定可以确定:要么a<b,要么b<a。对许多类型来说,这个要求很容易满足,而且,对象间完全有序使得set可以在性能方面提供某些保证,这一点很吸引人。(参见条款49了解标准库在性能上更多的保证)然而,你所需要的是更广泛的东西:一个类似set的类,但对象不必完全有序;用C++标准所包装的术语来说,它们只需要所谓的 "相等可比较性":对于同种类型的a和b对象来说,要能确定是否a==b。这种要求更简单,它更适合于那些表示颜色这类东西的类型。总不能说红色比绿色更少或绿色比红色更少吧?看来,对你的程序来说,还是得需要自己来写个模板。

当然,重用还是件好事。作为数据结构专家,你知道,在实现集合的众多选择中,一个最简单的办法是采用链表。你一定猜到了什么。对,标准库中正有这么一个list模板(用来产生链表类)!所以可以重用它。

具体来说,你决定让自己的Set模板从list继承。即,Set<T>将从list<T>继承。因为,在你的实现中,Set对象实际上将是list对象。于是你这样声明Set模板:

// Set中错误地使用了list
template<class T>
class Set: public list<T> { ... };

至此,一切好象都很正确,但实际上错误不小。正如条款35所说明的,如果D "是一个" B,对B成立的所有事实对D也成立。但是,list对象可以包含重复元素,所以如果3051这个值被增加到list<int>中两次,list中将包含3051的两个拷贝。相反,Set不可以包含重复元素,所以如果3051被增加到Set<int>中两次,Set中将只包含这个值的一个拷贝。于是,说一个Set "是一个" list就是弥天大谎,因为如上所述,有一些在list对象中成立的事实在Set对象中不成立。

因为这两个类的关系并非 "是一个",所以用公有继承来表示它们的关系就是一个错误。正确的方法是让Set对象 "用list对象来实现":

// Set中使用list的正确方法
template<class T>
class Set {
public:
  bool member(const T& item) const;

  void insert(const T& item);
  void remove(const T& item);

  int cardinality() const;

private:
  list<T> rep;                       // 表示一个Set
};

Set的成员函数可以利用list以及标准库其它部分所提供的大量功能,所以,实现代码既不难写也很易读:

template<class T>
bool Set<T>::member(const T& item) const
{ return find(rep.begin(), rep.end(), item) != rep.end(); }

template<class T>
void Set<T>::insert(const T& item)
{ if (!member(item)) rep.push_back(item); }

template<class T>
void Set<T>::remove(const T& item)
{
  list<T>::iterator it =
    find(rep.begin(), rep.end(), item);

  if (it != rep.end()) rep.erase(it);
}

template<class T>
int Set<T>::cardinality() const
{ return rep.size(); }

这些函数很简单,所以很自然地想到将它们作为内联函数;但在做最后决定前,还是回顾一下条款33所做的讨论。(上面的代码中,find, begin, end, push_back等函数是标准库基本框架的一部分,它们可用来对list这样的容器模板进行操作。标准库框架的总体介绍参见条款49和M35。)

值得指出的是,Set类的接口没有做到完整并且最小(参见条款18)。从完整性上来说,它最大的遗漏在于不能对Set中的内容进行循环,而这一功能对很多程序来说是必需的(标准库中的所有成员都提供了这一功能,包括set)。Set的另一个缺陷是没有遵循标准库所采用的容器类常规(见条款49和M35),从而造成使用Set时更难以利用库中其它的部分。

Set的接口尽管有这些瑕疵,但下面这一点不能被掩盖:Set在理解它和list的关系上,具有无可辩驳的正确性。这种关系并非 "是一个"(虽然初看会以为是),而是 "用...来实现",通过分层来实现这种关系是类的设计者应该感到自豪的。

顺便说一句,当通过分层使两个类产生联系时,实际上在两个类之间建立了编译时的依赖关系。关于为什么要考虑到这一点以及如何减少这方面的麻烦,参见条款34。
条款41: 区分继承和模板

考虑下面两个设计问题:

· 作为一位立志献身计算机科学的学生,你想设计一个类来表示对象的堆栈。这将需要多个不同的类,因为每个堆栈中的元素必须是同类的,即,它里面包含的必须只是同种类型的对象。例如,会有一个类来表示int的堆栈,第二个类来表示string的堆栈,第三个类来表示string的堆栈的堆栈,等等。你也许对设计一个最小的类接口(参见条款18)很感兴趣,所以会将对堆栈的操作限制在:创建堆栈,销毁堆栈,将对象压入堆栈,将对象弹出堆栈,以及检查堆栈是否为空。设计中,你不会借助标准库中的类(包括stack ---- 参见条款49),因为你渴望亲手写这些代码。重用(Reuse)是一件美事,但当你的目标是探究事情的工作原理时,那就只有挖地三尺了。

· 作为一位爱猫的宠物迷,你想设计一个类来表示猫。这也将需要多个不同的类,因为每个品种的猫都会有点不同。和所有对象一样,猫可以被创建和销毁,但,正如所有猫迷所知道的,猫所做的其它事不外乎吃和睡。然而,每一种猫吃和睡都有各自惹人喜爱的方式。

这两个问题的说明听起来很相似,但却导致完全不同的两种设计。为什么?

答案涉及到"类的行为" 和 "类所操作的对象的类型"之间的关系。对于堆栈和猫来说,要处理的都是各种不同的类型(堆栈包含类型为T的对象,猫则为品种T),但你必须问自己这样一个问题:类型T影响类的行为吗?如果T不影响行为,你可以使用模板。如果T影响行为,你就需要虚函数,从而要使用继承。

下面的代码通过定义一个链表来实现Stack类,假设堆栈的对象类型为T:

class Stack {
public:
  Stack();
  ~Stack();

  void push(const T& object);
  T pop();

  bool empty() const;             // 堆栈为空?

private:
  struct StackNode {              // 链表节点
    T data;                       // 此节点数据
    StackNode *next;              // 链表中下一节点

    // StackNode构造函数,初始化两个域
    StackNode(const T& newData, StackNode *nextNode)
    : data(newData), next(nextNode) {}
  };

  StackNode *top;                 // 堆栈顶部

  Stack(const Stack& rhs);               // 防止拷贝和
  Stack& operator=(const Stack& rhs);    // 赋值(见条款27)
};

于是,Stack对象将构造如下所示的数据结构:

Stack对象 top--> data+next--> data+next--> data+next--> data+next
                        ------------------------------------------------------------------------------------
                                                 StackNode对象

链表本身是由StackNode对象构成的,但那只是Stack类的一个实现细节,所以StackNode被声明为Stack的私有类型。注意StackNode有一个构造函数,用来确保它所有的域都被正确初始化。即使你闭着眼睛都可以写出一个链表,但也不要忽视了C++的一些新特性,如struct中的构造函数。

下面看看你对Stack成员函数的实现。和许多原型(prototype)的实现(离制作成软件产品相差太远)一样,这里没有错误检查,因为在原型世界里,没有东西会出错。

Stack::Stack(): top(0) {}      // 顶部初始化为null

void Stack::push(const T& object)
{
  top = new StackNode(object, top);    // 新节点放在
}                                      // 链表头部

T Stack::pop()
{
  StackNode *topOfStack = top;    // 记住头节点
  top = top->next;

  T data = topOfStack->data;      // 记住节点数据
  delete topOfStack;

  return data;
}

Stack::~Stack()                   // 删除堆栈中所有对象
{
  while (top) {
    StackNode *toDie = top;       // 得到头节点指针
    top = top->next;              // 移向下一节点
    delete toDie;                 // 删除前面的头节点
  }
}

bool Stack::empty() const
{ return top == 0; }

这些代码毫无吸引人之处。实际上,唯一有趣的一点在于:即使对T一无所知,你还是能够写出每个成员函数。(上面的代码中实际上有个假设,即,假设可以调用T的拷贝构造函数;但正如条款45所说明的,这是一个绝对合理的假设)不管T是什么,对构造,销毁,压栈,出栈,确定栈是否为空等操作所写的代码不会变。除了 "可以调用T的拷贝构造函数" 这一假设外,stack的行为在任何地方都不依赖于T。这就是模板类的特点:行为不依赖于类型。

将stack类转化成一个模板就很简单了,即使是Dilbert的老板都会写:

template<class T> class Stack {

  ...                          // 完全和上面相同

};

但是,猫呢?为什么猫不适合模板?

重读上面的说明,注意这一条:"每一种猫吃和睡都有各自惹人喜爱的方式"。这意味着必须为每种不同的猫实现不同的行为。不可能写一个函数来处理所有的猫,所能做的只能是制定一个函数接口,所有种类的猫都必须实现它。啊哈!衍生一个函数接口的方法只能是去声明一个纯虚函数(参见条款36):

class Cat {
public:
  virtual ~Cat();                     // 参见条款14

  virtual void eat() = 0;             // 所有的猫吃食
  virtual void sleep() = 0;           // 所有的猫睡觉
};

Cat的子类 ---- 比如,Siamese和BritishShortHairedTabby ---- 当然得重新定义继承而来的eat和sleep函数接口:

class Siamese: public Cat {
public:
  void eat();
  void sleep();

  ...

};

class BritishShortHairedTabby: public Cat {
public:
  void eat();
  void sleep();

  ...

};

好了,现在知道了为什么模板适合Stack类而不适合Cat类,也知道了为什么继承适合Cat类。唯一剩下的问题是,为什么继承不适合Stack类。想知道为什么,不妨试着去声明一个Stack层次结构的根类 ---- 所有其它的堆栈类都从这个唯一的类继承:

class Stack {      // a stack of anything
public:
  virtual void push(const ??? object) = 0;
  virtual ??? pop() = 0;

  ...

};

现在问题很明显了。该为纯虚函数push和pop声明什么类型呢?记住,每一个子类必须重新声明继承而来的虚函数,而且参数类型和返回类型都要和基类的声明完全相同。不幸的是,一个int堆栈只能压入和弹出int对象,而一个Cat堆栈只能压入和弹出Cat对象。Stack类要怎样声明它的纯虚函数才能使用户既可以创建出int堆栈又可以创建出Cat堆栈呢?冷酷而严峻的事实是,做不到。这就是为什么说继承不适合创建堆栈。

但也许你做事喜欢偷偷摸摸。或许你认为自己可以通过使用通用(void*)指针来骗过编译器。但事实证明,现在这种情况下,通用指针也帮不上忙。因为你无法避开这一条件:派生类虚函数的声明永远不能和它在基类中的声明相抵触。但是,通用指针可以帮助解决另外一个不同的问题,它和模板所生成的类的效率有关。详细介绍参见条款42。

讲完了堆栈和猫,下面将本条款得到的结论总结如下:

· 当对象的类型不影响类中函数的行为时,就要使用模板来生成这样一组类。
· 当对象的类型影响类中函数的行为时,就要使用继承来得到这样一组类。

真正消化了以上两点的含义,你就可以在设计中游刃于继承或模板之间。
条款42: 明智地使用私有继承

条款35说明,C++将公有继承视为 "是一个" 的关系。它是通过这个例子来证实的:假如某个类层次结构中,Student类从Person类公有继承,为了使某个函数成功调用,编译器可以在必要时隐式地将Student转换为Person。这个例子很值得再看一遍,只是现在,公有继承换成了私有继承:

class Person { ... };

class Student:                      // 这一次我们
  private Person { ... };           // 使用私有继承

void dance(const Person& p);        // 每个人会跳舞

void study(const Student& s);       // 只有学生才学习


Person p;                           // p是一个人
Student s;                          // s是一个学生

dance(p);                           // 正确, p是一个人

dance(s);                           // 错误!一个学生不是一个人

很显然,私有继承的含义不是 "是一个",那它的含义是什么呢?

"别忙!" 你说。"在弄清含义之前,让我们先看看行为。私有继承有那些行为特征呢?" 那好吧。关于私有继承的第一个规则正如你现在所看到的:和公有继承相反,如果两个类之间的继承关系为私有,编译器一般不会将派生类对象(如Student)转换成基类对象(如Person)。这就是上面的代码中为对象s调用dance会失败的原因。第二个规则是,从私有基类继承而来的成员都成为了派生类的私有成员,即使它们在基类中是保护或公有成员。行为特征就这些。

这为我们引出了私有继承的含义:私有继承意味着 "用...来实现"。如果使类D私有继承于类B,这样做是因为你想利用类B中已经存在的某些代码,而不是因为类型B的对象和类型D的对象之间有什么概念上的关系。因而,私有继承纯粹是一种实现技术。用条款36引入的术语来说,私有继承意味着只是继承实现,接口会被忽略。如果D私有继承于B,就是说D对象在实现中用到了B对象,仅此而已。私有继承在软件 "设计" 过程中毫无意义,只是在软件 "实现" 时才有用。

私有继承意味着 "用...来实现" 这一事实会给程序员带来一点混淆,因为条款40指出,"分层" 也具有相同的含义。怎么在二者之间进行选择呢?答案很简单:尽可能地使用分层,必须时才使用私有继承。什么时候必须呢?这往往是指有保护成员和/或虚函数介入的时候 ---- 但这个问题过一会儿再深入讨论。

条款41提供了一种方法来写一个Stack 模板,此模板生成的类保存不同类型的对象。你应该熟悉一下那个条款。模板是C++最有用的组成部分之一,但一旦开始经常性地使用它,你会发现,如果实例化一个模板一百次,你就可能实例化了那个模板的代码一百次。例如Stack模板,构成Stack<int>成员函数的代码和构成Stack<double>成员函数的代码是完全分开的。有时这是不可避免的,但即使模板函数实际上可以共享代码,这种代码重复还是可能存在。这种目标代码体积的增加有一个名字:模板导致的 "代码膨胀"。这不是件好事。

对于某些类,可以采用通用指针来避免它。采用这种方法的类存储的是指针,而不是对象,实现起来就是:

· 创建一个类,它存储的是对象的void*指针。
· 创建另外一组类,其唯一目的是用来保证类型安全。这些类都借助第一步中的通用类来完成实际工作。

下面的例子使用了条款41中的非模板Stack类,不同的是这里存储的是通用指针,而不是对象:

class GenericStack {
public:
  GenericStack();
  ~GenericStack();

  void push(void *object);
  void * pop();

  bool empty() const;

private:
  struct StackNode {
    void *data;                    // 节点数据
    StackNode *next;               // 下一节点

    StackNode(void *newData, StackNode *nextNode)
    : data(newData), next(nextNode) {}
  };

  StackNode *top;                          // 栈顶

  GenericStack(const GenericStack& rhs);   // 防止拷贝和
  GenericStack&                            // 赋值(参见
    operator=(const GenericStack& rhs);    // 条款27)
};


因为这个类存储的是指针而不是对象,就有可能出现一个对象被多个堆栈指向的情况(即,被压入到多个堆栈)。所以极其重要的一点是,pop和类的析构函数销毁任何StackNode对象时,都不能删除data指针 ---- 虽然还是得要删除StackNode对象本身。毕竟,StackNode 对象是在GenericStack类内部分配的,所以还是得在类的内部释放。所以,条款41中Stack类的实现几乎完全满足the GenericStack的要求。仅有的改变只是用void*来替换T。

仅仅有GenericStack这一个类是没有什么用处的,但很多人会很容易误用它。例如,对于一个用来保存int的堆栈,一个用户会错误地将一个指向Cat对象的指针压入到这个堆栈中,但编译却会通过,因为对void*参数来说,指针就是指针。

为了重新获得你所习惯的类型安全,就要为GenericStack创建接口类(interface class),象这样:

class IntStack {                  // int接口类
public:
  void push(int *intPtr) { s.push(intPtr); }
  int * pop() { return static_cast<int*>(s.pop()); }
  bool empty() const { return s.empty(); }

private:
  GenericStack s;                 // 实现
};

class CatStack {                  // cat接口类
public:
  void push(Cat *catPtr) { s.push(catPtr); }
  Cat * pop() { return static_cast<Cat*>(s.pop()); }
  bool empty() const { return s.empty(); }

private:
  GenericStack s;                 // 实现
};

正如所看到的,IntStack和CatStack只是适用于特定类型。只有int指针可以被压入或弹出IntStack,只有Cat指针可以被压入或弹出CatStack。IntStack和CatStack都通过GenericStack类来实现,这种关系是通过分层(参见条款40)来体现的,IntStack和CatStack将共享GenericStack中真正实现它们行为的函数代码。另外,IntStack和CatStack所有成员函数是(隐式)内联函数,这意味着使用这些接口类所带来的开销几乎是零。

但如果有些用户没认识到这一点怎么办?如果他们错误地认为使用GenericStack更高效,或者,如果他们鲁莽而轻率地认为类型安全不重要,那该怎么办?怎么才能阻止他们绕过IntStack和CatStack而直接使用GenericStack(这会让他们很容易地犯类型错误,而这正是设计C++所要特别避免的)呢?

没办法!没办法防止。但,也许应该有什么办法。

在本条款的开始我就提到,要表示类之间 "用...来实现" 的关系,有一个选择是通过私有继承。现在这种情况下,这一技术就比分层更有优势,因为通过它可以让你告诉别人:GenericStack使用起来不安全,它只能用来实现其它的类。具体做法是将GenericStack的成员函数声明为保护类型:

class GenericStack {
protected:
  GenericStack();
  ~GenericStack();

  void push(void *object);
  void * pop();

  bool empty() const;

private:
  ...                             // 同上
};

GenericStack s;                   // 错误! 构造函数被保护


class IntStack: private GenericStack {
public:
  void push(int *intPtr) { GenericStack::push(intPtr); }
  int * pop() { return static_cast<int*>(GenericStack::pop()); }
  bool empty() const { return GenericStack::empty(); }
};

class CatStack: private GenericStack {
public:
  void push(Cat *catPtr) { GenericStack::push(catPtr); }
  Cat * pop() { return static_cast<Cat*>(GenericStack::pop()); }
  bool empty() const { return GenericStack::empty(); }
};

IntStack is;                     // 正确

CatStack cs;                     // 也正确

和分层的方法一样,基于私有继承的实现避免了代码重复,因为这个类型安全的接口类只包含有对GenericStack函数的内联调用。

在GenericStack类之上构筑类型安全的接口是个很花俏的技巧,但需要手工去写所有那些接口类是件很烦的事。幸运的是,你不必这样。你可以让模板来自动生成它们。下面是一个模板,它通过私有继承来生成类型安全的堆栈接口:

template<class T>
class Stack: private GenericStack {
public:
  void push(T *objectPtr) { GenericStack::push(objectPtr); }
  T * pop() { return static_cast<T*>(GenericStack::pop()); }
  bool empty() const { return GenericStack::empty(); }
};

这是一段令人惊叹的代码,虽然你可能一时还没意识到。因为这是一个模板,编译器将根据你的需要自动生成所有的接口类。因为这些类是类型安全的,用户类型错误在编译期间就能发现。因为GenericStack的成员函数是保护类型,并且接口类把GenericStack作为私有基类来使用,用户将不可能绕过接口类。因为每个接口类成员函数被(隐式)声明为inline,使用这些类型安全的类时不会带来运行开销;生成的代码就象用户直接使用GenericStack来编写的一样(假设编译器满足了inline请求 ---- 参见条款33)。因为GenericStack使用了void*指针,操作堆栈的代码就只需要一份,而不管程序中使用了多少不同类型的堆栈。简而言之,这个设计使代码达到了最高的效率和最高的类型安全。很难做得比这更好。

本书的基本认识之一是,C++的各种特性是以非凡的方式相互作用的。这个例子,我希望你能同意,确实是非凡的。

从这个例子中可以发现,如果使用分层,就达不到这样的效果。只有继承才能访问保护成员,只有继承才使得虚函数可以重新被定义。(虚函数的存在会引发私有继承的使用,例子参见条款43)因为存在虚函数和保护成员,有时私有继承是表达类之间 "用...来实现" 关系的唯一有效途径。所以,当私有继承是你可以使用的最合适的实现方法时,就要大胆地使用它。同时,广泛意义上来说,分层是应该优先采用的技术,所以只要有可能,就要尽量使用它。

相关文章推荐

Effective C++学习有感--第六章 继承与面向对象设计(二)

今天继续学习继承与面向对象的章节,在这个章节里,我们会学到一些易于常规的东西。 心得1:考虑Virtual函数以外的选择          在条款35里,作者举了一个例子,一个游戏人软件里,会有暴...
  • thansky
  • thansky
  • 2011年12月23日 10:18
  • 154

第六章:面向对象的程序设计(继承)

继承 许多面向对象语言都支持两种继承方式:接口继承和实现继承。接口继承只继承方法签名,而js中函数是没有签名的,所以加上中没有接口继承。但是js中有实现继承,而且实现继承主要是依靠原型链来实现的。...
  • miaoch
  • miaoch
  • 2017年03月31日 15:35
  • 231

《Effective C++》第六章:继承与面向对象设计

条款32:确定你的public继承塑模出is-a关系 is-a的关系就是说派生类一定是一个基类,额,通俗一点说就是派生类可以当做基类一样来使用。即本章阐述的观点就是,public继承就意味着is-a。...

Effective C++学习有感--第六章 继承与面向对象设计(一)

最近从图书馆借了一本Effective C++, 仰慕Scott Meyers和侯捷大师的心情,十分膜拜的读了这本书,就像导读里面说的那样,“阅读本书的一个方式是,从你感兴趣的条款开始,然后看他逐步把...
  • thansky
  • thansky
  • 2011年12月21日 21:27
  • 90

第六章 继承和面向对象设计 (Effective C++ Second Edition 读书笔记)

条款35: 使公有继承体现 "是一个" 的含义 注释:C++面向对象编程中一条重要的规则是:公有继承意味着 "是一个" 。 条款36: 区分接口继承和实现继承 1) 定义纯虚函数的目的在于,...

Effective C++ 第六章--继承与面向对象设计笔记

条款32确定你的public继承塑模处is-a关系 条款32:确定你的public继承塑模处is-a关系 ”public继承“意味着is-a。...

你不知道的JavaScript--Item37 面向对象高级程序设计

1. JS是基于原型的程序建立一个简单的面向对象的类。有属性,有方法。function Aaa(){ this.name = '小明'; } Aaa.prototype.showName = ...

连载:面向对象葵花宝典:思想、技巧与实践(37) - 设计模式:瑞士军刀 or 锤子?

“设计模式”这个词几乎成为了软件设计的代名词,很多人非常天真的以为掌握了设计模式就掌握了软件设计,但实际上如果只是握了设计模式,软件设计的门都还没摸到!========================...

【javascript高级程序设计】读书摘录3 第六章、面向对象

第六章、面向对象的程序设计     这一章应该是Javascript中最抽象的一章,其中原型、原型链、构造函数等多次出现,几乎贯穿了整个章节。而对于创建对象和继承,也都是基于原型和构造函数而来的。因...

第六章:面向对象的程序设计

面向对象:OO(Object-Oriented) 对象:一组没有特定顺序的值,可以是数据或函数 6.1理解对象 最简单: var person=new Object(); perso...
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章: 第六章 继承和面向对象设计(37-42)
举报原因:
原因补充:

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